F1TV: Widevine-sessies via CDM naar ClearKey-sleutels voor de app.
This commit is contained in:
parent
7b25b266f8
commit
cc9f3b175a
7 changed files with 239 additions and 4 deletions
57
apps/master-api/scripts/f1tv_cdm.py
Normal file
57
apps/master-api/scripts/f1tv_cdm.py
Normal 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)
|
||||
143
apps/master-api/src/f1tv/cdm.ts
Normal file
143
apps/master-api/src/f1tv/cdm.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "\
|
||||
|
|
|
|||
Loading…
Reference in a new issue