Cache Odido MPDs for ~1h with admin tweak knobs and background refresh.
This commit is contained in:
parent
03e5c9c661
commit
57972ca5f0
13 changed files with 849 additions and 74 deletions
|
|
@ -4,6 +4,16 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
|
type TweakMeta = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
kind: "number" | "boolean";
|
||||||
|
unit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OdidoTweaks = Record<string, number | boolean>;
|
||||||
|
|
||||||
type OdidoSettings = {
|
type OdidoSettings = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -14,6 +24,11 @@ type OdidoSettings = {
|
||||||
wvdUploadedAt: string | null;
|
wvdUploadedAt: string | null;
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
lastSyncSummary: Record<string, unknown> | null;
|
lastSyncSummary: Record<string, unknown> | null;
|
||||||
|
lastMpdRefreshAt: string | null;
|
||||||
|
lastMpdRefreshSummary: Record<string, unknown> | null;
|
||||||
|
tweaks: OdidoTweaks;
|
||||||
|
tweakDefaults: OdidoTweaks;
|
||||||
|
tweakMeta: TweakMeta[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function readFileAsBase64(file: File): Promise<string> {
|
function readFileAsBase64(file: File): Promise<string> {
|
||||||
|
|
@ -36,22 +51,29 @@ export default function ScriptsPage() {
|
||||||
const [pin, setPin] = useState("");
|
const [pin, setPin] = useState("");
|
||||||
const [wvdPath, setWvdPath] = useState("");
|
const [wvdPath, setWvdPath] = useState("");
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [tweaks, setTweaks] = useState<OdidoTweaks>({});
|
||||||
|
const [showTweaks, setShowTweaks] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const wvdInputRef = useRef<HTMLInputElement>(null);
|
const wvdInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const applySettings = useCallback((d: OdidoSettings) => {
|
||||||
const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" });
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt");
|
|
||||||
setOdido(d);
|
setOdido(d);
|
||||||
setUsername(d.username ?? "");
|
setUsername(d.username ?? "");
|
||||||
setWvdPath(d.wvdPath ?? "");
|
setWvdPath(d.wvdPath ?? "");
|
||||||
setEnabled(d.enabled !== false);
|
setEnabled(d.enabled !== false);
|
||||||
|
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
|
||||||
setPin("");
|
setPin("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt");
|
||||||
|
applySettings(d);
|
||||||
|
}, [applySettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load().catch((e) => setErr(String(e.message ?? e)));
|
void load().catch((e) => setErr(String(e.message ?? e)));
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
@ -66,6 +88,7 @@ export default function ScriptsPage() {
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
wvdPath: wvdPath.trim(),
|
wvdPath: wvdPath.trim(),
|
||||||
enabled,
|
enabled,
|
||||||
|
tweaks,
|
||||||
};
|
};
|
||||||
if (pin.trim()) body.pin = pin.trim();
|
if (pin.trim()) body.pin = pin.trim();
|
||||||
const r = await fetch("/api/v1/admin/scripts/odido", {
|
const r = await fetch("/api/v1/admin/scripts/odido", {
|
||||||
|
|
@ -76,8 +99,7 @@ export default function ScriptsPage() {
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
|
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
|
||||||
setOdido(d);
|
applySettings(d);
|
||||||
setPin("");
|
|
||||||
setMsg("Odido-instellingen opgeslagen");
|
setMsg("Odido-instellingen opgeslagen");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(String((e as Error).message ?? e));
|
setErr(String((e as Error).message ?? e));
|
||||||
|
|
@ -104,8 +126,7 @@ export default function ScriptsPage() {
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!r.ok) throw new Error(d.error?.message ?? "WVD-upload mislukt");
|
if (!r.ok) throw new Error(d.error?.message ?? "WVD-upload mislukt");
|
||||||
setOdido(d);
|
applySettings(d);
|
||||||
setWvdPath(d.wvdPath ?? "");
|
|
||||||
setMsg(`WVD.wvd geüpload (${d.bytes ?? file.size} bytes)`);
|
setMsg(`WVD.wvd geüpload (${d.bytes ?? file.size} bytes)`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(String((e as Error).message ?? e));
|
setErr(String((e as Error).message ?? e));
|
||||||
|
|
@ -115,7 +136,56 @@ export default function ScriptsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshMpds() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/odido/refresh-mpd", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "MPD-refresh mislukt");
|
||||||
|
setMsg(
|
||||||
|
`MPD-cache vernieuwd: ${d.refreshed ?? 0} ok · ${d.failed ?? 0} fout · ${d.skipped ?? 0} skip (${d.durationMs ?? "?"} ms)`
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMpds() {
|
||||||
|
if (!confirm("Alle gecachte Odido-MPD’s wissen? Volgende play haalt dan verse URLs.")) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/odido/flush-mpd", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Flush mislukt");
|
||||||
|
setMsg(`MPD-cache geleegd (${d.cleared ?? 0} zenders)`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTweaks() {
|
||||||
|
if (odido?.tweakDefaults) setTweaks({ ...odido.tweakDefaults });
|
||||||
|
}
|
||||||
|
|
||||||
const summary = odido?.lastSyncSummary;
|
const summary = odido?.lastSyncSummary;
|
||||||
|
const refreshSummary = odido?.lastMpdRefreshSummary;
|
||||||
|
const meta = odido?.tweakMeta ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -144,11 +214,11 @@ export default function ScriptsPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ maxWidth: 640 }}>
|
<div className="card" style={{ maxWidth: 720 }}>
|
||||||
<h2 style={{ marginTop: 0 }}>Odido</h2>
|
<h2 style={{ marginTop: 0 }}>Odido</h2>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
Haalt TV-zenders uit je abonnement (geen radio). Play gebruikt verse MPD + gecachte
|
Play gebruikt een gecachte MPD als die jonger is dan de TTL (default 1 uur). Anders
|
||||||
ClearKey; CDM alleen bij key-fout.
|
(of via achtergrond-refresh) verse PlayChannel. ClearKey blijft in de DB.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={saveOdido} className="form-group">
|
<form onSubmit={saveOdido} className="form-group">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
|
|
@ -215,7 +285,105 @@ export default function ScriptsPage() {
|
||||||
/>
|
/>
|
||||||
Odido-integratie actief
|
Odido-integratie actief
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="btn-primary" disabled={busy}>
|
|
||||||
|
<div style={{ marginTop: "1.25rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => setShowTweaks((v) => !v)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{showTweaks ? "Geavanceerd verbergen" : "Geavanceerd / tweaks tonen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTweaks && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid var(--border, #333)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Tweak-opties</h3>
|
||||||
|
<button type="button" disabled={busy} onClick={resetTweaks}>
|
||||||
|
Defaults herstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
Wijzigingen gelden na Opslaan. Achtergrond-refresh leest deze waarden live.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
|
||||||
|
gap: "0.75rem 1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.map((item) => {
|
||||||
|
const value = tweaks[item.key];
|
||||||
|
if (item.kind === "boolean") {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={item.key}
|
||||||
|
className="field"
|
||||||
|
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === true}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTweaks((prev) => ({ ...prev, [item.key]: e.target.checked }))
|
||||||
|
}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong style={{ fontWeight: 600 }}>{item.label}</strong>
|
||||||
|
<br />
|
||||||
|
<span className="muted" style={{ fontSize: "0.8rem" }}>
|
||||||
|
{item.hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label key={item.key} className="field">
|
||||||
|
{item.label}
|
||||||
|
{item.unit ? ` (${item.unit})` : ""}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={typeof value === "number" ? value : ""}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTweaks((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.key]: e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: "0.8rem" }}>
|
||||||
|
{item.hint}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: "1rem" }}>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void refreshMpds()}>
|
||||||
|
MPD-cache nu vernieuwen
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void flushMpds()}>
|
||||||
|
MPD-cache legen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: "1rem" }}>
|
||||||
Opslaan
|
Opslaan
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -233,20 +401,45 @@ export default function ScriptsPage() {
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")}
|
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")}
|
||||||
|
{summary && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(
|
||||||
|
{[
|
||||||
|
summary.added != null ? `+${String(summary.added)}` : null,
|
||||||
|
summary.updated != null ? `~${String(summary.updated)}` : null,
|
||||||
|
summary.disabled != null ? `uit ${String(summary.disabled)}` : null,
|
||||||
|
summary.skipped != null ? `skip ${String(summary.skipped)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "ok"}
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{summary && (
|
{odido?.lastMpdRefreshAt && (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
Resultaat:{" "}
|
Laatste MPD-refresh: {new Date(odido.lastMpdRefreshAt).toLocaleString("nl-NL")}
|
||||||
{[
|
{refreshSummary && (
|
||||||
summary.added != null ? `+${String(summary.added)}` : null,
|
<>
|
||||||
summary.updated != null ? `~${String(summary.updated)}` : null,
|
{" "}
|
||||||
summary.disabled != null ? `uit ${String(summary.disabled)}` : null,
|
(
|
||||||
summary.skipped != null ? `skip ${String(summary.skipped)}` : null,
|
{[
|
||||||
]
|
refreshSummary.refreshed != null
|
||||||
.filter(Boolean)
|
? `${String(refreshSummary.refreshed)} ok`
|
||||||
.join(" · ") || JSON.stringify(summary)}
|
: null,
|
||||||
|
refreshSummary.failed != null ? `${String(refreshSummary.failed)} fout` : null,
|
||||||
|
refreshSummary.triggeredBy != null
|
||||||
|
? String(refreshSummary.triggeredBy)
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Cached Odido PlayChannel stream URLs (TTL-based instant play)
|
||||||
|
ALTER TABLE "live_channels" ADD COLUMN "stream_url_cache" TEXT;
|
||||||
|
ALTER TABLE "live_channels" ADD COLUMN "stream_url_cached_at" TIMESTAMP(3);
|
||||||
|
|
@ -334,6 +334,9 @@ 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
|
||||||
|
/** Laatst opgehaalde speelbare MPD/HLS-URL (Odido PlayChannel); marker blijft in mpdUrl */
|
||||||
|
streamUrlCache String? @map("stream_url_cache") @db.Text
|
||||||
|
streamUrlCachedAt DateTime? @map("stream_url_cached_at")
|
||||||
/** AES-GCM ciphertext (base64) — ClearKey KID; leeg = geen DRM */
|
/** 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; leeg = geen DRM */
|
/** AES-GCM ciphertext (base64) — ClearKey KEY; leeg = geen DRM */
|
||||||
|
|
|
||||||
|
|
@ -1619,9 +1619,13 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
pin?: string;
|
pin?: string;
|
||||||
wvdPath?: string;
|
wvdPath?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
tweaks?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
const { upsertOdidoSettings } = await import("../odido/settings");
|
const { upsertOdidoSettings } = await import("../odido/settings");
|
||||||
return upsertOdidoSettings(config.SESSION_SECRET, body);
|
return upsertOdidoSettings(config.SESSION_SECRET, {
|
||||||
|
...body,
|
||||||
|
tweaks: body.tweaks as import("../odido/tweaks").OdidoTweaks | undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/admin/scripts/odido/wvd", { preHandler: requireAdmin }, async (request) => {
|
app.post("/api/v1/admin/scripts/odido/wvd", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
|
@ -1633,6 +1637,16 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return saveOdidoWvdUpload(body.contentBase64);
|
return saveOdidoWvdUpload(body.contentBase64);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/scripts/odido/refresh-mpd", { preHandler: requireAdmin }, async () => {
|
||||||
|
const { refreshOdidoMpdCache } = await import("../odido/refresh");
|
||||||
|
return refreshOdidoMpdCache(config.SESSION_SECRET, "manual");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/scripts/odido/flush-mpd", { preHandler: requireAdmin }, async () => {
|
||||||
|
const { flushOdidoMpdCache } = await import("../odido/refresh");
|
||||||
|
return flushOdidoMpdCache();
|
||||||
|
});
|
||||||
|
|
||||||
// --- 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");
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { createMetadataService } from "./settings/metadata-factory";
|
||||||
import { toErrorResponse } from "./security/errors";
|
import { toErrorResponse } from "./security/errors";
|
||||||
import { startIptvEpgWarmup } from "./viewer/iptv-client";
|
import { startIptvEpgWarmup } from "./viewer/iptv-client";
|
||||||
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
||||||
|
import { startOdidoMpdRefreshWorker } from "./odido/refresh";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -106,6 +107,7 @@ async function main() {
|
||||||
registerSettingsRoutes(app, config, googlePlay);
|
registerSettingsRoutes(app, config, googlePlay);
|
||||||
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
||||||
startIptvEpgWarmup(config);
|
startIptvEpgWarmup(config);
|
||||||
|
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);
|
||||||
|
|
||||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||||
void nodeConnectionManager.handleConnection(socket);
|
void nodeConnectionManager.handleConnection(socket);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export const ODIDO_USER_AGENT =
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||||
const DEFAULT_DEVICE_ID = "MzkzNjIwMTU1NjAxMDNydA==";
|
const DEFAULT_DEVICE_ID = "MzkzNjIwMTU1NjAxMDNydA==";
|
||||||
const CACHE_MS = 20 * 60_000;
|
const DEFAULT_SESSION_CACHE_MS = 20 * 60_000;
|
||||||
|
|
||||||
export type OdidoCookies = Record<string, string>;
|
export type OdidoCookies = Record<string, string>;
|
||||||
|
|
||||||
|
|
@ -143,11 +143,18 @@ async function loginWithDevice(
|
||||||
return cookies;
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<OdidoCookies> {
|
export async function login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
sessionCacheMs = DEFAULT_SESSION_CACHE_MS
|
||||||
|
): Promise<OdidoCookies> {
|
||||||
const cached = sessions.get(username);
|
const cached = sessions.get(username);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.cookies;
|
if (cached && cached.expiresAt > Date.now()) return cached.cookies;
|
||||||
const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID);
|
const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID);
|
||||||
sessions.set(username, { cookies, expiresAt: Date.now() + CACHE_MS });
|
const ttl = Number.isFinite(sessionCacheMs) && sessionCacheMs > 0
|
||||||
|
? sessionCacheMs
|
||||||
|
: DEFAULT_SESSION_CACHE_MS;
|
||||||
|
sessions.set(username, { cookies, expiresAt: Date.now() + ttl });
|
||||||
return cookies;
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
198
apps/master-api/src/odido/play.ts
Normal file
198
apps/master-api/src/odido/play.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
import { encryptSecret } from "../security/crypto";
|
||||||
|
import { extractOdidoKeys } from "./cdm";
|
||||||
|
import {
|
||||||
|
getPssh,
|
||||||
|
login,
|
||||||
|
ODIDO_USER_AGENT,
|
||||||
|
playChannel,
|
||||||
|
type OdidoCookies,
|
||||||
|
type OdidoPlayResult,
|
||||||
|
} from "./client";
|
||||||
|
import { loadOdidoSettings } from "./settings";
|
||||||
|
import { isMpdCacheFresh, type OdidoTweaks } from "./tweaks";
|
||||||
|
|
||||||
|
export type OdidoChannelPlayState = {
|
||||||
|
id: string;
|
||||||
|
externalId: string | null;
|
||||||
|
mediaId: string | null;
|
||||||
|
contentId: string | null;
|
||||||
|
mpdUrl: string;
|
||||||
|
streamUrlCache: string | null;
|
||||||
|
streamUrlCachedAt: Date | null;
|
||||||
|
kidEnc: string | null;
|
||||||
|
keyEnc: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveOdidoPlayResult = {
|
||||||
|
streamUrl: string;
|
||||||
|
kidEnc: string | null;
|
||||||
|
keyEnc: string | null;
|
||||||
|
fromCache: boolean;
|
||||||
|
contentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseKey(value: string): { kid: string; key: string } | null {
|
||||||
|
const [kid, key] = value.trim().toLowerCase().split(":", 2);
|
||||||
|
if (!/^[0-9a-f]{32}$/.test(kid ?? "") || !/^[0-9a-f]{32}$/.test(key ?? "")) return null;
|
||||||
|
return { kid, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeMpd(url: string, timeoutMs: number, cookies?: OdidoCookies): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": ODIDO_USER_AGENT,
|
||||||
|
...(cookies && Object.keys(cookies).length
|
||||||
|
? {
|
||||||
|
Cookie: Object.entries(cookies)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("; "),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const text = await response.text();
|
||||||
|
return text.includes("<MPD") || text.includes("Period") || text.includes("#EXTM3U");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchKeysIfNeeded(
|
||||||
|
play: OdidoPlayResult,
|
||||||
|
wvdPath: string,
|
||||||
|
sessionSecret: string,
|
||||||
|
existingKid: string | null,
|
||||||
|
existingKey: string | null,
|
||||||
|
allowFetch: boolean
|
||||||
|
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
|
||||||
|
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
|
||||||
|
if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
|
||||||
|
return { kidEnc: existingKid, keyEnc: existingKey };
|
||||||
|
}
|
||||||
|
const pssh = await getPssh(play.playUrl, play.cookies);
|
||||||
|
if (!pssh) throw new Error("Widevine PSSH ontbreekt");
|
||||||
|
const keys = await extractOdidoKeys({
|
||||||
|
wvdPath,
|
||||||
|
pssh,
|
||||||
|
licUrl: play.licenseUrl,
|
||||||
|
licToken: play.licenseToken,
|
||||||
|
cookies: play.cookies,
|
||||||
|
userAgent: play.userAgent,
|
||||||
|
});
|
||||||
|
const key = keys.map(parseKey).find((item) => item !== null);
|
||||||
|
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
||||||
|
return {
|
||||||
|
kidEnc: encryptSecret(key.kid, sessionSecret),
|
||||||
|
keyEnc: encryptSecret(key.key, sessionSecret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshChannelMpd(
|
||||||
|
channel: OdidoChannelPlayState,
|
||||||
|
sessionSecret: string,
|
||||||
|
opts?: { cookies?: OdidoCookies; tweaks?: OdidoTweaks; fetchKeys?: boolean }
|
||||||
|
): Promise<ResolveOdidoPlayResult> {
|
||||||
|
const settings = await loadOdidoSettings(sessionSecret);
|
||||||
|
const tweaks = opts?.tweaks ?? settings.config;
|
||||||
|
const externalId = channel.externalId;
|
||||||
|
const mediaId = channel.mediaId;
|
||||||
|
if (!externalId || !mediaId) throw new Error("Odido-zenderreferentie is onvolledig");
|
||||||
|
|
||||||
|
const cookies =
|
||||||
|
opts?.cookies ??
|
||||||
|
(await login(settings.username, settings.pin, tweaks.sessionCacheMinutes * 60_000));
|
||||||
|
const play = await playChannel(cookies, externalId, mediaId);
|
||||||
|
const keys = await fetchKeysIfNeeded(
|
||||||
|
play,
|
||||||
|
settings.config.wvdPath,
|
||||||
|
sessionSecret,
|
||||||
|
channel.kidEnc,
|
||||||
|
channel.keyEnc,
|
||||||
|
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.liveChannel.update({
|
||||||
|
where: { id: channel.id },
|
||||||
|
data: {
|
||||||
|
streamUrlCache: play.playUrl,
|
||||||
|
streamUrlCachedAt: now,
|
||||||
|
contentId: play.contentId ?? channel.contentId,
|
||||||
|
externalId,
|
||||||
|
mediaId,
|
||||||
|
mpdUrl: `odido://${externalId}/${mediaId}`,
|
||||||
|
...(keys.kidEnc ? { kidEnc: keys.kidEnc } : {}),
|
||||||
|
...(keys.keyEnc ? { keyEnc: keys.keyEnc } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamUrl: play.playUrl,
|
||||||
|
kidEnc: keys.kidEnc,
|
||||||
|
keyEnc: keys.keyEnc,
|
||||||
|
fromCache: false,
|
||||||
|
contentId: play.contentId ?? channel.contentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveOdidoPlay(
|
||||||
|
channel: OdidoChannelPlayState,
|
||||||
|
sessionSecret: string
|
||||||
|
): Promise<ResolveOdidoPlayResult> {
|
||||||
|
const settings = await loadOdidoSettings(sessionSecret);
|
||||||
|
const tweaks = settings.config;
|
||||||
|
const haveCache = !!channel.streamUrlCache && /^https?:\/\//i.test(channel.streamUrlCache);
|
||||||
|
const fresh = isMpdCacheFresh(channel.streamUrlCachedAt, tweaks.mpdTtlSeconds);
|
||||||
|
|
||||||
|
const canUseCache =
|
||||||
|
tweaks.mpdPreferCacheOnPlay &&
|
||||||
|
!tweaks.mpdForceFreshOnPlay &&
|
||||||
|
haveCache &&
|
||||||
|
fresh;
|
||||||
|
|
||||||
|
if (canUseCache) {
|
||||||
|
if (tweaks.mpdProbeCachedBeforeServe) {
|
||||||
|
const ok = await probeMpd(channel.streamUrlCache!, tweaks.mpdProbeTimeoutMs);
|
||||||
|
if (ok) {
|
||||||
|
return {
|
||||||
|
streamUrl: channel.streamUrlCache!,
|
||||||
|
kidEnc: channel.kidEnc,
|
||||||
|
keyEnc: channel.keyEnc,
|
||||||
|
fromCache: true,
|
||||||
|
contentId: channel.contentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!tweaks.mpdFallbackFreshOnProbeFail) {
|
||||||
|
throw new Error("Gecachte MPD niet bereikbaar");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
streamUrl: channel.streamUrlCache!,
|
||||||
|
kidEnc: channel.kidEnc,
|
||||||
|
keyEnc: channel.keyEnc,
|
||||||
|
fromCache: true,
|
||||||
|
contentId: channel.contentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tweaks.mpdRefreshOnPlayIfStale && haveCache && !tweaks.mpdForceFreshOnPlay) {
|
||||||
|
return {
|
||||||
|
streamUrl: channel.streamUrlCache!,
|
||||||
|
kidEnc: channel.kidEnc,
|
||||||
|
keyEnc: channel.keyEnc,
|
||||||
|
fromCache: true,
|
||||||
|
contentId: channel.contentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshChannelMpd(channel, sessionSecret, {
|
||||||
|
tweaks,
|
||||||
|
fetchKeys: tweaks.refetchKeysIfMissingOnPlay,
|
||||||
|
});
|
||||||
|
}
|
||||||
146
apps/master-api/src/odido/refresh.ts
Normal file
146
apps/master-api/src/odido/refresh.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
import { login } from "./client";
|
||||||
|
import { refreshChannelMpd } from "./play";
|
||||||
|
import {
|
||||||
|
loadOdidoConfigOnly,
|
||||||
|
loadOdidoSettings,
|
||||||
|
saveOdidoMpdRefreshResult,
|
||||||
|
} from "./settings";
|
||||||
|
|
||||||
|
export type OdidoMpdRefreshSummary = {
|
||||||
|
refreshed: number;
|
||||||
|
failed: number;
|
||||||
|
skipped: number;
|
||||||
|
durationMs: number;
|
||||||
|
triggeredBy: "schedule" | "manual";
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mapLimit<T>(
|
||||||
|
values: T[],
|
||||||
|
limit: number,
|
||||||
|
staggerMs: number,
|
||||||
|
worker: (value: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(limit, values.length) }, async (_, workerIndex) => {
|
||||||
|
if (staggerMs > 0 && workerIndex > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, staggerMs * workerIndex));
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const index = cursor++;
|
||||||
|
if (index >= values.length) return;
|
||||||
|
await worker(values[index]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshOdidoMpdCache(
|
||||||
|
sessionSecret: string,
|
||||||
|
triggeredBy: "schedule" | "manual" = "manual"
|
||||||
|
): Promise<OdidoMpdRefreshSummary> {
|
||||||
|
const started = Date.now();
|
||||||
|
const settings = await loadOdidoSettings(sessionSecret);
|
||||||
|
const tweaks = settings.config;
|
||||||
|
const channels = await prisma.liveChannel.findMany({
|
||||||
|
where: {
|
||||||
|
list: { provider: "odido", enabled: true },
|
||||||
|
...(tweaks.mpdRefreshOnlyEnabled ? { enabled: true } : {}),
|
||||||
|
externalId: { not: null },
|
||||||
|
mediaId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
externalId: true,
|
||||||
|
mediaId: true,
|
||||||
|
contentId: true,
|
||||||
|
mpdUrl: true,
|
||||||
|
streamUrlCache: true,
|
||||||
|
streamUrlCachedAt: true,
|
||||||
|
kidEnc: true,
|
||||||
|
keyEnc: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = { refreshed: 0, failed: 0, skipped: 0 };
|
||||||
|
const cookies = await login(
|
||||||
|
settings.username,
|
||||||
|
settings.pin,
|
||||||
|
tweaks.sessionCacheMinutes * 60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
await mapLimit(
|
||||||
|
channels,
|
||||||
|
tweaks.mpdRefreshConcurrency,
|
||||||
|
tweaks.mpdRefreshStaggerMs,
|
||||||
|
async (channel) => {
|
||||||
|
if (!channel.externalId || !channel.mediaId) {
|
||||||
|
counts.skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refreshChannelMpd(channel, sessionSecret, {
|
||||||
|
cookies,
|
||||||
|
tweaks,
|
||||||
|
fetchKeys: false,
|
||||||
|
});
|
||||||
|
counts.refreshed += 1;
|
||||||
|
} catch {
|
||||||
|
counts.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary: OdidoMpdRefreshSummary = {
|
||||||
|
...counts,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
triggeredBy,
|
||||||
|
};
|
||||||
|
await saveOdidoMpdRefreshResult(summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushOdidoMpdCache(): Promise<{ cleared: number }> {
|
||||||
|
const result = await prisma.liveChannel.updateMany({
|
||||||
|
where: {
|
||||||
|
list: { provider: "odido" },
|
||||||
|
OR: [{ streamUrlCache: { not: null } }, { streamUrlCachedAt: { not: null } }],
|
||||||
|
},
|
||||||
|
data: { streamUrlCache: null, streamUrlCachedAt: null },
|
||||||
|
});
|
||||||
|
return { cleared: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poller: elke minuut kijken of mpdRefreshIntervalSeconds verstreken is.
|
||||||
|
* Interval/enable wordt live uit IntegrationSetting gelezen.
|
||||||
|
*/
|
||||||
|
export function startOdidoMpdRefreshWorker(getSessionSecret: () => string): () => void {
|
||||||
|
let running = false;
|
||||||
|
const tick = async () => {
|
||||||
|
if (running) return;
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: "odido" } });
|
||||||
|
if (!row?.enabled || !row.username || !row.passwordEnc) return;
|
||||||
|
const config = await loadOdidoConfigOnly();
|
||||||
|
if (!config?.mpdRefreshEnabled) return;
|
||||||
|
const last = config.lastMpdRefreshAt ? Date.parse(config.lastMpdRefreshAt) : 0;
|
||||||
|
const due = !Number.isFinite(last) || Date.now() - last >= config.mpdRefreshIntervalSeconds * 1000;
|
||||||
|
if (!due) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
const summary = await refreshOdidoMpdCache(getSessionSecret(), "schedule");
|
||||||
|
console.log(
|
||||||
|
`[odido] MPD-cache refresh: ${summary.refreshed} ok, ${summary.failed} fail (${summary.durationMs}ms)`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[odido] MPD-cache refresh mislukt:", err);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = setInterval(() => void tick(), 60_000);
|
||||||
|
setTimeout(() => void tick(), 20_000);
|
||||||
|
return () => clearInterval(handle);
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,23 @@ import { dirname, resolve } from "node:path";
|
||||||
import { prisma } from "../database/client";
|
import { prisma } from "../database/client";
|
||||||
import { decryptSecret, encryptSecret } from "../security/crypto";
|
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
|
import {
|
||||||
|
mergeOdidoTweaks,
|
||||||
|
ODIDO_TWEAK_DEFAULTS,
|
||||||
|
ODIDO_TWEAK_META,
|
||||||
|
type OdidoTweaks,
|
||||||
|
} from "./tweaks";
|
||||||
|
|
||||||
const ODIDO_ID = "odido";
|
const ODIDO_ID = "odido";
|
||||||
const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd";
|
const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd";
|
||||||
|
|
||||||
export type OdidoConfig = {
|
export type OdidoConfig = OdidoTweaks & {
|
||||||
wvdPath: string;
|
wvdPath: string;
|
||||||
lastSyncAt?: string;
|
lastSyncAt?: string;
|
||||||
lastSyncSummary?: Record<string, unknown>;
|
lastSyncSummary?: Record<string, unknown>;
|
||||||
wvdUploadedAt?: string;
|
wvdUploadedAt?: string;
|
||||||
|
lastMpdRefreshAt?: string;
|
||||||
|
lastMpdRefreshSummary?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseConfig(raw: string | null | undefined): OdidoConfig {
|
function parseConfig(raw: string | null | undefined): OdidoConfig {
|
||||||
|
|
@ -24,9 +32,15 @@ function parseConfig(raw: string | null | undefined): OdidoConfig {
|
||||||
parsed = {};
|
parsed = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const tweaks = mergeOdidoTweaks(parsed);
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...tweaks,
|
||||||
wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH,
|
wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH,
|
||||||
|
lastSyncAt: parsed.lastSyncAt,
|
||||||
|
lastSyncSummary: parsed.lastSyncSummary,
|
||||||
|
wvdUploadedAt: parsed.wvdUploadedAt,
|
||||||
|
lastMpdRefreshAt: parsed.lastMpdRefreshAt,
|
||||||
|
lastMpdRefreshSummary: parsed.lastMpdRefreshSummary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,16 +48,30 @@ export async function getOdidoSettingsPublic() {
|
||||||
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
const config = parseConfig(row?.configJson);
|
const config = parseConfig(row?.configJson);
|
||||||
const hasWvd = existsSync(config.wvdPath);
|
const hasWvd = existsSync(config.wvdPath);
|
||||||
|
const {
|
||||||
|
lastSyncAt,
|
||||||
|
lastSyncSummary,
|
||||||
|
wvdUploadedAt,
|
||||||
|
lastMpdRefreshAt,
|
||||||
|
lastMpdRefreshSummary,
|
||||||
|
wvdPath,
|
||||||
|
...tweaks
|
||||||
|
} = config;
|
||||||
return {
|
return {
|
||||||
configured: !!row?.username && !!row.passwordEnc && hasWvd,
|
configured: !!row?.username && !!row.passwordEnc && hasWvd,
|
||||||
enabled: row?.enabled ?? true,
|
enabled: row?.enabled ?? true,
|
||||||
username: row?.username ?? "",
|
username: row?.username ?? "",
|
||||||
hasPin: !!row?.passwordEnc,
|
hasPin: !!row?.passwordEnc,
|
||||||
hasWvd,
|
hasWvd,
|
||||||
wvdPath: config.wvdPath,
|
wvdPath,
|
||||||
wvdUploadedAt: config.wvdUploadedAt ?? null,
|
wvdUploadedAt: wvdUploadedAt ?? null,
|
||||||
lastSyncAt: config.lastSyncAt ?? null,
|
lastSyncAt: lastSyncAt ?? null,
|
||||||
lastSyncSummary: config.lastSyncSummary ?? null,
|
lastSyncSummary: lastSyncSummary ?? null,
|
||||||
|
lastMpdRefreshAt: lastMpdRefreshAt ?? null,
|
||||||
|
lastMpdRefreshSummary: lastMpdRefreshSummary ?? null,
|
||||||
|
tweaks,
|
||||||
|
tweakDefaults: ODIDO_TWEAK_DEFAULTS,
|
||||||
|
tweakMeta: ODIDO_TWEAK_META,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +82,7 @@ export async function upsertOdidoSettings(
|
||||||
pin?: string;
|
pin?: string;
|
||||||
wvdPath?: string;
|
wvdPath?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
tweaks?: Partial<OdidoTweaks>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
|
@ -61,6 +90,10 @@ export async function upsertOdidoSettings(
|
||||||
if (input.wvdPath !== undefined) {
|
if (input.wvdPath !== undefined) {
|
||||||
config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH;
|
config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH;
|
||||||
}
|
}
|
||||||
|
if (input.tweaks) {
|
||||||
|
const merged = mergeOdidoTweaks({ ...config, ...input.tweaks });
|
||||||
|
Object.assign(config, merged);
|
||||||
|
}
|
||||||
const username =
|
const username =
|
||||||
input.username !== undefined ? input.username.trim() || null : existing?.username ?? null;
|
input.username !== undefined ? input.username.trim() || null : existing?.username ?? null;
|
||||||
const passwordEnc =
|
const passwordEnc =
|
||||||
|
|
@ -165,3 +198,21 @@ export async function saveOdidoSyncResult(summary: Record<string, unknown>) {
|
||||||
data: { configJson: JSON.stringify(config) },
|
data: { configJson: JSON.stringify(config) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveOdidoMpdRefreshResult(summary: Record<string, unknown>) {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
if (!row) return;
|
||||||
|
const config = parseConfig(row.configJson);
|
||||||
|
config.lastMpdRefreshAt = new Date().toISOString();
|
||||||
|
config.lastMpdRefreshSummary = summary;
|
||||||
|
await prisma.integrationSetting.update({
|
||||||
|
where: { id: ODIDO_ID },
|
||||||
|
data: { configJson: JSON.stringify(config) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOdidoConfigOnly(): Promise<OdidoConfig | null> {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
if (!row?.enabled) return null;
|
||||||
|
return parseConfig(row.configJson);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,12 @@ export async function syncOdidoList(
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await loadOdidoSettings(sessionSecret);
|
const settings = await loadOdidoSettings(sessionSecret);
|
||||||
const cookies = await login(settings.username, settings.pin);
|
const tweaks = settings.config;
|
||||||
|
const cookies = await login(
|
||||||
|
settings.username,
|
||||||
|
settings.pin,
|
||||||
|
tweaks.sessionCacheMinutes * 60_000
|
||||||
|
);
|
||||||
const allChannels = await queryAllChannels(cookies);
|
const allChannels = await queryAllChannels(cookies);
|
||||||
const videos = allChannels
|
const videos = allChannels
|
||||||
.map((channel, apiIndex) => ({ channel, apiIndex }))
|
.map((channel, apiIndex) => ({ channel, apiIndex }))
|
||||||
|
|
@ -93,7 +98,7 @@ export async function syncOdidoList(
|
||||||
const successful = new Set<string>();
|
const successful = new Set<string>();
|
||||||
const counts = { added: 0, updated: 0, skipped: 0, failed: 0 };
|
const counts = { added: 0, updated: 0, skipped: 0, failed: 0 };
|
||||||
|
|
||||||
await mapLimit(videos, 4, async ({ channel, apiIndex }) => {
|
await mapLimit(videos, tweaks.syncConcurrency, async ({ channel, apiIndex }) => {
|
||||||
const externalId = channel.ID?.trim();
|
const externalId = channel.ID?.trim();
|
||||||
const physical = channel.physicalChannels?.[0];
|
const physical = channel.physicalChannels?.[0];
|
||||||
const mediaId = physical?.ID?.trim();
|
const mediaId = physical?.ID?.trim();
|
||||||
|
|
@ -113,7 +118,12 @@ export async function syncOdidoList(
|
||||||
const previous = existing.get(externalId);
|
const previous = existing.get(externalId);
|
||||||
let kidEnc = previous?.kidEnc ?? null;
|
let kidEnc = previous?.kidEnc ?? null;
|
||||||
let keyEnc = previous?.keyEnc ?? null;
|
let keyEnc = previous?.keyEnc ?? null;
|
||||||
if (play.licenseUrl && play.licenseToken && (!kidEnc || !keyEnc)) {
|
if (
|
||||||
|
tweaks.syncFetchKeysIfMissing &&
|
||||||
|
play.licenseUrl &&
|
||||||
|
play.licenseToken &&
|
||||||
|
(!kidEnc || !keyEnc)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const pssh = await getPssh(play.playUrl, play.cookies);
|
const pssh = await getPssh(play.playUrl, play.cookies);
|
||||||
if (!pssh) throw new Error("Widevine PSSH ontbreekt");
|
if (!pssh) throw new Error("Widevine PSSH ontbreekt");
|
||||||
|
|
@ -137,6 +147,7 @@ export async function syncOdidoList(
|
||||||
|
|
||||||
const name = channel.name?.trim() || channel.channelName?.trim() ||
|
const name = channel.name?.trim() || channel.channelName?.trim() ||
|
||||||
physical?.mediaName?.trim() || externalId;
|
physical?.mediaName?.trim() || externalId;
|
||||||
|
const now = new Date();
|
||||||
const data = {
|
const data = {
|
||||||
name,
|
name,
|
||||||
logoUrl: logoUrl(channel),
|
logoUrl: logoUrl(channel),
|
||||||
|
|
@ -147,6 +158,9 @@ export async function syncOdidoList(
|
||||||
keyEnc,
|
keyEnc,
|
||||||
sortOrder: channelSortOrder(channel, apiIndex),
|
sortOrder: channelSortOrder(channel, apiIndex),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
...(tweaks.syncRefreshMpdCache
|
||||||
|
? { streamUrlCache: play.playUrl, streamUrlCachedAt: now }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
if (previous) {
|
if (previous) {
|
||||||
await prisma.liveChannel.update({ where: { id: previous.id }, data });
|
await prisma.liveChannel.update({ where: { id: previous.id }, data });
|
||||||
|
|
@ -160,8 +174,6 @@ export async function syncOdidoList(
|
||||||
successful.add(externalId);
|
successful.add(externalId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A full sync is authoritative: channels outside the current package (including
|
|
||||||
// PlayChannel failures) remain stored for stable IDs, but are disabled.
|
|
||||||
const disableIds = list.channels
|
const disableIds = list.channels
|
||||||
.filter((channel) => channel.externalId && !successful.has(channel.externalId))
|
.filter((channel) => channel.externalId && !successful.has(channel.externalId))
|
||||||
.map((channel) => channel.id);
|
.map((channel) => channel.id);
|
||||||
|
|
|
||||||
20
apps/master-api/src/odido/tweaks.test.ts
Normal file
20
apps/master-api/src/odido/tweaks.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { isMpdCacheFresh, mergeOdidoTweaks, ODIDO_TWEAK_DEFAULTS } from "./tweaks";
|
||||||
|
|
||||||
|
const now = Date.parse("2026-09-21T21:00:00.000Z");
|
||||||
|
|
||||||
|
assert.equal(isMpdCacheFresh(new Date(now - 30 * 60_000), 3600, now), true, "30m < 1h");
|
||||||
|
assert.equal(isMpdCacheFresh(new Date(now - 61 * 60_000), 3600, now), false, "61m > 1h");
|
||||||
|
assert.equal(isMpdCacheFresh(null, 3600, now), false, "null cache");
|
||||||
|
assert.equal(isMpdCacheFresh(new Date(now - 1000).toISOString(), 3600, now), true, "iso string");
|
||||||
|
|
||||||
|
const merged = mergeOdidoTweaks({ mpdTtlSeconds: 7200, mpdForceFreshOnPlay: true });
|
||||||
|
assert.equal(merged.mpdTtlSeconds, 7200);
|
||||||
|
assert.equal(merged.mpdForceFreshOnPlay, true);
|
||||||
|
assert.equal(merged.mpdPreferCacheOnPlay, ODIDO_TWEAK_DEFAULTS.mpdPreferCacheOnPlay);
|
||||||
|
|
||||||
|
const clamped = mergeOdidoTweaks({ mpdRefreshConcurrency: 99, sessionCacheMinutes: 0 });
|
||||||
|
assert.equal(clamped.mpdRefreshConcurrency, 16);
|
||||||
|
assert.equal(clamped.sessionCacheMinutes, 1);
|
||||||
|
|
||||||
|
console.log("odido tweaks/cache tests ok");
|
||||||
145
apps/master-api/src/odido/tweaks.ts
Normal file
145
apps/master-api/src/odido/tweaks.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/** Alle Odido-gedrag dat via Scripts → Geavanceerd te tweaken is. */
|
||||||
|
export type OdidoTweaks = {
|
||||||
|
/** Max leeftijd gecachte MPD bij play (seconden). Default 1 uur. */
|
||||||
|
mpdTtlSeconds: number;
|
||||||
|
/** Periodieke achtergrond-refresh van alle Odido-MPD’s. */
|
||||||
|
mpdRefreshEnabled: boolean;
|
||||||
|
/** Hoe vaak de worker mag refresh’en (seconden). */
|
||||||
|
mpdRefreshIntervalSeconds: number;
|
||||||
|
/** Parallelle PlayChannel-calls tijdens refresh/sync. */
|
||||||
|
mpdRefreshConcurrency: number;
|
||||||
|
/** Pauzes tussen starts binnen een batch (ms). */
|
||||||
|
mpdRefreshStaggerMs: number;
|
||||||
|
/** Alleen enabled zenders refresh’en. */
|
||||||
|
mpdRefreshOnlyEnabled: boolean;
|
||||||
|
/** Bij play: verse cache gebruiken als jonger dan TTL. */
|
||||||
|
mpdPreferCacheOnPlay: boolean;
|
||||||
|
/** Bij play: als cache te oud is, meteen vernieuwen. */
|
||||||
|
mpdRefreshOnPlayIfStale: boolean;
|
||||||
|
/** Debug: altijd verse PlayChannel (negeert cache). */
|
||||||
|
mpdForceFreshOnPlay: boolean;
|
||||||
|
/** Optioneel: gecachte MPD even proberen vóór serveren. */
|
||||||
|
mpdProbeCachedBeforeServe: boolean;
|
||||||
|
mpdProbeTimeoutMs: number;
|
||||||
|
/** Bij mislukte probe: verse MPD ophalen. */
|
||||||
|
mpdFallbackFreshOnProbeFail: boolean;
|
||||||
|
/** Login-cookie cache (minuten). */
|
||||||
|
sessionCacheMinutes: number;
|
||||||
|
playTimeoutMs: number;
|
||||||
|
cdmTimeoutMs: number;
|
||||||
|
syncConcurrency: number;
|
||||||
|
/** Tijdens volledige sync ontbrekende ClearKeys via CDM ophalen. */
|
||||||
|
syncFetchKeysIfMissing: boolean;
|
||||||
|
/** Tijdens sync de MPD-cache vullen. */
|
||||||
|
syncRefreshMpdCache: boolean;
|
||||||
|
/** Bij play ontbrekende keys alsnog via CDM. */
|
||||||
|
refetchKeysIfMissingOnPlay: boolean;
|
||||||
|
heartbeatEnabled: boolean;
|
||||||
|
heartbeatIntervalSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ODIDO_TWEAK_DEFAULTS: OdidoTweaks = {
|
||||||
|
mpdTtlSeconds: 3600,
|
||||||
|
mpdRefreshEnabled: true,
|
||||||
|
mpdRefreshIntervalSeconds: 3600,
|
||||||
|
mpdRefreshConcurrency: 4,
|
||||||
|
mpdRefreshStaggerMs: 75,
|
||||||
|
mpdRefreshOnlyEnabled: true,
|
||||||
|
mpdPreferCacheOnPlay: true,
|
||||||
|
mpdRefreshOnPlayIfStale: true,
|
||||||
|
mpdForceFreshOnPlay: false,
|
||||||
|
mpdProbeCachedBeforeServe: false,
|
||||||
|
mpdProbeTimeoutMs: 5000,
|
||||||
|
mpdFallbackFreshOnProbeFail: true,
|
||||||
|
sessionCacheMinutes: 20,
|
||||||
|
playTimeoutMs: 30_000,
|
||||||
|
cdmTimeoutMs: 60_000,
|
||||||
|
syncConcurrency: 4,
|
||||||
|
syncFetchKeysIfMissing: true,
|
||||||
|
syncRefreshMpdCache: true,
|
||||||
|
refetchKeysIfMissingOnPlay: true,
|
||||||
|
heartbeatEnabled: false,
|
||||||
|
heartbeatIntervalSeconds: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBool(value: unknown, fallback: boolean): boolean {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (value === "true" || value === 1 || value === "1") return true;
|
||||||
|
if (value === "false" || value === 0 || value === "0") return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeOdidoTweaks(raw: Partial<OdidoTweaks> | null | undefined): OdidoTweaks {
|
||||||
|
const d = ODIDO_TWEAK_DEFAULTS;
|
||||||
|
const r = raw ?? {};
|
||||||
|
return {
|
||||||
|
mpdTtlSeconds: clampInt(r.mpdTtlSeconds, 60, 24 * 3600, d.mpdTtlSeconds),
|
||||||
|
mpdRefreshEnabled: asBool(r.mpdRefreshEnabled, d.mpdRefreshEnabled),
|
||||||
|
mpdRefreshIntervalSeconds: clampInt(r.mpdRefreshIntervalSeconds, 60, 24 * 3600, d.mpdRefreshIntervalSeconds),
|
||||||
|
mpdRefreshConcurrency: clampInt(r.mpdRefreshConcurrency, 1, 16, d.mpdRefreshConcurrency),
|
||||||
|
mpdRefreshStaggerMs: clampInt(r.mpdRefreshStaggerMs, 0, 5000, d.mpdRefreshStaggerMs),
|
||||||
|
mpdRefreshOnlyEnabled: asBool(r.mpdRefreshOnlyEnabled, d.mpdRefreshOnlyEnabled),
|
||||||
|
mpdPreferCacheOnPlay: asBool(r.mpdPreferCacheOnPlay, d.mpdPreferCacheOnPlay),
|
||||||
|
mpdRefreshOnPlayIfStale: asBool(r.mpdRefreshOnPlayIfStale, d.mpdRefreshOnPlayIfStale),
|
||||||
|
mpdForceFreshOnPlay: asBool(r.mpdForceFreshOnPlay, d.mpdForceFreshOnPlay),
|
||||||
|
mpdProbeCachedBeforeServe: asBool(r.mpdProbeCachedBeforeServe, d.mpdProbeCachedBeforeServe),
|
||||||
|
mpdProbeTimeoutMs: clampInt(r.mpdProbeTimeoutMs, 500, 30_000, d.mpdProbeTimeoutMs),
|
||||||
|
mpdFallbackFreshOnProbeFail: asBool(r.mpdFallbackFreshOnProbeFail, d.mpdFallbackFreshOnProbeFail),
|
||||||
|
sessionCacheMinutes: clampInt(r.sessionCacheMinutes, 1, 180, d.sessionCacheMinutes),
|
||||||
|
playTimeoutMs: clampInt(r.playTimeoutMs, 5000, 120_000, d.playTimeoutMs),
|
||||||
|
cdmTimeoutMs: clampInt(r.cdmTimeoutMs, 5000, 180_000, d.cdmTimeoutMs),
|
||||||
|
syncConcurrency: clampInt(r.syncConcurrency, 1, 16, d.syncConcurrency),
|
||||||
|
syncFetchKeysIfMissing: asBool(r.syncFetchKeysIfMissing, d.syncFetchKeysIfMissing),
|
||||||
|
syncRefreshMpdCache: asBool(r.syncRefreshMpdCache, d.syncRefreshMpdCache),
|
||||||
|
refetchKeysIfMissingOnPlay: asBool(r.refetchKeysIfMissingOnPlay, d.refetchKeysIfMissingOnPlay),
|
||||||
|
heartbeatEnabled: asBool(r.heartbeatEnabled, d.heartbeatEnabled),
|
||||||
|
heartbeatIntervalSeconds: clampInt(r.heartbeatIntervalSeconds, 30, 3600, d.heartbeatIntervalSeconds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMpdCacheFresh(
|
||||||
|
cachedAt: Date | string | null | undefined,
|
||||||
|
ttlSeconds: number,
|
||||||
|
nowMs = Date.now()
|
||||||
|
): boolean {
|
||||||
|
if (!cachedAt) return false;
|
||||||
|
const at = typeof cachedAt === "string" ? Date.parse(cachedAt) : cachedAt.getTime();
|
||||||
|
if (!Number.isFinite(at)) return false;
|
||||||
|
return nowMs - at < ttlSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ODIDO_TWEAK_META: Array<{
|
||||||
|
key: keyof OdidoTweaks;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
kind: "number" | "boolean";
|
||||||
|
unit?: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "mpdTtlSeconds", label: "MPD cache-TTL", hint: "Play gebruikt cache als jonger dan dit", kind: "number", unit: "sec" },
|
||||||
|
{ key: "mpdRefreshEnabled", label: "Achtergrond MPD-refresh", hint: "Periodiek alle MPD’s vernieuwen", kind: "boolean" },
|
||||||
|
{ key: "mpdRefreshIntervalSeconds", label: "Refresh-interval", hint: "Hoe vaak de worker draait", kind: "number", unit: "sec" },
|
||||||
|
{ key: "mpdRefreshConcurrency", label: "Refresh parallel", hint: "Gelijktijdige PlayChannel-calls", kind: "number" },
|
||||||
|
{ key: "mpdRefreshStaggerMs", label: "Refresh stagger", hint: "Pauze tussen starts in een batch", kind: "number", unit: "ms" },
|
||||||
|
{ key: "mpdRefreshOnlyEnabled", label: "Alleen enabled zenders", hint: "Disabled kanalen overslaan bij refresh", kind: "boolean" },
|
||||||
|
{ key: "mpdPreferCacheOnPlay", label: "Cache bij play", hint: "Instant play als cache vers is", kind: "boolean" },
|
||||||
|
{ key: "mpdRefreshOnPlayIfStale", label: "Vernieuw bij stale play", hint: "Ouder dan TTL → verse PlayChannel", kind: "boolean" },
|
||||||
|
{ key: "mpdForceFreshOnPlay", label: "Altijd verse MPD", hint: "Debug: negeert cache volledig", kind: "boolean" },
|
||||||
|
{ key: "mpdProbeCachedBeforeServe", label: "Probe cache vóór play", hint: "Korte HTTP-check op gecachte MPD", kind: "boolean" },
|
||||||
|
{ key: "mpdProbeTimeoutMs", label: "Probe-timeout", hint: "Max wachttijd voor MPD-probe", kind: "number", unit: "ms" },
|
||||||
|
{ key: "mpdFallbackFreshOnProbeFail", label: "Verse MPD na mislukte probe", hint: "Bij 403/timeout opnieuw PlayChannel", kind: "boolean" },
|
||||||
|
{ key: "sessionCacheMinutes", label: "Login-sessie cache", hint: "Hergebruik Odido-cookies", kind: "number", unit: "min" },
|
||||||
|
{ key: "playTimeoutMs", label: "PlayChannel-timeout", hint: "HTTP-timeout voor PlayChannel", kind: "number", unit: "ms" },
|
||||||
|
{ key: "cdmTimeoutMs", label: "CDM-timeout", hint: "Widevine license-extractie", kind: "number", unit: "ms" },
|
||||||
|
{ key: "syncConcurrency", label: "Sync parallel", hint: "Gelijktijdige zenders bij volledig ophalen", kind: "number" },
|
||||||
|
{ key: "syncFetchKeysIfMissing", label: "Sync: keys ophalen", hint: "CDM tijdens sync als keys ontbreken", kind: "boolean" },
|
||||||
|
{ key: "syncRefreshMpdCache", label: "Sync: MPD-cache vullen", hint: "Sla playURL op tijdens sync", kind: "boolean" },
|
||||||
|
{ key: "refetchKeysIfMissingOnPlay", label: "Play: keys als ontbrekend", hint: "CDM bij play zonder opgeslagen keys", kind: "boolean" },
|
||||||
|
{ key: "heartbeatEnabled", label: "Odido heartbeat", hint: "Sessie warm houden via OnLineHeartbeat", kind: "boolean" },
|
||||||
|
{ key: "heartbeatIntervalSeconds", label: "Heartbeat-interval", hint: "Alleen als heartbeat aan staat", kind: "number", unit: "sec" },
|
||||||
|
];
|
||||||
|
|
@ -442,43 +442,24 @@ export async function getCustomPlayUrl(
|
||||||
throw new AppError("ODIDO_ERROR", "Odido-zenderreferentie is onvolledig", 500);
|
throw new AppError("ODIDO_ERROR", "Odido-zenderreferentie is onvolledig", 500);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { loadOdidoSettings } = await import("../odido/settings");
|
const { resolveOdidoPlay } = await import("../odido/play");
|
||||||
const { login, playChannel, getPssh } = await import("../odido/client");
|
const resolved = await resolveOdidoPlay(
|
||||||
const settings = await loadOdidoSettings(sessionSecret);
|
{
|
||||||
const cookies = await login(settings.username, settings.pin);
|
id: channel.id,
|
||||||
const play = await playChannel(cookies, externalId, mediaId);
|
externalId,
|
||||||
streamUrl = play.playUrl;
|
mediaId,
|
||||||
if (play.licenseUrl && play.licenseToken && (!kidEnc || !keyEnc)) {
|
contentId: channel.contentId,
|
||||||
const pssh = await getPssh(play.playUrl, play.cookies);
|
mpdUrl: channel.mpdUrl,
|
||||||
if (!pssh) throw new Error("Widevine PSSH ontbreekt");
|
streamUrlCache: channel.streamUrlCache,
|
||||||
const { extractOdidoKeys } = await import("../odido/cdm");
|
streamUrlCachedAt: channel.streamUrlCachedAt,
|
||||||
const keys = await extractOdidoKeys({
|
kidEnc: channel.kidEnc,
|
||||||
wvdPath: settings.config.wvdPath,
|
keyEnc: channel.keyEnc,
|
||||||
pssh,
|
},
|
||||||
licUrl: play.licenseUrl,
|
sessionSecret
|
||||||
licToken: play.licenseToken,
|
);
|
||||||
cookies: play.cookies,
|
streamUrl = resolved.streamUrl;
|
||||||
userAgent: play.userAgent,
|
kidEnc = resolved.kidEnc;
|
||||||
});
|
keyEnc = resolved.keyEnc;
|
||||||
const parsed = keys
|
|
||||||
.map((value) => value.trim().toLowerCase().split(":", 2))
|
|
||||||
.find(([kid, key]) => /^[0-9a-f]{32}$/.test(kid ?? "") &&
|
|
||||||
/^[0-9a-f]{32}$/.test(key ?? ""));
|
|
||||||
if (!parsed) throw new Error("Geen bruikbare ClearKey ontvangen");
|
|
||||||
kidEnc = encryptSecret(parsed[0], sessionSecret);
|
|
||||||
keyEnc = encryptSecret(parsed[1], sessionSecret);
|
|
||||||
await prisma.liveChannel.update({
|
|
||||||
where: { id: channel.id },
|
|
||||||
data: {
|
|
||||||
kidEnc,
|
|
||||||
keyEnc,
|
|
||||||
contentId: play.contentId ?? channel.contentId,
|
|
||||||
externalId,
|
|
||||||
mediaId,
|
|
||||||
mpdUrl: `odido://${externalId}/${mediaId}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "onbekende fout";
|
const message = error instanceof Error ? error.message : "onbekende fout";
|
||||||
throw new AppError("ODIDO_ERROR", `Odido-zender starten mislukt: ${message}`, 502);
|
throw new AppError("ODIDO_ERROR", `Odido-zender starten mislukt: ${message}`, 502);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue