diff --git a/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx b/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx
index fca0fc2..8736897 100644
--- a/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx
+++ b/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx
@@ -210,15 +210,21 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
- body: accountId ? undefined : JSON.stringify({}),
+ body: JSON.stringify(accountId ? { accountId } : {}),
});
- const d = await r.json();
- if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt");
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ throw new Error(
+ d?.error?.message || d?.message || `Statuscheck mislukt (${r.status})`
+ );
+ }
applySettings(d);
const list: ViaplayAccount[] = d.accounts ?? [];
const ok = list.filter((a) => a.healthStatus === "ok").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) {
setErr(String((e as Error).message ?? e));
} finally {
@@ -290,10 +296,10 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
return (
- dood
+ probleem
);
}
@@ -330,7 +336,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
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).
- 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).
@@ -378,9 +385,24 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
{a.email}
{" · "}
{a.hasSession && a.hasAccessToken ? "tokens ok" : "tokens missen"}
- {a.subscriptionSummary ? ` · ${a.subscriptionSummary}` : ""}
{a.eventCount ? ` · ${a.eventCount} events` : ""}
+ {(a.subscriptionSummary || a.partnerName || a.healthError) && (
+
+ {a.subscriptionSummary ? (
+
+ {a.subscriptionSummary}
+
+ ) : a.partnerName ? (
+ {a.partnerName}
+ ) : null}
+ {a.healthError ? (
+
+ {a.healthError}
+
+ ) : null}
+
+ )}
void probeAccounts(a.id)}>
diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts
index efe2b49..2bc5676 100644
--- a/apps/master-api/src/viaplay/client.ts
+++ b/apps/master-api/src/viaplay/client.ts
@@ -75,38 +75,145 @@ export async function fetchAccountOverview(
creds: ViaplayAccountCreds,
timeoutMs: number
): Promise {
- const res = await fetchJson("https://account.mtg-api.com/overview", {
- headers: authHeaders(creds),
- timeoutMs,
- });
- if (res.status !== 200 || !asRecord(res.json)) {
- return {
- ok: false,
- status: res.status,
- partnerName: null,
- productNames: [],
- summary: "",
- error:
- (asRecord(res.json)?.message as string) ||
- `Overview mislukt (${res.status})`,
- };
+ const attempts: Array<{ label: string; headers: Record }> = [
+ {
+ 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", {
+ headers: attempt.headers,
+ timeoutMs: Math.min(timeoutMs, 15_000),
+ });
+ 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);
+ }
}
- const body = asRecord(res.json)!;
+
+ // 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 {
+ ok: false,
+ status: lastStatus,
+ partnerName: null,
+ productNames: [],
+ summary: "",
+ error: lastError,
+ };
+}
+
+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): ViaplayOverview {
const partner = asRecord(body.partnerInformation);
const partnerName =
typeof partner?.partnerDisplayName === "string"
? partner.partnerDisplayName
- : null;
+ : typeof partner?.partnerId === "string"
+ ? partner.partnerId
+ : null;
const productNames = Array.isArray(partner?.productNames)
? 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 {
ok: true,
- status: res.status,
+ status: 200,
partnerName,
productNames,
- summary: summary || "Actief",
+ summary: parts.join(" · ") || "Actief abonnement",
};
}
diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts
index bb2c49b..75b0526 100644
--- a/apps/master-api/src/viaplay/events.ts
+++ b/apps/master-api/src/viaplay/events.ts
@@ -56,8 +56,12 @@ export async function listViaplayScheduleEvents(
): Promise {
const config = await loadViaplayConfigOnly();
if (!config?.events?.length) return [];
+ const aheadMs = Math.max(1, config.syncDaysAhead || 3) * 86_400_000;
+ const horizon = nowMs + aheadMs;
const out: ScheduleEventPublic[] = [];
for (const event of config.events) {
+ if (event.endMs < nowMs) continue;
+ if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs);
if (pub.phase === "ended") continue;
out.push(pub);
diff --git a/apps/master-api/src/viaplay/sync.ts b/apps/master-api/src/viaplay/sync.ts
index 725d1dd..98fab7d 100644
--- a/apps/master-api/src/viaplay/sync.ts
+++ b/apps/master-api/src/viaplay/sync.ts
@@ -133,9 +133,10 @@ export async function syncViaplayCatalog(_sessionSecret?: string) {
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 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);
const summary = {
diff --git a/apps/master-api/src/viaplay/tweaks.ts b/apps/master-api/src/viaplay/tweaks.ts
index d327a4b..6bfd42a 100644
--- a/apps/master-api/src/viaplay/tweaks.ts
+++ b/apps/master-api/src/viaplay/tweaks.ts
@@ -27,7 +27,7 @@ export type ViaplayTweaks = {
export const VIAPLAY_TWEAK_DEFAULTS: ViaplayTweaks = {
deviceKey: "pcdash-nl",
- syncDaysAhead: 7,
+ syncDaysAhead: 3,
httpTimeoutMs: 30_000,
preLiveMinutes: 5,
preLiveIntervalSeconds: 60,
@@ -55,8 +55,8 @@ export const VIAPLAY_TWEAK_META: Array<{
},
{
key: "syncDaysAhead",
- label: "EPG-dagen vooruit",
- hint: "Hoeveel dagen sport/all?date=… syncen.",
+ label: "Agenda-dagen vooruit",
+ hint: "Hoeveel dagen sportagenda syncen/tonen (max 3 aanbevolen).",
kind: "number",
},
{
@@ -141,7 +141,7 @@ export function mergeViaplayTweaks(
};
return {
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))),
preLiveMinutes: Math.min(60, Math.max(1, n(raw.preLiveMinutes, d.preLiveMinutes))),
preLiveIntervalSeconds: Math.min(
diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts
index 27918d5..fddb272 100644
--- a/apps/master-api/src/viewer/schedule-events.ts
+++ b/apps/master-api/src/viewer/schedule-events.ts
@@ -802,17 +802,39 @@ export async function discoverScheduleEventProviders(config: Config): Promise
+ a && a.enabled !== false && a.sessionEnc && a.accessTokenEnc
+ );
+ if (hasAccount && !seen.has("viaplay")) {
+ seen.add("viaplay");
+ out.push("viaplay");
+ }
}
} catch {
/* ignore */
@@ -839,9 +861,11 @@ export async function listScheduleEvents(
mapped = raw.events
.map((e) => toPublic(e, nowMs))
.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 {
- /* FAAB-feed optioneel wanneer F1TV events levert */
+ /* FAAB-feed optioneel wanneer F1TV/Viaplay events leveren */
}
try {
@@ -937,6 +961,9 @@ export async function getScheduleEvent(
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const pub = toPublic(found, nowMs);
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)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
@@ -1049,6 +1076,10 @@ export async function getScheduleEventPlay(
const pub = toPublic(found, nowMs);
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)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}