From 4f9a1a32f6e51a43e27d9c0c83a555b41d6a522a Mon Sep 17 00:00:00 2001 From: Jafea7 Date: Fri, 29 Aug 2025 22:42:50 +1000 Subject: [PATCH] Implement @reznick SC fix --- .../download/hls/AbstractHlsDownload.java | 8 +- .../sites/stripchat/StripchatModel.java | 90 +++++++++++++++++-- 2 files changed, 88 insertions(+), 10 deletions(-) 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 72caf0f5..f51297a8 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java @@ -22,6 +22,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 okhttp3.Request; import okhttp3.Request.Builder; import okhttp3.Response; @@ -122,8 +123,8 @@ public abstract class AbstractHlsDownload extends AbstractDownload { splitRecordingIfNecessary(); // by the spec we must wait `targetDuration` before next playlist request if there are changes // if there are none - half that amount - calculateRescheduleTime(playlistChanged(segmentPlaylist, nextSegmentNumber) - ? segmentPlaylist.targetDuration*1000 + calculateRescheduleTime(playlistChanged(segmentPlaylist, nextSegmentNumber) + ? segmentPlaylist.targetDuration*1000 : segmentPlaylist.targetDuration*500); processFinishedSegments(); @@ -293,6 +294,9 @@ public abstract class AbstractHlsDownload extends AbstractDownload { if (response.isSuccessful()) { consecutivePlaylistTimeouts = 0; String body = Objects.requireNonNull(response.body()).string(); + if (model.getSite().getName().equalsIgnoreCase("stripchat")) { + body = StripchatModel.m3uDecoder(body); + } if (!body.contains("#EXTINF")) { // no segments, empty playlist return new SegmentPlaylist(segmentPlaylistUrl); diff --git a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java index 93dd95d6..a379d736 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,10 @@ public class StripchatModel extends AbstractModel { private transient JSONObject modelInfo; private transient Instant lastInfoRequest = Instant.EPOCH; + // New fields for DRM fix + private transient String psch; + private transient String pkey; + @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { if (ignoreCache) { @@ -165,11 +171,17 @@ public class StripchatModel extends AbstractModel { 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('?'))); + // DRM patch @reznick + String mediaUrl = playlist.getUri(); + if (mediaUrl.contains("?")) { + mediaUrl = mediaUrl.substring(0, mediaUrl.lastIndexOf('?')); } - log.trace("Media playlist {}", src.getMediaPlaylistUrl()); + // Append psch & pkey if available + if (psch != null && pkey != null) { + mediaUrl += "?psch=" + psch + "&pkey=" + pkey; + } + src.setMediaPlaylistUrl(mediaUrl); + log.trace("Media playlist {}", mediaUrl); sources.add(src); } } @@ -185,12 +197,28 @@ public class StripchatModel extends AbstractModel { 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)); + + // Detect Mouflon DRM line + if (body.contains("#EXT-X-MOUFLON")) { + int start = body.indexOf("#EXT-X-MOUFLON:"); + int end = body.indexOf('\n', start); + if (end == -1) end = body.length(); + String line = body.substring(start, end).trim(); + String[] parts = line.split(":"); + if (parts.length >= 4) { + psch = parts[2]; + pkey = parts[3]; + log.debug("Extracted DRM params psch={} pkey={}", psch, pkey); + } + } + + // Run through m3uDecoder + String decoded = m3uDecoder(body); + + InputStream inputStream = new ByteArrayInputStream(decoded.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; + return playlist.getMasterPlaylist(); } else { throw new HttpException(response.code(), response.message()); } @@ -222,11 +250,57 @@ public class StripchatModel extends AbstractModel { } } + // 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, "Quean4cai9boJa5a"); + 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}; lastInfoRequest = Instant.EPOCH; modelInfo = null; + psch = null; + pkey = null; } @Override