package ctbrec.sites.jasmin; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.file.Files; import java.time.Instant; import org.json.JSONArray; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.eventbus.Subscribe; import ctbrec.Config; import ctbrec.Model; import ctbrec.event.Event; import ctbrec.event.EventBusHolder; import ctbrec.event.ModelStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.recorder.download.Download; import okhttp3.Request; import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; import okio.ByteString; public class LiveJasminWebSocketDownload implements Download { private static final transient Logger LOG = LoggerFactory.getLogger(LiveJasminWebSocketDownload.class); private String applicationId; private String sessionId; private String jsm2SessionId; private String sb_ip; private String sb_hash; private String relayHost; private String streamHost; private String clientInstanceId = "01234567890123456789012345678901"; // TODO where to get or generate a random id? private String streamPath = "streams/clonedLiveStream"; private WebSocket relay; private WebSocket stream; protected boolean connectionClosed; private volatile boolean isAlive = true; private HttpClient client; private Model model; private Instant startTime; private File targetFile; public LiveJasminWebSocketDownload(HttpClient client) { this.client = client; } @Override public void start(Model model, Config config) throws IOException { this.model = model; startTime = Instant.now(); File _targetFile = config.getFileForRecording(model); targetFile = new File(_targetFile.getAbsolutePath().replace(".ts", ".mp4")); getPerformerDetails(model.getName()); LOG.debug("appid: {}", applicationId); LOG.debug("sessionid: {}",sessionId); LOG.debug("jsm2sessionid: {}",jsm2SessionId); LOG.debug("sb_ip: {}",sb_ip); LOG.debug("sb_hash: {}",sb_hash); LOG.debug("relay host: {}",relayHost); LOG.debug("stream host: {}",streamHost); LOG.debug("clientinstanceid {}",clientInstanceId); EventBusHolder.BUS.register(this); Request request = new Request.Builder() .url("https://" + relayHost + "/") .header("Origin", "https://www.livejasmin.com") .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:63.0) Gecko/20100101 Firefox/63.0") .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(); relay = client.newWebSocket(request, new WebSocketListener() { boolean streamSocketStarted = false; @Override public void onOpen(WebSocket webSocket, Response response) { LOG.trace("relay open {}", model.getName()); sendToRelay("{\"event\":\"register\",\"applicationId\":\"" + applicationId + "\",\"connectionData\":{\"jasmin2App\":true,\"isMobileClient\":false,\"platform\":\"desktop\",\"chatID\":\"freechat\"," + "\"sessionID\":\"" + sessionId + "\"," + "\"jsm2SessionId\":\"" + jsm2SessionId + "\",\"userType\":\"user\"," + "\"performerId\":\"" + model + "\",\"clientRevision\":\"\",\"proxyIP\":\"\",\"playerVer\":\"nanoPlayerVersion: 3.10.3 appCodeName: Mozilla appName: Netscape appVersion: 5.0 (X11) platform: Linux x86_64\",\"livejasminTvmember\":false,\"newApplet\":true,\"livefeedtype\":null,\"gravityCookieId\":\"\",\"passparam\":\"\",\"brandID\":\"jasmin\",\"cobrandId\":\"\",\"subbrand\":\"livejasmin\",\"siteName\":\"LiveJasmin\",\"siteUrl\":\"https://www.livejasmin.com\"," + "\"clientInstanceId\":\"" + clientInstanceId + "\",\"armaVersion\":\"34.10.0\",\"isPassive\":false}}"); response.close(); } @Override public void onMessage(WebSocket webSocket, String text) { LOG.trace("relay <-- {} T{}", model.getName(), text); JSONObject event = new JSONObject(text); if (event.optString("event").equals("accept")) { new Thread(() -> { sendToRelay("{\"event\":\"connectSharedObject\",\"name\":\"data/chat_so\"}"); }).start(); } else if (event.optString("event").equals("updateSharedObject")) { JSONArray list = event.getJSONArray("list"); for (int i = 0; i < list.length(); i++) { JSONObject obj = list.getJSONObject(i); if (obj.optString("name").equals("streamList")) { //LOG.debug(obj.toString(2)); streamPath = getStreamPath(obj.getJSONObject("newValue")); } else if(obj.optString("name").equals("isPrivate") || obj.optString("name").equals("onPrivate") || obj.optString("name").equals("onPrivateAll") || obj.optString("name").equals("onPrivateLJ")) { if(obj.optBoolean("newValue")) { // model went private, stop recording LOG.debug("Model {} state changed to private -> stopping download", model.getName()); stop(); } } else if(obj.optString("name").equals("recommendedBandwidth") || obj.optString("name").equals("realQualityData")) { // stream quality related -> do nothing } else { LOG.debug("{} -{}", model.getName(), obj.toString()); } } if (!streamSocketStarted) { streamSocketStarted = true; sendToRelay("{\"event\":\"call\",\"funcName\":\"makeActive\",\"data\":[]}"); new Thread(() -> { try { startStreamSocket(); } catch (Exception e) { LOG.error("Couldn't start stream websocket", e); stop(); } }).start(); } } else if(event.optString("event").equals("call")) { String func = event.optString("funcName"); if (func.equals("closeConnection")) { connectionClosed = true; // System.out.println(event.get("data")); stop(); } else if (func.equals("addLine")) { // chat message -> ignore } else if (func.equals("receiveInvitation")) { // invitation to private show -> ignore } else { LOG.debug("{} -{}", model.getName(), event.toString()); } } else { if(!event.optString("event").equals("pong")) LOG.debug("{} -{}", model.getName(), event.toString()); } } private String getStreamPath(JSONObject obj) { String streamName = "streams/clonedLiveStream"; int height = 0; if(obj.has("streams")) { JSONArray streams = obj.getJSONArray("streams"); for (int i = 0; i < streams.length(); i++) { JSONObject stream = streams.getJSONObject(i); int h = stream.optInt("height"); if(h > height) { height = h; streamName = stream.getString("streamNameWithFolder"); streamName = "free/" + stream.getString("name"); } } } return streamName; } @Override public void onMessage(WebSocket webSocket, ByteString bytes) { LOG.trace("relay <-- {} B{}", model.getName(), bytes.toString()); } @Override public void onClosed(WebSocket webSocket, int code, String reason) { LOG.trace("relay closed {} {} {}", code, reason, model.getName()); stop(); } @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { if(!connectionClosed) { LOG.trace("relay failure {}", model.getName(), t); stop(); if (response != null) { response.close(); } } } }); } @Subscribe public void handleEvent(Event evt) { if(evt.getType() == Event.Type.MODEL_STATUS_CHANGED) { ModelStateChangedEvent me = (ModelStateChangedEvent) evt; if(me.getModel().equals(model) && me.getOldState() == Model.State.ONLINE) { LOG.debug("Model {} state changed to {} -> stopping download", me.getNewState(), model.getName()); stop(); } } } private void sendToRelay(String msg) { LOG.trace("relay --> {} {}", model.getName(), msg); relay.send(msg); } protected void getPerformerDetails(String name) throws IOException { String url = "https://m.livejasmin.com/en/chat-html5/" + name; Request req = new Request.Builder() .url(url) .header("User-Agent", "Mozilla/5.0 (iPhone; CPU OS 10_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.1 Mobile/14E304 Safari/605.1.15") .header("Accept", "application/json,*/*") .header("Accept-Language", "en") .header("Referer", "https://www.livejasmin.com") .header("X-Requested-With", "XMLHttpRequest") .build(); try (Response response = client.execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); JSONObject json = new JSONObject(body); // System.out.println(json.toString(2)); if (json.optBoolean("success")) { JSONObject data = json.getJSONObject("data"); JSONObject config = data.getJSONObject("config"); JSONObject armageddonConfig = config.getJSONObject("armageddonConfig"); JSONObject chatRoom = config.getJSONObject("chatRoom"); sessionId = armageddonConfig.getString("sessionid"); jsm2SessionId = armageddonConfig.getString("jsm2session"); sb_hash = chatRoom.getString("sb_hash"); sb_ip = chatRoom.getString("sb_ip"); applicationId = "memberChat/jasmin" + name + sb_hash; relayHost = "dss-relay-" + sb_ip.replace('.', '-') + ".dditscdn.com"; streamHost = "dss-live-" + sb_ip.replace('.', '-') + ".dditscdn.com"; } else { throw new IOException("Response was not successful: " + body); } } else { throw new IOException(response.code() + " - " + response.message()); } } } private void startStreamSocket() throws UnsupportedEncodingException { String rtmpUrl = "rtmp://" + sb_ip + "/" + applicationId + "?sessionId-" + sessionId + "|clientInstanceId-" + clientInstanceId; String url = "https://" + streamHost + "/stream/?url=" + URLEncoder.encode(rtmpUrl, "utf-8"); url = url += "&stream=" + URLEncoder.encode(streamPath, "utf-8") + "&cid=863621&pid=49247581854"; LOG.trace(rtmpUrl); LOG.trace(url); Request request = new Request.Builder().url(url).header("Origin", "https://www.livejasmin.com") .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:63.0) Gecko/20100101 Firefox/63.0") .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(); stream = client.newWebSocket(request, new WebSocketListener() { FileOutputStream fos; @Override public void onOpen(WebSocket webSocket, Response response) { LOG.trace("stream open {}", model.getName()); // webSocket.send("{\"event\":\"ping\"}"); // webSocket.send(""); response.close(); try { Files.createDirectories(targetFile.getParentFile().toPath()); fos = new FileOutputStream(targetFile); } catch (IOException e) { LOG.error("Couldn't create video file", e); stop(); } } @Override public void onMessage(WebSocket webSocket, String text) { LOG.trace("stream <-- {} T{}", model.getName(), text); JSONObject event = new JSONObject(text); if(event.optString("eventType").equals("onRandomAccessPoint")) { // send ping sendToRelay("{\"event\":\"ping\"}"); } } @Override public void onMessage(WebSocket webSocket, ByteString bytes) { //System.out.println("stream <-- B" + bytes.toString()); try { fos.write(bytes.toByteArray()); } catch (IOException e) { LOG.error("Couldn't write video chunk to file", e); stop(); } } @Override public void onClosed(WebSocket webSocket, int code, String reason) { LOG.trace("stream closed {} {} {}", code, reason, model.getName()); stop(); } @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { if(!connectionClosed) { LOG.trace("stream failure {}", model.getName(), t); stop(); if (response != null) { response.close(); } } } }); } @Override public void stop() { connectionClosed = true; EventBusHolder.BUS.unregister(this); isAlive = false; if (stream != null) { stream.close(1000, ""); } if (relay != null) { relay.close(1000, ""); } } @Override public boolean isAlive() { return isAlive; } @Override public File getTarget() { return targetFile; } @Override public Model getModel() { return model; } @Override public Instant getStartTime() { return startTime; } }