407 lines
16 KiB
Java
407 lines
16 KiB
Java
package ctbrec.sites.fc2live;
|
|
|
|
import static ctbrec.io.HttpConstants.*;
|
|
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Objects;
|
|
import java.util.concurrent.ExecutionException;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
import java.util.function.BiConsumer;
|
|
|
|
import org.json.JSONArray;
|
|
import org.json.JSONObject;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import com.iheartradio.m3u8.Encoding;
|
|
import com.iheartradio.m3u8.Format;
|
|
import com.iheartradio.m3u8.ParseException;
|
|
import com.iheartradio.m3u8.PlaylistException;
|
|
import com.iheartradio.m3u8.PlaylistParser;
|
|
import com.iheartradio.m3u8.data.MasterPlaylist;
|
|
import com.iheartradio.m3u8.data.Playlist;
|
|
import com.iheartradio.m3u8.data.PlaylistData;
|
|
import com.iheartradio.m3u8.data.StreamInfo;
|
|
import com.squareup.moshi.JsonReader;
|
|
import com.squareup.moshi.JsonWriter;
|
|
|
|
import ctbrec.AbstractModel;
|
|
import ctbrec.Config;
|
|
import ctbrec.io.HttpException;
|
|
import ctbrec.recorder.download.Download;
|
|
import ctbrec.recorder.download.StreamSource;
|
|
import okhttp3.FormBody;
|
|
import okhttp3.Request;
|
|
import okhttp3.RequestBody;
|
|
import okhttp3.Response;
|
|
import okhttp3.WebSocket;
|
|
import okhttp3.WebSocketListener;
|
|
import okio.ByteString;
|
|
|
|
public class Fc2Model extends AbstractModel {
|
|
private static final Logger LOG = LoggerFactory.getLogger(Fc2Model.class);
|
|
private String id;
|
|
private int viewerCount;
|
|
private boolean online;
|
|
private String version;
|
|
private WebSocket ws;
|
|
private String playlistUrl;
|
|
private AtomicInteger websocketUsage = new AtomicInteger(0);
|
|
private long lastHeartBeat = System.currentTimeMillis();
|
|
private int messageId = 1;
|
|
|
|
@Override
|
|
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
|
|
if(ignoreCache) {
|
|
loadModelInfo();
|
|
}
|
|
return online;
|
|
}
|
|
|
|
void loadModelInfo() throws IOException {
|
|
String url = Fc2Live.BASE_URL + "/api/memberApi.php";
|
|
RequestBody body = new FormBody.Builder()
|
|
.add("channel", "1")
|
|
.add("profile", "1")
|
|
.add("streamid", id)
|
|
.build();
|
|
Request req = new Request.Builder()
|
|
.url(url)
|
|
.method("POST", body)
|
|
.header(ACCEPT, "*/*")
|
|
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
|
|
.header(REFERER, Fc2Live.BASE_URL)
|
|
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
|
|
.header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
|
|
.build();
|
|
try(Response resp = getSite().getHttpClient().execute(req)) {
|
|
if(resp.isSuccessful()) {
|
|
String msg = resp.body().string();
|
|
JSONObject json = new JSONObject(msg);
|
|
// LOG.debug(json.toString(2));
|
|
JSONObject data = json.getJSONObject("data");
|
|
JSONObject channelData = data.getJSONObject("channel_data");
|
|
online = channelData.optInt("is_publish") == 1;
|
|
onlineState = online ? State.ONLINE : State.OFFLINE;
|
|
if(channelData.optInt("fee") == 1) {
|
|
onlineState = State.PRIVATE;
|
|
online = false;
|
|
}
|
|
version = channelData.optString("version");
|
|
if (data.has("profile_data")) {
|
|
JSONObject profileData = data.getJSONObject("profile_data");
|
|
setName(profileData.getString("name").replace('/', '_'));
|
|
}
|
|
} else {
|
|
throw new IOException("HTTP status " + resp.code() + " " + resp.message());
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public State getOnlineState(boolean failFast) throws IOException, ExecutionException {
|
|
if(failFast) {
|
|
return onlineState;
|
|
} else if(Objects.equals(onlineState, State.UNKNOWN)){
|
|
loadModelInfo();
|
|
}
|
|
return onlineState;
|
|
}
|
|
|
|
@Override
|
|
public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
|
|
try {
|
|
openWebsocket();
|
|
List<StreamSource> sources = new ArrayList<>();
|
|
LOG.debug("Paylist url {}", playlistUrl);
|
|
sources.addAll(parseMasterPlaylist(playlistUrl));
|
|
return sources;
|
|
} catch (InterruptedException e1) {
|
|
Thread.currentThread().interrupt();
|
|
throw new ExecutionException(e1);
|
|
} finally {
|
|
closeWebsocket();
|
|
}
|
|
}
|
|
|
|
private List<StreamSource> parseMasterPlaylist(String playlistUrl) throws IOException, ParseException, PlaylistException {
|
|
List<StreamSource> sources = new ArrayList<>();
|
|
Request req = new Request.Builder()
|
|
.url(playlistUrl)
|
|
.header(ACCEPT, "*/*")
|
|
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
|
|
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
|
|
.header(ORIGIN, Fc2Live.BASE_URL)
|
|
.header(REFERER, getUrl())
|
|
.build();
|
|
try(Response response = site.getHttpClient().execute(req)) {
|
|
if(response.isSuccessful()) {
|
|
InputStream inputStream = response.body().byteStream();
|
|
PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
|
|
Playlist playlist = parser.parse();
|
|
MasterPlaylist master = playlist.getMasterPlaylist();
|
|
sources.clear();
|
|
for (PlaylistData playlistData : master.getPlaylists()) {
|
|
StreamSource streamsource = new StreamSource();
|
|
streamsource.mediaPlaylistUrl = playlistData.getUri();
|
|
if (playlistData.hasStreamInfo()) {
|
|
StreamInfo info = playlistData.getStreamInfo();
|
|
streamsource.bandwidth = info.getBandwidth();
|
|
streamsource.width = info.hasResolution() ? info.getResolution().width : 0;
|
|
streamsource.height = info.hasResolution() ? info.getResolution().height : 0;
|
|
} else {
|
|
streamsource.bandwidth = 0;
|
|
streamsource.width = 0;
|
|
streamsource.height = 0;
|
|
}
|
|
sources.add(streamsource);
|
|
}
|
|
LOG.debug(sources.toString());
|
|
return sources;
|
|
} else {
|
|
throw new HttpException(response.code(), response.message());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void getControlToken(BiConsumer<String, String> callback) throws IOException {
|
|
String url = Fc2Live.BASE_URL + "/api/getControlServer.php";
|
|
RequestBody body = new FormBody.Builder()
|
|
.add("channel_id", id)
|
|
.add("channel_version", version)
|
|
.add("client_app", "browser_hls")
|
|
.add("client_type", "pc")
|
|
.add("client_version", "1.6.0 [1]")
|
|
.add("mode", "play")
|
|
.build();
|
|
Request req = new Request.Builder()
|
|
.url(url)
|
|
.method("POST", body)
|
|
.header(ACCEPT, "*/*")
|
|
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
|
|
.header(REFERER, Fc2Live.BASE_URL)
|
|
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
|
|
.header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
|
|
.build();
|
|
LOG.debug("Fetching page {}", url);
|
|
try(Response resp = getSite().getHttpClient().execute(req)) {
|
|
if(resp.isSuccessful()) {
|
|
String msg = resp.body().string();
|
|
JSONObject json = new JSONObject(msg);
|
|
if(json.has("url")) {
|
|
String wssurl = json.getString("url");
|
|
String token = json.getString("control_token");
|
|
callback.accept(token, wssurl);
|
|
} else {
|
|
throw new IOException("Couldn't determine websocket url");
|
|
}
|
|
} else {
|
|
throw new HttpException(resp.code(), resp.message());
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void invalidateCacheEntries() {
|
|
// not needed
|
|
}
|
|
|
|
@Override
|
|
public void receiveTip(Double tokens) throws IOException {
|
|
// tipping is not implemented for FC2
|
|
}
|
|
|
|
@Override
|
|
public int[] getStreamResolution(boolean failFast) throws ExecutionException {
|
|
return new int[2];
|
|
}
|
|
|
|
@Override
|
|
public boolean follow() throws IOException {
|
|
return followUnfollow("add");
|
|
}
|
|
|
|
@Override
|
|
public boolean unfollow() throws IOException {
|
|
return followUnfollow("remove");
|
|
}
|
|
|
|
private boolean followUnfollow(String mode) throws IOException {
|
|
if(!getSite().getHttpClient().login()) {
|
|
throw new IOException("Login didn't work");
|
|
}
|
|
|
|
RequestBody body = new FormBody.Builder()
|
|
.add("id", getId())
|
|
.add("mode", mode)
|
|
.build();
|
|
Request req = new Request.Builder()
|
|
.url(getSite().getBaseUrl() + "/api/favoriteManager.php")
|
|
.header(REFERER, getUrl())
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.post(body)
|
|
.build();
|
|
try(Response resp = getSite().getHttpClient().execute(req)) {
|
|
if(resp.isSuccessful()) {
|
|
String content = resp.body().string();
|
|
JSONObject json = new JSONObject(content);
|
|
return json.optInt("status") == 1;
|
|
} else {
|
|
LOG.error("Login failed {} {}", resp.code(), resp.message());
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void setId(String id) {
|
|
this.id = id;
|
|
}
|
|
|
|
public String getId() {
|
|
return id;
|
|
}
|
|
|
|
public int getViewerCount() {
|
|
return viewerCount;
|
|
}
|
|
|
|
public void setViewerCount(int viewerCount) {
|
|
this.viewerCount = viewerCount;
|
|
}
|
|
|
|
/**
|
|
* Opens a chat websocket connection. This connection is used to retrieve the HLS playlist url. It also has to be kept open as long as the HLS stream is
|
|
* "played". Fc2Model keeps track of the number of objects, which tried to open or close the websocket. As long as at least one object is using the
|
|
* websocket, it is kept open. If the last object, which is using it, calls closeWebsocket, the websocket is closed.
|
|
*
|
|
* @throws IOException
|
|
*/
|
|
public void openWebsocket() throws InterruptedException, IOException {
|
|
messageId = 1;
|
|
int usage = websocketUsage.incrementAndGet();
|
|
LOG.debug("{} objects using the websocket for {}", usage, this);
|
|
if(ws != null) {
|
|
return;
|
|
} else {
|
|
Object monitor = new Object();
|
|
loadModelInfo();
|
|
getControlToken((token, url) -> {
|
|
url = url + "?control_token=" + token;
|
|
LOG.debug("Session token: {}", token);
|
|
LOG.debug("Getting playlist token over websocket {}", url);
|
|
|
|
Request request = new Request.Builder()
|
|
.url(url)
|
|
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
|
|
.header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
.header(ACCEPT_LANGUAGE, "de,en-US;q=0.7,en;q=0.3")
|
|
.build();
|
|
ws = getSite().getHttpClient().newWebSocket(request, new WebSocketListener() {
|
|
@Override
|
|
public void onOpen(WebSocket webSocket, Response response) {
|
|
response.close();
|
|
webSocket.send("{\"name\":\"get_hls_information\",\"arguments\":{},\"id\":" + (messageId++) + "}");
|
|
}
|
|
|
|
@Override
|
|
public void onMessage(WebSocket webSocket, String text) {
|
|
JSONObject json = new JSONObject(text);
|
|
if(json.optString("name").equals("_response_")) {
|
|
if(json.has("arguments")) {
|
|
JSONObject args = json.getJSONObject("arguments");
|
|
if(args.has("playlists_high_latency")) {
|
|
JSONArray playlists = args.getJSONArray("playlists_high_latency");
|
|
JSONObject playlist = playlists.getJSONObject(0);
|
|
playlistUrl = playlist.getString("url");
|
|
LOG.debug("Master Playlist: {}", playlistUrl);
|
|
synchronized (monitor) {
|
|
monitor.notifyAll();
|
|
}
|
|
} else {
|
|
LOG.trace(json.toString());
|
|
}
|
|
}
|
|
} else if(json.optString("name").equals("user_count") || json.optString("name").equals("comment")) {
|
|
// ignore
|
|
} else {
|
|
LOG.trace("WS <-- {}: {}", getName(), text);
|
|
}
|
|
|
|
// send heartbeat every now and again
|
|
long now = System.currentTimeMillis();
|
|
if( (now - lastHeartBeat) > TimeUnit.SECONDS.toMillis(30)) {
|
|
webSocket.send("{\"name\":\"heartbeat\",\"arguments\":{},\"id\":" + messageId + "}");
|
|
lastHeartBeat = now;
|
|
LOG.trace("Sending heartbeat for {} (messageId: {})", getName(), messageId);
|
|
messageId++;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onMessage(WebSocket webSocket, ByteString bytes) {
|
|
LOG.debug("ws btxt {}", bytes);
|
|
}
|
|
|
|
@Override
|
|
public void onClosed(WebSocket webSocket, int code, String reason) {
|
|
LOG.debug("ws closed {} - {}", code, reason);
|
|
}
|
|
|
|
@Override
|
|
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
|
LOG.debug("ws failure", t);
|
|
response.close();
|
|
}
|
|
});
|
|
});
|
|
synchronized (monitor) {
|
|
// wait at max 10 seconds, otherwise we can assume, that the stream is not available
|
|
monitor.wait(TimeUnit.SECONDS.toMillis(20));
|
|
}
|
|
if(playlistUrl == null) {
|
|
throw new IOException("No playlist response for 20 seconds");
|
|
}
|
|
}
|
|
}
|
|
|
|
public void closeWebsocket() {
|
|
int websocketUsers = websocketUsage.decrementAndGet();
|
|
LOG.debug("{} objects using the websocket for {}", websocketUsers, this);
|
|
if(websocketUsers == 0) {
|
|
LOG.debug("Closing the websocket for {}", this);
|
|
ws.close(1000, "");
|
|
ws = null;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public Download createDownload() {
|
|
if(Config.isServerMode()) {
|
|
return new Fc2HlsDownload(getSite().getHttpClient());
|
|
} else {
|
|
return new Fc2MergedHlsDownload(getSite().getHttpClient());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void readSiteSpecificData(JsonReader reader) throws IOException {
|
|
reader.nextName();
|
|
id = reader.nextString();
|
|
}
|
|
|
|
@Override
|
|
public void writeSiteSpecificData(JsonWriter writer) throws IOException {
|
|
writer.name("id").value(id);
|
|
}
|
|
|
|
@Override
|
|
public String getSanitizedNamed() {
|
|
return getId();
|
|
}
|
|
}
|