diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index ff45847..12666bb 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -39,6 +39,7 @@ import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER import nl.vonas.mediacluster.tv.data.ClearKeyEntry import nl.vonas.mediacluster.tv.data.DrmInfo +import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.OkHttpClient import java.nio.ByteBuffer @@ -194,13 +195,22 @@ fun buildTvPlayer(context: Context): ExoPlayer { private const val VIAPLAY_BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" -private fun isViaplayCdnHost(host: String): Boolean = - host.contains("viaplay", ignoreCase = true) +private fun isViaplayCdn(url: HttpUrl): Boolean { + val host = url.host.lowercase() + val path = url.encodedPath.lowercase() + return host.contains("viaplay") || + host.contains("viasat") || + host.contains("live-dash") || + host.contains("vod-dash") || + path.contains("mediapkg") || + path.contains("tve_vsport") || + path.contains("vp-xtra") +} /** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */ private val viaplayBrowserHeaders = Interceptor { chain -> val req = chain.request() - if (!isViaplayCdnHost(req.url.host)) return@Interceptor chain.proceed(req) + if (!isViaplayCdn(req.url)) return@Interceptor chain.proceed(req) chain.proceed( req.newBuilder() .header("User-Agent", VIAPLAY_BROWSER_UA) @@ -216,7 +226,7 @@ private val liveOkHttp: OkHttpClient by lazy { .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .retryOnConnectionFailure(true) - .addInterceptor(viaplayBrowserHeaders) + .addNetworkInterceptor(viaplayBrowserHeaders) .build() } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index 4a7d59c..77ca74f 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -43,6 +43,7 @@ import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER +import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.OkHttpClient import java.util.UUID @@ -203,13 +204,22 @@ fun buildTvPlayer(context: Context, audioPassthrough: Boolean = false): ExoPlaye private const val VIAPLAY_BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" -private fun isViaplayCdnHost(host: String): Boolean = - host.contains("viaplay", ignoreCase = true) +private fun isViaplayCdn(url: HttpUrl): Boolean { + val host = url.host.lowercase() + val path = url.encodedPath.lowercase() + return host.contains("viaplay") || + host.contains("viasat") || + host.contains("live-dash") || + host.contains("vod-dash") || + path.contains("mediapkg") || + path.contains("tve_vsport") || + path.contains("vp-xtra") +} /** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */ private val viaplayBrowserHeaders = Interceptor { chain -> val req = chain.request() - if (!isViaplayCdnHost(req.url.host)) return@Interceptor chain.proceed(req) + if (!isViaplayCdn(req.url)) return@Interceptor chain.proceed(req) chain.proceed( req.newBuilder() .header("User-Agent", VIAPLAY_BROWSER_UA) @@ -225,7 +235,7 @@ private val liveOkHttp: OkHttpClient by lazy { .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .retryOnConnectionFailure(true) - .addInterceptor(viaplayBrowserHeaders) + .addNetworkInterceptor(viaplayBrowserHeaders) .build() } diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 30029ed..0113252 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -345,7 +345,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 69; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -360,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.55; + MARKETING_VERSION = 0.5.56; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -378,7 +378,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 69; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -393,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.55; + MARKETING_VERSION = 0.5.56; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index ff9ed5f..63eed36 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -10,7 +10,12 @@ enum ViaplayPlaybackHeaders { static func matches(_ url: URL) -> Bool { let h = (url.host ?? "").lowercased() - return h.contains("viaplay") || h.contains("viasat") + let s = url.absoluteString.lowercased() + if h.contains("viaplay") || h.contains("viasat") { return true } + if s.contains("viaplay") || s.contains("viasat") { return true } + if h.contains("live-dash") || h.contains("vod-dash") { return true } + if s.contains("mediapkg") || s.contains("tve_vsport") || s.contains("vp-xtra") { return true } + return false } static func apply(to req: inout URLRequest) { @@ -22,6 +27,21 @@ enum ViaplayPlaybackHeaders { private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy") +/// CFNetwork volgt redirects met de app-UA; wij volgen zelf en zetten Chrome/122 opnieuw. +private final class DenyRedirects: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +private let viaplayDenyRedirects = DenyRedirects() + private final class ResumeOnce: @unchecked Sendable { private var done = false private let lock = NSLock() @@ -110,7 +130,11 @@ final class ClearKeyDashProxy { "Origin": ViaplayPlaybackHeaders.origin, "Accept": "*/*", ] - return URLSession(configuration: cfg) + return URLSession( + configuration: cfg, + delegate: viaplayDenyRedirects, + delegateQueue: nil + ) }() /// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt). @@ -1082,24 +1106,42 @@ final class ClearKeyDashProxy { } private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) { - var req = URLRequest(url: url) - req.timeoutInterval = 15 - req.cachePolicy = .reloadIgnoringLocalCacheData - req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges") - let browser = withState { useViaplayBrowserHeaders } || ViaplayPlaybackHeaders.matches(url) - let session: URLSession - if browser { - ViaplayPlaybackHeaders.apply(to: &req) - session = viaplayBrowserSession - } else { - req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent") - session = urlSession + var current = url + for _ in 0..<8 { + var req = URLRequest(url: current) + req.timeoutInterval = 15 + req.cachePolicy = .reloadIgnoringLocalCacheData + req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges") + let browser = withState { useViaplayBrowserHeaders } || ViaplayPlaybackHeaders.matches(current) + let session: URLSession + if browser { + ViaplayPlaybackHeaders.apply(to: &req) + session = viaplayBrowserSession + } else { + req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent") + session = urlSession + } + let (data, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse else { + return (data, current) + } + if (300...399).contains(http.statusCode) { + guard + let loc = http.value(forHTTPHeaderField: "Location"), + let next = URL(string: loc, relativeTo: current)?.absoluteURL + else { + throw ProxyError.upstream(http.statusCode) + } + proxyLog.info("cdn redirect \(http.statusCode, privacy: .public) -> \(next.host ?? "?", privacy: .public)") + current = next + continue + } + if !(200...299).contains(http.statusCode) { + throw ProxyError.upstream(http.statusCode) + } + return (data, http.url ?? current) } - let (data, resp) = try await session.data(for: req) - if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { - throw ProxyError.upstream(http.statusCode) - } - return (data, resp.url ?? url) + throw ProxyError.upstream(310) } /// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel. diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 7fed467..fdb13b0 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -96,7 +96,7 @@ struct SettingsScreen: View { Section("Info") { LabeledContent("App", value: "VMC iOS") - LabeledContent("Versie", value: "0.5.55") + LabeledContent("Versie", value: "0.5.56") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") } diff --git a/apps/master-api/src/viaplay/cdn-proxy.ts b/apps/master-api/src/viaplay/cdn-proxy.ts new file mode 100644 index 0000000..42f1a55 --- /dev/null +++ b/apps/master-api/src/viaplay/cdn-proxy.ts @@ -0,0 +1,184 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { Readable } from "node:stream"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { Config } from "../config"; +import { VIAPLAY_USER_AGENT } from "./client"; + +const TTL_SEC = 8 * 3600; + +export function isAllowedViaplayOrigin(url: string): boolean { + try { + const u = new URL(url); + if (u.protocol !== "https:") return false; + const h = u.hostname.toLowerCase(); + return ( + h.includes("viaplay") || + h.includes("viasat") || + /live-dash|vod-dash/.test(h) + ); + } catch { + return false; + } +} + +function sign(secret: string, exp: number, host: string): string { + return createHmac("sha256", secret) + .update(`viaplay-cdn:${exp}:${host.toLowerCase()}`) + .digest("base64url"); +} + +function safeEq(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +/** Speel-URL voor apps: MPD + segmenten via onze API (Chrome/122), niet via de app-UA. */ +export function wrapViaplayCdnUrl(opts: { + publicBase: string; + secret: string; + originUrl: string; +}): string { + const raw = String(opts.originUrl || "").trim(); + if (!raw || /\/api\/v1\/client\/viaplay\/cdn\//i.test(raw)) return raw; + if (!isAllowedViaplayOrigin(raw)) return raw; + const origin = new URL(raw); + const exp = Math.floor(Date.now() / 1000) + TTL_SEC; + const sig = sign(opts.secret, exp, origin.hostname); + const base = opts.publicBase.replace(/\/+$/, ""); + const rest = `${origin.hostname}${origin.pathname}${origin.search}`; + return `${base}/api/v1/client/viaplay/cdn/s/${sig}/${exp}/${rest}`; +} + +function originFromSplat( + rest: string, + query: Record | undefined +): string { + let origin = `https://${rest.replace(/^\/+/, "")}`; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query || {})) { + if (value == null) continue; + if (Array.isArray(value)) { + for (const item of value) params.append(key, String(item)); + } else { + params.set(key, String(value)); + } + } + const q = params.toString(); + if (q) origin += (origin.includes("?") ? "&" : "?") + q; + return origin; +} + +function proxyPrefix(publicBase: string, sig: string, exp: string): string { + return `${publicBase.replace(/\/+$/, "")}/api/v1/client/viaplay/cdn/s/${sig}/${exp}`; +} + +function toProxyUrl(originUrl: string, prefix: string): string { + const u = new URL(originUrl); + return `${prefix}/${u.hostname}${u.pathname}${u.search}`; +} + +function rewriteMpdThroughProxy(xml: string, prefix: string, originMpd: string): string { + let out = xml; + out = out.replace(/https:\/\/[^\s"'<>]+/gi, (url) => { + if (!isAllowedViaplayOrigin(url)) return url; + return toProxyUrl(url, prefix); + }); + out = out.replace(/([\s\S]*?)<\/BaseURL>/gi, (full, inner: string) => { + const raw = String(inner || "").trim(); + if (!raw || raw.includes("/api/v1/client/viaplay/cdn/")) return full; + try { + const abs = new URL(raw, originMpd).toString(); + if (isAllowedViaplayOrigin(abs)) { + return `${toProxyUrl(abs, prefix)}`; + } + } catch { + /* fallback hieronder */ + } + const dir = originMpd.replace(/\/[^/]*$/, "/"); + return `${toProxyUrl(dir, prefix)}`; + }); + if (!/]*>/i, + (open) => `${open}\n ${toProxyUrl(dir, prefix)}` + ); + } + return out; +} + +export function registerViaplayCdnProxy(app: FastifyInstance, config: Config): void { + app.get( + "/api/v1/client/viaplay/cdn/s/:sig/:exp/*", + async (request: FastifyRequest, reply: FastifyReply) => { + const params = request.params as { sig: string; exp: string; "*": string }; + const rest = String(params["*"] || "").trim(); + const exp = Number(params.exp); + if (!rest || !Number.isFinite(exp) || exp < Date.now() / 1000 - 30) { + return reply.status(410).send("Viaplay-proxy verlopen"); + } + const host = rest.split("/")[0]?.toLowerCase() || ""; + const expected = sign(config.SESSION_SECRET, exp, host); + if (!safeEq(expected, params.sig)) { + return reply.status(403).send("Viaplay-proxy ongeldig"); + } + const origin = originFromSplat(rest, request.query as Record); + if (!isAllowedViaplayOrigin(origin)) { + return reply.status(400).send("Viaplay-proxy host geweigerd"); + } + + const range = request.headers.range; + const headers: Record = { + Accept: "*/*", + "Accept-Language": "nl,en;q=0.9", + "User-Agent": VIAPLAY_USER_AGENT, + Referer: "https://viaplay.com/", + Origin: "https://viaplay.com", + }; + if (typeof range === "string" && range) headers.Range = range; + + const upstream = await fetch(origin, { + headers, + redirect: "follow", + signal: AbortSignal.timeout(30_000), + }); + const ct = upstream.headers.get("content-type") || ""; + const urlLooksMpd = /\.mpd$/i.test(new URL(origin).pathname); + const maybeMpd = urlLooksMpd || /xml|mpd|dash\+xml/i.test(ct); + + if (!maybeMpd) { + reply.status(upstream.status); + if (ct) reply.header("content-type", ct); + const cr = upstream.headers.get("content-range"); + if (cr) reply.header("content-range", cr); + const ar = upstream.headers.get("accept-ranges"); + if (ar) reply.header("accept-ranges", ar); + reply.header("cache-control", "no-store"); + if (!upstream.body) return reply.send(); + return reply.send(Readable.fromWeb(upstream.body as never)); + } + + const buf = Buffer.from(await upstream.arrayBuffer()); + const head = buf.subarray(0, Math.min(buf.length, 400)).toString("utf8"); + const isMpd = /]/i.test(head); + + reply.status(upstream.status); + if (isMpd && upstream.ok) { + const xml = rewriteMpdThroughProxy( + buf.toString("utf8"), + proxyPrefix(config.PUBLIC_URL, params.sig, params.exp), + origin + ); + reply.header("content-type", "application/dash+xml; charset=utf-8"); + reply.header("cache-control", "no-store"); + return reply.send(xml); + } + + if (ct) reply.header("content-type", ct); + reply.header("cache-control", "no-store"); + return reply.send(buf); + } + ); +} diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 4a97b6b..3b941fc 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -1386,6 +1386,7 @@ export async function fetchMpdXml( Accept: "*/*", "User-Agent": VIAPLAY_USER_AGENT, Referer: "https://viaplay.com/", + Origin: "https://viaplay.com", }, signal: AbortSignal.timeout(timeoutMs), }); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 8e61c5a..79cef97 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -10,6 +10,7 @@ import type { SubtitleSource } from "../opensubtitles/client"; import { GooglePlayAccessService } from "../google-play/service"; import { subscribeViewersChanged } from "./admin-events"; import { registerAndroidTvClientUpdateRoutes } from "../install/routes"; +import { registerViaplayCdnProxy, wrapViaplayCdnUrl } from "../viaplay/cdn-proxy"; export function registerViewerRoutes( app: FastifyInstance, @@ -20,6 +21,7 @@ export function registerViewerRoutes( const viewers = new ViewerService(config, playback, downloads); const profiles = new ViewerProfileService(); const googlePlay = new GooglePlayAccessService(config); + registerViaplayCdnProxy(app, config); registerAndroidTvClientUpdateRoutes(app, (authorization) => viewers.authFromBearer(authorization) @@ -1090,7 +1092,11 @@ export function registerViewerRoutes( eventId: `viaplay-content:${guid}`, name: displayTitle, logoUrl: null, - streamUrl: played.streamUrl, + streamUrl: wrapViaplayCdnUrl({ + publicBase: config.PUBLIC_URL, + secret: config.SESSION_SECRET, + originUrl: played.streamUrl, + }), format: played.format, fallbackStreamUrl: null, fallbackFormat: null, diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index a0dda30..24c599b 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -5,6 +5,7 @@ import { prisma } from "../database/client"; import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art"; import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art"; import { applyCategoryFallbacks } from "./event-fallbacks"; +import { wrapViaplayCdnUrl } from "../viaplay/cdn-proxy"; const CACHE_TTL_MS = 60_000; const FETCH_TIMEOUT_MS = 20_000; @@ -1130,7 +1131,11 @@ export async function getScheduleEventPlay( eventId: pub.id, name: pub.name, logoUrl: pub.logoUrl ?? pub.imagePortrait, - streamUrl: played.streamUrl, + streamUrl: wrapViaplayCdnUrl({ + publicBase: config.PUBLIC_URL, + secret: config.SESSION_SECRET, + originUrl: played.streamUrl, + }), format: played.format, fallbackStreamUrl: null, fallbackFormat: null,