SC fix (credit @Gabi_uy
This commit is contained in:
parent
7b6767051d
commit
83715195b6
|
@ -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;
|
||||||
|
@ -286,30 +287,26 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
|
||||||
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,8 +319,6 @@ 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++;
|
||||||
|
@ -331,6 +326,47 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
Playlist playlist = parser.parse();
|
Playlist playlist = parser.parse();
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue