Add searchable DRM key database in admin for reused ClearKeys.

Persist kids/keys with channel/event/MPD metadata, expose /drm search UI, and record keys from Odido sync/play for cache-first reuse.
This commit is contained in:
Jos Vooges | STH 2026-09-22 21:50:50 +02:00
parent d8a1c28283
commit 4b1f9eb763
9 changed files with 813 additions and 2 deletions

View file

@ -0,0 +1,325 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
type DrmKeyRow = {
id: string;
kid: string;
keyMasked: string;
hasKey: boolean;
mpdUrl: string | null;
channelName: string | null;
eventName: string | null;
source: string;
externalId: string | null;
mediaGuid: string | null;
lastSeenAt: string;
createdAt: string;
updatedAt: string;
};
function shortUrl(url: string | null, max = 48): string {
if (!url) return "—";
const s = url.trim();
if (s.length <= max) return s;
try {
const u = new URL(s);
const path = u.pathname.split("/").filter(Boolean).pop() ?? "";
const host = u.host.replace(/^www\./, "");
const tip = path ? `${host}/…/${path}` : host;
return tip.length <= max ? tip : `${tip.slice(0, max - 1)}…`;
} catch {
return `${s.slice(0, max - 1)}…`;
}
}
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("nl-NL", {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
export default function DrmPage() {
useAuth();
const [search, setSearch] = useState("");
const [source, setSource] = useState("");
const [keys, setKeys] = useState<DrmKeyRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async (q: string, src: string) => {
setLoading(true);
setErr(null);
try {
const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim());
if (src.trim()) params.set("source", src.trim());
params.set("limit", "200");
const r = await fetch(`/api/v1/admin/drm-keys?${params}`, {
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.message || d.error || "Laden mislukt");
setKeys(d.keys ?? []);
setTotal(d.total ?? 0);
if (d.requiresQuery) {
setMsg("Typ minstens 2 tekens om te zoeken, of laat leeg voor recente keys.");
} else {
setMsg(null);
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load("", "");
}, [load]);
function onSearch(e: FormEvent) {
e.preventDefault();
void load(search, source);
}
async function reveal(id: string) {
setBusyId(id);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/drm-keys/${id}/reveal`, {
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.message || "Reveal mislukt");
setRevealed((prev) => ({ ...prev, [id]: d.key as string }));
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}
async function copyText(label: string, text: string) {
try {
await navigator.clipboard.writeText(text);
setMsg(`${label} gekopieerd`);
} catch {
setErr("Clipboard niet beschikbaar");
}
}
async function backfill() {
setBusyId("backfill");
setErr(null);
try {
const r = await fetch("/api/v1/admin/drm-keys/backfill", {
method: "POST",
credentials: "include",
});
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`
);
await load(search, source);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
if (!confirm("Deze DRM-key verwijderen uit de database?")) return;
setBusyId(id);
try {
const r = await fetch(`/api/v1/admin/drm-keys/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.message || "Verwijderen mislukt");
}
setKeys((prev) => prev.filter((k) => k.id !== id));
setMsg("Key verwijderd");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}
return (
<div className="page">
<Nav />
<main className="container">
<header className="page-header">
<div>
<p className="page-kicker">Beveiliging</p>
<h1>DRM keys</h1>
<p className="page-lead">
Alle ooit opgehaalde ClearKey/Widevine-keys. Zoek op KID, KEY, MPD, zender of
event. Altijd eerst cache — alleen ophalen bij miss.
</p>
</div>
<div className="page-actions">
<button
type="button"
className="btn"
disabled={busyId === "backfill"}
onClick={() => void backfill()}
>
{busyId === "backfill" ? "Bezig…" : "Import uit live-lijsten"}
</button>
</div>
</header>
<form className="card" onSubmit={onSearch} style={{ marginBottom: "1rem" }}>
<div className="form-row" style={{ gap: "0.75rem", flexWrap: "wrap" }}>
<input
type="search"
placeholder="Zoek kid, key, mpd, zender, event…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: "1 1 240px" }}
/>
<select
value={source}
onChange={(e) => setSource(e.target.value)}
style={{ flex: "0 0 140px" }}
>
<option value="">Alle bronnen</option>
<option value="odido">odido</option>
<option value="viaplay">viaplay</option>
<option value="manual">manual</option>
<option value="live-channel">live-channel</option>
<option value="event">event</option>
</select>
<button type="submit" className="btn" disabled={loading}>
{loading ? "Zoeken…" : "Zoeken"}
</button>
</div>
</form>
{msg && <p className="ok-msg">{msg}</p>}
{err && <p className="err-msg">{err}</p>}
<div className="card">
<div className="card-head">
<h2>Keys</h2>
<span className="muted">{total} totaal · {keys.length} getoond</span>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>KID</th>
<th>KEY</th>
<th>Zender / event</th>
<th>MPD</th>
<th>Bron</th>
<th>Laatst gezien</th>
<th />
</tr>
</thead>
<tbody>
{keys.length === 0 && (
<tr>
<td colSpan={7} className="muted">
Geen keys. Gebruik “Import uit live-lijsten” of wacht tot sync keys
ophaalt.
</td>
</tr>
)}
{keys.map((k) => {
const fullKey = revealed[k.id];
return (
<tr key={k.id}>
<td>
<code
title={k.kid}
style={{ cursor: "pointer" }}
onClick={() => void copyText("KID", k.kid)}
>
{k.kid.length > 20 ? `${k.kid.slice(0, 16)}…` : k.kid}
</code>
</td>
<td>
{fullKey ? (
<code
style={{ cursor: "pointer" }}
onClick={() => void copyText("KEY", fullKey)}
>
{fullKey.length > 20 ? `${fullKey.slice(0, 16)}…` : fullKey}
</code>
) : (
<span className="muted">{k.hasKey ? "••••••••" : "—"}</span>
)}
</td>
<td>
<div>{k.channelName || "—"}</div>
{k.eventName && (
<div className="muted" style={{ fontSize: "0.85em" }}>
{k.eventName}
</div>
)}
</td>
<td title={k.mpdUrl ?? undefined}>
<span className="muted">{shortUrl(k.mpdUrl)}</span>
</td>
<td>
<code>{k.source}</code>
</td>
<td className="muted">{fmtWhen(k.lastSeenAt)}</td>
<td style={{ whiteSpace: "nowrap" }}>
{!fullKey && k.hasKey && (
<button
type="button"
className="btn btn-sm"
disabled={busyId === k.id}
onClick={() => void reveal(k.id)}
>
Toon
</button>
)}
{k.mpdUrl && (
<button
type="button"
className="btn btn-sm"
onClick={() => void copyText("MPD", k.mpdUrl!)}
>
MPD
</button>
)}
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busyId === k.id}
onClick={() => void remove(k.id)}
>
×
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</main>
</div>
);
}

View file

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

View file

@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "drm_keys" (
"id" TEXT NOT NULL,
"kid" TEXT NOT NULL,
"key_enc" TEXT NOT NULL,
"key_hash" TEXT NOT NULL,
"mpd_url" TEXT,
"channel_name" TEXT,
"event_name" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"external_id" TEXT,
"media_guid" TEXT,
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "drm_keys_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "drm_keys_kid_key" ON "drm_keys"("kid");
-- CreateIndex
CREATE INDEX "drm_keys_key_hash_idx" ON "drm_keys"("key_hash");
-- CreateIndex
CREATE INDEX "drm_keys_last_seen_at_idx" ON "drm_keys"("last_seen_at");
-- CreateIndex
CREATE INDEX "drm_keys_source_idx" ON "drm_keys"("source");
-- CreateIndex
CREATE INDEX "drm_keys_channel_name_idx" ON "drm_keys"("channel_name");
-- CreateIndex
CREATE INDEX "drm_keys_event_name_idx" ON "drm_keys"("event_name");

View file

@ -790,6 +790,37 @@ model LibrarySubtitle {
@@map("library_subtitles") @@map("library_subtitles")
} }
/**
* Globale ClearKey/Widevine-key cache (admin DRM-pagina).
* KID plaintext (staat vaak in MPD); KEY encrypted. Uniek op genormaliseerde KID.
*/
model DrmKey {
id String @id @default(uuid())
/** Genormaliseerde KID hex (zonder dashes), uniek */
kid String @unique
/** AES-GCM ciphertext van de KEY hex */
keyEnc String @map("key_enc") @db.Text
/** SHA-256 van genormaliseerde KEY hex — exacte key-search zonder decrypt */
keyHash String @map("key_hash")
mpdUrl String? @map("mpd_url") @db.Text
channelName String? @map("channel_name")
eventName String? @map("event_name")
/** odido | viaplay | manual | event | … */
source String @default("manual")
externalId String? @map("external_id")
mediaGuid String? @map("media_guid")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([keyHash])
@@index([lastSeenAt])
@@index([source])
@@index([channelName])
@@index([eventName])
@@map("drm_keys")
}
/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */ /** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
model GooglePlayAccessAuditLog { model GooglePlayAccessAuditLog {
id String @id @default(uuid()) id String @id @default(uuid())

View file

@ -1810,6 +1810,95 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return syncF1tvCatalog(config.SESSION_SECRET); return syncF1tvCatalog(config.SESSION_SECRET);
}); });
// --- DRM key database ---
app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
const q = request.query as { search?: string; source?: string; limit?: string };
const { searchDrmKeys } = await import("../drm/keys");
return searchDrmKeys(prisma, {
search: q.search,
source: q.source,
limit: q.limit ? Number(q.limit) : undefined,
});
});
app.get("/api/v1/admin/drm-keys/:id/reveal", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { revealDrmKey } = await import("../drm/keys");
try {
return await revealDrmKey(prisma, config.SESSION_SECRET, id);
} catch {
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
}
});
app.patch("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
channelName?: string | null;
eventName?: string | null;
mpdUrl?: string | null;
source?: string;
};
const { updateDrmKeyMeta } = await import("../drm/keys");
try {
return { key: await updateDrmKeyMeta(prisma, id, body) };
} catch {
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
}
});
app.delete("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { deleteDrmKey } = await import("../drm/keys");
try {
await deleteDrmKey(prisma, id);
return { ok: true };
} catch {
throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404);
}
});
app.post("/api/v1/admin/drm-keys/backfill", { preHandler: requireAdmin }, async () => {
const { backfillDrmKeysFromLiveChannels } = await import("../drm/keys");
return backfillDrmKeysFromLiveChannels(prisma, config.SESSION_SECRET);
});
app.post("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
kid?: string;
key?: string;
mpdUrl?: string;
channelName?: string;
eventName?: string;
source?: string;
externalId?: string;
mediaGuid?: string;
};
if (!body.kid?.trim() || !body.key?.trim()) {
throw new AppError("INVALID_REQUEST", "kid en key verplicht", 400);
}
const { upsertDrmKey } = await import("../drm/keys");
try {
const result = await upsertDrmKey(prisma, config.SESSION_SECRET, {
kid: body.kid,
key: body.key,
mpdUrl: body.mpdUrl,
channelName: body.channelName,
eventName: body.eventName,
source: body.source || "manual",
externalId: body.externalId,
mediaGuid: body.mediaGuid,
});
return result;
} catch (e) {
throw new AppError(
"INVALID_REQUEST",
e instanceof Error ? e.message : "Ongeldige key",
400
);
}
});
// --- Custom ClearKey/DASH live channel lists --- // --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");

View file

@ -0,0 +1,294 @@
import { createHash } from "crypto";
import type { PrismaClient } from "@prisma/client";
import { decryptSecret, encryptSecret } from "../security/crypto";
export type DrmKeyUpsertInput = {
kid: string;
key: string;
mpdUrl?: string | null;
channelName?: string | null;
eventName?: string | null;
source?: string;
externalId?: string | null;
mediaGuid?: string | null;
};
export type DrmKeyPublic = {
id: string;
kid: string;
keyMasked: string;
hasKey: boolean;
mpdUrl: string | null;
channelName: string | null;
eventName: string | null;
source: string;
externalId: string | null;
mediaGuid: string | null;
lastSeenAt: string;
createdAt: string;
updatedAt: string;
};
function normalizeHex(value: string): string {
return value.trim().toLowerCase().replace(/[^0-9a-f]/g, "");
}
export function normalizeKid(kid: string): string {
return normalizeHex(kid);
}
export function normalizeKey(key: string): string {
return normalizeHex(key);
}
export function hashKey(key: string): string {
return createHash("sha256").update(normalizeKey(key), "utf8").digest("hex");
}
function maskKey(key: string | null | undefined): string {
if (!key) return "";
if (key.length <= 8) return "********";
return `${key.slice(0, 4)}…${key.slice(-4)}`;
}
function toPublic(row: {
id: string;
kid: string;
keyEnc: string;
mpdUrl: string | null;
channelName: string | null;
eventName: string | null;
source: string;
externalId: string | null;
mediaGuid: string | null;
lastSeenAt: Date;
createdAt: Date;
updatedAt: Date;
}): DrmKeyPublic {
return {
id: row.id,
kid: row.kid,
keyMasked: "********",
hasKey: Boolean(row.keyEnc),
mpdUrl: row.mpdUrl,
channelName: row.channelName,
eventName: row.eventName,
source: row.source,
externalId: row.externalId,
mediaGuid: row.mediaGuid,
lastSeenAt: row.lastSeenAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/** Upsert by KID; refreshes lastSeen + metadata. Never downgrades an existing key. */
export async function upsertDrmKey(
prisma: PrismaClient,
sessionSecret: string,
input: DrmKeyUpsertInput
): Promise<{ id: string; kid: string; created: boolean }> {
const kid = normalizeKid(input.kid);
const key = normalizeKey(input.key);
if (kid.length < 16 || key.length < 16) {
throw new Error("Ongeldige kid/key");
}
const keyEnc = encryptSecret(key, sessionSecret);
const keyHash = hashKey(key);
const now = new Date();
const existing = await prisma.drmKey.findUnique({ where: { kid } });
if (existing) {
await prisma.drmKey.update({
where: { kid },
data: {
keyEnc,
keyHash,
lastSeenAt: now,
...(input.mpdUrl != null && input.mpdUrl !== ""
? { mpdUrl: input.mpdUrl }
: {}),
...(input.channelName != null && input.channelName !== ""
? { channelName: input.channelName }
: {}),
...(input.eventName != null && input.eventName !== ""
? { eventName: input.eventName }
: {}),
...(input.source ? { source: input.source } : {}),
...(input.externalId != null ? { externalId: input.externalId } : {}),
...(input.mediaGuid != null ? { mediaGuid: input.mediaGuid } : {}),
},
});
return { id: existing.id, kid, created: false };
}
const created = await prisma.drmKey.create({
data: {
kid,
keyEnc,
keyHash,
mpdUrl: input.mpdUrl?.trim() || null,
channelName: input.channelName?.trim() || null,
eventName: input.eventName?.trim() || null,
source: input.source?.trim() || "manual",
externalId: input.externalId?.trim() || null,
mediaGuid: input.mediaGuid?.trim() || null,
lastSeenAt: now,
},
});
return { id: created.id, kid, created: true };
}
export async function lookupDrmKeyByKid(
prisma: PrismaClient,
sessionSecret: string,
kidRaw: string
): Promise<{ kid: string; key: string; mpdUrl: string | null } | null> {
const kid = normalizeKid(kidRaw);
if (!kid) return null;
const row = await prisma.drmKey.findUnique({ where: { kid } });
if (!row) return null;
return {
kid: row.kid,
key: decryptSecret(row.keyEnc, sessionSecret),
mpdUrl: row.mpdUrl,
};
}
export async function searchDrmKeys(
prisma: PrismaClient,
opts: { search?: string; source?: string; limit?: number }
): Promise<{ keys: DrmKeyPublic[]; total: number; requiresQuery: boolean }> {
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
const q = (opts.search || "").trim();
const source = opts.source?.trim() || undefined;
if (q.length > 0 && q.length < 2) {
return { keys: [], total: 0, requiresQuery: true };
}
const where: Record<string, unknown> = {};
if (source) where.source = source;
if (q.length >= 2) {
const hex = normalizeHex(q);
const or: Record<string, unknown>[] = [
{ kid: { contains: hex.length >= 2 ? hex : q, mode: "insensitive" } },
{ mpdUrl: { contains: q, mode: "insensitive" } },
{ channelName: { contains: q, mode: "insensitive" } },
{ eventName: { contains: q, mode: "insensitive" } },
{ externalId: { contains: q, mode: "insensitive" } },
{ mediaGuid: { contains: q, mode: "insensitive" } },
{ source: { contains: q, mode: "insensitive" } },
];
// Exact key search via hash when query looks like a key (32+ hex)
if (hex.length >= 32) {
or.push({ keyHash: hashKey(hex) });
or.push({ kid: hex });
}
where.OR = or;
}
const [total, rows] = await Promise.all([
prisma.drmKey.count({ where }),
prisma.drmKey.findMany({
where,
orderBy: { lastSeenAt: "desc" },
take: limit,
}),
]);
return {
keys: rows.map(toPublic),
total,
requiresQuery: false,
};
}
export async function revealDrmKey(
prisma: PrismaClient,
sessionSecret: string,
id: string
): Promise<{ id: string; kid: string; key: string; mpdUrl: string | null }> {
const row = await prisma.drmKey.findUnique({ where: { id } });
if (!row) throw new Error("DRM-key niet gevonden");
return {
id: row.id,
kid: row.kid,
key: decryptSecret(row.keyEnc, sessionSecret),
mpdUrl: row.mpdUrl,
};
}
export async function updateDrmKeyMeta(
prisma: PrismaClient,
id: string,
patch: {
channelName?: string | null;
eventName?: string | null;
mpdUrl?: string | null;
source?: string;
}
): Promise<DrmKeyPublic> {
const row = await prisma.drmKey.update({
where: { id },
data: {
...(patch.channelName !== undefined
? { channelName: patch.channelName?.trim() || null }
: {}),
...(patch.eventName !== undefined
? { eventName: patch.eventName?.trim() || null }
: {}),
...(patch.mpdUrl !== undefined ? { mpdUrl: patch.mpdUrl?.trim() || null } : {}),
...(patch.source !== undefined ? { source: patch.source.trim() || "manual" } : {}),
},
});
return toPublic(row);
}
export async function deleteDrmKey(prisma: PrismaClient, id: string): Promise<void> {
await prisma.drmKey.delete({ where: { id } });
}
/** One-time / on-demand: import keys from live_channels into drm_keys. */
export async function backfillDrmKeysFromLiveChannels(
prisma: PrismaClient,
sessionSecret: string
): Promise<{ imported: number; skipped: number; failed: number }> {
const channels = await prisma.liveChannel.findMany({
where: { kidEnc: { not: null }, keyEnc: { not: null } },
select: {
name: true,
mpdUrl: true,
streamUrlCache: true,
kidEnc: true,
keyEnc: true,
externalId: true,
list: { select: { provider: true } },
},
});
let imported = 0;
let skipped = 0;
let failed = 0;
for (const ch of channels) {
if (!ch.kidEnc || !ch.keyEnc) {
skipped += 1;
continue;
}
try {
const kid = decryptSecret(ch.kidEnc, sessionSecret);
const key = decryptSecret(ch.keyEnc, sessionSecret);
const result = await upsertDrmKey(prisma, sessionSecret, {
kid,
key,
mpdUrl: ch.streamUrlCache || ch.mpdUrl,
channelName: ch.name,
source: ch.list.provider || "live-channel",
externalId: ch.externalId,
});
if (result.created) imported += 1;
else skipped += 1;
} catch {
failed += 1;
}
}
return { imported, skipped, failed };
}

View file

@ -19,6 +19,7 @@ import {
export type OdidoChannelPlayState = { export type OdidoChannelPlayState = {
id: string; id: string;
name?: string | null;
externalId: string | null; externalId: string | null;
mediaId: string | null; mediaId: string | null;
contentId: string | null; contentId: string | null;
@ -74,7 +75,8 @@ async function fetchKeysIfNeeded(
sessionSecret: string, sessionSecret: string,
existingKid: string | null, existingKid: string | null,
existingKey: string | null, existingKey: string | null,
allowFetch: boolean allowFetch: boolean,
meta?: { channelName?: string | null; externalId?: string | null }
): Promise<{ kidEnc: string | null; keyEnc: string | null }> { ): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey }; if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
if (!allowFetch || !play.licenseUrl || !play.licenseToken) { if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
@ -92,6 +94,19 @@ async function fetchKeysIfNeeded(
}); });
const key = keys.map(parseKey).find((item) => item !== null); const key = keys.map(parseKey).find((item) => item !== null);
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
try {
const { upsertDrmKey } = await import("../drm/keys");
await upsertDrmKey(prisma, sessionSecret, {
kid: key.kid,
key: key.key,
mpdUrl: play.playUrl,
channelName: meta?.channelName,
source: "odido",
externalId: meta?.externalId,
});
} catch {
/* DRM-db mag play niet breken */
}
return { return {
kidEnc: encryptSecret(key.kid, sessionSecret), kidEnc: encryptSecret(key.kid, sessionSecret),
keyEnc: encryptSecret(key.key, sessionSecret), keyEnc: encryptSecret(key.key, sessionSecret),
@ -159,7 +174,8 @@ export async function refreshChannelMpd(
sessionSecret, sessionSecret,
channel.kidEnc, channel.kidEnc,
channel.keyEnc, channel.keyEnc,
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay,
{ channelName: channel.name, externalId }
); );
const now = new Date(); const now = new Date();

View file

@ -314,6 +314,24 @@ export async function syncOdidoList(
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
kidEnc = encryptSecret(key.kid, sessionSecret); kidEnc = encryptSecret(key.kid, sessionSecret);
keyEnc = encryptSecret(key.key, sessionSecret); keyEnc = encryptSecret(key.key, sessionSecret);
try {
const { upsertDrmKey } = await import("../drm/keys");
const chName =
entry.channel.name?.trim() ||
entry.channel.channelName?.trim() ||
physical?.mediaName?.trim() ||
externalId;
await upsertDrmKey(prisma, sessionSecret, {
kid: key.kid,
key: key.key,
mpdUrl: entry.playUrl,
channelName: chName,
source: "odido",
externalId,
});
} catch {
/* ignore drm db errors */
}
} catch { } catch {
counts.failed += 1; counts.failed += 1;
} }

View file

@ -446,6 +446,7 @@ export async function getCustomPlayUrl(
const resolved = await resolveOdidoPlay( const resolved = await resolveOdidoPlay(
{ {
id: channel.id, id: channel.id,
name: channel.name,
externalId, externalId,
mediaId, mediaId,
contentId: channel.contentId, contentId: channel.contentId,