diff --git a/apps/master-api/scripts/f1tv_cdm.py b/apps/master-api/scripts/f1tv_cdm.py new file mode 100644 index 0000000..2638ee5 --- /dev/null +++ b/apps/master-api/scripts/f1tv_cdm.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""F1TV Widevine license helper. Requires: pip install pywidevine requests""" +import json +import sys + +import requests +from pywidevine.cdm import Cdm +from pywidevine.device import Device +from pywidevine.pssh import PSSH + + +def main(): + data = json.load(sys.stdin) + device = Device.load(data["wvdPath"]) + headers = { + "Accept": "*/*", + "Origin": "https://f1tv.formula1.com", + "Referer": "https://f1tv.formula1.com/", + "User-Agent": data.get("userAgent") + or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36", + "Content-Type": "application/octet-stream", + "ascendontoken": data["ascendontoken"], + "entitlementtoken": data["entitlementtoken"], + } + keys = [] + for pssh in data["psshList"]: + cdm = Cdm.from_device(device) + session_id = cdm.open() + try: + # Browser vraagt eerst het service-certificaat op (body 08 04) en gebruikt privacy mode. + try: + cert = requests.post(data["licUrl"], headers=headers, data=b"\x08\x04", timeout=30) + if cert.ok and cert.content: + cdm.set_service_certificate(session_id, cert.content) + except Exception: + pass + challenge = cdm.get_license_challenge(session_id, PSSH(pssh)) + response = requests.post(data["licUrl"], headers=headers, data=challenge, timeout=30) + if not response.ok: + raise RuntimeError(f"license HTTP {response.status_code}: {response.text[:200]}") + cdm.parse_license(session_id, response.content) + for key in cdm.get_keys(session_id): + if key.type == "CONTENT": + pair = f"{key.kid.hex}:{key.key.hex()}" + if pair not in keys: + keys.append(pair) + finally: + cdm.close(session_id) + print(json.dumps({"keys": keys}, separators=(",", ":"))) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) diff --git a/apps/master-api/src/f1tv/cdm.ts b/apps/master-api/src/f1tv/cdm.ts new file mode 100644 index 0000000..b245014 --- /dev/null +++ b/apps/master-api/src/f1tv/cdm.ts @@ -0,0 +1,143 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { prisma } from "../database/client"; + +const WIDEVINE_KEYFORMAT = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"; +const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd"; +const KEY_CACHE_TTL_MS = 12 * 60 * 60 * 1000; + +const keyCache = new Map; at: number }>(); + +/** Widevine PSSH-boxen (base64) uit `EXT-X-SESSION-KEY` / `EXT-X-KEY` in een HLS-playlist. */ +export function extractWidevinePsshFromHls(playlist: string): string[] { + const out: string[] = []; + for (const line of playlist.split(/\r?\n/)) { + if (!/^#EXT-X-(SESSION-)?KEY:/i.test(line)) continue; + const format = /KEYFORMAT="([^"]+)"/i.exec(line)?.[1]?.toLowerCase(); + const uri = /URI="data:[^;"]*;base64,([^"]+)"/i.exec(line)?.[1]; + if (!uri) continue; + if (format && format !== WIDEVINE_KEYFORMAT) continue; + if (!out.includes(uri)) out.push(uri); + } + return out; +} + +async function resolveWvdPath(): Promise { + try { + const odido = await prisma.integrationSetting.findUnique({ where: { id: "odido" } }); + if (odido?.configJson) { + const parsed = JSON.parse(odido.configJson) as { wvdPath?: string }; + const path = String(parsed.wvdPath ?? "").trim(); + if (path && existsSync(path)) return path; + } + } catch { + /* fall through */ + } + return process.env.ODIDO_WVD_PATH?.trim() || DEFAULT_WVD_PATH; +} + +function runCdm(input: { + wvdPath: string; + psshList: string[]; + licUrl: string; + ascendontoken: string; + entitlementtoken: string; + userAgent: string; +}): Promise { + const cwdScript = resolve(process.cwd(), "scripts", "f1tv_cdm.py"); + const script = existsSync(cwdScript) + ? cwdScript + : resolve(__dirname, "..", "..", "scripts", "f1tv_cdm.py"); + const python = process.env.PYTHON_ODIDO?.trim() || "python3"; + + return new Promise((resolvePromise, reject) => { + const child = spawn(python, [script], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill(); + reject(new Error("F1TV CDM-time-out")); + }, 60_000); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + child.on("error", (error) => { + clearTimeout(timer); + reject(new Error(`F1TV CDM kon niet starten: ${error.message}`)); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`F1TV CDM mislukt: ${stderr.trim() || `exit ${code}`}`)); + return; + } + try { + const output = JSON.parse(stdout) as { keys?: unknown }; + if (!Array.isArray(output.keys) || !output.keys.every((key) => typeof key === "string")) { + throw new Error("ongeldige CDM-uitvoer"); + } + resolvePromise(output.keys); + } catch (error) { + reject(new Error(`F1TV CDM-uitvoer ongeldig: ${(error as Error).message}`)); + } + }); + child.stdin.end(JSON.stringify(input)); + }); +} + +async function fetchMasterPlaylist(url: string, userAgent: string): Promise { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 20_000); + try { + const res = await fetch(url, { + headers: { Accept: "*/*", "User-Agent": userAgent, Origin: "https://f1tv.formula1.com" }, + signal: ac.signal, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`master playlist HTTP ${res.status}`); + return text; + } finally { + clearTimeout(timer); + } +} + +/** ClearKey-sleutels voor een Widevine-versleutelde F1TV HLS-stream (leeg = stream is clear). */ +export async function getF1tvClearKeys(input: { + streamUrl: string; + licenseUrl: string; + ascendontoken: string; + entitlementtoken: string; + userAgent: string; +}): Promise> { + const master = await fetchMasterPlaylist(input.streamUrl, input.userAgent); + const psshList = extractWidevinePsshFromHls(master); + if (!psshList.length) return []; + + const cacheKey = psshList.slice().sort().join("|"); + const hit = keyCache.get(cacheKey); + if (hit && Date.now() - hit.at < KEY_CACHE_TTL_MS) return hit.keys; + + const raw = await runCdm({ + wvdPath: await resolveWvdPath(), + psshList, + licUrl: input.licenseUrl, + ascendontoken: input.ascendontoken, + entitlementtoken: input.entitlementtoken, + userAgent: input.userAgent, + }); + const keys = raw + .map((pair) => { + const [kid, key] = pair.split(":"); + return kid && key ? { kid: kid.toLowerCase(), key: key.toLowerCase() } : null; + }) + .filter((k): k is { kid: string; key: string } => !!k); + if (!keys.length) throw new Error("F1TV license leverde geen content-sleutels"); + keyCache.set(cacheKey, { keys, at: Date.now() }); + return keys; +} diff --git a/apps/master-api/src/f1tv/client.ts b/apps/master-api/src/f1tv/client.ts index 4b87630..56bf919 100644 --- a/apps/master-api/src/f1tv/client.ts +++ b/apps/master-api/src/f1tv/client.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; const F1TV_ORIGIN = "https://f1tv.formula1.com"; -const DEFAULT_UA = +export const DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; /** @@ -159,6 +159,9 @@ export async function fetchContentPlay(input: { }): Promise<{ entitlementToken: string | null; feeds: Array<{ channelId: number | null; url: string; uuid: string | null }>; + /** `widevine` bij versleutelde sessies (HAR live race), anders null. */ + drmType: string | null; + licenseUrl: string | null; raw: unknown; }> { const platform = input.playPlatform || "WEB_HLS"; @@ -180,6 +183,8 @@ export async function fetchContentPlay(input: { resultObj?: { entitlementToken?: string; url?: string; + drmType?: string; + laURL?: string; tme?: { feeds?: Array<{ uuid?: string; @@ -212,6 +217,8 @@ export async function fetchContentPlay(input: { return { entitlementToken: obj.resultObj?.entitlementToken?.trim() || null, feeds, + drmType: obj.resultObj?.drmType?.trim().toLowerCase() || null, + licenseUrl: obj.resultObj?.laURL?.trim() || null, raw: json, }; } diff --git a/apps/master-api/src/f1tv/play.ts b/apps/master-api/src/f1tv/play.ts index 19683fb..fd9f75d 100644 --- a/apps/master-api/src/f1tv/play.ts +++ b/apps/master-api/src/f1tv/play.ts @@ -1,6 +1,7 @@ import { AppError } from "../security/errors"; import { pickAccountIdForContent } from "./accounts"; -import { ensureSessionId, fetchContentPlay, pickDefaultFeed } from "./client"; +import { getF1tvClearKeys } from "./cdm"; +import { DEFAULT_UA, ensureSessionId, fetchContentPlay, pickDefaultFeed } from "./client"; import { loadF1tvSettings } from "./settings"; import type { F1tvSessionSnapshot } from "./tweaks"; @@ -21,6 +22,8 @@ export async function resolveF1tvPlay( format: "hls" | "dash"; channelId: number | null; feedCount: number; + /** ClearKey kid/key (hex) voor Widevine-sessies; leeg bij clear streams. */ + keys: Array<{ kid: string; key: string }>; session: F1tvSessionSnapshot | null; }> { const { accounts, config } = await loadF1tvSettings(sessionSecret); @@ -107,6 +110,29 @@ export async function resolveF1tvPlay( ); } + let keys: Array<{ kid: string; key: string }> = []; + if (played.drmType === "widevine") { + if (!played.licenseUrl) { + throw new AppError("UPSTREAM_ERROR", "F1TV-stream is versleuteld maar zonder license-URL", 502); + } + try { + keys = await getF1tvClearKeys({ + streamUrl: feed.url, + licenseUrl: played.licenseUrl, + ascendontoken: auth.ascendontoken, + entitlementtoken: played.entitlementToken || auth.entitlementtoken, + userAgent: DEFAULT_UA, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new AppError( + "UPSTREAM_ERROR", + `F1TV DRM-sleutels ophalen mislukt: ${msg.slice(0, 160)}`, + 502 + ); + } + } + return { contentId, name: session?.title ?? `F1TV ${contentId}`, @@ -114,6 +140,7 @@ export async function resolveF1tvPlay( format: detectFormat(feed.url), channelId: feed.channelId, feedCount: played.feeds.length, + keys, session, }; } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 0d5b0c8..0318ff0 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1030,7 +1030,7 @@ export function registerViewerRoutes( format: played.format, fallbackStreamUrl: null, fallbackFormat: null, - drm: null, + drm: played.keys.length ? { type: "clearkey", keys: played.keys } : null, channelId: played.channelId, feedCount: played.feedCount, watchSessionId, diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index 3c2162e..04e6593 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -1101,7 +1101,7 @@ export async function getScheduleEventPlay( format: played.format, fallbackStreamUrl: null, fallbackFormat: null, - drm: null, + drm: played.keys.length ? { type: "clearkey" as const, keys: played.keys } : null, watchSessionId: await attachWatch(pub), }; } diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api index 8c8834b..6fdfdd0 100644 --- a/deploy/docker/Dockerfile.master-api +++ b/deploy/docker/Dockerfile.master-api @@ -51,6 +51,7 @@ COPY deploy/node/install-synology.sh /app/install/synology.sh COPY deploy/node/upgrade-node.sh /app/install/upgrade-node.sh COPY deploy/agent/ /app/install/agent/ COPY apps/master-api/scripts/odido_cdm.py /app/scripts/odido_cdm.py +COPY apps/master-api/scripts/f1tv_cdm.py /app/scripts/f1tv_cdm.py # Remove workspace protocol deps before npm install RUN node -e "\