From 2b3f3810d49856b16453d6c56552266a7047be64 Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Wed, 4 Mar 2026 14:14:49 +0100 Subject: [PATCH] https fixes --- .../xtreamplayer/XtreamPlayerApplication.java | 108 ++++++++++++++++++ src/main/resources/web/assets/app.js | 24 +++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java index bc9c68a..2e92581 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java @@ -28,6 +28,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public final class XtreamPlayerApplication { private static final int DEFAULT_PORT = 8080; @@ -35,6 +37,7 @@ public final class XtreamPlayerApplication { private static final String ATTR_REQ_START_NANOS = "reqStartNanos"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final Logger LOGGER = LogManager.getLogger(XtreamPlayerApplication.class); + private static final Pattern URI_ATTR_PATTERN = Pattern.compile("URI=\"([^\"]+)\""); private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(20)) .followRedirects(HttpClient.Redirect.NORMAL) @@ -60,6 +63,7 @@ public final class XtreamPlayerApplication { server.createContext("/api/test-login", new TestLoginHandler(configStore)); server.createContext("/api/xtream", new XtreamProxyHandler(configStore)); server.createContext("/api/stream-url", new StreamUrlHandler(configStore)); + server.createContext("/api/stream-proxy", new StreamProxyHandler()); server.createContext("/api/open-in-player", new OpenInPlayerHandler(configStore)); server.createContext("/api/library/load", new LibraryLoadHandler(libraryService)); server.createContext("/api/library/status", new LibraryStatusHandler(libraryService)); @@ -203,6 +207,66 @@ public final class XtreamPlayerApplication { } } + private record StreamProxyHandler() implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/stream-proxy", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + + String rawUrl = query.getOrDefault("url", "").trim(); + if (rawUrl.isBlank()) { + writeJson(exchange, 400, errorJson("Missing url parameter.")); + return; + } + + URI target; + try { + target = URI.create(rawUrl); + } catch (Exception exception) { + writeJson(exchange, 400, errorJson("Invalid url parameter.")); + return; + } + String scheme = target.getScheme() == null ? "" : target.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + writeJson(exchange, 400, errorJson("Unsupported URL protocol.")); + return; + } + + try { + HttpRequest request = HttpRequest.newBuilder(target) + .GET() + .timeout(Duration.ofSeconds(60)) + .header("User-Agent", "XtreamPlayer/1.0") + .header("Accept", "*/*") + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray()); + String contentType = response.headers().firstValue("Content-Type").orElse("application/octet-stream"); + byte[] body = response.body() == null ? new byte[0] : response.body(); + + if (isHlsPlaylist(target, contentType)) { + String rewritten = rewritePlaylistForProxy(target, body); + exchange.getResponseHeaders().set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8"); + writeBytes(exchange, response.statusCode(), rewritten.getBytes(StandardCharsets.UTF_8), + "application/vnd.apple.mpegurl; charset=utf-8"); + return; + } + + exchange.getResponseHeaders().set("Content-Type", contentType); + writeBytes(exchange, response.statusCode(), body, contentType); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + writeJson(exchange, 500, errorJson("Stream proxy interrupted.")); + } catch (Exception exception) { + LOGGER.error("Stream proxy failed for {}", maskUri(target), exception); + writeJson(exchange, 502, errorJson("Unable to proxy stream: " + exception.getMessage())); + } + } + } + private record OpenInPlayerHandler(ConfigStore configStore) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { @@ -641,6 +705,50 @@ public final class XtreamPlayerApplication { return URI.create(config.serverUrl() + "/player_api.php?" + query); } + private static boolean isHlsPlaylist(URI target, String contentType) { + String path = target.getPath() == null ? "" : target.getPath().toLowerCase(Locale.ROOT); + String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT); + return path.endsWith(".m3u8") + || ct.contains("application/vnd.apple.mpegurl") + || ct.contains("application/x-mpegurl"); + } + + private static String rewritePlaylistForProxy(URI baseUri, byte[] bodyBytes) { + String raw = new String(bodyBytes, StandardCharsets.UTF_8); + String[] lines = raw.split("\\r?\\n", -1); + StringBuilder out = new StringBuilder(raw.length() + 256); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + out.append('\n'); + continue; + } + if (trimmed.startsWith("#")) { + out.append(rewriteTagUris(line, baseUri)).append('\n'); + continue; + } + URI absolute = baseUri.resolve(trimmed); + out.append(proxyStreamUrl(absolute.toString())).append('\n'); + } + return out.toString(); + } + + private static String rewriteTagUris(String line, URI baseUri) { + Matcher matcher = URI_ATTR_PATTERN.matcher(line); + StringBuilder out = new StringBuilder(line.length() + 64); + while (matcher.find()) { + String current = matcher.group(1); + String rewritten = proxyStreamUrl(baseUri.resolve(current).toString()); + matcher.appendReplacement(out, "URI=\"" + Matcher.quoteReplacement(rewritten) + "\""); + } + matcher.appendTail(out); + return out.toString(); + } + + private static String proxyStreamUrl(String absoluteUrl) { + return "/api/stream-proxy?url=" + urlEncode(absoluteUrl); + } + private static Map parseKeyValue(String raw) { Map result = new LinkedHashMap<>(); if (raw == null || raw.isBlank()) { diff --git a/src/main/resources/web/assets/app.js b/src/main/resources/web/assets/app.js index f0d7987..7ab3264 100644 --- a/src/main/resources/web/assets/app.js +++ b/src/main/resources/web/assets/app.js @@ -1493,6 +1493,7 @@ function setPlayer(title, url, info = {}) { el.playerTitle.textContent = title || "Player"; const systemPlayerUrl = buildSystemPlayerHref(title, url, info); + const playbackUrl = buildBrowserPlaybackUrl(url); el.openDirect.dataset.url = url; el.openDirect.disabled = !url; el.openSystemPlayer.dataset.url = systemPlayerUrl; @@ -1512,7 +1513,7 @@ setSubtitleStatus("No subtitle loaded.", false); scheduleEmbeddedSubtitleScan(); - if (isLikelyHls(url) && shouldUseHlsJs()) { + if (isLikelyHls(playbackUrl) && shouldUseHlsJs()) { state.currentStreamInfo.playbackEngine = "hls.js"; renderStreamInfo(); hlsInstance = new window.Hls({ @@ -1531,7 +1532,7 @@ : (Array.isArray(hlsInstance?.subtitleTracks) ? hlsInstance.subtitleTracks : []); refreshEmbeddedSubtitleTracks(); }); - hlsInstance.loadSource(url); + hlsInstance.loadSource(playbackUrl); hlsInstance.attachMedia(el.player); hlsInstance.on(window.Hls.Events.ERROR, (_event, data) => { if (!data?.fatal) { @@ -1545,7 +1546,7 @@ } }); } else { - el.player.src = url; + el.player.src = playbackUrl; } const playbackPromise = el.player.play(); @@ -1579,6 +1580,23 @@ return `/api/open-in-player?${params.toString()}`; } + function buildBrowserPlaybackUrl(urlRaw) { + const url = String(urlRaw || "").trim(); + if (!url) { + return url; + } + try { + const pageIsHttps = window.location.protocol === "https:"; + const target = new URL(url, window.location.href); + if (pageIsHttps && target.protocol === "http:") { + return `/api/stream-proxy?url=${encodeURIComponent(target.toString())}`; + } + } catch (error) { + return url; + } + return url; + } + function resetPlayerElement() { disposeHls(); el.player.pause();