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 ? (
<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>
{a.partnerName && a.subscriptionSummary && !a.subscriptionSummary.includes(a.partnerName) ? (

View file

@ -69,37 +69,56 @@ export type ViaplayOverview = {
productNames: string[];
summary: string;
error?: string;
/** Sessie werkt, maar pakketinfo ontbreekt/mislukt. */
sessionOk?: boolean;
};
export async function fetchAccountOverview(
creds: ViaplayAccountCreds,
timeoutMs: number
): 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",
url: "https://account.mtg-api.com/overview",
headers: {
...authHeaders(creds),
Accept: "application/json",
...(creds.accessToken
? { Authorization: `Bearer ${creds.accessToken}` }
: {}),
...baseHeaders,
Cookie: cookie,
...(creds.accessToken ? { Authorization: `Bearer ${creds.accessToken}` } : {}),
},
},
{
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: {
Accept: "application/json",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
...(creds.accessToken
? { Authorization: `Bearer ${creds.accessToken}` }
: {}),
...baseHeaders,
...(creds.accessToken ? { Authorization: `Bearer ${creds.accessToken}` } : {}),
...(creds.profileId ? { Cookie: `viaplay_profileId=${creds.profileId}` } : {}),
},
},
];
@ -108,7 +127,7 @@ export async function fetchAccountOverview(
let lastError = "Overview mislukt";
for (const attempt of attempts) {
try {
const res = await fetchJson("https://account.mtg-api.com/overview", {
const res = await fetchJson(attempt.url, {
headers: attempt.headers,
timeoutMs: Math.min(timeoutMs, 15_000),
});
@ -116,21 +135,9 @@ export async function fetchAccountOverview(
const body = asRecord(res.json);
if (res.status === 200 && body) {
const parsed = parseOverviewBody(body);
if (!parsed.productNames.length || !parsed.partnerName) {
const fromSession = subscriptionFromSession(creds.session);
const partnerName = parsed.partnerName || fromSession.partnerName;
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;
if (parsed.ok && parsed.summary) return parsed;
lastError = "Overview gaf geen pakketinformatie terug";
continue;
}
lastError =
(typeof body?.message === "string" && body.message) ||
@ -141,139 +148,52 @@ export async function fetchAccountOverview(
}
}
// Fallback: sessie geldig? (light content call)
let sessionOk = false;
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 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"]
sessionOk = Boolean(
ping.status === 200 &&
(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 {
/* 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 {
ok: false,
status: lastStatus,
partnerName: null,
productNames: [],
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 {
const rec = asRecord(raw);
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];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
/** Alleen echte overview-velden — geen aannames over abonnementstype. */
function parseOverviewBody(body: Record<string, unknown>): ViaplayOverview {
const partner = asRecord(body.partnerInformation);
const partnerName =
typeof partner?.partnerDisplayName === "string"
? partner.partnerDisplayName
: typeof partner?.partnerId === "string"
? partner.partnerId
: null;
? partner.partnerDisplayName.trim()
: null;
const productNames = Array.isArray(partner?.productNames)
? 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);
return {
ok: true,
ok: parts.length > 0,
status: 200,
partnerName,
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;
const overview = await fetchAccountOverview(creds, config.httpTimeoutMs);
if (!overview.ok) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = overview.error || `HTTP ${overview.status}`;
account.subscriptionSummary = undefined;
account.partnerName = undefined;
} else {
if (overview.ok && overview.summary) {
account.healthStatus = "ok";
account.healthCheckedAt = now;
account.healthError = undefined;
account.subscriptionSummary = overview.summary;
account.partnerName = overview.partnerName ?? undefined;
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) {
account.healthStatus = "dead";