F1TV: Widevine-sessies via CDM naar ClearKey-sleutels voor de app.

This commit is contained in:
Jos Vooges | STH 2026-09-27 21:16:50 +02:00
parent 7b25b266f8
commit cc9f3b175a
7 changed files with 239 additions and 4 deletions

View file

@ -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)

View file

@ -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<string, { keys: Array<{ kid: string; key: string }>; 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<string> {
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<string[]> {
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<string[]>((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<string> {
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<Array<{ kid: string; key: string }>> {
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;
}

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
const F1TV_ORIGIN = "https://f1tv.formula1.com"; 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"; "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<{ }): Promise<{
entitlementToken: string | null; entitlementToken: string | null;
feeds: Array<{ channelId: number | null; url: string; uuid: 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; raw: unknown;
}> { }> {
const platform = input.playPlatform || "WEB_HLS"; const platform = input.playPlatform || "WEB_HLS";
@ -180,6 +183,8 @@ export async function fetchContentPlay(input: {
resultObj?: { resultObj?: {
entitlementToken?: string; entitlementToken?: string;
url?: string; url?: string;
drmType?: string;
laURL?: string;
tme?: { tme?: {
feeds?: Array<{ feeds?: Array<{
uuid?: string; uuid?: string;
@ -212,6 +217,8 @@ export async function fetchContentPlay(input: {
return { return {
entitlementToken: obj.resultObj?.entitlementToken?.trim() || null, entitlementToken: obj.resultObj?.entitlementToken?.trim() || null,
feeds, feeds,
drmType: obj.resultObj?.drmType?.trim().toLowerCase() || null,
licenseUrl: obj.resultObj?.laURL?.trim() || null,
raw: json, raw: json,
}; };
} }

View file

@ -1,6 +1,7 @@
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { pickAccountIdForContent } from "./accounts"; 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 { loadF1tvSettings } from "./settings";
import type { F1tvSessionSnapshot } from "./tweaks"; import type { F1tvSessionSnapshot } from "./tweaks";
@ -21,6 +22,8 @@ export async function resolveF1tvPlay(
format: "hls" | "dash"; format: "hls" | "dash";
channelId: number | null; channelId: number | null;
feedCount: number; feedCount: number;
/** ClearKey kid/key (hex) voor Widevine-sessies; leeg bij clear streams. */
keys: Array<{ kid: string; key: string }>;
session: F1tvSessionSnapshot | null; session: F1tvSessionSnapshot | null;
}> { }> {
const { accounts, config } = await loadF1tvSettings(sessionSecret); 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 { return {
contentId, contentId,
name: session?.title ?? `F1TV ${contentId}`, name: session?.title ?? `F1TV ${contentId}`,
@ -114,6 +140,7 @@ export async function resolveF1tvPlay(
format: detectFormat(feed.url), format: detectFormat(feed.url),
channelId: feed.channelId, channelId: feed.channelId,
feedCount: played.feeds.length, feedCount: played.feeds.length,
keys,
session, session,
}; };
} }

View file

@ -1030,7 +1030,7 @@ export function registerViewerRoutes(
format: played.format, format: played.format,
fallbackStreamUrl: null, fallbackStreamUrl: null,
fallbackFormat: null, fallbackFormat: null,
drm: null, drm: played.keys.length ? { type: "clearkey", keys: played.keys } : null,
channelId: played.channelId, channelId: played.channelId,
feedCount: played.feedCount, feedCount: played.feedCount,
watchSessionId, watchSessionId,

View file

@ -1101,7 +1101,7 @@ export async function getScheduleEventPlay(
format: played.format, format: played.format,
fallbackStreamUrl: null, fallbackStreamUrl: null,
fallbackFormat: null, fallbackFormat: null,
drm: null, drm: played.keys.length ? { type: "clearkey" as const, keys: played.keys } : null,
watchSessionId: await attachWatch(pub), watchSessionId: await attachWatch(pub),
}; };
} }

View file

@ -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/node/upgrade-node.sh /app/install/upgrade-node.sh
COPY deploy/agent/ /app/install/agent/ COPY deploy/agent/ /app/install/agent/
COPY apps/master-api/scripts/odido_cdm.py /app/scripts/odido_cdm.py 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 # Remove workspace protocol deps before npm install
RUN node -e "\ RUN node -e "\