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";
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 "\
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue