package ctbrec.sites.chaturbate; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; import static java.nio.charset.StandardCharsets.*; import java.io.ByteArrayInputStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; 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.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; 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 ChaturbateModel extends AbstractModel { // NOSONAR private static final String PUBLIC = "public"; private static final Logger LOG = LoggerFactory.getLogger(ChaturbateModel.class); private int[] resolution = new int[2]; private transient StreamInfo streamInfo; private long streamInfoTimestamp = 0; /** * This constructor exists only for deserialization. Please don't call it directly */ public ChaturbateModel() { } ChaturbateModel(Chaturbate site) { this.site = site; } @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { String roomStatus; if(ignoreCache) { StreamInfo info = loadStreamInfo(); roomStatus = Optional.ofNullable(info).map(i -> i.room_status).orElse(""); LOG.trace("Model {} room status: {}", getName(), info.room_status); } else { StreamInfo info = getStreamInfo(true); roomStatus = Optional.ofNullable(info).map(i -> i.room_status).orElse(""); } return Objects.equals(PUBLIC, roomStatus); } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { if(failFast) { return resolution; } try { resolution = getResolution(); } catch(Exception e) { throw new ExecutionException(e); } return resolution; } /** * Invalidates the entries in StreamInfo and resolution cache for this model * and thus causes causes the LoadingCache to update them */ @Override public void invalidateCacheEntries() { streamInfo = null; } public State getOnlineState() throws IOException, ExecutionException { return getOnlineState(false); } @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { if(failFast) { setOnlineStateByRoomStatus(Optional.ofNullable(streamInfo).map(si -> si.room_status).orElse("Unknown")); } else { try { streamInfo = loadStreamInfo(); setOnlineStateByRoomStatus(streamInfo.room_status); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ExecutionException(e); } } return onlineState; } private void setOnlineStateByRoomStatus(String roomStatus) { if (roomStatus != null) { switch (roomStatus) { case PUBLIC: case "Unknown": onlineState = ONLINE; break; case "offline": onlineState = OFFLINE; break; case "private": case "hidden": case "password protected": onlineState = PRIVATE; break; case "away": onlineState = AWAY; break; case "group": onlineState = State.GROUP; break; default: LOG.debug("Unknown show type {}", roomStatus); onlineState = State.UNKNOWN; } } } @Override public void receiveTip(Double tokens) throws IOException { if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { RequestBody body = new FormBody.Builder() .add("csrfmiddlewaretoken", ((ChaturbateHttpClient)getSite().getHttpClient()).getToken()) .add("tip_amount", Integer.toString(tokens.intValue())) .add("tip_room_type", PUBLIC) .build(); Request req = new Request.Builder() .url("https://chaturbate.com/tipping/send_tip/"+getName()+"/") .post(body) .header(REFERER, "https://chaturbate.com/"+getName()+"/") .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (!response.isSuccessful()) { throw new IOException(response.code() + " " + response.message()); } } } } @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { try { streamInfo = loadStreamInfo(); MasterPlaylist masterPlaylist = getMasterPlaylist(); List sources = new ArrayList<>(); for (PlaylistData playlist : masterPlaylist.getPlaylists()) { if (playlist.hasStreamInfo()) { StreamSource src = new StreamSource(); src.bandwidth = playlist.getStreamInfo().getBandwidth(); src.height = playlist.getStreamInfo().getResolution().height; String masterUrl = streamInfo.url; String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String segmentUri = baseUrl + playlist.getUri(); src.mediaPlaylistUrl = segmentUri; if(src.mediaPlaylistUrl.contains("?")) { src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?')); } LOG.trace("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } } return sources; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ExecutionException(e); } } @Override public boolean follow() throws IOException { return follow(true); } @Override public boolean unfollow() throws IOException { return follow(false); } private boolean follow(boolean follow) throws IOException { Request req = new Request.Builder() .url(getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response resp = site.getHttpClient().execute(req)) { // do an initial request to get cookies } String url = null; if (follow) { url = getSite().getBaseUrl() + "/follow/follow/" + getName() + "/"; } else { url = getSite().getBaseUrl() + "/follow/unfollow/" + getName() + "/"; } RequestBody body = RequestBody.create(new byte[0]); req = new Request.Builder() .url(url) .method("POST", body) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header("X-CSRFToken", ((ChaturbateHttpClient)site.getHttpClient()).getToken()) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); try (Response resp2 = site.getHttpClient().execute(req)) { if (resp2.isSuccessful()) { String responseBody = resp2.body().string(); JSONObject json = new JSONObject(responseBody); if (!json.has("following")) { LOG.debug(responseBody); throw new IOException("Response was " + responseBody.substring(0, Math.min(responseBody.length(), 500))); } else { LOG.debug("Follow/Unfollow -> {}", responseBody); return json.getBoolean("following") == follow; } } else { throw new HttpException(resp2.code(), resp2.message()); } } } private StreamInfo getStreamInfo() throws IOException, InterruptedException { return getStreamInfo(false); } private StreamInfo getStreamInfo(boolean failFast) throws IOException, InterruptedException { if(failFast) { return streamInfo; } else { return Optional.ofNullable(streamInfo).orElse(loadStreamInfo()); } } private StreamInfo loadStreamInfo() throws IOException, InterruptedException { long now = System.currentTimeMillis(); long streamInfoAge = now - streamInfoTimestamp; if (streamInfo != null && streamInfoAge < 5000) { return streamInfo; } RequestBody body = new FormBody.Builder() .add("room_slug", getName()) .add("bandwidth", "high") .build(); Request req = new Request.Builder() .url(getSite().getBaseUrl() + "/get_edge_hls_url_ajax/") .post(body) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { String content = response.body().string(); LOG.trace("Raw stream info: {}", content); Moshi moshi = new Moshi.Builder().build(); JsonAdapter adapter = moshi.adapter(StreamInfo.class); streamInfo = adapter.fromJson(content); streamInfoTimestamp = System.currentTimeMillis(); return streamInfo; } else { int code = response.code(); String message = response.message(); throw new HttpException(code, message); } } } private int[] getResolution() throws IOException, ParseException, PlaylistException, InterruptedException { int[] res = new int[2]; if(!getStreamInfo().url.startsWith("http")) { return res; } EOFException ex = null; for(int i=0; i<2; i++) { try { MasterPlaylist master = getMasterPlaylist(); for (PlaylistData playlistData : master.getPlaylists()) { if(playlistData.hasStreamInfo() && playlistData.getStreamInfo().hasResolution()) { int h = playlistData.getStreamInfo().getResolution().height; int w = playlistData.getStreamInfo().getResolution().width; if(w > res[1]) { res[0] = w; res[1] = h; } } } ex = null; break; // this attempt worked, exit loop } catch(EOFException e) { // the cause might be, that the playlist url in streaminfo is outdated, // so let's remove it from cache and retry in the next iteration ex = e; } } if(ex != null) { throw ex; } return res; } public MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException, InterruptedException { return getMasterPlaylist(getStreamInfo()); } private MasterPlaylist getMasterPlaylist(StreamInfo streamInfo) throws IOException, ParseException, PlaylistException { LOG.trace("Loading master playlist {}", streamInfo.url); Request req = new Request.Builder() .url(streamInfo.url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); LOG.trace(body); InputStream inputStream = new ByteArrayInputStream(body.getBytes(UTF_8)); PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; } else { throw new HttpException(response.code(), response.message()); } } } }