Proxy Viaplay DASH through the API so clients never fetch Don't go MPDs.
CDN returns the promo MPD when the app UA survives a redirect; the play API now serves a signed Chrome/122 proxy for MPD and segments. iOS pins that UA on every hop.
This commit is contained in:
parent
80ab9cdb76
commit
abdd829615
9 changed files with 292 additions and 34 deletions
|
|
@ -39,6 +39,7 @@ import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
|
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
|
||||||
import nl.vonas.mediacluster.tv.data.DrmInfo
|
import nl.vonas.mediacluster.tv.data.DrmInfo
|
||||||
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
@ -194,13 +195,22 @@ fun buildTvPlayer(context: Context): ExoPlayer {
|
||||||
private const val VIAPLAY_BROWSER_UA =
|
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"
|
"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 =
|
private fun isViaplayCdn(url: HttpUrl): Boolean {
|
||||||
host.contains("viaplay", ignoreCase = true)
|
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. */
|
/** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */
|
||||||
private val viaplayBrowserHeaders = Interceptor { chain ->
|
private val viaplayBrowserHeaders = Interceptor { chain ->
|
||||||
val req = chain.request()
|
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(
|
chain.proceed(
|
||||||
req.newBuilder()
|
req.newBuilder()
|
||||||
.header("User-Agent", VIAPLAY_BROWSER_UA)
|
.header("User-Agent", VIAPLAY_BROWSER_UA)
|
||||||
|
|
@ -216,7 +226,7 @@ private val liveOkHttp: OkHttpClient by lazy {
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
.addInterceptor(viaplayBrowserHeaders)
|
.addNetworkInterceptor(viaplayBrowserHeaders)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_OFF
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -203,13 +204,22 @@ fun buildTvPlayer(context: Context, audioPassthrough: Boolean = false): ExoPlaye
|
||||||
private const val VIAPLAY_BROWSER_UA =
|
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"
|
"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 =
|
private fun isViaplayCdn(url: HttpUrl): Boolean {
|
||||||
host.contains("viaplay", ignoreCase = true)
|
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. */
|
/** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */
|
||||||
private val viaplayBrowserHeaders = Interceptor { chain ->
|
private val viaplayBrowserHeaders = Interceptor { chain ->
|
||||||
val req = chain.request()
|
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(
|
chain.proceed(
|
||||||
req.newBuilder()
|
req.newBuilder()
|
||||||
.header("User-Agent", VIAPLAY_BROWSER_UA)
|
.header("User-Agent", VIAPLAY_BROWSER_UA)
|
||||||
|
|
@ -225,7 +235,7 @@ private val liveOkHttp: OkHttpClient by lazy {
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
.addInterceptor(viaplayBrowserHeaders)
|
.addNetworkInterceptor(viaplayBrowserHeaders)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -345,7 +345,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 68;
|
CURRENT_PROJECT_VERSION = 69;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -360,7 +360,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.55;
|
MARKETING_VERSION = 0.5.56;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
@ -378,7 +378,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 68;
|
CURRENT_PROJECT_VERSION = 69;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -393,7 +393,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.55;
|
MARKETING_VERSION = 0.5.56;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ enum ViaplayPlaybackHeaders {
|
||||||
|
|
||||||
static func matches(_ url: URL) -> Bool {
|
static func matches(_ url: URL) -> Bool {
|
||||||
let h = (url.host ?? "").lowercased()
|
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) {
|
static func apply(to req: inout URLRequest) {
|
||||||
|
|
@ -22,6 +27,21 @@ enum ViaplayPlaybackHeaders {
|
||||||
|
|
||||||
private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy")
|
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 final class ResumeOnce: @unchecked Sendable {
|
||||||
private var done = false
|
private var done = false
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
@ -110,7 +130,11 @@ final class ClearKeyDashProxy {
|
||||||
"Origin": ViaplayPlaybackHeaders.origin,
|
"Origin": ViaplayPlaybackHeaders.origin,
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
]
|
]
|
||||||
return URLSession(configuration: cfg)
|
return URLSession(
|
||||||
|
configuration: cfg,
|
||||||
|
delegate: viaplayDenyRedirects,
|
||||||
|
delegateQueue: nil
|
||||||
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt).
|
/// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt).
|
||||||
|
|
@ -1082,11 +1106,13 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) {
|
private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) {
|
||||||
var req = URLRequest(url: url)
|
var current = url
|
||||||
|
for _ in 0..<8 {
|
||||||
|
var req = URLRequest(url: current)
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = 15
|
||||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
||||||
let browser = withState { useViaplayBrowserHeaders } || ViaplayPlaybackHeaders.matches(url)
|
let browser = withState { useViaplayBrowserHeaders } || ViaplayPlaybackHeaders.matches(current)
|
||||||
let session: URLSession
|
let session: URLSession
|
||||||
if browser {
|
if browser {
|
||||||
ViaplayPlaybackHeaders.apply(to: &req)
|
ViaplayPlaybackHeaders.apply(to: &req)
|
||||||
|
|
@ -1096,10 +1122,26 @@ final class ClearKeyDashProxy {
|
||||||
session = urlSession
|
session = urlSession
|
||||||
}
|
}
|
||||||
let (data, resp) = try await session.data(for: req)
|
let (data, resp) = try await session.data(for: req)
|
||||||
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
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)
|
throw ProxyError.upstream(http.statusCode)
|
||||||
}
|
}
|
||||||
return (data, resp.url ?? url)
|
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)
|
||||||
|
}
|
||||||
|
throw ProxyError.upstream(310)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel.
|
/// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel.
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ struct SettingsScreen: View {
|
||||||
|
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
LabeledContent("App", value: "VMC iOS")
|
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("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
||||||
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
184
apps/master-api/src/viaplay/cdn-proxy.ts
Normal file
184
apps/master-api/src/viaplay/cdn-proxy.ts
Normal file
|
|
@ -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<string, unknown> | 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(/<BaseURL>([\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 `<BaseURL>${toProxyUrl(abs, prefix)}</BaseURL>`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fallback hieronder */
|
||||||
|
}
|
||||||
|
const dir = originMpd.replace(/\/[^/]*$/, "/");
|
||||||
|
return `<BaseURL>${toProxyUrl(dir, prefix)}</BaseURL>`;
|
||||||
|
});
|
||||||
|
if (!/<BaseURL/i.test(out)) {
|
||||||
|
const dir = originMpd.replace(/\/[^/]*$/, "/");
|
||||||
|
out = out.replace(
|
||||||
|
/<MPD\b[^>]*>/i,
|
||||||
|
(open) => `${open}\n <BaseURL>${toProxyUrl(dir, prefix)}</BaseURL>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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<string, unknown>);
|
||||||
|
if (!isAllowedViaplayOrigin(origin)) {
|
||||||
|
return reply.status(400).send("Viaplay-proxy host geweigerd");
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = request.headers.range;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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 = /<MPD[\s>]/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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1386,6 +1386,7 @@ export async function fetchMpdXml(
|
||||||
Accept: "*/*",
|
Accept: "*/*",
|
||||||
"User-Agent": VIAPLAY_USER_AGENT,
|
"User-Agent": VIAPLAY_USER_AGENT,
|
||||||
Referer: "https://viaplay.com/",
|
Referer: "https://viaplay.com/",
|
||||||
|
Origin: "https://viaplay.com",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { SubtitleSource } from "../opensubtitles/client";
|
||||||
import { GooglePlayAccessService } from "../google-play/service";
|
import { GooglePlayAccessService } from "../google-play/service";
|
||||||
import { subscribeViewersChanged } from "./admin-events";
|
import { subscribeViewersChanged } from "./admin-events";
|
||||||
import { registerAndroidTvClientUpdateRoutes } from "../install/routes";
|
import { registerAndroidTvClientUpdateRoutes } from "../install/routes";
|
||||||
|
import { registerViaplayCdnProxy, wrapViaplayCdnUrl } from "../viaplay/cdn-proxy";
|
||||||
|
|
||||||
export function registerViewerRoutes(
|
export function registerViewerRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
|
|
@ -20,6 +21,7 @@ export function registerViewerRoutes(
|
||||||
const viewers = new ViewerService(config, playback, downloads);
|
const viewers = new ViewerService(config, playback, downloads);
|
||||||
const profiles = new ViewerProfileService();
|
const profiles = new ViewerProfileService();
|
||||||
const googlePlay = new GooglePlayAccessService(config);
|
const googlePlay = new GooglePlayAccessService(config);
|
||||||
|
registerViaplayCdnProxy(app, config);
|
||||||
|
|
||||||
registerAndroidTvClientUpdateRoutes(app, (authorization) =>
|
registerAndroidTvClientUpdateRoutes(app, (authorization) =>
|
||||||
viewers.authFromBearer(authorization)
|
viewers.authFromBearer(authorization)
|
||||||
|
|
@ -1090,7 +1092,11 @@ export function registerViewerRoutes(
|
||||||
eventId: `viaplay-content:${guid}`,
|
eventId: `viaplay-content:${guid}`,
|
||||||
name: displayTitle,
|
name: displayTitle,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
streamUrl: played.streamUrl,
|
streamUrl: wrapViaplayCdnUrl({
|
||||||
|
publicBase: config.PUBLIC_URL,
|
||||||
|
secret: config.SESSION_SECRET,
|
||||||
|
originUrl: played.streamUrl,
|
||||||
|
}),
|
||||||
format: played.format,
|
format: played.format,
|
||||||
fallbackStreamUrl: null,
|
fallbackStreamUrl: null,
|
||||||
fallbackFormat: null,
|
fallbackFormat: null,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { prisma } from "../database/client";
|
||||||
import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art";
|
import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art";
|
||||||
import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art";
|
import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art";
|
||||||
import { applyCategoryFallbacks } from "./event-fallbacks";
|
import { applyCategoryFallbacks } from "./event-fallbacks";
|
||||||
|
import { wrapViaplayCdnUrl } from "../viaplay/cdn-proxy";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
@ -1130,7 +1131,11 @@ export async function getScheduleEventPlay(
|
||||||
eventId: pub.id,
|
eventId: pub.id,
|
||||||
name: pub.name,
|
name: pub.name,
|
||||||
logoUrl: pub.logoUrl ?? pub.imagePortrait,
|
logoUrl: pub.logoUrl ?? pub.imagePortrait,
|
||||||
streamUrl: played.streamUrl,
|
streamUrl: wrapViaplayCdnUrl({
|
||||||
|
publicBase: config.PUBLIC_URL,
|
||||||
|
secret: config.SESSION_SECRET,
|
||||||
|
originUrl: played.streamUrl,
|
||||||
|
}),
|
||||||
format: played.format,
|
format: played.format,
|
||||||
fallbackStreamUrl: null,
|
fallbackStreamUrl: null,
|
||||||
fallbackFormat: null,
|
fallbackFormat: null,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue