Stop guessing Viaplay packages; only show overview API pakketinfo.

Remove session-based subscription inventing and surface a clear error when package lookup fails.
This commit is contained in:
Jos Vooges | STH 2026-09-22 22:43:38 +02:00
parent 8be634f783
commit 1c0275c21d
3 changed files with 78 additions and 145 deletions

View file

@ -410,7 +410,11 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
{a.subscriptionSummary ? ( {a.subscriptionSummary ? (
<strong>{a.subscriptionSummary}</strong> <strong>{a.subscriptionSummary}</strong>
) : ( ) : (
<span className="muted">nog niet opgehaald — druk Check</span> <span className="muted">
{a.healthError?.includes("pakket")
? "niet opgehaald (zie melding hieronder)"
: "nog niet opgehaald — druk Check"}
</span>
)} )}
</div> </div>
{a.partnerName && a.subscriptionSummary && !a.subscriptionSummary.includes(a.partnerName) ? ( {a.partnerName && a.subscriptionSummary && !a.subscriptionSummary.includes(a.partnerName) ? (

View file

@ -69,37 +69,56 @@ export type ViaplayOverview = {
productNames: string[]; productNames: string[];
summary: string; summary: string;
error?: string; error?: string;
/** Sessie werkt, maar pakketinfo ontbreekt/mislukt. */
sessionOk?: boolean;
}; };
export async function fetchAccountOverview( 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> }> = [ const baseHeaders: Record<string, string> = {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"Accept-Language": "nl-NL,nl;q=0.9,en;q=0.8",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
};
const cookie = [
creds.session ? `session=${creds.session}` : "",
creds.accessToken ? `accessToken=${creds.accessToken}` : "",
creds.profileId ? `viaplay_profileId=${creds.profileId}` : "",
]
.filter(Boolean)
.join("; ");
const attempts: Array<{ label: string; url: string; headers: Record<string, string> }> = [
{ {
label: "cookie+bearer", label: "cookie+bearer",
url: "https://account.mtg-api.com/overview",
headers: { headers: {
...authHeaders(creds), ...baseHeaders,
Accept: "application/json", Cookie: cookie,
...(creds.accessToken ...(creds.accessToken ? { Authorization: `Bearer ${creds.accessToken}` } : {}),
? { Authorization: `Bearer ${creds.accessToken}` }
: {}),
}, },
}, },
{ {
label: "cookie", label: "cookie",
headers: { ...authHeaders(creds), Accept: "application/json" }, url: "https://account.mtg-api.com/overview",
headers: { ...baseHeaders, Cookie: cookie },
}, },
{ {
label: "bearer", label: "bearer+deviceKey",
url: "https://account.mtg-api.com/overview?deviceKey=pcdash-nl",
headers: { headers: {
Accept: "application/json", ...baseHeaders,
Origin: "https://viaplay.com", ...(creds.accessToken ? { Authorization: `Bearer ${creds.accessToken}` } : {}),
Referer: "https://viaplay.com/", ...(creds.profileId ? { Cookie: `viaplay_profileId=${creds.profileId}` } : {}),
"User-Agent": VIAPLAY_USER_AGENT,
...(creds.accessToken
? { Authorization: `Bearer ${creds.accessToken}` }
: {}),
}, },
}, },
]; ];
@ -108,7 +127,7 @@ export async function fetchAccountOverview(
let lastError = "Overview mislukt"; let lastError = "Overview mislukt";
for (const attempt of attempts) { for (const attempt of attempts) {
try { try {
const res = await fetchJson("https://account.mtg-api.com/overview", { const res = await fetchJson(attempt.url, {
headers: attempt.headers, headers: attempt.headers,
timeoutMs: Math.min(timeoutMs, 15_000), timeoutMs: Math.min(timeoutMs, 15_000),
}); });
@ -116,21 +135,9 @@ export async function fetchAccountOverview(
const body = asRecord(res.json); const body = asRecord(res.json);
if (res.status === 200 && body) { if (res.status === 200 && body) {
const parsed = parseOverviewBody(body); const parsed = parseOverviewBody(body);
if (!parsed.productNames.length || !parsed.partnerName) { if (parsed.ok && parsed.summary) return parsed;
const fromSession = subscriptionFromSession(creds.session); lastError = "Overview gaf geen pakketinformatie terug";
const partnerName = parsed.partnerName || fromSession.partnerName; continue;
const productNames = parsed.productNames.length
? parsed.productNames
: fromSession.productNames;
const parts = [partnerName, productNames.join(", ")].filter(Boolean);
return {
...parsed,
partnerName,
productNames,
summary: parts.join(" · ") || parsed.summary,
};
}
return parsed;
} }
lastError = lastError =
(typeof body?.message === "string" && body.message) || (typeof body?.message === "string" && body.message) ||
@ -141,139 +148,52 @@ export async function fetchAccountOverview(
} }
} }
// Fallback: sessie geldig? (light content call) let sessionOk = false;
try { try {
const ping = await fetchJson( const ping = await fetchJson("https://content.viaplay.com/pcdash-nl?language=nl", {
"https://content.viaplay.com/pcdash-nl?language=nl", headers: { ...authHeaders(creds), Accept: "application/json" },
{ timeoutMs: Math.min(timeoutMs, 12_000),
headers: { ...authHeaders(creds), Accept: "application/json" }, });
timeoutMs: Math.min(timeoutMs, 12_000),
}
);
const root = asRecord(ping.json); const root = asRecord(ping.json);
const hasUser = Boolean( sessionOk = Boolean(
asRecord(root?._links)?.["viaplay:userData"] || ping.status === 200 &&
asRecord(root?._links)?.["viaplay:logout"] (asRecord(root?._links)?.["viaplay:userData"] ||
asRecord(root?._links)?.["viaplay:logout"])
); );
if (ping.status === 200 && hasUser) {
const fromSession = subscriptionFromSession(creds.session);
const exp = accessTokenExpiry(creds.accessToken);
const summary =
fromSession.summary ||
(exp
? `Sessie actief · token tot ${exp}`
: "Sessie actief (abonnementdetails niet beschikbaar)");
return {
ok: true,
status: ping.status,
partnerName: fromSession.partnerName,
productNames: fromSession.productNames,
summary,
};
}
} catch { } catch {
/* ignore */ /* ignore */
} }
// Laatste redmiddel: abonnement uit session-cookie (lokaal, geen extra call)
const fromSession = subscriptionFromSession(creds.session);
if (fromSession.summary) {
return {
ok: true,
status: lastStatus || 200,
partnerName: fromSession.partnerName,
productNames: fromSession.productNames,
summary: fromSession.summary,
};
}
return { return {
ok: false, ok: false,
status: lastStatus, status: lastStatus,
partnerName: null, partnerName: null,
productNames: [], productNames: [],
summary: "", summary: "",
error: lastError, error: sessionOk
? `Sessie geldig, pakketinfo niet opgehaald (${lastError})`
: lastError,
sessionOk,
}; };
} }
/** Haal partner/pakket uit de Viaplay session-cookie (zonder netwerk). */
export function subscriptionFromSession(session: string | null | undefined): {
partnerName: string | null;
productNames: string[];
summary: string | null;
} {
if (!session?.trim()) {
return { partnerName: null, productNames: [], summary: null };
}
try {
let raw = session.trim();
try {
raw = decodeURIComponent(raw);
} catch {
/* already decoded */
}
// express-session vorm: s:j:{json}.{signature}
const m = /^s:j:(.+)\.[^.]+$/s.exec(raw);
const jsonText = m?.[1] ?? null;
if (!jsonText) return { partnerName: null, productNames: [], summary: null };
const data = JSON.parse(jsonText) as Record<string, unknown>;
const contracts = String(data.vmsContracts ?? "").trim();
const productNames: string[] = [];
let partnerName: string | null = null;
if (/tmobile|odido/i.test(contracts) || data.isVmsCustomer === true) {
partnerName = "Odido Internet + TV";
}
if (/b2b_vtotal|vtotal/i.test(contracts)) productNames.push("Total / V Total");
else if (/basis|basic/i.test(contracts)) productNames.push("Basis");
else if (contracts) {
productNames.push(contracts.replace(/\s+/g, " "));
}
if (data.isTveBCustomer === true && !productNames.length) {
productNames.push("TV-pakket");
}
const parts = [partnerName, productNames.join(", ")].filter(Boolean);
return {
partnerName,
productNames,
summary: parts.length ? parts.join(" · ") : null,
};
} catch {
return { partnerName: null, productNames: [], summary: null };
}
}
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 { function packageLabel(raw: unknown): string | null {
const rec = asRecord(raw); const rec = asRecord(raw);
if (!rec) return null; if (!rec) return null;
for (const key of ["name", "displayName", "title", "productName", "id"]) { for (const key of ["name", "displayName", "title", "productName"]) {
const v = rec[key]; const v = rec[key];
if (typeof v === "string" && v.trim()) return v.trim(); if (typeof v === "string" && v.trim()) return v.trim();
} }
return null; return null;
} }
/** Alleen echte overview-velden — geen aannames over abonnementstype. */
function parseOverviewBody(body: Record<string, unknown>): ViaplayOverview { 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.trim()
: typeof partner?.partnerId === "string" : null;
? 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)
: []; : [];
@ -286,11 +206,11 @@ function parseOverviewBody(body: Record<string, unknown>): ViaplayOverview {
const parts = [partnerName, productNames.join(", ")].filter(Boolean); const parts = [partnerName, productNames.join(", ")].filter(Boolean);
return { return {
ok: true, ok: parts.length > 0,
status: 200, status: 200,
partnerName, partnerName,
productNames, productNames,
summary: parts.join(" · ") || "Actief abonnement", summary: parts.join(" · "),
}; };
} }

View file

@ -349,19 +349,28 @@ export async function probeViaplayAccounts(sessionSecret: string, accountId?: st
} }
if (!account.deviceId) account.deviceId = creds.deviceId; if (!account.deviceId) account.deviceId = creds.deviceId;
const overview = await fetchAccountOverview(creds, config.httpTimeoutMs); const overview = await fetchAccountOverview(creds, config.httpTimeoutMs);
if (!overview.ok) { if (overview.ok && overview.summary) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = overview.error || `HTTP ${overview.status}`;
account.subscriptionSummary = undefined;
account.partnerName = undefined;
} else {
account.healthStatus = "ok"; account.healthStatus = "ok";
account.healthCheckedAt = now; account.healthCheckedAt = now;
account.healthError = undefined; account.healthError = undefined;
account.subscriptionSummary = overview.summary; account.subscriptionSummary = overview.summary;
account.partnerName = overview.partnerName ?? undefined; account.partnerName = overview.partnerName ?? undefined;
account.overviewCheckedAt = now; account.overviewCheckedAt = now;
} else if (overview.sessionOk) {
// Tokens werken, maar account.mtg-api.com/overview gaf geen pakket terug
account.healthStatus = "ok";
account.healthCheckedAt = now;
account.subscriptionSummary = undefined;
account.partnerName = undefined;
account.healthError =
overview.error || "Pakketinformatie niet opgehaald — vernieuw tokens en Check opnieuw";
account.overviewCheckedAt = now;
} else {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = overview.error || `HTTP ${overview.status}`;
account.subscriptionSummary = undefined;
account.partnerName = undefined;
} }
} catch (err) { } catch (err) {
account.healthStatus = "dead"; account.healthStatus = "dead";