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:
parent
df9782c8f5
commit
d8f018bd07
4 changed files with 67 additions and 33 deletions
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 }],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue