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();
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() {
/>
</label>
<label className="field">
KID (32 hex)
KID (32 hex, optioneel)
<input
value={channelForm.kid}
onChange={(e) => 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"
/>
</label>
<label className="field">
KEY (32 hex)
KEY (32 hex, optioneel)
<input
value={channelForm.key}
onChange={(e) => 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() {
</div>
</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 style={{ whiteSpace: "nowrap" }}>
<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
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")

View file

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