Compare commits
8 Commits
180bfe72c2
...
64ddf4977e
Author | SHA1 | Date |
---|---|---|
|
64ddf4977e | |
|
292c572175 | |
|
2418b31a25 | |
|
0a3655434b | |
|
7701ff9d30 | |
|
649d21ce26 | |
|
a70194dee2 | |
|
0f8ff720b7 |
|
@ -11,6 +11,11 @@ If this version doesn't do what you want, don't use it ... simple.
|
||||||
|
|
||||||
Changes from 0xb00bface's v5.3.0 version.
|
Changes from 0xb00bface's v5.3.0 version.
|
||||||
|
|
||||||
|
25.09.10 (fix stream playback)
|
||||||
|
========================
|
||||||
|
* SC encrypted stream fix (credit @Gabi_uy)
|
||||||
|
* Reset Selected Resolution if model offline (credit @Gabi_uy)
|
||||||
|
|
||||||
25.08.31
|
25.08.31
|
||||||
========================
|
========================
|
||||||
* Fix for SC Streams
|
* Fix for SC Streams
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>ctbrec</groupId>
|
<groupId>ctbrec</groupId>
|
||||||
<artifactId>master</artifactId>
|
<artifactId>master</artifactId>
|
||||||
<version>25.8.31</version>
|
<version>25.9.10</version>
|
||||||
<relativePath>../master</relativePath>
|
<relativePath>../master</relativePath>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,10 @@
|
||||||
<source>${project.basedir}/LICENSE.txt</source>
|
<source>${project.basedir}/LICENSE.txt</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
</file>
|
</file>
|
||||||
|
<file>
|
||||||
|
<source>${project.basedir}/../CHANGELOG.md</source>
|
||||||
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
</file>
|
||||||
<file>
|
<file>
|
||||||
<source>${project.basedir}/README.md</source>
|
<source>${project.basedir}/README.md</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
|
|
@ -36,6 +36,10 @@
|
||||||
<source>${project.basedir}/LICENSE.txt</source>
|
<source>${project.basedir}/LICENSE.txt</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
</file>
|
</file>
|
||||||
|
<file>
|
||||||
|
<source>${project.basedir}/../CHANGELOG.md</source>
|
||||||
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
</file>
|
||||||
<file>
|
<file>
|
||||||
<source>${project.basedir}/README.md</source>
|
<source>${project.basedir}/README.md</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
|
|
@ -31,6 +31,10 @@
|
||||||
<source>${project.basedir}/LICENSE.txt</source>
|
<source>${project.basedir}/LICENSE.txt</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
</file>
|
</file>
|
||||||
|
<file>
|
||||||
|
<source>${project.basedir}/../CHANGELOG.md</source>
|
||||||
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
</file>
|
||||||
<file>
|
<file>
|
||||||
<source>${project.basedir}/README.md</source>
|
<source>${project.basedir}/README.md</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
|
|
@ -1,38 +1,57 @@
|
||||||
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.URI;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.net.URL;
|
||||||
|
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.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
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() {
|
||||||
}
|
}
|
||||||
|
@ -56,6 +75,46 @@ public class Player {
|
||||||
return play(model, true);
|
return play(model, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Precompile VR suffix regex
|
||||||
|
// private static final Pattern VR_SUFFIX = Pattern.compile("(_vr)$");
|
||||||
|
|
||||||
|
// Map of host replacements for non-VR streams
|
||||||
|
/* private static final Map<String, String> HOST_REPLACEMENTS = Map.of(
|
||||||
|
"media-hls.doppiocdn.com", "media-hls.saawsedge.com"
|
||||||
|
); */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites non-VR doppiocdn.com URLs:
|
||||||
|
* - swaps host
|
||||||
|
* - strips _vr from directory if present
|
||||||
|
* - forces _480p resolution
|
||||||
|
*/
|
||||||
|
/* private static String rewriteNonVrStripchatUrl(String url) {
|
||||||
|
try {
|
||||||
|
URI u = URI.create(url);
|
||||||
|
String host = u.getHost();
|
||||||
|
|
||||||
|
if (host != null && host.contains("doppiocdn.com")) {
|
||||||
|
String newHost = HOST_REPLACEMENTS.getOrDefault(host, host);
|
||||||
|
|
||||||
|
// Split the path: /b-hls-02/89673378/89673378_720p60.m3u8
|
||||||
|
String[] parts = u.getPath().split("/");
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
String dir1 = parts[1]; // e.g., b-hls-02
|
||||||
|
String dir2 = VR_SUFFIX.matcher(parts[2]).replaceAll(""); // strip _vr if present
|
||||||
|
String newFile = dir2 + "_480p.m3u8"; // force 480p
|
||||||
|
|
||||||
|
String rewritten = String.format("https://%s/%s/%s/%s", newHost, dir1, dir2, newFile);
|
||||||
|
log.trace("Rewrote non-VR Stripchat URL: {} -> {}", url, rewritten);
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Failed to rewrite Stripchat URL {}: {}", url, ex.toString());
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} */
|
||||||
|
|
||||||
public static boolean play(Model model, boolean async) {
|
public static boolean play(Model model, boolean async) {
|
||||||
try {
|
try {
|
||||||
if (model.isOnline(true)) {
|
if (model.isOnline(true)) {
|
||||||
|
@ -75,18 +134,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 +155,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 = Config.getInstance().getSettings().stripchatDecrypt;
|
||||||
|
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, KEY_PRIMARY);
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
name = Player.decryptMouflon(enc, KEY_FALLBACK);
|
||||||
|
}
|
||||||
|
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 +220,39 @@ 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 {
|
||||||
log.debug("Playing {}", url);
|
throw new IllegalStateException("No model/recording to play");
|
||||||
String[] cmdline = createCmdline(url, model);
|
|
||||||
log.debug("Player command line: {}", Arrays.toString(cmdline));
|
|
||||||
playerProcess = rt.exec(cmdline);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// create threads, which read stdout and stderr of the player process. these are needed,
|
String mediaUrlForPlayer = upstreamUrl;
|
||||||
// because otherwise the internal buffer for these streams fill up and block the process
|
if (looksLikeStripchatM3u8(upstreamUrl)) {
|
||||||
|
// if (isVrStripchatStream(upstreamUrl)) {
|
||||||
|
// VR stream -> use proxy
|
||||||
|
proxy = new LocalHlsProxy(model, upstreamUrl);
|
||||||
|
proxy.start();
|
||||||
|
mediaUrlForPlayer = proxy.localUrl();
|
||||||
|
// } else if (upstreamUrl.contains("doppiocdn.com")) {
|
||||||
|
// Non-VR -> rewrite host + resolution
|
||||||
|
// mediaUrlForPlayer = rewriteNonVrStripchatUrl(upstreamUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object[] cmdline = createCmdline(mediaUrlForPlayer, model);
|
||||||
|
log.debug("Player command line: {}", (Object)Arrays.toString(cmdline));
|
||||||
|
playerProcess = robustSpawn((String[])cmdline);
|
||||||
|
}
|
||||||
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 +261,467 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the URL is a VR doppiocdn.com stream (.m3u8 + _vr)
|
||||||
|
*/
|
||||||
|
private boolean isVrStripchatStream(String url) {
|
||||||
|
try {
|
||||||
|
URI u = URI.create(url);
|
||||||
|
String host = u.getHost();
|
||||||
|
String path = u.getPath().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
return host != null
|
||||||
|
&& host.contains("doppiocdn.com")
|
||||||
|
&& path.endsWith(".m3u8")
|
||||||
|
&& path.contains("_vr");
|
||||||
|
} catch (Throwable ignore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeStripchatM3u8(String url) {
|
||||||
|
try {
|
||||||
|
URL u = new URL(url);
|
||||||
|
String host = u.getHost();
|
||||||
|
String path = u.getPath().toLowerCase(Locale.ROOT);
|
||||||
|
// Only proxy if it's doppiocdn.com AND it's a VR stream (.m3u8 ending + "_vr" somewhere)
|
||||||
|
return host != null
|
||||||
|
&& host.contains("doppiocdn.com")
|
||||||
|
&& path.endsWith(".m3u8");
|
||||||
|
// && path.contains("_vr");
|
||||||
|
} 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,30 +38,72 @@ public class SwitchStreamResolutionAction {
|
||||||
var couldntSwitchHeaderText = "Couldn't switch stream resolution";
|
var couldntSwitchHeaderText = "Couldn't switch stream resolution";
|
||||||
|
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
checkOnlineState();
|
checkOnlineState();
|
||||||
return selectedModel;
|
return Boolean.TRUE; // model is online
|
||||||
|
} catch (ModelOfflineException e) {
|
||||||
|
return Boolean.FALSE; // model is offline
|
||||||
|
}
|
||||||
}, GlobalThreadPool.get())
|
}, GlobalThreadPool.get())
|
||||||
.thenAccept(m -> Platform.runLater(() -> {
|
.thenAccept(isOnline -> Platform.runLater(() -> {
|
||||||
StreamSourceSelectionDialog dialog = new StreamSourceSelectionDialog(source.getScene(), selectedModel);
|
if (isOnline) {
|
||||||
|
// --- model is online: open selection dialog ---
|
||||||
|
StreamSourceSelectionDialog dialog =
|
||||||
|
new StreamSourceSelectionDialog(source.getScene(), selectedModel);
|
||||||
Optional<StreamSource> selectedSource = dialog.showAndWait();
|
Optional<StreamSource> selectedSource = dialog.showAndWait();
|
||||||
if (selectedSource.isPresent()) {
|
if (selectedSource.isPresent()) {
|
||||||
StreamSource src = selectedSource.get();
|
StreamSource src = selectedSource.get();
|
||||||
if (src != StreamSourceSelectionDialog.LOADING) {
|
if (src != StreamSourceSelectionDialog.LOADING) {
|
||||||
int index = dialog.indexOf(selectedSource.get());
|
int index = dialog.indexOf(src);
|
||||||
selectedModel.setStreamUrlIndex(index);
|
selectedModel.setStreamUrlIndex(index);
|
||||||
try {
|
try {
|
||||||
recorder.switchStreamSource(selectedModel);
|
recorder.switchStreamSource(selectedModel);
|
||||||
} catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) {
|
} catch (InvalidKeyException | NoSuchAlgorithmException |
|
||||||
|
IllegalStateException | IOException e) {
|
||||||
log.error(couldntSwitchHeaderText, e);
|
log.error(couldntSwitchHeaderText, e);
|
||||||
Dialogs.showError(source.getScene(), "Couldn't switch stream resolution", "Error while switching stream resolution", e);
|
Dialogs.showError(source.getScene(), couldntSwitchHeaderText,
|
||||||
|
"Error while switching stream resolution", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// --- model is offline: ask if user wants to reset ---
|
||||||
|
boolean confirmed = Dialogs.showConfirmDialog(
|
||||||
|
"Model is offline",
|
||||||
|
"Yes to reset to Default (Best resolution),\nNo to leave existing resolution unchanged.",
|
||||||
|
"",
|
||||||
|
source.getScene()
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
selectedModel.setStreamUrlIndex(-1);
|
||||||
|
try {
|
||||||
|
recorder.switchStreamSource(selectedModel); // persist the change
|
||||||
|
} catch (InvalidKeyException | NoSuchAlgorithmException |
|
||||||
|
IllegalStateException | IOException e) {
|
||||||
|
log.error("Couldn't update recorder with reset stream index", e);
|
||||||
|
Dialogs.showError(source.getScene(),
|
||||||
|
"Couldn't reset stream resolution",
|
||||||
|
"Error while updating stream resolution", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// show confirmation popup
|
||||||
|
Dialogs.showError(
|
||||||
|
source.getScene(),
|
||||||
|
"Stream Resolution Reset",
|
||||||
|
"Stream resolution has been reset to Best (default).",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
source.setCursor(Cursor.DEFAULT);
|
source.setCursor(Cursor.DEFAULT);
|
||||||
}))
|
}))
|
||||||
.exceptionally(ex -> {
|
.exceptionally(ex -> {
|
||||||
Dialogs.showError(source.getScene(), couldntSwitchHeaderText, "The resolution can only be changed when the model is online", null);
|
log.error("Unexpected error while switching resolution", ex);
|
||||||
Platform.runLater(() -> source.setCursor(Cursor.DEFAULT));
|
Platform.runLater(() -> {
|
||||||
|
Dialogs.showError(source.getScene(), couldntSwitchHeaderText,
|
||||||
|
"Unexpected error occurred", ex);
|
||||||
|
source.setCursor(Cursor.DEFAULT);
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,6 +89,19 @@ public class StripchatConfigUI extends AbstractConfigUI {
|
||||||
GridPane.setMargin(hbox, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
|
GridPane.setMargin(hbox, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
|
||||||
layout.add(hbox, 1, row++);
|
layout.add(hbox, 1, row++);
|
||||||
|
|
||||||
|
layout.add(new Label("Decryption key"), 0, row);
|
||||||
|
var decrypt_key = new TextField(Config.getInstance().getSettings().stripchatDecrypt);
|
||||||
|
decrypt_key.textProperty().addListener((ob, o, n) -> {
|
||||||
|
if (!n.equals(Config.getInstance().getSettings().stripchatDecrypt)) {
|
||||||
|
Config.getInstance().getSettings().stripchatDecrypt = decrypt_key.getText();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
GridPane.setFillWidth(decrypt_key, true);
|
||||||
|
GridPane.setHgrow(decrypt_key, Priority.ALWAYS);
|
||||||
|
GridPane.setColumnSpan(decrypt_key, 2);
|
||||||
|
layout.add(decrypt_key, 1, row++);
|
||||||
|
|
||||||
layout.add(new Label("Stripchat User"), 0, row);
|
layout.add(new Label("Stripchat User"), 0, row);
|
||||||
var username = new TextField(Config.getInstance().getSettings().stripchatUsername);
|
var username = new TextField(Config.getInstance().getSettings().stripchatUsername);
|
||||||
username.textProperty().addListener((ob, o, n) -> {
|
username.textProperty().addListener((ob, o, n) -> {
|
||||||
|
|
|
@ -686,6 +686,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
|
||||||
thumbs.remove(i);
|
thumbs.remove(i);
|
||||||
thumbsToMove.add(0, thumb);
|
thumbsToMove.add(0, thumb);
|
||||||
}
|
}
|
||||||
|
if (recorder.isMarkedForLaterRecording(thumb.getModel())) {
|
||||||
|
thumbs.remove(i);
|
||||||
|
thumbsToMove.add(thumb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thumbs.addAll(0, thumbsToMove);
|
thumbs.addAll(0, thumbsToMove);
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>ctbrec</groupId>
|
<groupId>ctbrec</groupId>
|
||||||
<artifactId>master</artifactId>
|
<artifactId>master</artifactId>
|
||||||
<version>25.8.31</version>
|
<version>25.9.10</version>
|
||||||
<relativePath>../master</relativePath>
|
<relativePath>../master</relativePath>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
|
|
|
@ -188,6 +188,7 @@ public class Settings {
|
||||||
public String streamateUsername = "";
|
public String streamateUsername = "";
|
||||||
public List<String> streamateTabs = new ArrayList<>(Arrays.asList("f,ff"));
|
public List<String> streamateTabs = new ArrayList<>(Arrays.asList("f,ff"));
|
||||||
public List<String> streamrayTabs = new ArrayList<>(Arrays.asList("F"));
|
public List<String> streamrayTabs = new ArrayList<>(Arrays.asList("F"));
|
||||||
|
public String stripchatDecrypt = "Quean4cai9boJa5a";
|
||||||
public String stripchatUsername = "";
|
public String stripchatUsername = "";
|
||||||
public String stripchatPassword = "";
|
public String stripchatPassword = "";
|
||||||
public List<String> stripchatTabs = new ArrayList<>(Arrays.asList("girls"));
|
public List<String> stripchatTabs = new ArrayList<>(Arrays.asList("girls"));
|
||||||
|
|
|
@ -7,6 +7,7 @@ import java.net.URISyntaxException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import ctbrec.Config;
|
import ctbrec.Config;
|
||||||
import ctbrec.Hmac;
|
import ctbrec.Hmac;
|
||||||
|
|
|
@ -19,6 +19,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 lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.Request.Builder;
|
import okhttp3.Request.Builder;
|
||||||
|
@ -40,6 +41,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;
|
||||||
|
@ -273,36 +275,38 @@ 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();
|
||||||
|
// Stripchat: normalize Mouflon FILE -> real segment names
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
} catch (SocketTimeoutException e) {
|
} catch (SocketTimeoutException e) {
|
||||||
log.debug("Playlist request timed out ({}ms) for model {} {} time{}", config.getSettings().playlistRequestTimeout, model,
|
log.debug("Playlist request timed out ({}ms) for model {}:{} {} time{} (took {}ms)",
|
||||||
++consecutivePlaylistTimeouts, (consecutivePlaylistTimeouts > 1) ? 's' : "");
|
config.getSettings().playlistRequestTimeout,
|
||||||
// times out, return an empty playlist, so that the process can continue without wasting much more time
|
model.getSite().getName(),
|
||||||
recordingEvents.add(RecordingEvent.of("Playlist request timed out " + consecutivePlaylistTimeouts));
|
model,
|
||||||
|
++consecutivePlaylistTimeouts,
|
||||||
|
(consecutivePlaylistTimeouts > 1) ? 's' : "",
|
||||||
|
(Duration.between(start, Instant.now()).toMillis()));
|
||||||
throw new PlaylistTimeoutException(e);
|
throw new PlaylistTimeoutException(e);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
consecutivePlaylistErrors++;
|
consecutivePlaylistErrors++;
|
||||||
|
@ -310,6 +314,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();
|
||||||
|
|
|
@ -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,9 @@ 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;
|
||||||
|
|
||||||
|
// Mouflon pkey taken from master (#EXT-X-MOUFLON:PSCH:v1:<pkey>)
|
||||||
|
private volatile String mouflonPKey = null;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
|
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
|
||||||
if (ignoreCache) {
|
if (ignoreCache) {
|
||||||
|
@ -160,40 +165,95 @@ 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);
|
||||||
src.setMediaPlaylistUrl(playlist.getUri());
|
|
||||||
if (src.getMediaPlaylistUrl().contains("?")) {
|
|
||||||
src.setMediaPlaylistUrl(src.getMediaPlaylistUrl().substring(0, src.getMediaPlaylistUrl().lastIndexOf('?')));
|
|
||||||
}
|
}
|
||||||
log.trace("Media playlist {}", src.getMediaPlaylistUrl());
|
src.setBandwidth(playlist.getStreamInfo().getBandwidth());
|
||||||
|
// 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);
|
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()) {
|
||||||
String body = response.body().string();
|
|
||||||
log.trace(body);
|
|
||||||
InputStream inputStream = new ByteArrayInputStream(body.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;
|
|
||||||
} else {
|
|
||||||
throw new HttpException(response.code(), response.message());
|
throw new HttpException(response.code(), response.message());
|
||||||
}
|
}
|
||||||
|
String body = response.body().string();
|
||||||
|
// Extract Mouflon pkey, e.g. "#EXT-X-MOUFLON:PSCH:v1:<pkey>"
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,15 +275,57 @@ public class StripchatModel extends AbstractModel {
|
||||||
log.debug("Spy start for {}", getName());
|
log.debug("Spy start for {}", getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// String hlsUrlTemplate = "https://edge-hls.doppiocdn.com/hls/{0}{1}/master/{0}{1}_auto.m3u8?playlistType=Standart{2}";
|
|
||||||
String hlsUrlTemplate = "https://edge-hls.saawsedge.com/hls/{0}{1}/master/{0}{1}.m3u8";
|
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);
|
||||||
return MessageFormat.format(hlsUrlTemplate, String.valueOf(id), vrSuffix);
|
|
||||||
} else {
|
} else {
|
||||||
throw new IOException("Playlist URL not found");
|
throw new IOException("Playlist URL not found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, 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};
|
||||||
|
|
|
@ -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.31</version>
|
<version>25.9.10</version>
|
||||||
|
|
||||||
<modules>
|
<modules>
|
||||||
<module>../common</module>
|
<module>../common</module>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>ctbrec</groupId>
|
<groupId>ctbrec</groupId>
|
||||||
<artifactId>master</artifactId>
|
<artifactId>master</artifactId>
|
||||||
<version>25.8.31</version>
|
<version>25.9.10</version>
|
||||||
<relativePath>../master</relativePath>
|
<relativePath>../master</relativePath>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,10 @@
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
<filtered>true</filtered>
|
<filtered>true</filtered>
|
||||||
</file>
|
</file>
|
||||||
|
<file>
|
||||||
|
<source>${project.basedir}/../CHANGELOG.md</source>
|
||||||
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
</file>
|
||||||
<file>
|
<file>
|
||||||
<source>${project.basedir}/LICENSE.txt</source>
|
<source>${project.basedir}/LICENSE.txt</source>
|
||||||
<outputDirectory>ctbrec</outputDirectory>
|
<outputDirectory>ctbrec</outputDirectory>
|
||||||
|
|
Loading…
Reference in New Issue