package ctbrec.sites.camsoda; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.Random; import java.util.concurrent.ExecutionException; import org.json.JSONException; 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.ParsingMode; 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 ctbrec.AbstractModel; import ctbrec.Config; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; import okhttp3.FormBody; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; public class CamsodaModel extends AbstractModel { private static final String STREAM_NAME = "stream_name"; private static final String EDGE_SERVERS = "edge_servers"; private static final String STATUS = "status"; private static final Logger LOG = LoggerFactory.getLogger(CamsodaModel.class); private transient List streamSources = null; private transient boolean isNew; private transient String gender; private float sortOrder = 0; private Random random = new Random(); int[] resolution = new int[2]; public String getStreamUrl() throws IOException { Request req = createJsonRequest(getTokenInfoUrl()); JSONObject response = executeJsonRequest(req); if (response.optInt(STATUS) == 1 && response.optJSONArray(EDGE_SERVERS) != null && response.optJSONArray(EDGE_SERVERS).length() > 0) { String edgeServer = response.getJSONArray(EDGE_SERVERS).getString(0); String streamName = response.getString(STREAM_NAME); String token = response.getString("token"); return constructStreamUrl(edgeServer, streamName, token); } else { throw new JSONException("JSON response has not the expected structure"); } } private String getTokenInfoUrl() { String guestUsername = "guest_" + 10_000 + random.nextInt(50_000); String tokenInfoUrl = site.getBaseUrl() + "/api/v1/video/vtoken/" + getName() + "?username=" + guestUsername; return tokenInfoUrl; } private String constructStreamUrl(String edgeServer, String streamName, String token) { StringBuilder url = new StringBuilder("https://"); url.append(edgeServer).append('/'); if (streamName.contains("-flu")) { url.append(streamName); url.append("_h264_aac"); url.append(streamName.contains("-flu-hd") ? "_720p" : "_480p"); url.append("/index.m3u8"); if (!isPublic(streamName)) { url.append("?token=").append(token); } } else { // https://vide7-ord.camsoda.com/cam/mp4:maxandtokio-enc10-ord_h264_aac_480p/playlist.m3u8 url.append("cam/mp4:"); url.append(streamName); url.append("_h264_aac_480p/playlist.m3u8"); } LOG.trace("Stream URL: {}", url); return url.toString(); } private Request createJsonRequest(String tokenInfoUrl) { return new Request.Builder() .url(tokenInfoUrl) .header(ACCEPT, MIMETYPE_APPLICATION_JSON) .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); } private JSONObject executeJsonRequest(Request request) throws IOException { try (Response response = site.getHttpClient().execute(request)) { if (response.isSuccessful()) { JSONObject jsonResponse = new JSONObject(response.body().string()); return jsonResponse; } else { throw new HttpException(response.code(), response.message()); } } } private boolean isPublic(String streamName) { return Optional.ofNullable(streamName).orElse("").contains("_public"); } @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { try { String playlistUrl = getStreamUrl(); if (playlistUrl == null) { return Collections.emptyList(); } LOG.trace("Loading playlist {}", playlistUrl); Request req = new Request.Builder() .url(playlistUrl) .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .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, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); PlaylistData playlistData = master.getPlaylists().get(0); StreamSource streamsource = new StreamSource(); int cutOffAt = Math.max(playlistUrl.indexOf("index.m3u8"), playlistUrl.indexOf("playlist.m3u8")); String segmentPlaylistUrl = playlistUrl.substring(0, cutOffAt) + playlistData.getUri(); streamsource.mediaPlaylistUrl = segmentPlaylistUrl; 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; } streamSources = new ArrayList<>(); streamSources.add(streamsource); } else { LOG.trace("Response: {}", response.body().string()); throw new HttpException(playlistUrl, response.code(), response.message()); } } return streamSources; } catch (JSONException e) { return Collections.emptyList(); } } private void loadModel() throws IOException { String modelUrl = site.getBaseUrl() + "/api/v1/user/" + getName(); Request req = new Request.Builder() .url(modelUrl) .header(ACCEPT, MIMETYPE_APPLICATION_JSON) .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = site.getHttpClient().execute(req)) { if (response.isSuccessful()) { JSONObject result = new JSONObject(response.body().string()); if (result.optBoolean(STATUS)) { JSONObject chat = result.getJSONObject("user").getJSONObject("chat"); String status = chat.getString(STATUS); setOnlineStateByStatus(status); } else { throw new IOException("Result was not ok"); } } else throw new HttpException(response.code(), response.message()); } } public void setOnlineStateByStatus(String status) { switch(status) { case "online": onlineState = ONLINE; break; case "offline": onlineState = OFFLINE; break; case "connected": onlineState = AWAY; break; case "private": onlineState = PRIVATE; break; case "limited": onlineState = GROUP; break; default: LOG.debug("Unknown show type {}", status); onlineState = UNKNOWN; } } @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { if(ignoreCache || onlineState == UNKNOWN) { loadModel(); } return onlineState == ONLINE; } @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { if(failFast) { return onlineState; } else { if(onlineState == UNKNOWN) { loadModel(); } return onlineState; } } @Override public void invalidateCacheEntries() { streamSources = null; } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { if (failFast) { return resolution; } else { try { List sources = getStreamSources(); if (sources.isEmpty()) { return new int[] { 0, 0 }; } else { StreamSource src = sources.get(0); resolution = new int[] { src.width, src.height }; return resolution; } } catch (IOException | ParseException | PlaylistException e) { throw new ExecutionException(e); } } } @Override public void receiveTip(Double tokens) throws IOException { String csrfToken = ((CamsodaHttpClient) site.getHttpClient()).getCsrfToken(); String url = site.getBaseUrl() + "/api/v1/tip/" + getName(); if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { LOG.debug("Sending tip {}", url); RequestBody body = new FormBody.Builder() .add("amount", Integer.toString(tokens.intValue())) .add("comment", "") .build(); Request request = new Request.Builder() .url(url) .post(body) .addHeader(REFERER, Camsoda.BASE_URI + '/' + getName()) .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .addHeader(ACCEPT, MIMETYPE_APPLICATION_JSON) .addHeader(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .addHeader(X_CSRF_TOKEN, csrfToken) .build(); try (Response response = site.getHttpClient().execute(request)) { if (!response.isSuccessful()) { throw new HttpException(response.code(), response.message()); } } } } @Override public boolean follow() throws IOException { String url = Camsoda.BASE_URI + "/api/v1/follow/" + getName(); LOG.debug("Sending follow request {}", url); String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken(); Request request = new Request.Builder() .url(url) .post(RequestBody.create(null, "")) .addHeader(REFERER, Camsoda.BASE_URI + '/' + getName()) .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .addHeader(ACCEPT, MIMETYPE_APPLICATION_JSON) .addHeader(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .addHeader(X_CSRF_TOKEN, csrfToken) .build(); try (Response response = site.getHttpClient().execute(request)) { if (response.isSuccessful()) { return true; } else { throw new HttpException(response.code(), response.message()); } } } @Override public boolean unfollow() throws IOException { String url = Camsoda.BASE_URI + "/api/v1/unfollow/" + getName(); LOG.debug("Sending follow request {}", url); String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken(); Request request = new Request.Builder() .url(url) .post(RequestBody.create(null, "")) .addHeader(REFERER, Camsoda.BASE_URI + '/' + getName()) .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .addHeader(ACCEPT, MIMETYPE_APPLICATION_JSON) .addHeader(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .addHeader(X_CSRF_TOKEN, csrfToken) .build(); try (Response response = site.getHttpClient().execute(request)) { if (response.isSuccessful()) { return true; } else { throw new HttpException(response.code(), response.message()); } } } public float getSortOrder() { return sortOrder; } public void setSortOrder(float sortOrder) { this.sortOrder = sortOrder; } public boolean isNew() { return isNew; } public void setNew(boolean isNew) { this.isNew = isNew; } public String getGender() { return gender; } public void setGender(String gender) { this.gender = gender; } }