diff --git a/apps/admin-ui/src/app/live-lists/page.tsx b/apps/admin-ui/src/app/live-lists/page.tsx index af5495c..613e9b4 100644 --- a/apps/admin-ui/src/app/live-lists/page.tsx +++ b/apps/admin-ui/src/app/live-lists/page.tsx @@ -235,8 +235,10 @@ export default function LiveListsPage() { if (channelForm.key.trim()) payload.key = channelForm.key.trim(); const isNew = editingChannelId === "new"; - if (isNew && (!payload.kid || !payload.key)) { - throw new Error("KID en KEY zijn verplicht voor een nieuwe zender"); + const kidFilled = !!channelForm.kid.trim(); + const keyFilled = !!channelForm.key.trim(); + if (kidFilled !== keyFilled) { + throw new Error("KID en KEY samen invullen, of beide leeg laten"); } const r = await fetch( @@ -417,24 +419,28 @@ export default function LiveListsPage() { /> - KID (32 hex) + KID (32 hex, optioneel) setChannelForm((f) => ({ ...f, kid: e.target.value }))} placeholder={ - editingChannelId !== "new" ? "•••••••• (leeg = behouden)" : "ClearKey KID" + editingChannelId !== "new" + ? "•••••••• (leeg = behouden; wis KEY ook om DRM te verwijderen)" + : "Leeg = geen ClearKey" } disabled={busy} autoComplete="off" /> - KEY (32 hex) + KEY (32 hex, optioneel) setChannelForm((f) => ({ ...f, key: e.target.value }))} placeholder={ - editingChannelId !== "new" ? "•••••••• (leeg = behouden)" : "ClearKey KEY" + editingChannelId !== "new" + ? "•••••••• (leeg = behouden)" + : "Leeg = geen ClearKey" } disabled={busy} autoComplete="off" @@ -537,7 +543,7 @@ export default function LiveListsPage() { {c.epgChannelId || "—"} - {c.hasKid && c.hasKey ? "opgeslagen" : "ontbreekt"} + {c.hasKid && c.hasKey ? "ClearKey" : "geen DRM"} {c.enabled ? "aan" : "uit"} startEditChannel(c)}> diff --git a/apps/master-api/prisma/migrations/20260918190000_live_channel_optional_clearkey/migration.sql b/apps/master-api/prisma/migrations/20260918190000_live_channel_optional_clearkey/migration.sql new file mode 100644 index 0000000..5aca44a --- /dev/null +++ b/apps/master-api/prisma/migrations/20260918190000_live_channel_optional_clearkey/migration.sql @@ -0,0 +1,3 @@ +-- ClearKey KID/KEY optioneel (clear DASH zonder DRM) +ALTER TABLE "live_channels" ALTER COLUMN "kid_enc" DROP NOT NULL; +ALTER TABLE "live_channels" ALTER COLUMN "key_enc" DROP NOT NULL; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 6f61fae..3f88120 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -323,10 +323,10 @@ model LiveChannel { name String logoUrl String? @map("logo_url") mpdUrl String @map("mpd_url") @db.Text - /** AES-GCM ciphertext (base64) — ClearKey KID */ - kidEnc String @map("kid_enc") @db.Text - /** AES-GCM ciphertext (base64) — ClearKey KEY */ - keyEnc String @map("key_enc") @db.Text + /** AES-GCM ciphertext (base64) — ClearKey KID; leeg = geen DRM */ + kidEnc String? @map("kid_enc") @db.Text + /** AES-GCM ciphertext (base64) — ClearKey KEY; leeg = geen DRM */ + keyEnc String? @map("key_enc") @db.Text /** XMLTV channel id voor EPG-mapping */ epgChannelId String? @map("epg_channel_id") sortOrder Int @default(0) @map("sort_order") diff --git a/apps/master-api/src/viewer/live-channels.ts b/apps/master-api/src/viewer/live-channels.ts index cf5370c..e32c312 100644 --- a/apps/master-api/src/viewer/live-channels.ts +++ b/apps/master-api/src/viewer/live-channels.ts @@ -195,11 +195,22 @@ export async function createLiveChannel( const mpdUrl = body.mpdUrl?.trim(); const kid = body.kid?.trim(); const key = body.key?.trim(); - if (!name || !mpdUrl || !kid || !key) { - throw new AppError("INVALID_REQUEST", "name, mpdUrl, kid en key verplicht", 400); + if (!name || !mpdUrl) { + throw new AppError("INVALID_REQUEST", "name en mpdUrl verplicht", 400); } - const kidNorm = normalizeHexKey(kid, "kid"); - const keyNorm = normalizeHexKey(key, "key"); + const hasKid = !!kid && kid !== "********"; + const hasKey = !!key && key !== "********"; + if (hasKid !== hasKey) { + throw new AppError("INVALID_REQUEST", "KID en KEY samen invullen, of beide leeg laten", 400); + } + const kidEnc = + hasKid && hasKey + ? encryptSecret(normalizeHexKey(kid!, "kid"), sessionSecret) + : null; + const keyEnc = + hasKid && hasKey + ? encryptSecret(normalizeHexKey(key!, "key"), sessionSecret) + : null; const maxOrder = await prisma.liveChannel.aggregate({ where: { listId }, _max: { sortOrder: true }, @@ -210,8 +221,8 @@ export async function createLiveChannel( name, logoUrl: body.logoUrl?.trim() || null, mpdUrl, - kidEnc: encryptSecret(kidNorm, sessionSecret), - keyEnc: encryptSecret(keyNorm, sessionSecret), + kidEnc, + keyEnc, epgChannelId: body.epgChannelId?.trim() || null, sortOrder: body.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 10, enabled: body.enabled !== false, @@ -239,14 +250,21 @@ export async function updateLiveChannel( const kidRaw = body.kid?.trim(); const keyRaw = body.key?.trim(); - const kidEnc = - kidRaw && kidRaw !== "********" - ? encryptSecret(normalizeHexKey(kidRaw, "kid"), sessionSecret) - : undefined; - const keyEnc = - keyRaw && keyRaw !== "********" - ? encryptSecret(normalizeHexKey(keyRaw, "key"), sessionSecret) - : undefined; + const clearingKeys = kidRaw === "" && keyRaw === ""; + const hasKid = !!kidRaw && kidRaw !== "********"; + const hasKey = !!keyRaw && keyRaw !== "********"; + if ((hasKid || hasKey) && hasKid !== hasKey) { + throw new AppError("INVALID_REQUEST", "KID en KEY samen invullen, of beide leeg laten", 400); + } + let kidEnc: string | null | undefined; + let keyEnc: string | null | undefined; + if (clearingKeys) { + kidEnc = null; + keyEnc = null; + } else if (hasKid && hasKey) { + kidEnc = encryptSecret(normalizeHexKey(kidRaw!, "kid"), sessionSecret); + keyEnc = encryptSecret(normalizeHexKey(keyRaw!, "key"), sessionSecret); + } await prisma.liveChannel.update({ where: { id: channelId }, @@ -254,8 +272,8 @@ export async function updateLiveChannel( ...(body.name?.trim() ? { name: body.name.trim() } : {}), ...(body.logoUrl !== undefined ? { logoUrl: body.logoUrl?.trim() || null } : {}), ...(body.mpdUrl?.trim() ? { mpdUrl: body.mpdUrl.trim() } : {}), - ...(kidEnc ? { kidEnc } : {}), - ...(keyEnc ? { keyEnc } : {}), + ...(kidEnc !== undefined ? { kidEnc } : {}), + ...(keyEnc !== undefined ? { keyEnc } : {}), ...(body.epgChannelId !== undefined ? { epgChannelId: body.epgChannelId?.trim() || null } : {}), @@ -401,8 +419,18 @@ export async function getCustomPlayUrl( }); if (!channel) throw new AppError("FORBIDDEN", "Geen toegang tot deze zender", 403); - const kid = decryptSecret(channel.kidEnc, sessionSecret); - const key = decryptSecret(channel.keyEnc, sessionSecret); + const drm = + channel.kidEnc && channel.keyEnc + ? { + type: "clearkey" as const, + keys: [ + { + kid: decryptSecret(channel.kidEnc, sessionSecret), + key: decryptSecret(channel.keyEnc, sessionSecret), + }, + ], + } + : null; return { streamId: customStreamId(channel.id), @@ -412,10 +440,7 @@ export async function getCustomPlayUrl( format: "dash" as const, fallbackStreamUrl: null, fallbackFormat: null, - drm: { - type: "clearkey" as const, - keys: [{ kid, key }], - }, + drm, }; }