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:
parent
f92323847a
commit
7cab13c5a8
6 changed files with 203 additions and 38 deletions
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -75,38 +75,145 @@ export async function fetchAccountOverview(
|
||||||
creds: ViaplayAccountCreds,
|
creds: ViaplayAccountCreds,
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): Promise<ViaplayOverview> {
|
): Promise<ViaplayOverview> {
|
||||||
const res = await fetchJson("https://account.mtg-api.com/overview", {
|
const attempts: Array<{ label: string; headers: Record<string, string> }> = [
|
||||||
headers: authHeaders(creds),
|
{
|
||||||
timeoutMs,
|
label: "cookie+bearer",
|
||||||
});
|
headers: {
|
||||||
if (res.status !== 200 || !asRecord(res.json)) {
|
...authHeaders(creds),
|
||||||
return {
|
Accept: "application/json",
|
||||||
ok: false,
|
...(creds.accessToken
|
||||||
status: res.status,
|
? { Authorization: `Bearer ${creds.accessToken}` }
|
||||||
partnerName: null,
|
: {}),
|
||||||
productNames: [],
|
},
|
||||||
summary: "",
|
},
|
||||||
error:
|
{
|
||||||
(asRecord(res.json)?.message as string) ||
|
label: "cookie",
|
||||||
`Overview mislukt (${res.status})`,
|
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<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
|
||||||
: null;
|
: typeof partner?.partnerId === "string"
|
||||||
|
? partner.partnerId
|
||||||
|
: 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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue