Stop proxying Viaplay video through the API.

Clients fetch the CDN directly; Don't go stays a User-Agent problem on the device, not a server relay.
This commit is contained in:
Jos Vooges | STH 2026-09-28 21:15:39 +02:00
parent abdd829615
commit 6b2eaf89c6
3 changed files with 2 additions and 197 deletions

View file

@ -1,184 +0,0 @@
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);
}
);
}

View file

@ -10,7 +10,6 @@ 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,
@ -21,7 +20,6 @@ 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)
@ -1092,11 +1090,7 @@ export function registerViewerRoutes(
eventId: `viaplay-content:${guid}`,
name: displayTitle,
logoUrl: null,
streamUrl: wrapViaplayCdnUrl({
publicBase: config.PUBLIC_URL,
secret: config.SESSION_SECRET,
originUrl: played.streamUrl,
}),
streamUrl: played.streamUrl,
format: played.format,
fallbackStreamUrl: null,
fallbackFormat: null,

View file

@ -5,7 +5,6 @@ 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;
@ -1131,11 +1130,7 @@ export async function getScheduleEventPlay(
eventId: pub.id,
name: pub.name,
logoUrl: pub.logoUrl ?? pub.imagePortrait,
streamUrl: wrapViaplayCdnUrl({
publicBase: config.PUBLIC_URL,
secret: config.SESSION_SECRET,
originUrl: played.streamUrl,
}),
streamUrl: played.streamUrl,
format: played.format,
fallbackStreamUrl: null,
fallbackFormat: null,