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:
parent
abdd829615
commit
6b2eaf89c6
3 changed files with 2 additions and 197 deletions
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue