diff --git a/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModel.java b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModel.java index 4bce9f9d..b9908854 100644 --- a/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModel.java +++ b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModel.java @@ -1,15 +1,11 @@ package ctbrec.sites.jasmin; -import com.iheartradio.m3u8.*; -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.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; import ctbrec.AbstractModel; import ctbrec.Config; -import ctbrec.StringUtil; import ctbrec.io.HttpException; import ctbrec.recorder.download.Download; import ctbrec.recorder.download.StreamSource; @@ -20,7 +16,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.io.InputStream; import java.util.*; import java.util.concurrent.ExecutionException; @@ -29,11 +24,14 @@ import static ctbrec.io.HttpConstants.*; public class LiveJasminModel extends AbstractModel { private static final Logger LOG = LoggerFactory.getLogger(LiveJasminModel.class); - private final Random rng = new Random(); private String id; private boolean online = false; private int[] resolution; + private final Random rng = new Random(); + + private transient LiveJasminModelInfo modelInfo; + @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { if (ignoreCache) { @@ -62,6 +60,7 @@ public class LiveJasminModel extends AbstractModel { JSONObject data = json.getJSONObject("data"); JSONObject config = data.getJSONObject("config"); JSONObject chatRoom = config.getJSONObject("chatRoom"); + JSONObject armageddonConfig = config.getJSONObject("armageddonConfig"); setId(chatRoom.getString("p_id")); setName(chatRoom.getString("performer_id")); setDisplayName(chatRoom.getString("display_name")); @@ -79,6 +78,14 @@ public class LiveJasminModel extends AbstractModel { resolution = new int[2]; resolution[0] = config.optInt("streamWidth"); resolution[1] = config.optInt("streamHeight"); + modelInfo = new LiveJasminModelInfo.LiveJasminModelInfoBuilder() + .sbIp(chatRoom.getString("sb_ip")) + .sbHash(chatRoom.getString("sb_hash")) + .sessionId(armageddonConfig.getString("sessionid")) + .jsm2session(armageddonConfig.getString("jsm2session")) + .performerId(getName()) + .clientInstanceId(randomClientInstanceId()) + .build(); online = onlineState == State.ONLINE; LOG.trace("{} - status:{} {} {} {} {}", getName(), online, onlineState, Arrays.toString(resolution), getUrl(), id); } else { @@ -90,6 +97,14 @@ public class LiveJasminModel extends AbstractModel { } } + private String randomClientInstanceId() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 32; i++) { + sb.append(rng.nextInt(9) + 1); + } + return sb.toString(); + } + public static State mapStatus(int status) { switch (status) { case 0: @@ -112,85 +127,20 @@ public class LiveJasminModel extends AbstractModel { @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { - String masterUrl = getMasterPlaylistUrl(); - LOG.debug("Master playlist: {}", masterUrl); - List streamSources = new ArrayList<>(); - Request req = new Request.Builder().url(masterUrl).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(); - for (PlaylistData playlistData : master.getPlaylists()) { - StreamSource streamsource = new StreamSource(); - String baseUrl = masterUrl; - baseUrl = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1); - streamsource.mediaPlaylistUrl = baseUrl + 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; - } - streamSources.add(streamsource); - } - } else { - throw new HttpException(response.code(), response.message()); - } - } - return streamSources; - } - - private String getMasterPlaylistUrl() throws IOException { loadModelInfo(); - // generate a fake guest session ID - byte[] sessionIdRandom = new byte[16]; - rng.nextBytes(sessionIdRandom); - String sessionId = 'g' + StringUtil.toHexString(sessionIdRandom, 32); + String websocketUrlTemplate = "wss://dss-relay-{ipWithDashes}.dditscdn.com/?random={clientInstanceId}"; + String websocketUrl = websocketUrlTemplate + .replace("{ipWithDashes}", modelInfo.getSbIp().replace('.', '-')) + .replace("{clientInstanceId}", modelInfo.getClientInstanceId()); + modelInfo.setWebsocketUrl(websocketUrl); - String highResUrl = "https://api-gateway.dditsadn.com/v1/stream/performers/" + getName() + "/streams/free/formats/hls?brandId=jasmin&session=" + sessionId + "&streamName=stream_1280_720_3000"; - String lowResUrl = "https://api-gateway.dditsadn.com/v1/stream/performers/" + getName() + "/streams/free/formats/hls?brandId=jasmin&session=" + sessionId + "&streamName=stream_1280_720_1953"; - - String body; - try { - body = getMasterPlaylistUrl(highResUrl); - } catch (Exception e) { - LOG.debug("High resolution URL not available for {}. Falling back to low res.", getName()); - body = getMasterPlaylistUrl(lowResUrl); - } - - JSONObject json = new JSONObject(body); - if (json.has("data")) { - JSONObject data = json.getJSONObject("data"); - return data.getString("url"); - } else { - throw new IOException("Response was not successful: " + lowResUrl + "\n" + body); - } - } - - private String getMasterPlaylistUrl(String fromUrl) throws IOException { - LOG.debug("Getting master playlist URL from {}", fromUrl); - Request request = new Request.Builder() - .url(fromUrl) - .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgentMobile) - .addHeader(ACCEPT, MIMETYPE_APPLICATION_JSON) - .addHeader(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) - .addHeader(REFERER, getUrl()) - .addHeader(X_REQUESTED_WITH, XML_HTTP_REQUEST) - .build(); - try (Response response = site.getHttpClient().execute(request)) { - if (response.isSuccessful()) { - return response.body().string(); - } else { - throw new HttpException(response.code(), response.message()); - } - } + LiveJasminStreamRegistration liveJasminStreamRegistration = new LiveJasminStreamRegistration(site, modelInfo); + List streamSources = liveJasminStreamRegistration.getStreamSources(); + streamSources.stream().max(Comparator.naturalOrder()).ifPresent(ss -> { + new LiveJasminStreamStarter().start(site, modelInfo, (LiveJasminStreamSource) ss); + }); + return streamSources; } @Override diff --git a/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModelInfo.java b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModelInfo.java new file mode 100644 index 00000000..ed0e3eef --- /dev/null +++ b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminModelInfo.java @@ -0,0 +1,16 @@ +package ctbrec.sites.jasmin; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class LiveJasminModelInfo { + private String websocketUrl; + private String sbIp; + private String sbHash; + private String sessionId; + private String jsm2session; + private String performerId; + private String clientInstanceId; +} diff --git a/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamRegistration.java b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamRegistration.java new file mode 100644 index 00000000..41a7770b --- /dev/null +++ b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamRegistration.java @@ -0,0 +1,218 @@ +package ctbrec.sites.jasmin; + +import ctbrec.Config; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.Site; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URLEncoder; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static ctbrec.io.HttpConstants.USER_AGENT; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class LiveJasminStreamRegistration { + + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminStreamRegistration.class); + private static final String KEY_EVENT = "event"; + private static final String KEY_FUNC_NAME = "funcName"; + + private final Site site; + private final LiveJasminModelInfo modelInfo; + private final CyclicBarrier barrier = new CyclicBarrier(2); + + public LiveJasminStreamRegistration(Site site, LiveJasminModelInfo modelInfo) { + this.site = site; + this.modelInfo = modelInfo; + } + + List getStreamSources() { + var streamSources = new LinkedList(); + try { + Request webSocketRequest = new Request.Builder() + .url(modelInfo.getWebsocketUrl()) + .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgentMobile) + .build(); + LOG.debug("Websocket: {}", modelInfo.getWebsocketUrl()); + site.getHttpClient().newWebSocket(webSocketRequest, new WebSocketListener() { + @Override + public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) { + LOG.debug("onOpen"); + JSONObject register = new JSONObject() + .put(KEY_EVENT, "register") + .put("applicationId", "memberChat/jasmin" + modelInfo.getPerformerId() + modelInfo.getSbHash()) + .put("connectionData", new JSONObject() + .put("jasmin2App", false) + .put("isMobileClient", true) + .put("platform", "mobile") + .put("chatID", "freechat") + .put("sessionID", modelInfo.getSessionId()) + .put("jsm2SessionId", modelInfo.getJsm2session()) + .put("userType", "user") + .put("performerId", modelInfo.getPerformerId()) + .put("clientRevision", "") + .put("playerVer", "nanoPlayerVersion: 4.12.1 appCodeName: Mozilla appName: Netscape appVersion: 5.0 (iPad; CPU OS 10_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.1 Mobile/15E148 Safari/605.1.15 platform: iPad") + .put("livejasminTvmember", false) + .put("newApplet", true) + .put("livefeedtype", JSONObject.NULL) + .put("gravityCookieId", "") + .put("passparam", "") + .put("clientInstanceId", modelInfo.getClientInstanceId()) + .put("armaVersion", "39.158.0") + .put("isPassive", false) + .put("brandID", "jasmin") + .put("cobrandId", "") + .put("subbrand", "livejasmin") + .put("siteName", "LiveJasmin") + .put("siteUrl", "https://m." + LiveJasmin.baseDomain) + .put("chatHistoryRequired", false) + .put("peekPatternJsm2", true) + ); + webSocket.send(register.toString()); + webSocket.send(new JSONObject().put(KEY_EVENT, "ping").toString()); + webSocket.send(new JSONObject() + .put(KEY_EVENT, "call") + .put(KEY_FUNC_NAME, "makeActive") + .put("data", new JSONArray()) + .toString()); + webSocket.send(new JSONObject() + .put(KEY_EVENT, "call") + .put(KEY_FUNC_NAME, "setVideo") + .put("data", new JSONArray() + .put(JSONObject.NULL) + .put(false) + .put(true) + .put(modelInfo.getJsm2session()) + ) + .toString()); + webSocket.send(new JSONObject() + .put(KEY_EVENT, "connectSharedObject") + .put("name", "data/chat_so") + .toString()); + //webSocket.close(1000, "Good bye"); + } + + @Override + public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) { + LOG.error("onFailure", t); + awaitBarrier(); + webSocket.close(1000, ""); + } + + @Override + public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) { + JSONObject message = new JSONObject(text); + if (message.opt(KEY_EVENT).equals("pong")) { + new Thread(() -> { + try { + Thread.sleep(message.optInt("nextPing")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + webSocket.send(new JSONObject().put(KEY_EVENT, "ping").toString()); + }).start(); + } else if (message.optString(KEY_EVENT).equals("updateSharedObject") && message.optString("name").equals("data/chat_so")) { + LOG.trace(message.toString(2)); + JSONArray list = message.getJSONArray("list"); + for (int i = 0; i < list.length(); i++) { + JSONObject attribute = list.getJSONObject(i); + if (attribute.optString("name").equals("streamList")) { + JSONObject value = attribute.getJSONObject("newValue"); + JSONObject patterns = value.getJSONObject("patterns"); + String freePattern = patterns.getString("free"); + JSONArray streams = value.getJSONArray("streams"); + for (int j = 0; j < streams.length(); j++) { + JSONObject stream = streams.getJSONObject(j); + addStreamSource(streamSources, freePattern, stream); + } + webSocket.close(1000, ""); + } + } + } else if (!message.optString(KEY_FUNC_NAME).equals("chatHistory")) { + LOG.trace("onMessageT {}", new JSONObject(text).toString(2)); + } + } + + @Override + public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) { + LOG.trace("onMessageB"); + super.onMessage(webSocket, bytes); + } + + @Override + public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + LOG.debug("onClosed {} {}", code, reason); + super.onClosed(webSocket, code, reason); + } + + @Override + public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + LOG.trace("onClosing {} {}", code, reason); + awaitBarrier(); + } + }); + + LOG.debug("Waiting for websocket to return"); + awaitBarrier(); + LOG.debug("Websocket is done. Stream sources {}", streamSources); + } catch (Exception e) { + LOG.error("Couldn't determine stream sources", e); + } + return streamSources; + } + + private void addStreamSource(LinkedList streamSources, String pattern, JSONObject stream) { + int w = stream.getInt("width"); + int h = stream.getInt("height"); + int bitrate = stream.getInt("bitrate") * 1024; + String name = stream.getString("name"); + String streamName = pattern.replace("{$streamname}", name); + + String rtmpUrl = "rtmp://{ip}/memberChat/jasmin{modelName}{sb_hash}?sessionId-{sessionId}|clientInstanceId-{clientInstanceId}" + .replace("{ip}", modelInfo.getSbIp()) + .replace("{modelName}", modelInfo.getPerformerId()) + .replace("{sb_hash}", modelInfo.getSbHash()) + .replace("{sessionId}", modelInfo.getSessionId()) + .replace("{clientInstanceId}", modelInfo.getClientInstanceId()); + + String hlsUrl = "https://dss-hls-{ipWithDashes}.dditscdn.com/h5live/http/playlist.m3u8?url={rtmpUrl}&stream={streamName}" + .replace("{ipWithDashes}", modelInfo.getSbIp().replace('.', '-')) + .replace("{rtmpUrl}", URLEncoder.encode(rtmpUrl, UTF_8)) + .replace("{streamName}", URLEncoder.encode(streamName, UTF_8)); + + LiveJasminStreamSource streamSource = new LiveJasminStreamSource(); + streamSource.mediaPlaylistUrl = hlsUrl; + streamSource.width = w; + streamSource.height = h; + streamSource.bandwidth = bitrate; + streamSource.rtmpUrl = rtmpUrl; + streamSource.streamName = streamName; + streamSources.add(streamSource); + } + + private void awaitBarrier() { + try { + barrier.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error(e.getLocalizedMessage(), e); + } catch (TimeoutException | BrokenBarrierException e) { + LOG.error(e.getLocalizedMessage(), e); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamSource.java b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamSource.java new file mode 100644 index 00000000..7f7d5a13 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamSource.java @@ -0,0 +1,8 @@ +package ctbrec.sites.jasmin; + +import ctbrec.recorder.download.StreamSource; + +public class LiveJasminStreamSource extends StreamSource { + public String rtmpUrl; + public String streamName; +} diff --git a/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamStarter.java b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamStarter.java new file mode 100644 index 00000000..6a09d498 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamStarter.java @@ -0,0 +1,107 @@ +package ctbrec.sites.jasmin; + +import ctbrec.Config; +import ctbrec.sites.Site; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.Optional; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static ctbrec.io.HttpConstants.USER_AGENT; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class LiveJasminStreamStarter { + + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminStreamStarter.class); + private final CyclicBarrier barrier = new CyclicBarrier(2); + + void start(Site site, LiveJasminModelInfo modelInfo, LiveJasminStreamSource ss) { + try { + String websocketUrl = "wss://dss-live-{ipWithDashes}.dditscdn.com/h5live/http/playlist.m3u8?url={rtmpUrl}&stream={streamName}" + .replace("{ipWithDashes}", modelInfo.getSbIp().replace('.', '-')) + .replace("{rtmpUrl}", URLEncoder.encode(ss.rtmpUrl, UTF_8)) + .replace("{streamName}", URLEncoder.encode(ss.streamName, UTF_8)); + + Request webSocketRequest = new Request.Builder() + .url(websocketUrl) + .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgentMobile) + .build(); + LOG.debug("Websocket: {}", websocketUrl); + site.getHttpClient().newWebSocket(webSocketRequest, new WebSocketListener() { + @Override + public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) { + LOG.debug("onOpen"); + } + + @Override + public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) { + String body = Optional.ofNullable(response).map(Response::body).map(responseBody -> { + try { + return responseBody.string(); + } catch (IOException e) { + return ""; + } + }).orElse(""); + LOG.error("onFailure Body:[{}]", body, t); + awaitBarrier(); + webSocket.close(1000, ""); + } + + @Override + public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) { + LOG.debug("{}", new JSONObject(text).toString(2)); + webSocket.close(1000, ""); + } + + @Override + public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) { + LOG.trace("onMessageB"); + super.onMessage(webSocket, bytes); + } + + @Override + public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + LOG.debug("onClosed {} {}", code, reason); + super.onClosed(webSocket, code, reason); + } + + @Override + public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + LOG.trace("onClosing {} {}", code, reason); + awaitBarrier(); + } + }); + + LOG.debug("Waiting for websocket to return"); + awaitBarrier(); + LOG.debug("Websocket is done."); + } catch (Exception e) { + LOG.error("Couldn't start stream", e); + } + } + + private void awaitBarrier() { + try { + barrier.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error(e.getLocalizedMessage(), e); + } catch (TimeoutException | BrokenBarrierException e) { + LOG.error(e.getLocalizedMessage(), e); + } + } +} diff --git a/master/pom.xml b/master/pom.xml index 653bc316..8b01b3ff 100644 --- a/master/pom.xml +++ b/master/pom.xml @@ -156,6 +156,12 @@ jetty-rewrite [9.4.19.v20190610,9.99.99) + + org.projectlombok + lombok + 1.18.24 + true + @@ -175,6 +181,11 @@ mockito-inline test + + org.projectlombok + lombok + true +