package ctbrec.sites.manyvids; import com.iheartradio.m3u8.*; import com.iheartradio.m3u8.data.MasterPlaylist; import com.iheartradio.m3u8.data.Playlist; import com.iheartradio.m3u8.data.PlaylistData; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; import ctbrec.*; import ctbrec.io.HttpException; import ctbrec.recorder.download.Download; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.ModelOfflineException; import okhttp3.Request; import okhttp3.Response; import org.json.JSONException; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.concurrent.ExecutionException; import static ctbrec.Model.State.OFFLINE; import static ctbrec.Model.State.ONLINE; import static ctbrec.io.HttpConstants.*; import static java.nio.charset.StandardCharsets.UTF_8; public class MVLiveModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(MVLiveModel.class); private transient MVLiveHttpClient httpClient; private transient MVLiveClient client; private transient JSONObject roomLocation; private transient Instant lastRoomLocationUpdate = Instant.EPOCH; private String roomNumber; private String id; @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { if (ignoreCache) { boolean modelFound = false; MVLive site = (MVLive) getSite(); for (Model model : site.getModels()) { if (model.getName().equalsIgnoreCase(getName()) || model.getDisplayName().equalsIgnoreCase(getName())) { this.onlineState = model.getOnlineState(true); setName(model.getName()); setDisplayName(model.getDisplayName()); setUrl(model.getUrl()); modelFound = true; break; } } if (!modelFound) { this.onlineState = OFFLINE; } } return this.onlineState == ONLINE; } @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { LOG.debug("Loading {}", getUrl()); try { StreamLocation streamLocation = getClient().getStreamLocation(this); LOG.debug("Got the stream location from WS {}", streamLocation.masterPlaylist); roomNumber = streamLocation.roomNumber; updateCloudFlareCookies(); MasterPlaylist masterPlaylist = getMasterPlaylist(streamLocation.masterPlaylist); 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 = streamLocation.masterPlaylist; 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.debug("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } } return sources; } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return Collections.emptyList(); } private MasterPlaylist getMasterPlaylist(String url) throws IOException, ParseException, PlaylistException { LOG.trace("Loading master playlist {}", url); Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getHttpClient().execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); 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 { LOG.debug("{} URL: {}\n\tResponse: {}", response.code(), url, response.body().string()); throw new HttpException(response.code(), response.message()); } } } public void updateCloudFlareCookies() throws IOException { String url = getApiUrl() + '/' + getRoomNumber() + "/player-settings/" + getDisplayName(); LOG.trace("Getting CF cookies: {}", url); Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getHttpClient().execute(req)) { if (!response.isSuccessful()) { LOG.debug("Loading CF cookies not successful: {}", response.body().string()); throw new HttpException(response.code(), response.message()); } } } String getApiUrl() throws JSONException, IOException { return getRoomLocation().getString("publicAPIURL"); } public String getRoomNumber() throws IOException { if (StringUtil.isBlank(roomNumber)) { JSONObject json = getRoomLocation(); if (json.optBoolean("success")) { roomNumber = json.getString("floorId"); } else { LOG.debug("Room number response: {}", json.toString(2)); throw new ModelOfflineException(this); } } return roomNumber; } private void fetchGeneralCookies() throws IOException { Request req = new Request.Builder() .url(getSite().getBaseUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getHttpClient().execute(req)) { if (!response.isSuccessful()) { throw new HttpException(response.code(), response.message()); } } } public JSONObject getRoomLocation() throws IOException { if (Duration.between(lastRoomLocationUpdate, Instant.now()).getSeconds() > 60) { fetchGeneralCookies(); httpClient.fetchAuthenticationCookies(); String url = "https://roompool.live.manyvids.com/roompool/" + getDisplayName() + "?private=false"; LOG.debug("Fetching room location from {}", url); Request req = new Request.Builder() .url(url) .header(ACCEPT, MIMETYPE_APPLICATION_JSON) .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(REFERER, MVLive.WS_ORIGIN + "/stream/" + getName()) .build(); try (Response response = getHttpClient().execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); roomLocation = new JSONObject(body); LOG.trace("Room location response: {}", roomLocation); lastRoomLocationUpdate = Instant.now(); return roomLocation; } else { throw new HttpException(response.code(), response.message()); } } } else { return roomLocation; } } private synchronized MVLiveClient getClient() { if (client == null) { client = new MVLiveClient(getHttpClient()); } return client; } @Override public void invalidateCacheEntries() { roomNumber = null; } @Override public void receiveTip(Double tokens) throws IOException { throw new NotImplementedExcetion("Sending tips is not implemeted for MVLive"); } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { return new int[]{1280, 720}; } @Override public boolean follow() throws IOException { return false; } @Override public boolean unfollow() throws IOException { return false; } @Override public Download createDownload() { if (Config.isServerMode() && !Config.getInstance().getSettings().recordSingleFile) { return new MVLiveHlsDownload(getHttpClient()); } else { return new MVLiveMergedHlsDownload(getHttpClient()); } } private synchronized MVLiveHttpClient getHttpClient() { if (httpClient == null) { MVLiveHttpClient siteHttpClient = (MVLiveHttpClient) getSite().getHttpClient(); httpClient = siteHttpClient.newSession(); } return httpClient; } @Override public void writeSiteSpecificData(JsonWriter writer) throws IOException { writer.name("id").value(id); } @Override public void readSiteSpecificData(JsonReader reader) throws IOException { if (reader.hasNext()) { reader.nextName(); id = reader.nextString(); } } public void setId(String id) { this.id = id; } public String getId() { return id; } }