F1TV: drm.scheme (cbcs) meesturen zodat de app zelf kan ontsleutelen.

This commit is contained in:
Jos Vooges | STH 2026-09-27 21:36:10 +02:00
parent cc9f3b175a
commit e89ba46eb8
4 changed files with 21 additions and 9 deletions

View file

@ -107,21 +107,25 @@ async function fetchMasterPlaylist(url: string, userAgent: string): Promise<stri
} }
} }
/** ClearKey-sleutels voor een Widevine-versleutelde F1TV HLS-stream (leeg = stream is clear). */ /**
* ClearKey-sleutels voor een Widevine-versleutelde F1TV HLS-stream (leeg = stream is clear).
* `scheme` = "cbcs" bij SAMPLE-AES: Android ClearKey kan alleen AES-CTR, de app ontsleutelt dan zelf.
*/
export async function getF1tvClearKeys(input: { export async function getF1tvClearKeys(input: {
streamUrl: string; streamUrl: string;
licenseUrl: string; licenseUrl: string;
ascendontoken: string; ascendontoken: string;
entitlementtoken: string; entitlementtoken: string;
userAgent: string; userAgent: string;
}): Promise<Array<{ kid: string; key: string }>> { }): Promise<{ keys: Array<{ kid: string; key: string }>; scheme: "cbcs" | "cenc" | null }> {
const master = await fetchMasterPlaylist(input.streamUrl, input.userAgent); const master = await fetchMasterPlaylist(input.streamUrl, input.userAgent);
const psshList = extractWidevinePsshFromHls(master); const psshList = extractWidevinePsshFromHls(master);
if (!psshList.length) return []; if (!psshList.length) return { keys: [], scheme: null };
const scheme = /^#EXT-X-(SESSION-)?KEY:.*METHOD=SAMPLE-AES/im.test(master) ? "cbcs" : "cenc";
const cacheKey = psshList.slice().sort().join("|"); const cacheKey = psshList.slice().sort().join("|");
const hit = keyCache.get(cacheKey); const hit = keyCache.get(cacheKey);
if (hit && Date.now() - hit.at < KEY_CACHE_TTL_MS) return hit.keys; if (hit && Date.now() - hit.at < KEY_CACHE_TTL_MS) return { keys: hit.keys, scheme };
const raw = await runCdm({ const raw = await runCdm({
wvdPath: await resolveWvdPath(), wvdPath: await resolveWvdPath(),
@ -139,5 +143,5 @@ export async function getF1tvClearKeys(input: {
.filter((k): k is { kid: string; key: string } => !!k); .filter((k): k is { kid: string; key: string } => !!k);
if (!keys.length) throw new Error("F1TV license leverde geen content-sleutels"); if (!keys.length) throw new Error("F1TV license leverde geen content-sleutels");
keyCache.set(cacheKey, { keys, at: Date.now() }); keyCache.set(cacheKey, { keys, at: Date.now() });
return keys; return { keys, scheme };
} }

View file

@ -24,6 +24,8 @@ export async function resolveF1tvPlay(
feedCount: number; feedCount: number;
/** ClearKey kid/key (hex) voor Widevine-sessies; leeg bij clear streams. */ /** ClearKey kid/key (hex) voor Widevine-sessies; leeg bij clear streams. */
keys: Array<{ kid: string; key: string }>; keys: Array<{ kid: string; key: string }>;
/** "cbcs" = app moet zelf ontsleutelen (Android ClearKey ondersteunt alleen AES-CTR). */
drmScheme: "cbcs" | "cenc" | null;
session: F1tvSessionSnapshot | null; session: F1tvSessionSnapshot | null;
}> { }> {
const { accounts, config } = await loadF1tvSettings(sessionSecret); const { accounts, config } = await loadF1tvSettings(sessionSecret);
@ -111,18 +113,19 @@ export async function resolveF1tvPlay(
} }
let keys: Array<{ kid: string; key: string }> = []; let keys: Array<{ kid: string; key: string }> = [];
let drmScheme: "cbcs" | "cenc" | null = null;
if (played.drmType === "widevine") { if (played.drmType === "widevine") {
if (!played.licenseUrl) { if (!played.licenseUrl) {
throw new AppError("UPSTREAM_ERROR", "F1TV-stream is versleuteld maar zonder license-URL", 502); throw new AppError("UPSTREAM_ERROR", "F1TV-stream is versleuteld maar zonder license-URL", 502);
} }
try { try {
keys = await getF1tvClearKeys({ ({ keys, scheme: drmScheme } = await getF1tvClearKeys({
streamUrl: feed.url, streamUrl: feed.url,
licenseUrl: played.licenseUrl, licenseUrl: played.licenseUrl,
ascendontoken: auth.ascendontoken, ascendontoken: auth.ascendontoken,
entitlementtoken: played.entitlementToken || auth.entitlementtoken, entitlementtoken: played.entitlementToken || auth.entitlementtoken,
userAgent: DEFAULT_UA, userAgent: DEFAULT_UA,
}); }));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
throw new AppError( throw new AppError(
@ -141,6 +144,7 @@ export async function resolveF1tvPlay(
channelId: feed.channelId, channelId: feed.channelId,
feedCount: played.feeds.length, feedCount: played.feeds.length,
keys, keys,
drmScheme,
session, session,
}; };
} }

View file

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

View file

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