Compare commits

..

5 Commits

Author SHA1 Message Date
Jafea7 d1a4e622e1 Update v25.9.06 2025-09-07 10:17:43 +10:00
Jafea7 f587096128 Server added for SC stream playback (credit @Gabi_uy) 2025-09-07 10:17:43 +10:00
Jafea7 4d64b12539 Update index.html 2025-09-07 10:17:43 +10:00
Jafea7 83715195b6 SC fix (credit @Gabi_uy 2025-09-07 10:17:43 +10:00
Jafea7 7b6767051d Update UserAgent strings 2025-09-07 10:17:18 +10:00
9 changed files with 669 additions and 184 deletions

View File

@ -8,7 +8,7 @@
<parent> <parent>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<version>25.8.29</version> <version>25.9.6</version>
<relativePath>../master</relativePath> <relativePath>../master</relativePath>
</parent> </parent>

View File

@ -1,38 +1,54 @@
package ctbrec.ui; package ctbrec.ui;
import com.iheartradio.m3u8.ParseException; import com.sun.net.httpserver.HttpExchange;
import com.iheartradio.m3u8.PlaylistException; import com.sun.net.httpserver.HttpHandler;
import ctbrec.*; 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.event.EventBusHolder;
import ctbrec.io.HttpClient;
import ctbrec.io.StreamRedirector; import ctbrec.io.StreamRedirector;
import ctbrec.io.UrlUtil; import ctbrec.io.UrlUtil;
import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.StreamSource;
import ctbrec.recorder.download.hls.NoStreamFoundException; import ctbrec.sites.Site;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.event.PlayerStartedEvent; import ctbrec.ui.event.PlayerStartedEvent;
import ctbrec.variableexpansion.ModelVariableExpander; 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.File;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.UnsupportedEncodingException; import java.lang.reflect.Method;
import java.net.MalformedURLException; import java.net.InetSocketAddress;
import java.security.InvalidKeyException; import java.net.URL;
import java.security.NoSuchAlgorithmException; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64;
import java.util.Collections; import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.ExecutionException; 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 @Slf4j
public class Player { public class Player {
private static PlayerThread playerThread; private static PlayerThread playerThread;
private static Scene scene; private static volatile Scene appScene;
public static void setScene(Scene scene) {
appScene = scene;
}
private Player() { private Player() {
} }
@ -75,18 +91,17 @@ public class Player {
playerThread.join(); playerThread.join();
} }
return true; 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) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
log.error("Couldn't get stream information for model {}", model, 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; return false;
} catch (Exception e) { } catch (Exception e) {
log.error("Couldn't get stream information for model {}", model, 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; return false;
} }
} }
@ -97,12 +112,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 = "Quean4cai9boJa5a";
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, "Quean4cai9boJa5a");
if (name == null || name.isEmpty()) {
name = Player.decryptMouflon(enc, "Zokee2OhPh9kugh4");
}
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 { private static class PlayerThread extends Thread {
@Getter private volatile boolean running = false;
private boolean running = false;
private Process playerProcess; private Process playerProcess;
private Recording rec; private Recording rec;
private Model model; private Model model;
private LocalHlsProxy proxy;
PlayerThread(Model model) { PlayerThread(Model model) {
this.model = model; this.model = model;
@ -120,29 +177,36 @@ public class Player {
@Override @Override
public void run() { public void run() {
running = true; running = true;
Runtime rt = Runtime.getRuntime();
Config cfg = Config.getInstance();
try { try {
if (cfg.getSettings().localRecording && rec != null) { if (Config.getInstance().getSettings().localRecording && rec != null) {
File file = rec.getAbsoluteFile(); File file = rec.getAbsoluteFile();
String[] cmdline = createCmdline(file.getAbsolutePath(), model); Object[] cmdline = createCmdline(file.getAbsolutePath(), model);
playerProcess = rt.exec(cmdline, OS.getEnvironment(), file.getParentFile()); log.debug("Player command line (local): {}", (Object)Arrays.toString(cmdline));
playerProcess = Runtime.getRuntime().exec((String[])cmdline, OS.getEnvironment(), file.getParentFile());
} else { } else {
String url = null; String upstreamUrl;
if (rec != null) { if (rec != null) {
url = getRemoteRecordingUrl(rec, cfg); upstreamUrl = getRemoteRecordingUrl(rec, Config.getInstance());
model = rec.getModel(); model = rec.getModel();
} else if (model != null) { } 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 mediaUrlForPlayer = upstreamUrl;
String[] cmdline = createCmdline(url, model); if (looksLikeStripchatM3u8(upstreamUrl)) {
log.debug("Player command line: {}", Arrays.toString(cmdline)); try {
playerProcess = rt.exec(cmdline); proxy = new LocalHlsProxy(model, upstreamUrl);
proxy.start();
mediaUrlForPlayer = proxy.localUrl();
} catch (Exception ex) {
log.warn("Local proxy failed, fallback to remote URL: {}", (Object)ex.toString());
}
}
Object[] cmdline = createCmdline(mediaUrlForPlayer, model);
log.debug("Player command line: {}", (Object)Arrays.toString(cmdline));
playerProcess = robustSpawn((String[])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
Thread std = new Thread(new StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream())); Thread std = new Thread(new StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream()));
std.setName("Player stdout pipe"); std.setName("Player stdout pipe");
std.setDaemon(true); std.setDaemon(true);
@ -151,90 +215,445 @@ public class Player {
err.setName("Player stderr pipe"); err.setName("Player stderr pipe");
err.setDaemon(true); err.setDaemon(true);
err.start(); err.start();
playerProcess.waitFor(); playerProcess.waitFor();
log.debug("Media player finished."); log.debug("Media player finished.");
} catch (InterruptedException e) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
log.error("Error in player thread", e); log.error("Player thread interrupted", (Throwable)ie);
Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e); Dialogs.showError(null, "Playback interrupted", ie.getLocalizedMessage(), ie);
} catch (Exception e) { } catch (Exception e) {
log.error("Error in player thread", e); log.error("Error in player thread", (Throwable)e);
Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e); Dialogs.showError(null, "Playback failed", "Couldn't start playback", e);
} } finally {
running = false; 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;
}
}
} }
private static String getPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { public void stopThread() {
List<StreamSource> sources = model.getStreamSources(); 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;
}
}
}
private boolean looksLikeStripchatM3u8(String url) {
try {
URL u = new URL(url);
String host = u.getHost();
String path = u.getPath().toLowerCase(Locale.ROOT);
return host != null && host.contains("doppiocdn.com") && path.endsWith(".m3u8");
} 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); Collections.sort(sources);
StreamSource best;
int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer; int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer;
if (maxRes > 0 && !sources.isEmpty()) { if (maxRes > 0 && !sources.isEmpty()) {
for (Iterator<StreamSource> iterator = sources.iterator(); iterator.hasNext(); ) { Iterator it = sources.iterator();
StreamSource streamSource = iterator.next(); while (it.hasNext()) {
if (streamSource.getHeight() > 0 && maxRes < streamSource.getHeight()) { StreamSource s = (StreamSource)it.next();
log.trace("Res too high {} > {}", streamSource.getHeight(), maxRes); if (s.getHeight() <= 0 || maxRes >= s.getHeight()) continue;
iterator.remove(); it.remove();
}
} }
} }
if (sources.isEmpty()) { if (sources.isEmpty()) {
throw new NoStreamFoundException("No stream left in playlist, because player resolution is set to " + maxRes); throw new IOException("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;
} }
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) { private String[] createCmdline(String mediaSource, Model model) {
boolean isVlc;
Config cfg = Config.getInstance(); Config cfg = Config.getInstance();
String params = cfg.getSettings().mediaPlayerParams.trim(); String params = Optional.ofNullable(cfg.getSettings().mediaPlayerParams).orElse("").trim();
ArrayList<Object> cmd = new ArrayList<Object>();
String[] cmdline; String playerPath = cfg.getSettings().mediaPlayer;
if (params.isEmpty()) { cmd.add(playerPath);
cmdline = new String[2]; if (!params.isEmpty()) {
} else { String[] playerArgs = StringUtil.splitParams((String)params);
String[] playerArgs = StringUtil.splitParams(params); Collections.addAll(cmd, playerArgs);
cmdline = new String[playerArgs.length + 2];
System.arraycopy(playerArgs, 0, cmdline, 1, playerArgs.length);
} }
cmdline[0] = cfg.getSettings().mediaPlayer; boolean isLocalProxy = mediaSource != null && mediaSource.startsWith("http://127.0.0.1:");
cmdline[cmdline.length - 1] = mediaSource; boolean isMpv = playerPath != null && playerPath.toLowerCase(Locale.ROOT).contains("mpv");
if (model != null) { boolean bl = isVlc = playerPath != null && playerPath.toLowerCase(Locale.ROOT).contains("vlc");
expandPlaceHolders(cmdline); 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; return cmdline;
} }
private String getRemoteRecordingUrl(Recording rec, Config cfg) private void expandPlaceHolders(String[] cmdline, Model model) {
throws MalformedURLException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { 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<String> cmd = new ArrayList<String>(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 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) { 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; return recUrl;
} }
public void stopThread() { public boolean isRunning() {
if (playerProcess != null) { return running;
playerProcess.destroy(); }
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) {}
} }
} }
} }
public static void setScene(Scene scene) { private void writeBytes(HttpExchange ex, int code, String ct, byte[] body) throws IOException {
Player.scene = scene; 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();
}
}
}
} }
} }

View File

@ -8,7 +8,7 @@
<parent> <parent>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<version>25.8.29</version> <version>25.9.6</version>
<relativePath>../master</relativePath> <relativePath>../master</relativePath>
</parent> </parent>

View File

@ -97,8 +97,8 @@ public class Settings {
public int httpSecurePort = 8443; public int httpSecurePort = 8443;
public String httpServer = "localhost"; public String httpServer = "localhost";
public int httpTimeout = 10000; public int httpTimeout = 10000;
public String httpUserAgent = "Mozilla/5.0 (X11; Linux i686; rv:137.0) Gecko/20100101 Firefox/137.0"; public String httpUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0";
public String httpUserAgentMobile = "Mozilla/5.0 (Android 15; Mobile; rv:137.0) Gecko/137.0 Firefox/137.0"; public String httpUserAgentMobile = "Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:142.0) Gecko/142.0 Firefox/142.0";
public byte[] key = null; public byte[] key = null;
public List<String> ignoredModels = new ArrayList<>(); public List<String> ignoredModels = new ArrayList<>();
public String lastDownloadDir = ""; public String lastDownloadDir = "";

View File

@ -42,6 +42,7 @@ import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@ -282,34 +283,30 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
return url; return url;
} }
protected SegmentPlaylist getNextSegments(String segmentPlaylistUrl) throws IOException, ParseException, PlaylistException { protected SegmentPlaylist getNextSegments(String segmentPlaylistUrl) throws IOException, ParseException, PlaylistException {
Instant start = Instant.now(); Instant start = Instant.now();
recordingEvents.add(RecordingEvent.of("Playlist request")); recordingEvents.add(RecordingEvent.of("Playlist request"));
URL segmentsUrl = URI.create(segmentPlaylistUrl).toURL(); URL segmentsUrl = URI.create(segmentPlaylistUrl).toURL();
Builder builder = new Request.Builder().url(segmentsUrl); Request.Builder builder = new Request.Builder().url(segmentsUrl);
addHeaders(builder, Optional.ofNullable(model).map(Model::getHttpHeaderFactory).map(HttpHeaderFactory::createSegmentPlaylistHeaders).orElse(new HashMap<>()), model); // project-accurate headers:
addHeaders(builder, model.getHttpHeaderFactory().createSegmentPlaylistHeaders(), model);
Request request = builder.build(); Request request = builder.build();
try (Response response = client.execute(request, config.getSettings().playlistRequestTimeout)) { try (Response response = client.execute(request, config.getSettings().playlistRequestTimeout)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
consecutivePlaylistTimeouts = 0; consecutivePlaylistTimeouts = 0;
String body = Objects.requireNonNull(response.body()).string(); String body = Objects.requireNonNull(response.body()).string();
if (model.getSite().getName().equalsIgnoreCase("stripchat")) { // Stripchat: normalize Mouflon FILE -> real segment names
body = StripchatModel.m3uDecoder(body); body = normalizeStripchatMouflon(response.request().url().toString(), body);
}
if (!body.contains("#EXTINF")) { if (!body.contains("#EXTINF")) {
// no segments, empty playlist
return new SegmentPlaylist(segmentPlaylistUrl); return new SegmentPlaylist(segmentPlaylistUrl);
} }
byte[] bytes = body.getBytes(UTF_8); byte[] bytes = body.getBytes(UTF_8);
BandwidthMeter.add(bytes.length); BandwidthMeter.add(bytes.length);
InputStream inputStream = new ByteArrayInputStream(bytes); try (InputStream inputStream = new ByteArrayInputStream(bytes)) {
SegmentPlaylist playList = parsePlaylist(response.request().url().toString(), inputStream); SegmentPlaylist playList = parsePlaylist(response.request().url().toString(), inputStream);
consecutivePlaylistErrors = 0; 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; return playList;
}
} else { } else {
recordingEvents.add(RecordingEvent.of("HTTP code " + response.code())); recordingEvents.add(RecordingEvent.of("HTTP code " + response.code()));
throw new HttpException(response.code(), response.message()); throw new HttpException(response.code(), response.message());
@ -322,14 +319,53 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
++consecutivePlaylistTimeouts, ++consecutivePlaylistTimeouts,
(consecutivePlaylistTimeouts > 1) ? 's' : "", (consecutivePlaylistTimeouts > 1) ? 's' : "",
(Duration.between(start, Instant.now()).toMillis())); (Duration.between(start, Instant.now()).toMillis()));
// 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));
throw new PlaylistTimeoutException(e); throw new PlaylistTimeoutException(e);
} catch (Exception e) { } catch (Exception e) {
consecutivePlaylistErrors++; consecutivePlaylistErrors++;
throw e; throw e;
} }
}
// Decode "#EXT-X-MOUFLON:FILE:<base64>" 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<String,String,String> 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 { private SegmentPlaylist parsePlaylist(String segmentPlaylistUrl, InputStream inputStream) throws IOException, ParseException, PlaylistException {
PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT);

View File

@ -45,9 +45,8 @@ public class StripchatModel extends AbstractModel {
private transient JSONObject modelInfo; private transient JSONObject modelInfo;
private transient Instant lastInfoRequest = Instant.EPOCH; private transient Instant lastInfoRequest = Instant.EPOCH;
// New fields for DRM fix // Mouflon pkey taken from master (#EXT-X-MOUFLON:PSCH:v1:<pkey>)
private transient String psch; private volatile String mouflonPKey = null;
private transient String pkey;
@Override @Override
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
@ -166,61 +165,94 @@ public class StripchatModel extends AbstractModel {
private List<StreamSource> extractStreamSources(MasterPlaylist masterPlaylist) { private List<StreamSource> extractStreamSources(MasterPlaylist masterPlaylist) {
List<StreamSource> sources = new ArrayList<>(); List<StreamSource> sources = new ArrayList<>();
for (PlaylistData playlist : masterPlaylist.getPlaylists()) { for (PlaylistData playlist : masterPlaylist.getPlaylists()) {
if (playlist.hasStreamInfo()) { if (!playlist.hasStreamInfo()) continue;
StreamSource src = new StreamSource(); StreamSource src = new StreamSource();
src.setBandwidth(playlist.getStreamInfo().getBandwidth()); if (playlist.getStreamInfo().getResolution() != null) {
src.setHeight(playlist.getStreamInfo().getResolution().height); src.setHeight(playlist.getStreamInfo().getResolution().height);
src.setWidth(playlist.getStreamInfo().getResolution().width); src.setWidth(playlist.getStreamInfo().getResolution().width);
// DRM patch @reznick
String mediaUrl = playlist.getUri();
if (mediaUrl.contains("?")) {
mediaUrl = mediaUrl.substring(0, mediaUrl.lastIndexOf('?'));
} }
// Append psch & pkey if available src.setBandwidth(playlist.getStreamInfo().getBandwidth());
if (psch != null && pkey != null) { // IMPORTANT: never strip the query from the media playlist URL
mediaUrl += "?psch=" + psch + "&pkey=" + pkey; 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);
} }
src.setMediaPlaylistUrl(mediaUrl); } else if (!media.contains(PT)) {
log.trace("Media playlist {}", mediaUrl); 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); sources.add(src);
} }
}
return sources; return sources;
} }
private MasterPlaylist getMasterPlaylist(String url) throws IOException, ParseException, PlaylistException { 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); log.trace("Loading master playlist {}", url);
Request req = new Request.Builder() Request req = new Request.Builder()
.url(url) .url(url)
.header(ACCEPT, "*/*")
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.header(REFERER, getUrl()) // https://stripchat.com/<model>
.header(ORIGIN, getSite().getBaseUrl()) // https://stripchat.com
.build(); .build();
try (Response response = getSite().getHttpClient().execute(req)) { try (Response response = getSite().getHttpClient().execute(req)) {
if (response.isSuccessful()) { if (!response.isSuccessful()) {
throw new HttpException(response.code(), response.message());
}
String body = response.body().string(); String body = response.body().string();
// Extract Mouflon pkey, e.g. "#EXT-X-MOUFLON:PSCH:v1:<pkey>"
// Detect Mouflon DRM line for (String line : body.split("\n")) {
if (body.contains("#EXT-X-MOUFLON")) { if (line.startsWith("#EXT-X-MOUFLON") && line.contains("PSCH")) {
int start = body.indexOf("#EXT-X-MOUFLON:"); String pk = line.substring(line.lastIndexOf(':') + 1).trim();
int end = body.indexOf('\n', start); if (!pk.isEmpty()) {
if (end == -1) end = body.length(); mouflonPKey = pk;
String line = body.substring(start, end).trim(); log.debug("MOUFLON pkey from master: {}", mouflonPKey);
String[] parts = line.split(":"); }
if (parts.length >= 4) { break;
psch = parts[2];
pkey = parts[3];
log.debug("Extracted DRM params psch={} pkey={}", psch, pkey);
} }
} }
// Run through m3uDecoder log.trace(body);
String decoded = m3uDecoder(body); try (InputStream in = new ByteArrayInputStream(body.getBytes(UTF_8))) {
PlaylistParser parser = new PlaylistParser(in, Format.EXT_M3U, Encoding.UTF_8,
InputStream inputStream = new ByteArrayInputStream(decoded.getBytes(UTF_8)); ParsingMode.LENIENT);
PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT);
Playlist playlist = parser.parse(); Playlist playlist = parser.parse();
return playlist.getMasterPlaylist(); return playlist.getMasterPlaylist();
} else {
throw new HttpException(response.code(), response.message());
} }
} }
} }
@ -243,7 +275,7 @@ public class StripchatModel extends AbstractModel {
log.debug("Spy start for {}", getName()); log.debug("Spy start for {}", getName());
} }
} }
String hlsUrlTemplate = "https://edge-hls.doppiocdn.live/hls/{0}{1}/master/{0}{1}_auto.m3u8?playlistType=Standart{2}"; 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); return MessageFormat.format(hlsUrlTemplate, String.valueOf(id), vrSuffix, token);
} else { } else {
throw new IOException("Playlist URL not found"); throw new IOException("Playlist URL not found");
@ -299,8 +331,6 @@ public class StripchatModel extends AbstractModel {
resolution = new int[]{0, 0}; resolution = new int[]{0, 0};
lastInfoRequest = Instant.EPOCH; lastInfoRequest = Instant.EPOCH;
modelInfo = null; modelInfo = null;
psch = null;
pkey = null;
} }
@Override @Override

View File

@ -11,7 +11,7 @@
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<packaging>pom</packaging> <packaging>pom</packaging>
<version>25.8.29</version> <version>25.9.6</version>
<modules> <modules>
<module>../common</module> <module>../common</module>

View File

@ -8,7 +8,7 @@
<parent> <parent>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<version>25.8.29</version> <version>25.9.6</version>
<relativePath>../master</relativePath> <relativePath>../master</relativePath>
</parent> </parent>

View File

@ -7,9 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=yes"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=yes">
<meta name="description" content="CTB Recorder is a free recording software for Chaturbate"> <meta name="description" content="CTB Recorder is a free recording software for Chaturbate">
<meta name="author" content=""> <meta name="author" content="">
<meta name="version" content="25.8.29"> <meta name="version" content="25.9.06">
<title>CTB Recorder 25.8.29</title> <title>CTB Recorder 25.9.06</title>
<!-- Bootstrap core CSS --> <!-- Bootstrap core CSS -->
<link href="vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet"> <link href="vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">