diff --git a/apps/admin-ui/src/app/drm/page.tsx b/apps/admin-ui/src/app/drm/page.tsx index bed79ce..3921767 100644 --- a/apps/admin-ui/src/app/drm/page.tsx +++ b/apps/admin-ui/src/app/drm/page.tsx @@ -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) { diff --git a/apps/admin-ui/src/components/Nav.tsx b/apps/admin-ui/src/components/Nav.tsx index b2ca82f..c0d70e1 100644 --- a/apps/admin-ui/src/components/Nav.tsx +++ b/apps/admin-ui/src/components/Nav.tsx @@ -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" }, diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index faf69e3..6859867 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -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) => { diff --git a/apps/master-api/src/drm/keys.ts b/apps/master-api/src/drm/keys.ts index 82257a3..017cae0 100644 --- a/apps/master-api/src/drm/keys.ts +++ b/apps/master-api/src/drm/keys.ts @@ -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 { + 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, + }; +} diff --git a/apps/master-api/src/viewer/live-channels.ts b/apps/master-api/src/viewer/live-channels.ts index 52f33d7..6f0ecd9 100644 --- a/apps/master-api/src/viewer/live-channels.ts +++ b/apps/master-api/src/viewer/live-channels.ts @@ -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 { diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index fddb272..3c2162e 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -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(); - 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 { 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);