ctbrec-5.3.2-experimental/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.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();
}
}