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 f51297a8..47cf1534 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java @@ -42,6 +42,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; @@ -282,54 +283,89 @@ public abstract class AbstractHlsDownload extends AbstractDownload { return url; } - protected SegmentPlaylist getNextSegments(String segmentPlaylistUrl) throws IOException, ParseException, PlaylistException { - 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 request = builder.build(); - - try (Response response = client.execute(request, config.getSettings().playlistRequestTimeout)) { - 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); - } - byte[] bytes = body.getBytes(UTF_8); - BandwidthMeter.add(bytes.length); - InputStream inputStream = new ByteArrayInputStream(bytes); +protected SegmentPlaylist getNextSegments(String segmentPlaylistUrl) throws IOException, ParseException, PlaylistException { + Instant start = Instant.now(); + recordingEvents.add(RecordingEvent.of("Playlist request")); + URL segmentsUrl = URI.create(segmentPlaylistUrl).toURL(); + 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")) { + return new SegmentPlaylist(segmentPlaylistUrl); + } + byte[] bytes = body.getBytes(UTF_8); + BandwidthMeter.add(bytes.length); + try (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; - } 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{} (took {}ms)", - config.getSettings().playlistRequestTimeout, - model.getSite().getName(), - model, - ++consecutivePlaylistTimeouts, - (consecutivePlaylistTimeouts > 1) ? 's' : "", - (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); + } 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{} (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++; + throw e; + } +} + +// 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) { - consecutivePlaylistErrors++; - throw 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); diff --git a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java index 8a149cf1..06fc443e 100644 --- a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java +++ b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java @@ -45,9 +45,8 @@ 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; + // 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 { @@ -166,61 +165,94 @@ 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(); + if (playlist.getStreamInfo().getResolution() != null) { + src.setHeight(playlist.getStreamInfo().getResolution().height); + src.setWidth(playlist.getStreamInfo().getResolution().width); + } src.setBandwidth(playlist.getStreamInfo().getBandwidth()); - src.setHeight(playlist.getStreamInfo().getResolution().height); - 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 - if (psch != null && pkey != null) { - mediaUrl += "?psch=" + psch + "&pkey=" + pkey; - } - src.setMediaPlaylistUrl(mediaUrl); - log.trace("Media playlist {}", mediaUrl); - sources.add(src); + // 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(); - - // 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); + 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; } + } - // 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); + 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(); - } else { - throw new HttpException(response.code(), response.message()); } } } @@ -243,7 +275,7 @@ public class StripchatModel extends AbstractModel { 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); } else { throw new IOException("Playlist URL not found"); @@ -299,8 +331,6 @@ public class StripchatModel extends AbstractModel { resolution = new int[]{0, 0}; lastInfoRequest = Instant.EPOCH; modelInfo = null; - psch = null; - pkey = null; } @Override