Rename Licenties to DRM and ingest event API ClearKeys into drm_keys.

Schedule feed kids/keys are persisted on fetch/play; admin backfill covers live lists and events.
This commit is contained in:
Jos Vooges | STH 2026-09-23 01:02:41 +02:00
parent 62a7ddec63
commit fafe97e729
6 changed files with 191 additions and 20 deletions

View file

@ -130,7 +130,10 @@ export default function DrmPage() {
const d = await r.json();
if (!r.ok) throw new Error(d.message || "Backfill mislukt");
setMsg(
`Backfill: ${d.imported ?? 0} nieuw, ${d.skipped ?? 0} overgeslagen, ${d.failed ?? 0} mislukt`
`Import: ${d.imported ?? 0} nieuw · ${d.skipped ?? 0} bekend · ${d.failed ?? 0} fout` +
(d.liveChannels || d.scheduleEvents
? ` (live ${d.liveChannels?.imported ?? 0}/${d.liveChannels?.skipped ?? 0}, events ${d.scheduleEvents?.imported ?? 0}/${d.scheduleEvents?.skipped ?? 0})`
: "")
);
await load(search, source);
} catch (e) {

View file

@ -14,7 +14,7 @@ const desktopLinks = [
{ href: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" },
{ href: "/live-lists", label: "Live-lijsten" },
{ href: "/drm", label: "Licenties" },
{ href: "/drm", label: "DRM" },
{ href: "/scripts", label: "Scripts" },
{ href: "/agent", label: "Agent" },
{ href: "/viewers", label: "Gebruikers" },

View file

@ -1964,8 +1964,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
});
app.post("/api/v1/admin/drm-keys/backfill", { preHandler: requireAdmin }, async () => {
const { backfillDrmKeysFromLiveChannels } = await import("../drm/keys");
return backfillDrmKeysFromLiveChannels(prisma, config.SESSION_SECRET);
const { backfillAllDrmKeys } = await import("../drm/keys");
const { harvestScheduleEventDrmKeys } = await import("../viewer/schedule-events");
return backfillAllDrmKeys(prisma, config.SESSION_SECRET, () =>
harvestScheduleEventDrmKeys(config)
);
});
app.post("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {

View file

@ -292,3 +292,37 @@ export async function backfillDrmKeysFromLiveChannels(
}
return { imported, skipped, failed };
}
export type DrmBackfillResult = {
imported: number;
skipped: number;
failed: number;
liveChannels: { imported: number; skipped: number; failed: number };
scheduleEvents: { imported: number; skipped: number; failed: number };
};
/** Live-lijsten + schedule/events-feed (kids/keys in de API). */
export async function backfillAllDrmKeys(
prisma: PrismaClient,
sessionSecret: string,
harvestScheduleEvents: () => Promise<{
imported: number;
skipped: number;
failed: number;
}>
): Promise<DrmBackfillResult> {
const liveChannels = await backfillDrmKeysFromLiveChannels(prisma, sessionSecret);
let scheduleEvents = { imported: 0, skipped: 0, failed: 0 };
try {
scheduleEvents = await harvestScheduleEvents();
} catch {
scheduleEvents = { imported: 0, skipped: 0, failed: 1 };
}
return {
imported: liveChannels.imported + scheduleEvents.imported,
skipped: liveChannels.skipped + scheduleEvents.skipped,
failed: liveChannels.failed + scheduleEvents.failed,
liveChannels,
scheduleEvents,
};
}

View file

@ -237,6 +237,24 @@ export async function createLiveChannel(
enabled: body.enabled !== false,
},
});
if (hasKid && hasKey) {
try {
const { upsertDrmKey } = await import("../drm/keys");
const listMeta = await prisma.liveChannelList.findUnique({
where: { id: listId },
select: { provider: true },
});
await upsertDrmKey(prisma, sessionSecret, {
kid: normalizeHexKey(kid!, "kid"),
key: normalizeHexKey(key!, "key"),
mpdUrl,
channelName: name,
source: listMeta?.provider || "live-channel",
});
} catch {
/* DRM-db mag create niet breken */
}
}
return getLiveChannelListAdmin(listId);
}
@ -290,6 +308,25 @@ export async function updateLiveChannel(
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
},
});
if (hasKid && hasKey) {
try {
const { upsertDrmKey } = await import("../drm/keys");
const listMeta = await prisma.liveChannelList.findUnique({
where: { id: existing.listId },
select: { provider: true },
});
await upsertDrmKey(prisma, sessionSecret, {
kid: normalizeHexKey(kidRaw!, "kid"),
key: normalizeHexKey(keyRaw!, "key"),
mpdUrl: body.mpdUrl?.trim() || existing.mpdUrl,
channelName: body.name?.trim() || existing.name,
source: listMeta?.provider || "live-channel",
externalId: existing.externalId,
});
} catch {
/* best-effort */
}
}
return getLiveChannelListAdmin(existing.listId);
}
@ -480,6 +517,24 @@ export async function getCustomPlayUrl(
}
: null;
if (drm?.keys?.length) {
try {
const { upsertDrmKey } = await import("../drm/keys");
for (const k of drm.keys) {
await upsertDrmKey(prisma, sessionSecret, {
kid: k.kid,
key: k.key,
mpdUrl: streamUrl || channel.mpdUrl,
channelName: channel.name,
source: channel.list?.provider || channel.list?.name || "live-channel",
externalId: channel.externalId,
});
}
} catch {
/* best-effort */
}
}
const sid = customStreamId(channel.id);
let watchSessionId: string | null = null;
try {

View file

@ -552,34 +552,101 @@ function phaseFor(start: string, stop: string, isLiveFlag: boolean, nowMs: numbe
}
function extractKids(raw: RawEvent): Array<{ kid: string; key: string }> {
const fromKids = Array.isArray(raw.kids) ? raw.kids : [];
const out: Array<{ kid: string; key: string }> = [];
const seen = new Set<string>();
for (const item of fromKids) {
const k = item as RawKid;
const kid = str(k.kid).toLowerCase();
const key = str(k.key).toLowerCase();
if (!/^[0-9a-f]{32}$/.test(kid) || !/^[0-9a-f]{32}$/.test(key)) continue;
if (seen.has(kid)) continue;
const push = (kidRaw: unknown, keyRaw: unknown) => {
const kid = str(kidRaw).toLowerCase().replace(/[^0-9a-f]/g, "");
const key = str(keyRaw).toLowerCase().replace(/[^0-9a-f]/g, "");
if (!/^[0-9a-f]{32}$/.test(kid) || !/^[0-9a-f]{32}$/.test(key)) return;
if (seen.has(kid)) return;
seen.add(kid);
out.push({ kid, key });
};
const fromKids = Array.isArray(raw.kids) ? raw.kids : [];
for (const item of fromKids) {
if (item && typeof item === "object") {
const k = item as RawKid;
push(k.kid, k.key);
} else {
const s = str(item);
const m = s.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
if (m) push(m[1], m[2]);
}
}
if (out.length) return out;
const keyPairs = Array.isArray(raw.keys) ? raw.keys : [];
for (const item of keyPairs) {
const s = str(item);
const m = s.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
if (!m) continue;
const kid = m[1].toLowerCase();
const key = m[2].toLowerCase();
if (seen.has(kid)) continue;
seen.add(kid);
out.push({ kid, key });
if (item && typeof item === "object") {
const k = item as RawKid;
push(k.kid, k.key);
} else {
const s = str(item);
const m = s.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
if (m) push(m[1], m[2]);
}
}
// Sommige feeds zetten één string op top-level
for (const field of [raw.keys, (raw as { key?: unknown }).key] as unknown[]) {
if (typeof field === "string") {
const m = field.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
if (m) push(m[1], m[2]);
}
}
return out;
}
async function persistKidsFromRawEvents(
config: Config,
events: RawEvent[]
): Promise<{ imported: number; skipped: number; failed: number }> {
const { upsertDrmKey } = await import("../drm/keys");
let imported = 0;
let skipped = 0;
let failed = 0;
for (const raw of events) {
const keys = extractKids(raw);
if (!keys.length) continue;
const eventName = str(raw.name) || str(raw.epg?.title) || null;
const channelName = cleanChannelLabel(
str(raw.epg?.channel) || str(raw.metadata?.zender)
);
const mpdUrl = str(raw.mpd) || str(raw.manifest_url) || null;
const provider = (str(raw.provider) || "event").toLowerCase();
const externalId = str(raw.id) || null;
for (const k of keys) {
try {
const result = await upsertDrmKey(prisma, config.SESSION_SECRET, {
kid: k.kid,
key: k.key,
mpdUrl,
eventName,
channelName,
source: provider,
externalId,
});
if (result.created) imported += 1;
else skipped += 1;
} catch {
failed += 1;
}
}
}
return { imported, skipped, failed };
}
/** Force-fetch schedule feed and ingest all kids/keys into drm_keys. */
export async function harvestScheduleEventDrmKeys(
config: Config
): Promise<{ imported: number; skipped: number; failed: number }> {
cache = null;
const raw = await loadRawEvents(config);
return persistKidsFromRawEvents(config, raw.events);
}
function isPlayable(raw: RawEvent): boolean {
const mpd = str(raw.mpd) || str(raw.manifest_url);
if (!mpd) return false;
@ -774,6 +841,10 @@ async function loadRawEvents(config: Config): Promise<CacheEntry> {
events,
generatedAt: str(json.Meta?.generated_at) || null,
};
// Keys uit de feed persistent maken (async, mag list niet blokkeren)
void persistKidsFromRawEvents(config, events).catch(() => {
/* best-effort */
});
return cache;
} catch (err) {
if (cache && cache.url === url) return cache;
@ -1101,6 +1172,11 @@ export async function getScheduleEventPlay(
);
}
// Zorg dat gespeelde event-keys altijd in drm_keys belanden
void persistKidsFromRawEvents(config, [found]).catch(() => {
/* best-effort */
});
// ESPN/Disney e.d. leveren HLS/CMAF (.m3u8); Prime/Viaplay meestal DASH (.mpd).
// FAAB-veld heet vaak `mpd` ook als de URL een m3u8 is — nooit blind "dash" aannemen.
const format = detectLiveManifestFormat(streamUrl);