Server added for SC stream playback (credit @Gabi_uy)
This commit is contained in:
parent
4d64b12539
commit
f587096128
|
@ -1,38 +1,54 @@
|
|||
package ctbrec.ui;
|
||||
|
||||
import com.iheartradio.m3u8.ParseException;
|
||||
import com.iheartradio.m3u8.PlaylistException;
|
||||
import ctbrec.*;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
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.io.HttpClient;
|
||||
import ctbrec.io.StreamRedirector;
|
||||
import ctbrec.io.UrlUtil;
|
||||
import ctbrec.recorder.download.StreamSource;
|
||||
import ctbrec.recorder.download.hls.NoStreamFoundException;
|
||||
import ctbrec.sites.Site;
|
||||
import ctbrec.ui.controls.Dialogs;
|
||||
import ctbrec.ui.event.PlayerStartedEvent;
|
||||
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.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
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
|
||||
public class Player {
|
||||
private static PlayerThread playerThread;
|
||||
private static Scene scene;
|
||||
private static volatile Scene appScene;
|
||||
|
||||
public static void setScene(Scene scene) {
|
||||
appScene = scene;
|
||||
}
|
||||
|
||||
private Player() {
|
||||
}
|
||||
|
@ -75,18 +91,17 @@ public class Player {
|
|||
playerThread.join();
|
||||
}
|
||||
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) {
|
||||
Thread.currentThread().interrupt();
|
||||
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;
|
||||
} catch (Exception 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;
|
||||
}
|
||||
}
|
||||
|
@ -97,12 +112,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 = "Quean4cai9boJa5a";
|
||||
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, "Quean4cai9boJa5a");
|
||||
if (name == null || name.isEmpty()) {
|
||||
name = Player.decryptMouflon(enc, "Zokee2OhPh9kugh4");
|
||||
}
|
||||
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 {
|
||||
@Getter
|
||||
private boolean running = false;
|
||||
private volatile boolean running = false;
|
||||
private Process playerProcess;
|
||||
private Recording rec;
|
||||
private Model model;
|
||||
private LocalHlsProxy proxy;
|
||||
|
||||
PlayerThread(Model model) {
|
||||
this.model = model;
|
||||
|
@ -120,29 +177,36 @@ public class Player {
|
|||
@Override
|
||||
public void run() {
|
||||
running = true;
|
||||
Runtime rt = Runtime.getRuntime();
|
||||
Config cfg = Config.getInstance();
|
||||
try {
|
||||
if (cfg.getSettings().localRecording && rec != null) {
|
||||
if (Config.getInstance().getSettings().localRecording && rec != null) {
|
||||
File file = rec.getAbsoluteFile();
|
||||
String[] cmdline = createCmdline(file.getAbsolutePath(), model);
|
||||
playerProcess = rt.exec(cmdline, OS.getEnvironment(), file.getParentFile());
|
||||
Object[] cmdline = createCmdline(file.getAbsolutePath(), model);
|
||||
log.debug("Player command line (local): {}", (Object)Arrays.toString(cmdline));
|
||||
playerProcess = Runtime.getRuntime().exec((String[])cmdline, OS.getEnvironment(), file.getParentFile());
|
||||
} else {
|
||||
String url = null;
|
||||
String upstreamUrl;
|
||||
if (rec != null) {
|
||||
url = getRemoteRecordingUrl(rec, cfg);
|
||||
upstreamUrl = getRemoteRecordingUrl(rec, Config.getInstance());
|
||||
model = rec.getModel();
|
||||
} else if (model != null) {
|
||||
url = getPlaylistUrl(model);
|
||||
upstreamUrl = PlayerThread.getPlaylistUrl(model);
|
||||
} else {
|
||||
throw new IllegalStateException("No model/recording to play");
|
||||
}
|
||||
log.debug("Playing {}", url);
|
||||
String[] cmdline = createCmdline(url, model);
|
||||
log.debug("Player command line: {}", Arrays.toString(cmdline));
|
||||
playerProcess = rt.exec(cmdline);
|
||||
String mediaUrlForPlayer = upstreamUrl;
|
||||
if (looksLikeStripchatM3u8(upstreamUrl)) {
|
||||
try {
|
||||
proxy = new LocalHlsProxy(model, upstreamUrl);
|
||||
proxy.start();
|
||||
mediaUrlForPlayer = proxy.localUrl();
|
||||
} catch (Exception ex) {
|
||||
log.warn("Local proxy failed, fallback to remote URL: {}", (Object)ex.toString());
|
||||
}
|
||||
}
|
||||
Object[] cmdline = createCmdline(mediaUrlForPlayer, model);
|
||||
log.debug("Player command line: {}", (Object)Arrays.toString(cmdline));
|
||||
playerProcess = robustSpawn((String[])cmdline);
|
||||
}
|
||||
|
||||
// create threads, which read stdout and stderr of the player process. these are needed,
|
||||
// because otherwise the internal buffer for these streams fill up and block the process
|
||||
Thread std = new Thread(new StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream()));
|
||||
std.setName("Player stdout pipe");
|
||||
std.setDaemon(true);
|
||||
|
@ -151,90 +215,445 @@ public class Player {
|
|||
err.setName("Player stderr pipe");
|
||||
err.setDaemon(true);
|
||||
err.start();
|
||||
|
||||
playerProcess.waitFor();
|
||||
log.debug("Media player finished.");
|
||||
} catch (InterruptedException e) {
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Error in player thread", e);
|
||||
Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e);
|
||||
log.error("Player thread interrupted", (Throwable)ie);
|
||||
Dialogs.showError(null, "Playback interrupted", ie.getLocalizedMessage(), ie);
|
||||
} catch (Exception e) {
|
||||
log.error("Error in player thread", e);
|
||||
Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e);
|
||||
log.error("Error in player thread", (Throwable)e);
|
||||
Dialogs.showError(null, "Playback failed", "Couldn't start playback", e);
|
||||
} finally {
|
||||
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;
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
}
|
||||
|
||||
private static String getPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException {
|
||||
List<StreamSource> sources = model.getStreamSources();
|
||||
public void stopThread() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksLikeStripchatM3u8(String url) {
|
||||
try {
|
||||
URL u = new URL(url);
|
||||
String host = u.getHost();
|
||||
String path = u.getPath().toLowerCase(Locale.ROOT);
|
||||
return host != null && host.contains("doppiocdn.com") && path.endsWith(".m3u8");
|
||||
} 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);
|
||||
StreamSource best;
|
||||
int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer;
|
||||
if (maxRes > 0 && !sources.isEmpty()) {
|
||||
for (Iterator<StreamSource> iterator = sources.iterator(); iterator.hasNext(); ) {
|
||||
StreamSource streamSource = iterator.next();
|
||||
if (streamSource.getHeight() > 0 && maxRes < streamSource.getHeight()) {
|
||||
log.trace("Res too high {} > {}", streamSource.getHeight(), maxRes);
|
||||
iterator.remove();
|
||||
}
|
||||
Iterator it = sources.iterator();
|
||||
while (it.hasNext()) {
|
||||
StreamSource s = (StreamSource)it.next();
|
||||
if (s.getHeight() <= 0 || maxRes >= s.getHeight()) continue;
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
if (sources.isEmpty()) {
|
||||
throw new NoStreamFoundException("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;
|
||||
throw new IOException("No stream left in playlist, because player resolution is set to " + maxRes);
|
||||
}
|
||||
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) {
|
||||
boolean isVlc;
|
||||
Config cfg = Config.getInstance();
|
||||
String params = cfg.getSettings().mediaPlayerParams.trim();
|
||||
|
||||
String[] cmdline;
|
||||
if (params.isEmpty()) {
|
||||
cmdline = new String[2];
|
||||
} else {
|
||||
String[] playerArgs = StringUtil.splitParams(params);
|
||||
cmdline = new String[playerArgs.length + 2];
|
||||
System.arraycopy(playerArgs, 0, cmdline, 1, playerArgs.length);
|
||||
String params = Optional.ofNullable(cfg.getSettings().mediaPlayerParams).orElse("").trim();
|
||||
ArrayList<Object> cmd = new ArrayList<Object>();
|
||||
String playerPath = cfg.getSettings().mediaPlayer;
|
||||
cmd.add(playerPath);
|
||||
if (!params.isEmpty()) {
|
||||
String[] playerArgs = StringUtil.splitParams((String)params);
|
||||
Collections.addAll(cmd, playerArgs);
|
||||
}
|
||||
cmdline[0] = cfg.getSettings().mediaPlayer;
|
||||
cmdline[cmdline.length - 1] = mediaSource;
|
||||
if (model != null) {
|
||||
expandPlaceHolders(cmdline);
|
||||
boolean isLocalProxy = mediaSource != null && mediaSource.startsWith("http://127.0.0.1:");
|
||||
boolean isMpv = playerPath != null && playerPath.toLowerCase(Locale.ROOT).contains("mpv");
|
||||
boolean bl = isVlc = playerPath != null && playerPath.toLowerCase(Locale.ROOT).contains("vlc");
|
||||
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;
|
||||
}
|
||||
|
||||
private String getRemoteRecordingUrl(Recording rec, Config cfg)
|
||||
throws MalformedURLException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
|
||||
private void expandPlaceHolders(String[] cmdline, Model model) {
|
||||
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 recUrl = hlsBase + '/' + rec.getId() + (rec.isSingleFile() ? "" : "/playlist.m3u8");
|
||||
String recUrl = hlsBase + "/" + rec.getId() + (rec.isSingleFile() ? "" : "/playlist.m3u8");
|
||||
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;
|
||||
}
|
||||
|
||||
public void stopThread() {
|
||||
if (playerProcess != null) {
|
||||
playerProcess.destroy();
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeBytes(HttpExchange ex, int code, String ct, byte[] body) throws IOException {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void setScene(Scene scene) {
|
||||
Player.scene = scene;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue