From 649d21ce267ef2b849e4acde5f4e6aa196d859b9 Mon Sep 17 00:00:00 2001 From: Jafea7 Date: Wed, 10 Sep 2025 21:27:30 +1000 Subject: [PATCH] Fix for SC encryption (credit @Gabi_uy) --- client/src/main/java/ctbrec/ui/Player.java | 666 +++++++++++++++--- .../ui/sites/stripchat/StripchatConfigUI.java | 13 + common/src/main/java/ctbrec/Settings.java | 1 + .../download/hls/AbstractHlsDownload.java | 75 +- .../sites/stripchat/StripchatModel.java | 152 +++- 5 files changed, 777 insertions(+), 130 deletions(-) diff --git a/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java index b3e3dbb9..8456a77c 100644 --- a/client/src/main/java/ctbrec/ui/Player.java +++ b/client/src/main/java/ctbrec/ui/Player.java @@ -1,38 +1,57 @@ package ctbrec.ui; -import com.iheartradio.m3u8.ParseException; -import com.iheartradio.m3u8.PlaylistException; -import ctbrec.*; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.OS; +import ctbrec.Recording; +import ctbrec.StringUtil; import ctbrec.event.EventBusHolder; +import ctbrec.io.HttpClient; import ctbrec.io.StreamRedirector; import ctbrec.io.UrlUtil; import ctbrec.recorder.download.StreamSource; -import ctbrec.recorder.download.hls.NoStreamFoundException; +import ctbrec.sites.Site; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.event.PlayerStartedEvent; import ctbrec.variableexpansion.ModelVariableExpander; -import javafx.scene.Scene; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -import javax.xml.bind.JAXBException; import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.io.UnsupportedEncodingException; -import java.net.MalformedURLException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import javafx.scene.Scene; +import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; @Slf4j public class Player { private static PlayerThread playerThread; - private static Scene scene; + private static volatile Scene appScene; + + public static void setScene(Scene scene) { + appScene = scene; + } private Player() { } @@ -56,6 +75,46 @@ public class Player { return play(model, true); } + // Precompile VR suffix regex + private static final Pattern VR_SUFFIX = Pattern.compile("(_vr)$"); + + // Map of host replacements for non-VR streams + private static final Map HOST_REPLACEMENTS = Map.of( + "media-hls.doppiocdn.com", "media-hls.saawsedge.com" + ); + + /** + * Rewrites non-VR doppiocdn.com URLs: + * - swaps host + * - strips _vr from directory if present + * - forces _480p resolution + */ + private static String rewriteNonVrStripchatUrl(String url) { + try { + URI u = URI.create(url); + String host = u.getHost(); + + if (host != null && host.contains("doppiocdn.com")) { + String newHost = HOST_REPLACEMENTS.getOrDefault(host, host); + + // Split the path: /b-hls-02/89673378/89673378_720p60.m3u8 + String[] parts = u.getPath().split("/"); + if (parts.length >= 4) { + String dir1 = parts[1]; // e.g., b-hls-02 + String dir2 = VR_SUFFIX.matcher(parts[2]).replaceAll(""); // strip _vr if present + String newFile = dir2 + "_480p.m3u8"; // force 480p + + String rewritten = String.format("https://%s/%s/%s/%s", newHost, dir1, dir2, newFile); + log.trace("Rewrote non-VR Stripchat URL: {} -> {}", url, rewritten); + return rewritten; + } + } + } catch (Exception ex) { + log.warn("Failed to rewrite Stripchat URL {}: {}", url, ex.toString()); + } + return url; + } + public static boolean play(Model model, boolean async) { try { if (model.isOnline(true)) { @@ -75,18 +134,17 @@ public class Player { playerThread.join(); } return true; - } else { - Dialogs.showError(scene, "Room not public", "Room is currently not public", null); - return false; } + Dialogs.showError(null, "Room not public", "Room is currently not public", null); + return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("Couldn't get stream information for model {}", model, e); - Dialogs.showError(scene, "Couldn't determine stream URL", e.getLocalizedMessage(), e); + Dialogs.showError(null, "Couldn't determine stream URL", e.getLocalizedMessage(), e); return false; } catch (Exception e) { log.error("Couldn't get stream information for model {}", model, e); - Dialogs.showError(scene, "Couldn't determine stream URL", e.getLocalizedMessage(), e); + Dialogs.showError(null, "Couldn't determine stream URL", e.getLocalizedMessage(), e); return false; } } @@ -97,12 +155,54 @@ public class Player { } } + private static String normalizeStripchatMouflon(String playlistUrl, String body) { + if (body == null || !body.contains("#EXT-X-MOUFLON:FILE")) { + return body; + } + String KEY_PRIMARY = Config.getInstance().getSettings().stripchatDecrypt; + String KEY_FALLBACK = "Zokee2OhPh9kugh4"; + String[] lines = body.split("\n"); + for (int i = 0; i < lines.length; ++i) { + String urlLine; + int j; + String line = lines[i].trim(); + if (!line.startsWith("#EXT-X-MOUFLON:FILE:")) continue; + String enc = line.substring("#EXT-X-MOUFLON:FILE:".length()).trim(); + String name = Player.decryptMouflon(enc, KEY_PRIMARY); + if (name == null || name.isEmpty()) { + name = Player.decryptMouflon(enc, KEY_FALLBACK); + } + if (name == null || name.isEmpty() || (j = i + 1) >= lines.length || !(urlLine = (lines[j]).trim()).endsWith("media.mp4") && !urlLine.equals("media.mp4")) continue; + int k = urlLine.lastIndexOf(47); + String replaced = k >= 0 ? urlLine.substring(0, k + 1) + name : name; + // log.debug("FIX>>> Mouflon FILE {} -> {}", (Object)enc, (Object)name); + lines[j] = replaced; + } + return String.join((CharSequence)"\n", lines); + } + + private static String decryptMouflon(String encB64, String key) { + try { + byte[] enc = Base64.getDecoder().decode(encB64); + byte[] hash = MessageDigest.getInstance("SHA-256").digest(key.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[enc.length]; + for (int i = 0; i < enc.length; ++i) { + out[i] = (byte)((enc[i] ^ hash[i % hash.length]) & 0xFF); + } + return new String(out, StandardCharsets.UTF_8); + } + catch (Throwable t) { + log.debug("Mouflon decrypt fail: {}", (Object)t.toString()); + return null; + } + } + private static class PlayerThread extends Thread { - @Getter - private boolean running = false; + private volatile boolean running = false; private Process playerProcess; private Recording rec; private Model model; + private LocalHlsProxy proxy; PlayerThread(Model model) { this.model = model; @@ -120,29 +220,38 @@ public class Player { @Override public void run() { running = true; - Runtime rt = Runtime.getRuntime(); - Config cfg = Config.getInstance(); try { - if (cfg.getSettings().localRecording && rec != null) { + if (Config.getInstance().getSettings().localRecording && rec != null) { File file = rec.getAbsoluteFile(); - String[] cmdline = createCmdline(file.getAbsolutePath(), model); - playerProcess = rt.exec(cmdline, OS.getEnvironment(), file.getParentFile()); + Object[] cmdline = createCmdline(file.getAbsolutePath(), model); + log.debug("Player command line (local): {}", (Object)Arrays.toString(cmdline)); + playerProcess = Runtime.getRuntime().exec((String[])cmdline, OS.getEnvironment(), file.getParentFile()); } else { - String url = null; + String upstreamUrl; if (rec != null) { - url = getRemoteRecordingUrl(rec, cfg); + upstreamUrl = getRemoteRecordingUrl(rec, Config.getInstance()); model = rec.getModel(); } else if (model != null) { - url = getPlaylistUrl(model); + upstreamUrl = PlayerThread.getPlaylistUrl(model); + } else { + throw new IllegalStateException("No model/recording to play"); } - log.debug("Playing {}", url); - String[] cmdline = createCmdline(url, model); - log.debug("Player command line: {}", Arrays.toString(cmdline)); - playerProcess = rt.exec(cmdline); - } - // create threads, which read stdout and stderr of the player process. these are needed, - // because otherwise the internal buffer for these streams fill up and block the process + String mediaUrlForPlayer = upstreamUrl; +// if (isVrStripchatStream(upstreamUrl)) { + // VR stream -> use proxy + proxy = new LocalHlsProxy(model, upstreamUrl); + proxy.start(); + mediaUrlForPlayer = proxy.localUrl(); +// } else if (upstreamUrl.contains("doppiocdn.com")) { + // Non-VR -> rewrite host + resolution +// mediaUrlForPlayer = rewriteNonVrStripchatUrl(upstreamUrl); +// } + + Object[] cmdline = createCmdline(mediaUrlForPlayer, model); + log.debug("Player command line: {}", (Object)Arrays.toString(cmdline)); + playerProcess = robustSpawn((String[])cmdline); + } Thread std = new Thread(new StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream())); std.setName("Player stdout pipe"); std.setDaemon(true); @@ -151,90 +260,467 @@ public class Player { err.setName("Player stderr pipe"); err.setDaemon(true); err.start(); - playerProcess.waitFor(); log.debug("Media player finished."); - } catch (InterruptedException e) { + } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - log.error("Error in player thread", e); - Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e); + log.error("Player thread interrupted", (Throwable)ie); + Dialogs.showError(null, "Playback interrupted", ie.getLocalizedMessage(), ie); } catch (Exception e) { - log.error("Error in player thread", e); - Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e); + log.error("Error in player thread", (Throwable)e); + Dialogs.showError(null, "Playback failed", "Couldn't start playback", e); + } finally { + running = false; + if (proxy != null) { + try { + proxy.stop(); + } + catch (Throwable t) { + log.debug("Proxy stop error", t); + } + proxy = null; + } + if (playerProcess != null) { + try { + playerProcess.destroy(); + } + catch (Throwable t) {} + playerProcess = null; + } } - running = false; } - private static String getPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { - List sources = model.getStreamSources(); + public void stopThread() { + running = false; + try { + if (playerProcess != null) { + playerProcess.destroy(); + } + } catch (Throwable t) { + } finally { + playerProcess = null; + try { + interrupt(); + } catch (Throwable throwable) {} + if (proxy != null) { + try { + proxy.stop(); + } catch (Throwable t) { + log.debug("Proxy stop error", t); + } + proxy = null; + } + } + } + + /** + * Returns true if the URL is a VR doppiocdn.com stream (.m3u8 + _vr) + */ + private boolean isVrStripchatStream(String url) { + try { + URI u = URI.create(url); + String host = u.getHost(); + String path = u.getPath().toLowerCase(Locale.ROOT); + + return host != null + && host.contains("doppiocdn.com") + && path.endsWith(".m3u8") + && path.contains("_vr"); + } catch (Throwable ignore) { + return false; + } + } + + private boolean looksLikeStripchatM3u8(String url) { + try { + URL u = new URL(url); + String host = u.getHost(); + String path = u.getPath().toLowerCase(Locale.ROOT); + // Only proxy if it's doppiocdn.com AND it's a VR stream (.m3u8 ending + "_vr" somewhere) + return host != null + && host.contains("doppiocdn.com") + && path.endsWith(".m3u8") + && path.contains("_vr"); + } catch (Throwable ignore) { + return false; + } + } + + private static String getPlaylistUrl(Model model) throws IOException, ExecutionException { + List sources; + try { + sources = model.getStreamSources(); + } catch (Throwable e) { + throw new IOException("Failed to get stream sources from model", e); + } Collections.sort(sources); - StreamSource best; int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer; if (maxRes > 0 && !sources.isEmpty()) { - for (Iterator iterator = sources.iterator(); iterator.hasNext(); ) { - StreamSource streamSource = iterator.next(); - if (streamSource.getHeight() > 0 && maxRes < streamSource.getHeight()) { - log.trace("Res too high {} > {}", streamSource.getHeight(), maxRes); - iterator.remove(); - } + Iterator it = sources.iterator(); + while (it.hasNext()) { + StreamSource s = (StreamSource)it.next(); + if (s.getHeight() <= 0 || maxRes >= s.getHeight()) continue; + it.remove(); } } if (sources.isEmpty()) { - throw new NoStreamFoundException("No stream left in playlist, because player resolution is set to " + maxRes); - } else { - log.debug("{} selected {}", model.getName(), sources.get(sources.size() - 1)); - best = sources.get(sources.size() - 1); - } - return best.getMediaPlaylistUrl(); - } - - private void expandPlaceHolders(String[] cmdline) { - ModelVariableExpander expander = new ModelVariableExpander(model, CamrecApplication.modelNotesService, null, null); - for (int i = 1; i < cmdline.length; i++) { - var param = cmdline[i]; - param = expander.expand(param); - cmdline[i] = param; + throw new IOException("No stream left in playlist, because player resolution is set to " + maxRes); } + StreamSource best = (StreamSource)sources.get(sources.size() - 1); + String url = best.getMediaPlaylistUrl(); + log.debug("{} selected {}", (Object)model.getName(), (Object)String.valueOf(best)); + return url; } private String[] createCmdline(String mediaSource, Model model) { + boolean isVlc; Config cfg = Config.getInstance(); - String params = cfg.getSettings().mediaPlayerParams.trim(); - - String[] cmdline; - if (params.isEmpty()) { - cmdline = new String[2]; - } else { - String[] playerArgs = StringUtil.splitParams(params); - cmdline = new String[playerArgs.length + 2]; - System.arraycopy(playerArgs, 0, cmdline, 1, playerArgs.length); + String params = Optional.ofNullable(cfg.getSettings().mediaPlayerParams).orElse("").trim(); + ArrayList cmd = new ArrayList(); + String playerPath = cfg.getSettings().mediaPlayer; + cmd.add(playerPath); + if (!params.isEmpty()) { + String[] playerArgs = StringUtil.splitParams((String)params); + Collections.addAll(cmd, playerArgs); } - cmdline[0] = cfg.getSettings().mediaPlayer; - cmdline[cmdline.length - 1] = mediaSource; - if (model != null) { - expandPlaceHolders(cmdline); + boolean isLocalProxy = mediaSource != null && mediaSource.startsWith("http://127.0.0.1:"); + boolean isMpv = playerPath != null && playerPath.toLowerCase(Locale.ROOT).contains("mpv"); + boolean bl = isVlc = playerPath != null && playerPath.toLowerCase(Locale.ROOT).contains("vlc"); + if (!isLocalProxy) { + String ref; + String ua = cfg.getSettings().httpUserAgent; + if (ua != null && !ua.isEmpty()) { + if (isMpv) { + cmd.add("--user-agent=" + ua); + } else if (isVlc) { + cmd.add("--http-user-agent=" + ua); + } + } + String string = ref = model != null && model.getUrl() != null ? model.getUrl() : "https://stripchat.com/"; + if (isMpv) { + cmd.add("--referrer=" + ref); + } else if (isVlc) { + cmd.add("--http-referrer=" + ref); + } } + cmd.add(mediaSource); + String[] cmdline = cmd.toArray(new String[0]); + expandPlaceHolders(cmdline, model); return cmdline; } - private String getRemoteRecordingUrl(Recording rec, Config cfg) - throws MalformedURLException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + private void expandPlaceHolders(String[] cmdline, Model model) { + try { + ModelVariableExpander expander = new ModelVariableExpander(model, null, null, null); + for (int i = 1; i < cmdline.length; ++i) { + cmdline[i] = expander.expand(cmdline[i]); + } + } + catch (Throwable t) { + log.warn("Placeholder expansion failed (non-fatal): {}", (Object)t.toString()); + } + } + + private Process robustSpawn(String[] cmdline) throws IOException { + ArrayList cmd = new ArrayList(cmdline.length); + Collections.addAll(cmd, cmdline); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + try { + Process p = pb.start(); + if (aliveAfter(p, 400)) { + return p; + } + warnDied(1, p); + } + catch (IOException ioe) { + log.warn("Spawn attempt #1 failed", (Throwable)ioe); + } + File exe = new File((String)cmd.get(0)); + File dir = exe.getParentFile(); + try { + if (dir != null && dir.isDirectory()) { + ProcessBuilder pb2 = new ProcessBuilder(cmd).directory(dir); + pb2.redirectErrorStream(true); + Process p = pb2.start(); + if (aliveAfter(p, 400)) { + return p; + } + this.warnDied(2, p); + } + } + catch (IOException ioe) { + log.warn("Spawn attempt #2 failed", (Throwable)ioe); + } + try { + Process p; + Process process = p = dir != null && dir.isDirectory() ? Runtime.getRuntime().exec(cmdline, OS.getEnvironment(), dir) : Runtime.getRuntime().exec(cmdline, OS.getEnvironment()); + if (aliveAfter(p, 400)) { + return p; + } + warnDied(3, p); + } + catch (IOException ioe) { + log.warn("Spawn attempt #3 failed", (Throwable)ioe); + } + throw new IOException("Player spawn failed (all attempts)"); + } + + private boolean aliveAfter(Process p, int ms) { + try { + Thread.sleep(ms); + } + catch (InterruptedException interruptedException) { + // empty catch block + } + return p != null && p.isAlive(); + } + + private void warnDied(int attempt, Process p) { + try { + log.warn("Attempt #{} died immediately{}", (Object)attempt, p != null ? " (exit=" + p.exitValue() + ")" : ""); + } + catch (Throwable throwable) { + // empty catch block + } + } + + private String getRemoteRecordingUrl(Recording rec, Config cfg) { String hlsBase = Config.getInstance().getServerUrl() + "/hls"; - String recUrl = hlsBase + '/' + rec.getId() + (rec.isSingleFile() ? "" : "/playlist.m3u8"); + String recUrl = hlsBase + "/" + rec.getId() + (rec.isSingleFile() ? "" : "/playlist.m3u8"); if (cfg.getSettings().requireAuthentication) { - recUrl = UrlUtil.addHmac(recUrl, cfg); + try { + recUrl = UrlUtil.addHmac((String)recUrl, (Config)cfg); + } + catch (Throwable t) { + log.warn("Failed to add HMAC to recording URL: {}", (Object)t.toString()); + } } return recUrl; } - public void stopThread() { - if (playerProcess != null) { - playerProcess.destroy(); + public boolean isRunning() { + return running; + } + + private static class LocalHlsProxy { + private final Model model; + private final String upstreamM3u8; + private HttpServer server; + private String localUrl; + private static final String UA_FALLBACK = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0"; + + LocalHlsProxy(Model model, String upstreamM3u8) { + this.model = model; + this.upstreamM3u8 = upstreamM3u8; + } + + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/p", new ProxyHandler()); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + String u = base64Url(upstreamM3u8); + String r = base64Url(model != null && model.getUrl() != null ? model.getUrl() : "https://stripchat.com/"); + localUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/p?u=" + u + "&r=" + r; + } + + String localUrl() { + return localUrl; + } + + void stop() { + if (server != null) { + server.stop(0); + server = null; + } + } + + private String findQueryParam(String query, String key) { + if (query == null) { + return null; + } + for (String part : query.split("&")) { + String v; + int i = part.indexOf(61); + String k = i >= 0 ? part.substring(0, i) : part; + String string = v = i >= 0 ? part.substring(i + 1) : ""; + if (!k.equals(key)) continue; + return v; + } + return null; + } + + private String base64Url(String s) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(s.getBytes(StandardCharsets.UTF_8)); + } + + private HttpClient resolveHttpClient(Model model) { + try { + Site site = model.getSite(); + Method m = site.getClass().getMethod("getHttpClient", new Class[0]); + Object hc = m.invoke((Object)site, new Object[0]); + if (hc instanceof HttpClient) { + return (HttpClient)hc; + } + } + catch (Throwable t) { + log.warn("Cannot resolve HttpClient from site", t); + } + return null; + } + + private String buildAcceptLanguage() { + try { + Locale loc = Locale.getDefault(); + String lang = loc.getLanguage(); + String country = loc.getCountry(); + String primary = lang != null && country != null && !country.isEmpty() ? lang + "-" + country : (lang != null ? lang : "en-US"); + return primary + ",es;q=0.8,en-US;q=0.5,en;q=0.3"; + } + catch (Throwable t) { + return "en-US,en;q=0.9"; + } + } + + private String originFrom(String referer) { + try { + URL u = new URL(referer); + return u.getProtocol() + "://" + u.getHost(); + } + catch (Throwable t) { + return "https://stripchat.com"; + } + } + + private class ProxyHandler + implements HttpHandler { + private ProxyHandler() { + } + + /* + * WARNING - Removed try catching itself - possible behaviour change. + * Enabled aggressive block sorting + * Enabled unnecessary exception pruning + * Enabled aggressive exception aggregation + */ + @Override + public void handle(HttpExchange ex) throws IOException { + String query = ex.getRequestURI().getRawQuery(); + String uParam = LocalHlsProxy.this.findQueryParam(query, "u"); + String rParam = LocalHlsProxy.this.findQueryParam(query, "r"); + if (uParam == null || uParam.isEmpty()) { + writeError(ex, 400, "Missing u"); + return; + } + String upstream = new String(Base64.getUrlDecoder().decode(uParam), StandardCharsets.UTF_8); + String ref = rParam != null && !rParam.isEmpty() ? new String(Base64.getUrlDecoder().decode(rParam), StandardCharsets.UTF_8) : "https://stripchat.com/"; + boolean isPlaylist = upstream.toLowerCase(Locale.ROOT).contains(".m3u8"); + HttpClient http = LocalHlsProxy.this.resolveHttpClient(LocalHlsProxy.this.model); + if (http == null) { + writeError(ex, 500, "No http client"); + return; + } + String ua = Optional.ofNullable(Config.getInstance().getSettings().httpUserAgent).filter(s -> !s.isEmpty()).orElse(LocalHlsProxy.UA_FALLBACK); + String acceptLang = LocalHlsProxy.this.buildAcceptLanguage(); + HttpUrl url = HttpUrl.parse((String)upstream); + String host = url != null ? url.host() : "unknown"; + Request.Builder rb = new Request.Builder().url(upstream).header("User-Agent", ua).header("Accept", "*/*").header("Accept-Encoding", "identity").header("Accept-Language", acceptLang).header("Connection", "keep-alive").header("Sec-Fetch-Mode", "cors").header("Sec-Fetch-Site", "cross-site").header("Sec-Fetch-Dest", isPlaylist ? "empty" : "video"); + rb.header("Referer", ref); + rb.header("Origin", LocalHlsProxy.this.originFrom(ref)); + log.debug("Proxy -> {} isPlaylist={} UA=true Referer={} CookiePresent=true", new Object[]{host, isPlaylist, ref}); + Response resp = null; + try { + resp = http.execute(rb.build()); + int code = resp.code(); + byte[] body = resp.body() != null ? resp.body().bytes() : new byte[]{}; + String ct = resp.header("Content-Type", isPlaylist ? "application/vnd.apple.mpegurl" : "application/octet-stream"); + log.debug("Proxy <- {} HTTP={} CT={} bytes={}", new Object[]{host, code, ct, body.length}); + if (code == 200 || code == 206) { + if (isPlaylist && upstream.contains("doppiocdn.com")) { + String text = new String(body, StandardCharsets.UTF_8); + text = Player.normalizeStripchatMouflon(upstream, text); + byte[] out = text.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/vnd.apple.mpegurl"); + ex.sendResponseHeaders(200, out.length); + ex.getResponseBody().write(out); + ex.close(); + return; + } + writeBytes(ex, code, ct, body); + return; + } + if (resp != null) { + try { + resp.close(); + } + catch (Throwable text) { + // empty catch block + } + } + Request retry = rb.header("Referer", "https://stripchat.com/").header("Origin", "https://stripchat.com").build(); + log.debug("Proxy RETRY -> {} Referer=https://stripchat.com/", (Object)host); + resp = http.execute(retry); + code = resp.code(); + body = resp.body() != null ? resp.body().bytes() : new byte[]{}; + ct = resp.header("Content-Type", isPlaylist ? "application/vnd.apple.mpegurl" : "application/octet-stream"); + log.debug("Proxy RETRY <- {} HTTP={} CT={} bytes={}", new Object[]{host, code, ct, body.length}); + if (code == 200 || code == 206) { + if (isPlaylist && upstream.contains("doppiocdn.com")) { + String text = new String(body, StandardCharsets.UTF_8); + text = Player.normalizeStripchatMouflon(upstream, text); + byte[] out = text.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/vnd.apple.mpegurl"); + ex.sendResponseHeaders(200, out.length); + ex.getResponseBody().write(out); + ex.close(); + return; + } + writeBytes(ex, code, ct, body); + return; + } + writeBytes(ex, code, ct, body); + return; + } + catch (Throwable t) { + log.warn("Proxy error", t); + try { + writeError(ex, 502, "Proxy error: " + t.getMessage()); + return; + } + catch (IOException iOException) { + // empty catch block + return; + } + } + finally { + if (resp != null) { + try { + resp.close(); + } + catch (Throwable throwable) {} + } + } + } + + private void writeBytes(HttpExchange ex, int code, String ct, byte[] body) throws IOException { + ex.getResponseHeaders().set("Content-Type", ct != null ? ct : "application/octet-stream"); + ex.sendResponseHeaders(code, body != null ? (long)body.length : 0L); + if (body != null && body.length > 0) { + ex.getResponseBody().write(body); + } + ex.close(); + } + + private void writeError(HttpExchange ex, int code, String msg) throws IOException { + byte[] b = msg.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + ex.sendResponseHeaders(code, b.length); + ex.getResponseBody().write(b); + ex.close(); + } } } } - - public static void setScene(Scene scene) { - Player.scene = scene; - } } + diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java index 44018ee7..8ad754a9 100644 --- a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java @@ -89,6 +89,19 @@ public class StripchatConfigUI extends AbstractConfigUI { GridPane.setMargin(hbox, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); layout.add(hbox, 1, row++); + layout.add(new Label("Decryption key"), 0, row); + var decrypt_key = new TextField(Config.getInstance().getSettings().stripchatDecrypt); + decrypt_key.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().stripchatDecrypt)) { + Config.getInstance().getSettings().stripchatDecrypt = decrypt_key.getText(); + save(); + } + }); + GridPane.setFillWidth(decrypt_key, true); + GridPane.setHgrow(decrypt_key, Priority.ALWAYS); + GridPane.setColumnSpan(decrypt_key, 2); + layout.add(decrypt_key, 1, row++); + layout.add(new Label("Stripchat User"), 0, row); var username = new TextField(Config.getInstance().getSettings().stripchatUsername); username.textProperty().addListener((ob, o, n) -> { diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index a491cf0a..53a6566f 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -188,6 +188,7 @@ public class Settings { public String streamateUsername = ""; public List streamateTabs = new ArrayList<>(Arrays.asList("f,ff")); public List streamrayTabs = new ArrayList<>(Arrays.asList("F")); + public String stripchatDecrypt = "Quean4cai9boJa5a"; public String stripchatUsername = ""; public String stripchatPassword = ""; public List stripchatTabs = new ArrayList<>(Arrays.asList("girls")); diff --git a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java index 49605c10..824a5451 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java @@ -19,6 +19,7 @@ import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.hls.SegmentPlaylist.Segment; import ctbrec.sites.Site; +import ctbrec.sites.stripchat.StripchatModel; import lombok.extern.slf4j.Slf4j; import okhttp3.Request; import okhttp3.Request.Builder; @@ -40,6 +41,7 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.Map.Entry; +import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.ExecutorService; @@ -273,36 +275,38 @@ public abstract class AbstractHlsDownload extends AbstractDownload { Instant start = Instant.now(); recordingEvents.add(RecordingEvent.of("Playlist request")); URL segmentsUrl = URI.create(segmentPlaylistUrl).toURL(); - Builder builder = new Request.Builder().url(segmentsUrl); - addHeaders(builder, Optional.ofNullable(model).map(Model::getHttpHeaderFactory).map(HttpHeaderFactory::createSegmentPlaylistHeaders).orElse(new HashMap<>()), model); + Request.Builder builder = new Request.Builder().url(segmentsUrl); + // project-accurate headers: + addHeaders(builder, model.getHttpHeaderFactory().createSegmentPlaylistHeaders(), model); Request request = builder.build(); - try (Response response = client.execute(request, config.getSettings().playlistRequestTimeout)) { if (response.isSuccessful()) { consecutivePlaylistTimeouts = 0; String body = Objects.requireNonNull(response.body()).string(); + // Stripchat: normalize Mouflon FILE -> real segment names + body = normalizeStripchatMouflon(response.request().url().toString(), body); if (!body.contains("#EXTINF")) { - // no segments, empty playlist return new SegmentPlaylist(segmentPlaylistUrl); } byte[] bytes = body.getBytes(UTF_8); BandwidthMeter.add(bytes.length); - InputStream inputStream = new ByteArrayInputStream(bytes); - SegmentPlaylist playList = parsePlaylist(response.request().url().toString(), inputStream); - consecutivePlaylistErrors = 0; - recordingEvents.add(RecordingEvent.of("Sequence: " + StringUtil.grep(body, "MEDIA-SEQUENCE"))); - recordingEvents.add(RecordingEvent.of("Playlist downloaded in " + (Duration.between(start, Instant.now()).toMillis()) + "ms: " - + StringUtil.grep(body, "X-PROGRAM-DATE-TIME"))); - return playList; + try (InputStream inputStream = new ByteArrayInputStream(bytes)) { + SegmentPlaylist playList = parsePlaylist(response.request().url().toString(), inputStream); + consecutivePlaylistErrors = 0; + return playList; + } } else { recordingEvents.add(RecordingEvent.of("HTTP code " + response.code())); throw new HttpException(response.code(), response.message()); } } catch (SocketTimeoutException e) { - log.debug("Playlist request timed out ({}ms) for model {} {} time{}", config.getSettings().playlistRequestTimeout, model, - ++consecutivePlaylistTimeouts, (consecutivePlaylistTimeouts > 1) ? 's' : ""); - // times out, return an empty playlist, so that the process can continue without wasting much more time - recordingEvents.add(RecordingEvent.of("Playlist request timed out " + consecutivePlaylistTimeouts)); + log.debug("Playlist request timed out ({}ms) for model {}:{} {} time{} (took {}ms)", + config.getSettings().playlistRequestTimeout, + model.getSite().getName(), + model, + ++consecutivePlaylistTimeouts, + (consecutivePlaylistTimeouts > 1) ? 's' : "", + (Duration.between(start, Instant.now()).toMillis())); throw new PlaylistTimeoutException(e); } catch (Exception e) { consecutivePlaylistErrors++; @@ -310,6 +314,47 @@ public abstract class AbstractHlsDownload extends AbstractDownload { } } + // Decode "#EXT-X-MOUFLON:FILE:" and replace the next "media.mp4" line with the real filename. + private String normalizeStripchatMouflon(String playlistUrl, String body) { + if (body == null || !body.contains("#EXT-X-MOUFLON:FILE")) return body; + final String KEY_PRIMARY = Config.getInstance().getSettings().stripchatDecrypt; // from XhRec (Session/Decrypter) + final String KEY_FALLBACK = "Zokee2OhPh9kugh4"; // fallback used there as well + java.util.function.BiFunction decrypt = (encB64, key) -> { + try { + byte[] enc = java.util.Base64.getDecoder().decode(encB64); + byte[] hash = java.security.MessageDigest.getInstance("SHA-256") + .digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] out = new byte[enc.length]; + for (int i = 0; i < enc.length; i++) out[i] = (byte) (enc[i] ^ hash[i % hash.length]); + return new String(out, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + return null; + } + }; + String[] lines = body.split("\n"); + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + if (line.startsWith("#EXT-X-MOUFLON:FILE:")) { + String enc = line.substring("#EXT-X-MOUFLON:FILE:".length()).trim(); + String name = decrypt.apply(enc, KEY_PRIMARY); + if (name == null || name.isEmpty()) name = decrypt.apply(enc, KEY_FALLBACK); + if (name != null && !name.isEmpty()) { + int j = i + 1; + if (j < lines.length) { + String urlLine = lines[j].trim(); + if (urlLine.endsWith("media.mp4") || urlLine.equals("media.mp4")) { + int k = urlLine.lastIndexOf('/'); + String replaced = (k >= 0) ? (urlLine.substring(0, k + 1) + name) : name; + // log.debug("FIX>>> Mouflon FILE {} -> {}", enc, name); + lines[j] = replaced; + } + } + } + } + } + return String.join("\n", lines); + } + private SegmentPlaylist parsePlaylist(String segmentPlaylistUrl, InputStream inputStream) throws IOException, ParseException, PlaylistException { PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); diff --git a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java index c1df6c86..054104ca 100644 --- a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java +++ b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java @@ -22,10 +22,12 @@ import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.security.MessageDigest; import java.text.MessageFormat; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -43,6 +45,9 @@ public class StripchatModel extends AbstractModel { private transient JSONObject modelInfo; private transient Instant lastInfoRequest = Instant.EPOCH; + // Mouflon pkey taken from master (#EXT-X-MOUFLON:PSCH:v1:) + private volatile String mouflonPKey = null; + @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { if (ignoreCache) { @@ -160,40 +165,95 @@ public class StripchatModel extends AbstractModel { private List extractStreamSources(MasterPlaylist masterPlaylist) { List sources = new ArrayList<>(); for (PlaylistData playlist : masterPlaylist.getPlaylists()) { - if (playlist.hasStreamInfo()) { + if (!playlist.hasStreamInfo()) continue; StreamSource src = new StreamSource(); - src.setBandwidth(playlist.getStreamInfo().getBandwidth()); - src.setHeight(playlist.getStreamInfo().getResolution().height); - src.setWidth(playlist.getStreamInfo().getResolution().width); - src.setMediaPlaylistUrl(playlist.getUri()); - if (src.getMediaPlaylistUrl().contains("?")) { - src.setMediaPlaylistUrl(src.getMediaPlaylistUrl().substring(0, src.getMediaPlaylistUrl().lastIndexOf('?'))); + if (playlist.getStreamInfo().getResolution() != null) { + src.setHeight(playlist.getStreamInfo().getResolution().height); + src.setWidth(playlist.getStreamInfo().getResolution().width); } - log.trace("Media playlist {}", src.getMediaPlaylistUrl()); - sources.add(src); + src.setBandwidth(playlist.getStreamInfo().getBandwidth()); + // IMPORTANT: never strip the query from the media playlist URL + String media = playlist.getUri(); + final String PT = "playlistType="; + final String STD = "standard"; + final String PSCH = "psch="; + final String PSCH_V = "v1"; + final String PKEY = "pkey="; + StringBuilder sb = new StringBuilder(media); + boolean hasQuery = media.contains("?"); + + // Ensure playlistType=standard + if (media.contains("playlistType=lowLatency") || + media.contains("playlistType=Standart") || media.contains("playlistType=auto")) { + int idx = sb.indexOf("playlistType="); + if (idx >= 0) { + int end = sb.indexOf("&", idx); + if (end < 0) end = sb.length(); + sb.replace(idx, end, "playlistType=" + STD); + } + } else if (!media.contains(PT)) { + sb.append(hasQuery ? "&" : "?").append(PT).append(STD); + hasQuery = true; } + // Ensure psch and pkey are present + if (!media.contains(PSCH)) { + sb.append(hasQuery ? "&" : "?").append(PSCH).append(PSCH_V); + hasQuery = true; + } + if (!media.contains(PKEY) && mouflonPKey != null && !mouflonPKey.isEmpty()) { + sb.append(hasQuery ? "&" : "?").append(PKEY).append(mouflonPKey); + } + String finalUrl = sb.toString(); + src.setMediaPlaylistUrl(finalUrl); + // log.debug("FIX>>> Media playlist {}", finalUrl); + sources.add(src); } return sources; } private MasterPlaylist getMasterPlaylist(String url) throws IOException, ParseException, PlaylistException { + // Force standard (avoid LL-HLS playlists with parts) + if (url.contains("playlistType=")) { + url = url + .replace("playlistType=lowLatency", "playlistType=standard") + .replace("playlistType=Standart", "playlistType=standard") + .replace("playlistType=auto", "playlistType=standard"); + } else { + url = url + (url.contains("?") ? "&" : "?") + "playlistType=standard"; + } log.trace("Loading master playlist {}", url); Request req = new Request.Builder() - .url(url) - .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) - .build(); + .url(url) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(REFERER, getUrl()) // https://stripchat.com/ + .header(ORIGIN, getSite().getBaseUrl()) // https://stripchat.com + .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 { + if (!response.isSuccessful()) { throw new HttpException(response.code(), response.message()); } + String body = response.body().string(); + // Extract Mouflon pkey, e.g. "#EXT-X-MOUFLON:PSCH:v1:" + for (String line : body.split("\n")) { + if (line.startsWith("#EXT-X-MOUFLON") && line.contains("PSCH")) { + String pk = line.substring(line.lastIndexOf(':') + 1).trim(); + if (!pk.isEmpty()) { + mouflonPKey = pk; + log.debug("MOUFLON pkey from master: {}", mouflonPKey); + } + break; + } + } + + log.trace(body); + try (InputStream in = new ByteArrayInputStream(body.getBytes(UTF_8))) { + PlaylistParser parser = new PlaylistParser(in, Format.EXT_M3U, Encoding.UTF_8, + ParsingMode.LENIENT); + Playlist playlist = parser.parse(); + return playlist.getMasterPlaylist(); + } } } @@ -215,15 +275,57 @@ public class StripchatModel extends AbstractModel { log.debug("Spy start for {}", getName()); } } -// String hlsUrlTemplate = "https://edge-hls.doppiocdn.com/hls/{0}{1}/master/{0}{1}_auto.m3u8?playlistType=Standart{2}"; - String hlsUrlTemplate = "https://edge-hls.saawsedge.com/hls/{0}{1}/master/{0}{1}.m3u8"; -// return MessageFormat.format(hlsUrlTemplate, String.valueOf(id), vrSuffix, token); - return MessageFormat.format(hlsUrlTemplate, String.valueOf(id), vrSuffix); + + String hlsUrlTemplate = "https://edge-hls.doppiocdn.com/hls/{0}{1}/master/{0}{1}_auto.m3u8?playlistType=Standart{2}"; + return MessageFormat.format(hlsUrlTemplate, String.valueOf(id), vrSuffix, token); } else { throw new IOException("Playlist URL not found"); } } + // Java equivalent of Python m3u_decoder + public static String m3uDecoder(String content) { + List decodedLines = new ArrayList<>(); + String[] lines = content.split("\\r?\\n"); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.startsWith("#EXT-X-MOUFLON:FILE:")) { + String encrypted = line.substring(20); + try { + String dec = decodeMouflon(encrypted, Config.getInstance().getSettings().stripchatDecrypt); + if (i + 1 < lines.length) { + lines[i + 1] = lines[i + 1].replace("media.mp4", dec); + } + } catch (Exception e) { + log.error("Failed to decode Mouflon line", e); + } + } + decodedLines.add(line); + } + return String.join("\n", decodedLines); + } + + private static String decodeMouflon(String encryptedB64, String key) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(key.getBytes(UTF_8)); + + // Normalize base64 like Python version + encryptedB64 = encryptedB64.trim().replace("-", "+").replace("_", "/"); + int padding = (4 - (encryptedB64.length() % 4)) % 4; + if (padding > 0) { + encryptedB64 += "=".repeat(padding); + } + + byte[] encrypted = Base64.getDecoder().decode(encryptedB64); + + // XOR decrypt with hash + byte[] decrypted = new byte[encrypted.length]; + for (int i = 0; i < encrypted.length; i++) { + decrypted[i] = (byte) (encrypted[i] ^ hash[i % hash.length]); + } + return new String(decrypted, UTF_8); + } + @Override public void invalidateCacheEntries() { resolution = new int[]{0, 0};