Implement @reznick SC fix
This commit is contained in:
parent
b015eb6aea
commit
4f9a1a32f6
|
@ -22,6 +22,7 @@ import ctbrec.recorder.download.HttpHeaderFactory;
|
||||||
import ctbrec.recorder.download.StreamSource;
|
import ctbrec.recorder.download.StreamSource;
|
||||||
import ctbrec.recorder.download.hls.SegmentPlaylist.Segment;
|
import ctbrec.recorder.download.hls.SegmentPlaylist.Segment;
|
||||||
import ctbrec.sites.Site;
|
import ctbrec.sites.Site;
|
||||||
|
import ctbrec.sites.stripchat.StripchatModel;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.Request.Builder;
|
import okhttp3.Request.Builder;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
|
@ -122,8 +123,8 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
|
||||||
splitRecordingIfNecessary();
|
splitRecordingIfNecessary();
|
||||||
// by the spec we must wait `targetDuration` before next playlist request if there are changes
|
// by the spec we must wait `targetDuration` before next playlist request if there are changes
|
||||||
// if there are none - half that amount
|
// if there are none - half that amount
|
||||||
calculateRescheduleTime(playlistChanged(segmentPlaylist, nextSegmentNumber)
|
calculateRescheduleTime(playlistChanged(segmentPlaylist, nextSegmentNumber)
|
||||||
? segmentPlaylist.targetDuration*1000
|
? segmentPlaylist.targetDuration*1000
|
||||||
: segmentPlaylist.targetDuration*500);
|
: segmentPlaylist.targetDuration*500);
|
||||||
processFinishedSegments();
|
processFinishedSegments();
|
||||||
|
|
||||||
|
@ -293,6 +294,9 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
|
||||||
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")) {
|
||||||
|
body = StripchatModel.m3uDecoder(body);
|
||||||
|
}
|
||||||
if (!body.contains("#EXTINF")) {
|
if (!body.contains("#EXTINF")) {
|
||||||
// no segments, empty playlist
|
// no segments, empty playlist
|
||||||
return new SegmentPlaylist(segmentPlaylistUrl);
|
return new SegmentPlaylist(segmentPlaylistUrl);
|
||||||
|
|
|
@ -22,10 +22,12 @@ import org.json.JSONObject;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
import java.text.MessageFormat;
|
import java.text.MessageFormat;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
@ -43,6 +45,10 @@ 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
|
||||||
|
private transient String psch;
|
||||||
|
private transient String pkey;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
|
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
|
||||||
if (ignoreCache) {
|
if (ignoreCache) {
|
||||||
|
@ -165,11 +171,17 @@ public class StripchatModel extends AbstractModel {
|
||||||
src.setBandwidth(playlist.getStreamInfo().getBandwidth());
|
src.setBandwidth(playlist.getStreamInfo().getBandwidth());
|
||||||
src.setHeight(playlist.getStreamInfo().getResolution().height);
|
src.setHeight(playlist.getStreamInfo().getResolution().height);
|
||||||
src.setWidth(playlist.getStreamInfo().getResolution().width);
|
src.setWidth(playlist.getStreamInfo().getResolution().width);
|
||||||
src.setMediaPlaylistUrl(playlist.getUri());
|
// DRM patch @reznick
|
||||||
if (src.getMediaPlaylistUrl().contains("?")) {
|
String mediaUrl = playlist.getUri();
|
||||||
src.setMediaPlaylistUrl(src.getMediaPlaylistUrl().substring(0, src.getMediaPlaylistUrl().lastIndexOf('?')));
|
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);
|
sources.add(src);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -185,12 +197,28 @@ public class StripchatModel extends AbstractModel {
|
||||||
try (Response response = getSite().getHttpClient().execute(req)) {
|
try (Response response = getSite().getHttpClient().execute(req)) {
|
||||||
if (response.isSuccessful()) {
|
if (response.isSuccessful()) {
|
||||||
String body = response.body().string();
|
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);
|
PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT);
|
||||||
Playlist playlist = parser.parse();
|
Playlist playlist = parser.parse();
|
||||||
MasterPlaylist master = playlist.getMasterPlaylist();
|
return playlist.getMasterPlaylist();
|
||||||
return master;
|
|
||||||
} else {
|
} else {
|
||||||
throw new HttpException(response.code(), response.message());
|
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<String> 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
|
@Override
|
||||||
public void invalidateCacheEntries() {
|
public void invalidateCacheEntries() {
|
||||||
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
|
||||||
|
|
Loading…
Reference in New Issue