Fix Viaplay account probe and serve agenda from Scripts only.

Show subscription status more reliably; drop FAAB Viaplay from Events and limit our agenda to 3 days ahead.
This commit is contained in:
Jos Vooges | STH 2026-09-22 22:30:55 +02:00
parent f92323847a
commit 7cab13c5a8
6 changed files with 203 additions and 38 deletions

View file

@ -210,15 +210,21 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: accountId ? undefined : JSON.stringify({}), body: JSON.stringify(accountId ? { accountId } : {}),
}); });
const d = await r.json(); const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt"); if (!r.ok) {
throw new Error(
d?.error?.message || d?.message || `Statuscheck mislukt (${r.status})`
);
}
applySettings(d); applySettings(d);
const list: ViaplayAccount[] = d.accounts ?? []; const list: ViaplayAccount[] = d.accounts ?? [];
const ok = list.filter((a) => a.healthStatus === "ok").length; const ok = list.filter((a) => a.healthStatus === "ok").length;
const dead = list.filter((a) => a.healthStatus === "dead").length; const dead = list.filter((a) => a.healthStatus === "dead").length;
setMsg(`Accountstatus: ${ok} actief · ${dead} dood`); const sample = list.find((a) => a.id === accountId) || list.find((a) => a.subscriptionSummary);
const sub = sample?.subscriptionSummary ? ` · ${sample.subscriptionSummary}` : "";
setMsg(`Accountstatus: ${ok} actief · ${dead} probleem${sub}`);
} catch (e) { } catch (e) {
setErr(String((e as Error).message ?? e)); setErr(String((e as Error).message ?? e));
} finally { } finally {
@ -290,10 +296,10 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
return ( return (
<span <span
className="err-banner" className="err-banner"
title={a.healthError || "Cookies ongeldig"} title={a.healthError || "Sessie ongeldig"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }} style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
> >
dood probleem
</span> </span>
); );
} }
@ -330,7 +336,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Voeg een account toe met Sportagenda en live streams voor gekoppelde Viaplay-accounts. Voeg een account toe met
sessie-token, access token en optioneel profile-id (uit een ingelogde browsersessie). sessie-token, access token en optioneel profile-id (uit een ingelogde browsersessie).
Manifests en licenties worden cache-first opgehaald (±5 min vóór start, gestaggerd). Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en
licenties cache-first (±5 min vóór start).
</p> </p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
@ -378,9 +385,24 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
{a.email} {a.email}
{" · "} {" · "}
{a.hasSession && a.hasAccessToken ? "tokens ok" : "tokens missen"} {a.hasSession && a.hasAccessToken ? "tokens ok" : "tokens missen"}
{a.subscriptionSummary ? ` · ${a.subscriptionSummary}` : ""}
{a.eventCount ? ` · ${a.eventCount} events` : ""} {a.eventCount ? ` · ${a.eventCount} events` : ""}
</span> </span>
{(a.subscriptionSummary || a.partnerName || a.healthError) && (
<div style={{ width: "100%", fontSize: "0.85rem", marginTop: 2 }}>
{a.subscriptionSummary ? (
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
{a.subscriptionSummary}
</span>
) : a.partnerName ? (
<span className="muted">{a.partnerName}</span>
) : null}
{a.healthError ? (
<span className="muted" style={{ marginLeft: 8 }} title={a.healthError}>
{a.healthError}
</span>
) : null}
</div>
)}
</div> </div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}> <button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>

View file

@ -75,38 +75,145 @@ export async function fetchAccountOverview(
creds: ViaplayAccountCreds, creds: ViaplayAccountCreds,
timeoutMs: number timeoutMs: number
): Promise<ViaplayOverview> { ): Promise<ViaplayOverview> {
const attempts: Array<{ label: string; headers: Record<string, string> }> = [
{
label: "cookie+bearer",
headers: {
...authHeaders(creds),
Accept: "application/json",
...(creds.accessToken
? { Authorization: `Bearer ${creds.accessToken}` }
: {}),
},
},
{
label: "cookie",
headers: { ...authHeaders(creds), Accept: "application/json" },
},
{
label: "bearer",
headers: {
Accept: "application/json",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
...(creds.accessToken
? { Authorization: `Bearer ${creds.accessToken}` }
: {}),
},
},
];
let lastStatus = 0;
let lastError = "Overview mislukt";
for (const attempt of attempts) {
try {
const res = await fetchJson("https://account.mtg-api.com/overview", { const res = await fetchJson("https://account.mtg-api.com/overview", {
headers: authHeaders(creds), headers: attempt.headers,
timeoutMs, timeoutMs: Math.min(timeoutMs, 15_000),
}); });
if (res.status !== 200 || !asRecord(res.json)) { lastStatus = res.status;
const body = asRecord(res.json);
if (res.status === 200 && body) {
return parseOverviewBody(body);
}
lastError =
(typeof body?.message === "string" && body.message) ||
(typeof body?.userMessage === "string" && body.userMessage) ||
`Overview HTTP ${res.status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
// Fallback: sessie geldig? (light content call)
try {
const ping = await fetchJson(
"https://content.viaplay.com/pcdash-nl?language=nl",
{
headers: { ...authHeaders(creds), Accept: "application/json" },
timeoutMs: Math.min(timeoutMs, 12_000),
}
);
const root = asRecord(ping.json);
const hasUser = Boolean(
asRecord(root?._links)?.["viaplay:userData"] ||
asRecord(root?._links)?.["viaplay:logout"]
);
if (ping.status === 200 && hasUser) {
const exp = accessTokenExpiry(creds.accessToken);
return {
ok: true,
status: ping.status,
partnerName: null,
productNames: [],
summary: exp
? `Sessie actief · token tot ${exp}`
: "Sessie actief (abonnementdetails niet beschikbaar)",
};
}
} catch {
/* ignore */
}
return { return {
ok: false, ok: false,
status: res.status, status: lastStatus,
partnerName: null, partnerName: null,
productNames: [], productNames: [],
summary: "", summary: "",
error: error: lastError,
(asRecord(res.json)?.message as string) ||
`Overview mislukt (${res.status})`,
}; };
} }
const body = asRecord(res.json)!;
function accessTokenExpiry(token: string | null): string | null {
if (!token) return null;
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
) as { exp?: number };
if (!payload.exp) return null;
return new Date(payload.exp * 1000).toLocaleString("nl-NL");
} catch {
return null;
}
}
function packageLabel(raw: unknown): string | null {
const rec = asRecord(raw);
if (!rec) return null;
for (const key of ["name", "displayName", "title", "productName", "id"]) {
const v = rec[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function parseOverviewBody(body: Record<string, unknown>): ViaplayOverview {
const partner = asRecord(body.partnerInformation); const partner = asRecord(body.partnerInformation);
const partnerName = const partnerName =
typeof partner?.partnerDisplayName === "string" typeof partner?.partnerDisplayName === "string"
? partner.partnerDisplayName ? partner.partnerDisplayName
: typeof partner?.partnerId === "string"
? partner.partnerId
: null; : null;
const productNames = Array.isArray(partner?.productNames) const productNames = Array.isArray(partner?.productNames)
? partner!.productNames.map((p) => String(p).trim()).filter(Boolean) ? partner!.productNames.map((p) => String(p).trim()).filter(Boolean)
: []; : [];
const summary = [partnerName, productNames.join(", ")].filter(Boolean).join(" · "); const pkg =
packageLabel(body.package) ||
packageLabel(body.contract) ||
packageLabel(body.rolloverPackage) ||
packageLabel(body.promotion);
if (pkg && !productNames.includes(pkg)) productNames.push(pkg);
const parts = [partnerName, productNames.join(", ")].filter(Boolean);
return { return {
ok: true, ok: true,
status: res.status, status: 200,
partnerName, partnerName,
productNames, productNames,
summary: summary || "Actief", summary: parts.join(" · ") || "Actief abonnement",
}; };
} }

View file

@ -56,8 +56,12 @@ export async function listViaplayScheduleEvents(
): Promise<ScheduleEventPublic[]> { ): Promise<ScheduleEventPublic[]> {
const config = await loadViaplayConfigOnly(); const config = await loadViaplayConfigOnly();
if (!config?.events?.length) return []; if (!config?.events?.length) return [];
const aheadMs = Math.max(1, config.syncDaysAhead || 3) * 86_400_000;
const horizon = nowMs + aheadMs;
const out: ScheduleEventPublic[] = []; const out: ScheduleEventPublic[] = [];
for (const event of config.events) { for (const event of config.events) {
if (event.endMs < nowMs) continue;
if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs); const pub = eventToPublic(event, nowMs);
if (pub.phase === "ended") continue; if (pub.phase === "ended") continue;
out.push(pub); out.push(pub);

View file

@ -133,9 +133,10 @@ export async function syncViaplayCatalog(_sessionSecret?: string) {
events.push(toSnapshot(product, prev, owners)); events.push(toSnapshot(product, prev, owners));
} }
// Drop ended events older than 6h to keep config small // Drop ended events older than 6h; also drop further than sync horizon+1d
const cutoff = now - 6 * 3_600_000; const cutoff = now - 6 * 3_600_000;
const kept = events.filter((e) => e.endMs >= cutoff); const horizon = now + config.syncDaysAhead * 86_400_000 + 86_400_000;
const kept = events.filter((e) => e.endMs >= cutoff && e.startMs <= horizon);
kept.sort((a, b) => a.startMs - b.startMs); kept.sort((a, b) => a.startMs - b.startMs);
const summary = { const summary = {

View file

@ -27,7 +27,7 @@ export type ViaplayTweaks = {
export const VIAPLAY_TWEAK_DEFAULTS: ViaplayTweaks = { export const VIAPLAY_TWEAK_DEFAULTS: ViaplayTweaks = {
deviceKey: "pcdash-nl", deviceKey: "pcdash-nl",
syncDaysAhead: 7, syncDaysAhead: 3,
httpTimeoutMs: 30_000, httpTimeoutMs: 30_000,
preLiveMinutes: 5, preLiveMinutes: 5,
preLiveIntervalSeconds: 60, preLiveIntervalSeconds: 60,
@ -55,8 +55,8 @@ export const VIAPLAY_TWEAK_META: Array<{
}, },
{ {
key: "syncDaysAhead", key: "syncDaysAhead",
label: "EPG-dagen vooruit", label: "Agenda-dagen vooruit",
hint: "Hoeveel dagen sport/all?date=… syncen.", hint: "Hoeveel dagen sportagenda syncen/tonen (max 3 aanbevolen).",
kind: "number", kind: "number",
}, },
{ {
@ -141,7 +141,7 @@ export function mergeViaplayTweaks(
}; };
return { return {
deviceKey: s(raw.deviceKey, d.deviceKey), deviceKey: s(raw.deviceKey, d.deviceKey),
syncDaysAhead: Math.min(21, Math.max(1, n(raw.syncDaysAhead, d.syncDaysAhead))), syncDaysAhead: Math.min(3, Math.max(1, n(raw.syncDaysAhead, d.syncDaysAhead))),
httpTimeoutMs: Math.min(120_000, Math.max(5_000, n(raw.httpTimeoutMs, d.httpTimeoutMs))), httpTimeoutMs: Math.min(120_000, Math.max(5_000, n(raw.httpTimeoutMs, d.httpTimeoutMs))),
preLiveMinutes: Math.min(60, Math.max(1, n(raw.preLiveMinutes, d.preLiveMinutes))), preLiveMinutes: Math.min(60, Math.max(1, n(raw.preLiveMinutes, d.preLiveMinutes))),
preLiveIntervalSeconds: Math.min( preLiveIntervalSeconds: Math.min(

View file

@ -802,17 +802,39 @@ export async function discoverScheduleEventProviders(config: Config): Promise<st
const raw = await loadRawEvents(config); const raw = await loadRawEvents(config);
for (const e of raw.events) { for (const e of raw.events) {
const p = (str(e.provider) || "unknown").toLowerCase(); const p = (str(e.provider) || "unknown").toLowerCase();
// Viaplay komt uit Scripts, niet uit FAAB-feed
if (p === "viaplay") continue;
if (seen.has(p)) continue; if (seen.has(p)) continue;
seen.add(p); seen.add(p);
out.push(p); out.push(p);
} }
} catch { } catch {
/* feed optioneel als F1TV aan staat */ /* feed optioneel als F1TV/Viaplay aan staat */
} }
try { try {
const { isF1tvIntegrationEnabled } = await import("../f1tv/settings"); const { isF1tvIntegrationEnabled } = await import("../f1tv/settings");
if (await isF1tvIntegrationEnabled()) { if (await isF1tvIntegrationEnabled()) {
if (!seen.has("f1tv")) out.push("f1tv"); if (!seen.has("f1tv")) {
seen.add("f1tv");
out.push("f1tv");
}
}
} catch {
/* ignore */
}
try {
const row = await prisma.integrationSetting.findUnique({ where: { id: "viaplay" } });
if (row?.enabled !== false) {
const cfg = row?.configJson ? JSON.parse(row.configJson) : null;
const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : [];
const hasAccount = accounts.some(
(a: { enabled?: boolean; sessionEnc?: string; accessTokenEnc?: string }) =>
a && a.enabled !== false && a.sessionEnc && a.accessTokenEnc
);
if (hasAccount && !seen.has("viaplay")) {
seen.add("viaplay");
out.push("viaplay");
}
} }
} catch { } catch {
/* ignore */ /* ignore */
@ -839,9 +861,11 @@ export async function listScheduleEvents(
mapped = raw.events mapped = raw.events
.map((e) => toPublic(e, nowMs)) .map((e) => toPublic(e, nowMs))
.filter((e): e is ScheduleEventPublic => e != null) .filter((e): e is ScheduleEventPublic => e != null)
.filter((e) => e.phase !== "ended"); .filter((e) => e.phase !== "ended")
// Viaplay komt uit Scripts → Viaplay, niet uit de externe FAAB-feed
.filter((e) => e.provider.toLowerCase() !== "viaplay");
} catch { } catch {
/* FAAB-feed optioneel wanneer F1TV events levert */ /* FAAB-feed optioneel wanneer F1TV/Viaplay events leveren */
} }
try { try {
@ -937,6 +961,9 @@ export async function getScheduleEvent(
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const pub = toPublic(found, nowMs); const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
if (pub.provider.toLowerCase() === "viaplay") {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) { if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404); throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
} }
@ -1049,6 +1076,10 @@ export async function getScheduleEventPlay(
const pub = toPublic(found, nowMs); const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
if (pub.provider.toLowerCase() === "viaplay") {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) { if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404); throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
} }