Allow live channels without ClearKey KID/KEY.

Clear DASH streams can be saved with name + MPD only; DRM remains optional.
This commit is contained in:
Jos Vooges | STH 2026-09-18 19:13:44 +02:00
parent df9782c8f5
commit d8f018bd07
4 changed files with 67 additions and 33 deletions

View file

@ -235,8 +235,10 @@ export default function LiveListsPage() {
if (channelForm.key.trim()) payload.key = channelForm.key.trim(); if (channelForm.key.trim()) payload.key = channelForm.key.trim();
const isNew = editingChannelId === "new"; const isNew = editingChannelId === "new";
if (isNew && (!payload.kid || !payload.key)) { const kidFilled = !!channelForm.kid.trim();
throw new Error("KID en KEY zijn verplicht voor een nieuwe zender"); const keyFilled = !!channelForm.key.trim();
if (kidFilled !== keyFilled) {
throw new Error("KID en KEY samen invullen, of beide leeg laten");
} }
const r = await fetch( const r = await fetch(
@ -417,24 +419,28 @@ export default function LiveListsPage() {
/> />
</label> </label>
<label className="field"> <label className="field">
KID (32 hex) KID (32 hex, optioneel)
<input <input
value={channelForm.kid} value={channelForm.kid}
onChange={(e) => setChannelForm((f) => ({ ...f, kid: e.target.value }))} onChange={(e) => setChannelForm((f) => ({ ...f, kid: e.target.value }))}
placeholder={ placeholder={
editingChannelId !== "new" ? "•••••••• (leeg = behouden)" : "ClearKey KID" editingChannelId !== "new"
? "•••••••• (leeg = behouden; wis KEY ook om DRM te verwijderen)"
: "Leeg = geen ClearKey"
} }
disabled={busy} disabled={busy}
autoComplete="off" autoComplete="off"
/> />
</label> </label>
<label className="field"> <label className="field">
KEY (32 hex) KEY (32 hex, optioneel)
<input <input
value={channelForm.key} value={channelForm.key}
onChange={(e) => setChannelForm((f) => ({ ...f, key: e.target.value }))} onChange={(e) => setChannelForm((f) => ({ ...f, key: e.target.value }))}
placeholder={ placeholder={
editingChannelId !== "new" ? "•••••••• (leeg = behouden)" : "ClearKey KEY" editingChannelId !== "new"
? "•••••••• (leeg = behouden)"
: "Leeg = geen ClearKey"
} }
disabled={busy} disabled={busy}
autoComplete="off" autoComplete="off"
@ -537,7 +543,7 @@ export default function LiveListsPage() {
</div> </div>
</td> </td>
<td>{c.epgChannelId || "—"}</td> <td>{c.epgChannelId || "—"}</td>
<td>{c.hasKid && c.hasKey ? "opgeslagen" : "ontbreekt"}</td> <td>{c.hasKid && c.hasKey ? "ClearKey" : "geen DRM"}</td>
<td>{c.enabled ? "aan" : "uit"}</td> <td>{c.enabled ? "aan" : "uit"}</td>
<td style={{ whiteSpace: "nowrap" }}> <td style={{ whiteSpace: "nowrap" }}>
<button type="button" disabled={busy} onClick={() => startEditChannel(c)}> <button type="button" disabled={busy} onClick={() => startEditChannel(c)}>

View file

@ -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;

View file

@ -323,10 +323,10 @@ model LiveChannel {
name String name String
logoUrl String? @map("logo_url") logoUrl String? @map("logo_url")
mpdUrl String @map("mpd_url") @db.Text mpdUrl String @map("mpd_url") @db.Text
/** AES-GCM ciphertext (base64) — ClearKey KID */ /** AES-GCM ciphertext (base64) — ClearKey KID; leeg = geen DRM */
kidEnc String @map("kid_enc") @db.Text kidEnc String? @map("kid_enc") @db.Text
/** AES-GCM ciphertext (base64) — ClearKey KEY */ /** AES-GCM ciphertext (base64) — ClearKey KEY; leeg = geen DRM */
keyEnc String @map("key_enc") @db.Text keyEnc String? @map("key_enc") @db.Text
/** XMLTV channel id voor EPG-mapping */ /** XMLTV channel id voor EPG-mapping */
epgChannelId String? @map("epg_channel_id") epgChannelId String? @map("epg_channel_id")
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")

View file

@ -195,11 +195,22 @@ export async function createLiveChannel(
const mpdUrl = body.mpdUrl?.trim(); const mpdUrl = body.mpdUrl?.trim();
const kid = body.kid?.trim(); const kid = body.kid?.trim();
const key = body.key?.trim(); const key = body.key?.trim();
if (!name || !mpdUrl || !kid || !key) { if (!name || !mpdUrl) {
throw new AppError("INVALID_REQUEST", "name, mpdUrl, kid en key verplicht", 400); throw new AppError("INVALID_REQUEST", "name en mpdUrl verplicht", 400);
} }
const kidNorm = normalizeHexKey(kid, "kid"); const hasKid = !!kid && kid !== "********";
const keyNorm = normalizeHexKey(key, "key"); 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({ const maxOrder = await prisma.liveChannel.aggregate({
where: { listId }, where: { listId },
_max: { sortOrder: true }, _max: { sortOrder: true },
@ -210,8 +221,8 @@ export async function createLiveChannel(
name, name,
logoUrl: body.logoUrl?.trim() || null, logoUrl: body.logoUrl?.trim() || null,
mpdUrl, mpdUrl,
kidEnc: encryptSecret(kidNorm, sessionSecret), kidEnc,
keyEnc: encryptSecret(keyNorm, sessionSecret), keyEnc,
epgChannelId: body.epgChannelId?.trim() || null, epgChannelId: body.epgChannelId?.trim() || null,
sortOrder: body.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 10, sortOrder: body.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 10,
enabled: body.enabled !== false, enabled: body.enabled !== false,
@ -239,14 +250,21 @@ export async function updateLiveChannel(
const kidRaw = body.kid?.trim(); const kidRaw = body.kid?.trim();
const keyRaw = body.key?.trim(); const keyRaw = body.key?.trim();
const kidEnc = const clearingKeys = kidRaw === "" && keyRaw === "";
kidRaw && kidRaw !== "********" const hasKid = !!kidRaw && kidRaw !== "********";
? encryptSecret(normalizeHexKey(kidRaw, "kid"), sessionSecret) const hasKey = !!keyRaw && keyRaw !== "********";
: undefined; if ((hasKid || hasKey) && hasKid !== hasKey) {
const keyEnc = throw new AppError("INVALID_REQUEST", "KID en KEY samen invullen, of beide leeg laten", 400);
keyRaw && keyRaw !== "********" }
? encryptSecret(normalizeHexKey(keyRaw, "key"), sessionSecret) let kidEnc: string | null | undefined;
: 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({ await prisma.liveChannel.update({
where: { id: channelId }, where: { id: channelId },
@ -254,8 +272,8 @@ export async function updateLiveChannel(
...(body.name?.trim() ? { name: body.name.trim() } : {}), ...(body.name?.trim() ? { name: body.name.trim() } : {}),
...(body.logoUrl !== undefined ? { logoUrl: body.logoUrl?.trim() || null } : {}), ...(body.logoUrl !== undefined ? { logoUrl: body.logoUrl?.trim() || null } : {}),
...(body.mpdUrl?.trim() ? { mpdUrl: body.mpdUrl.trim() } : {}), ...(body.mpdUrl?.trim() ? { mpdUrl: body.mpdUrl.trim() } : {}),
...(kidEnc ? { kidEnc } : {}), ...(kidEnc !== undefined ? { kidEnc } : {}),
...(keyEnc ? { keyEnc } : {}), ...(keyEnc !== undefined ? { keyEnc } : {}),
...(body.epgChannelId !== undefined ...(body.epgChannelId !== undefined
? { epgChannelId: body.epgChannelId?.trim() || null } ? { 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); if (!channel) throw new AppError("FORBIDDEN", "Geen toegang tot deze zender", 403);
const kid = decryptSecret(channel.kidEnc, sessionSecret); const drm =
const key = decryptSecret(channel.keyEnc, sessionSecret); channel.kidEnc && channel.keyEnc
? {
type: "clearkey" as const,
keys: [
{
kid: decryptSecret(channel.kidEnc, sessionSecret),
key: decryptSecret(channel.keyEnc, sessionSecret),
},
],
}
: null;
return { return {
streamId: customStreamId(channel.id), streamId: customStreamId(channel.id),
@ -412,10 +440,7 @@ export async function getCustomPlayUrl(
format: "dash" as const, format: "dash" as const,
fallbackStreamUrl: null, fallbackStreamUrl: null,
fallbackFormat: null, fallbackFormat: null,
drm: { drm,
type: "clearkey" as const,
keys: [{ kid, key }],
},
}; };
} }