import { existsSync } from "node:fs"; import { prisma } from "../database/client"; import { encryptSecret } from "../security/crypto"; import { AppError } from "../security/errors"; import { accountHasPlayCookies, accountToPublic, createAccountStored, decryptAccount, parseAccountsJson, parseEventOwners, type ViaplayAccountCreds, type ViaplayAccountStored, type ViaplayEventOwners, } from "./accounts"; import { fetchAccountOverview } from "./client"; import { mergeViaplayTweaks, VIAPLAY_TWEAK_DEFAULTS, VIAPLAY_TWEAK_META, type ViaplayEventSnapshot, type ViaplayTweaks, } from "./tweaks"; const VIAPLAY_ID = "viaplay"; const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd"; export type ViaplayConfig = ViaplayTweaks & { wvdPath: string; accounts: ViaplayAccountStored[]; eventOwners: ViaplayEventOwners; events: ViaplayEventSnapshot[]; lastSyncAt?: string; lastSyncSummary?: Record; lastPreLiveAt?: string; lastPreLiveSummary?: Record; }; function parseEvents(raw: unknown): ViaplayEventSnapshot[] { if (!Array.isArray(raw)) return []; const out: ViaplayEventSnapshot[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const row = item as Record; const guid = String(row.guid ?? "").trim(); const mediaGuid = String(row.mediaGuid ?? "").trim(); const title = String(row.title ?? "").trim(); const startMs = Number(row.startMs); const endMs = Number(row.endMs); if (!guid || !mediaGuid || !title) continue; if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue; out.push({ guid, mediaGuid, title, subtitle: typeof row.subtitle === "string" ? row.subtitle : null, category: typeof row.category === "string" ? row.category : null, sportLabel: typeof row.sportLabel === "string" ? row.sportLabel : null, sportBucket: typeof row.sportBucket === "string" ? row.sportBucket : null, startMs, endMs, imageUrl: typeof row.imageUrl === "string" ? row.imageUrl : null, pageUrl: typeof row.pageUrl === "string" ? row.pageUrl : null, channelName: typeof row.channelName === "string" ? row.channelName : null, stationId: typeof row.stationId === "string" ? row.stationId : null, mpdUrl: typeof row.mpdUrl === "string" ? row.mpdUrl : null, mpdCachedAt: typeof row.mpdCachedAt === "string" ? row.mpdCachedAt : null, hasPssh: row.hasPssh === true, hasKeys: row.hasKeys === true, accountIds: Array.isArray(row.accountIds) ? row.accountIds.map((id) => String(id).trim()).filter(Boolean) : [], lastPlayError: typeof row.lastPlayError === "string" ? row.lastPlayError : null, lastPlayAt: typeof row.lastPlayAt === "string" ? row.lastPlayAt : null, }); } return out; } function parseConfig(raw: string | null | undefined): ViaplayConfig { let parsed: Partial & Record = {}; if (raw) { try { parsed = JSON.parse(raw) as Partial & Record; } catch { parsed = {}; } } const tweaks = mergeViaplayTweaks(parsed); return { ...tweaks, wvdPath: String(parsed.wvdPath ?? "").trim() || process.env.VIAPLAY_WVD_PATH || DEFAULT_WVD_PATH, accounts: parseAccountsJson(parsed.accounts), eventOwners: parseEventOwners(parsed.eventOwners), events: parseEvents(parsed.events), lastSyncAt: typeof parsed.lastSyncAt === "string" ? parsed.lastSyncAt : undefined, lastSyncSummary: parsed.lastSyncSummary && typeof parsed.lastSyncSummary === "object" ? (parsed.lastSyncSummary as Record) : undefined, lastPreLiveAt: typeof parsed.lastPreLiveAt === "string" ? parsed.lastPreLiveAt : undefined, lastPreLiveSummary: parsed.lastPreLiveSummary && typeof parsed.lastPreLiveSummary === "object" ? (parsed.lastPreLiveSummary as Record) : undefined, }; } function hasUsableAccount(accounts: ViaplayAccountStored[]): boolean { return accounts.some((a) => a.enabled !== false && accountHasPlayCookies(a)); } async function persist(config: ViaplayConfig, enabled: boolean) { const primary = config.accounts.find((a) => a.enabled) ?? config.accounts[0]; await prisma.integrationSetting.upsert({ where: { id: VIAPLAY_ID }, create: { id: VIAPLAY_ID, username: primary?.email ?? null, passwordEnc: primary?.sessionEnc ?? null, configJson: JSON.stringify(config), enabled, }, update: { username: primary?.email ?? null, passwordEnc: primary?.sessionEnc ?? null, configJson: JSON.stringify(config), enabled, }, }); } async function resolveWvdPath(config: ViaplayConfig): Promise { if (config.useOdidoWvd) { try { const odido = await prisma.integrationSetting.findUnique({ where: { id: "odido" } }); if (odido?.configJson) { const parsed = JSON.parse(odido.configJson) as { wvdPath?: string }; const path = String(parsed.wvdPath ?? "").trim(); if (path && existsSync(path)) return path; } } catch { /* fall through */ } } return config.wvdPath; } export async function getViaplaySettingsPublic() { const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const config = parseConfig(row?.configJson); const accounts = config.accounts.map((a) => accountToPublic(a, config.eventOwners)); const wvdPath = await resolveWvdPath(config); const configured = hasUsableAccount(config.accounts); return { configured, enabled: row?.enabled ?? true, accounts, accountCount: accounts.filter((a) => a.enabled).length, eventCount: config.events.length, eventOwnerCount: Object.keys(config.eventOwners).length, lastSyncAt: config.lastSyncAt ?? null, lastSyncSummary: config.lastSyncSummary ?? null, lastPreLiveAt: config.lastPreLiveAt ?? null, lastPreLiveSummary: config.lastPreLiveSummary ?? null, wvdPath, wvdExists: existsSync(wvdPath), tweaks: mergeViaplayTweaks(config), tweakDefaults: VIAPLAY_TWEAK_DEFAULTS, tweakMeta: VIAPLAY_TWEAK_META, }; } export async function upsertViaplaySettings(input: { enabled?: boolean; tweaks?: Partial; wvdPath?: string; }) { const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const config = parseConfig(existing?.configJson); if (input.tweaks) { Object.assign(config, mergeViaplayTweaks({ ...config, ...input.tweaks })); } if (input.wvdPath !== undefined) { config.wvdPath = input.wvdPath.trim() || DEFAULT_WVD_PATH; } await persist(config, input.enabled ?? existing?.enabled ?? true); return getViaplaySettingsPublic(); } export async function addViaplayAccount( sessionSecret: string, input: { email: string; label?: string; enabled?: boolean; session?: string; accessToken?: string; profileId?: string; password?: string; persistentLogin?: string; } ) { const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const config = parseConfig(existing?.configJson); const email = input.email.trim().toLowerCase(); if (config.accounts.some((a) => a.email === email)) { throw new AppError("INVALID_REQUEST", "Dit e-mailadres staat al in de lijst", 400); } let account: ViaplayAccountStored; try { account = createAccountStored(sessionSecret, input); } catch (e) { throw new AppError("INVALID_REQUEST", (e as Error).message, 400); } config.accounts.push(account); await persist(config, existing?.enabled ?? true); // Direct abonnement ophalen zodat UI het meteen toont return probeViaplayAccounts(sessionSecret, account.id); } export async function updateViaplayAccount( sessionSecret: string, accountId: string, input: { email?: string; label?: string; enabled?: boolean; session?: string; accessToken?: string; profileId?: string; password?: string; persistentLogin?: string; clearCookies?: boolean; } ) { const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const config = parseConfig(existing?.configJson); const idx = config.accounts.findIndex((a) => a.id === accountId); if (idx < 0) throw new AppError("NOT_FOUND", "Account niet gevonden", 404); const current = config.accounts[idx]; const email = input.email !== undefined ? input.email.trim().toLowerCase() : current.email; if (!email) throw new AppError("INVALID_REQUEST", "E-mail verplicht", 400); if (config.accounts.some((a, i) => i !== idx && a.email === email)) { throw new AppError("INVALID_REQUEST", "Dit e-mailadres staat al in de lijst", 400); } let sessionEnc = current.sessionEnc; let accessTokenEnc = current.accessTokenEnc; let profileId = current.profileId; let persistentLoginEnc = current.persistentLoginEnc; if (input.clearCookies) { sessionEnc = undefined; accessTokenEnc = undefined; profileId = undefined; persistentLoginEnc = undefined; } if (input.session?.trim()) { sessionEnc = encryptSecret(input.session.trim(), sessionSecret); } if (input.accessToken?.trim()) { const token = input.accessToken .trim() .replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "") .trim(); accessTokenEnc = encryptSecret(token, sessionSecret); } if (input.profileId !== undefined) { profileId = input.profileId.trim() || undefined; } if (input.persistentLogin?.trim()) { persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret); } config.accounts[idx] = { ...current, email, label: input.label !== undefined ? input.label.trim() || email : current.label || email, enabled: input.enabled !== undefined ? input.enabled : current.enabled, sessionEnc, accessTokenEnc, profileId, persistentLoginEnc, passwordEnc: input.password?.trim() ? encryptSecret(input.password.trim(), sessionSecret) : current.passwordEnc, }; await persist(config, existing?.enabled ?? true); const tokensChanged = !!( input.session?.trim() || input.accessToken?.trim() || input.profileId !== undefined || input.clearCookies ); if (tokensChanged) { return probeViaplayAccounts(sessionSecret, accountId); } return getViaplaySettingsPublic(); } export async function removeViaplayAccount(sessionSecret: string, accountId: string) { void sessionSecret; const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const config = parseConfig(existing?.configJson); const next = config.accounts.filter((a) => a.id !== accountId); if (next.length === config.accounts.length) { throw new AppError("NOT_FOUND", "Account niet gevonden", 404); } config.accounts = next; const owners: ViaplayEventOwners = {}; for (const [mediaGuid, ids] of Object.entries(config.eventOwners)) { const filtered = ids.filter((id) => id !== accountId); if (filtered.length) owners[mediaGuid] = filtered; } config.eventOwners = owners; for (const event of config.events) { event.accountIds = event.accountIds.filter((id) => id !== accountId); } await persist(config, existing?.enabled ?? true); return getViaplaySettingsPublic(); } export async function probeViaplayAccounts(sessionSecret: string, accountId?: string) { const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const config = parseConfig(existing?.configJson); const targets = accountId ? config.accounts.filter((a) => a.id === accountId) : config.accounts; if (accountId && !targets.length) { throw new AppError("NOT_FOUND", "Account niet gevonden", 404); } const now = new Date().toISOString(); for (const account of targets) { if (account.enabled === false) { account.healthStatus = "unknown"; account.healthCheckedAt = now; account.healthError = "Uitgeschakeld"; continue; } try { const creds = decryptAccount(account, sessionSecret); if (!creds.session || !creds.accessToken) { account.healthStatus = "dead"; account.healthCheckedAt = now; account.healthError = "Geen sessie/access-token — voeg tokens toe uit een ingelogde browsersessie"; continue; } if (!account.deviceId) account.deviceId = creds.deviceId; const overview = await fetchAccountOverview(creds, config.httpTimeoutMs); 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"; account.healthCheckedAt = now; account.healthError = err instanceof Error ? err.message : String(err); } } await persist(config, existing?.enabled ?? true); return getViaplaySettingsPublic(); } export type LoadedViaplaySettings = { accounts: ViaplayAccountCreds[]; config: ViaplayConfig; wvdPath: string; }; export async function loadViaplaySettings(sessionSecret: string): Promise { const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); if (!row?.enabled) { throw new AppError("INVALID_REQUEST", "Viaplay-integratie is uitgeschakeld", 400); } const config = parseConfig(row.configJson); const accounts = config.accounts .filter((a) => a.enabled !== false) .map((a) => { try { return decryptAccount(a, sessionSecret); } catch { return null; } }) .filter((a): a is ViaplayAccountCreds => !!a && !!a.session && !!a.accessToken); if (!accounts.length) { throw new AppError( "INVALID_REQUEST", "Geen actief Viaplay-account met sessie-tokens — voeg tokens toe onder Scripts → Viaplay", 400 ); } return { accounts, config, wvdPath: await resolveWvdPath(config) }; } export async function loadViaplayConfigOnly(): Promise { const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); if (!row?.enabled) return null; return parseConfig(row.configJson); } export async function saveViaplaySyncResult( summary: Record, data: { events: ViaplayEventSnapshot[]; eventOwners: ViaplayEventOwners; } ) { const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); if (!row) return; const config = parseConfig(row.configJson); config.lastSyncAt = new Date().toISOString(); config.lastSyncSummary = summary; config.events = data.events; config.eventOwners = data.eventOwners; await prisma.integrationSetting.update({ where: { id: VIAPLAY_ID }, data: { configJson: JSON.stringify(config) }, }); } export async function patchViaplayEventCache( mediaGuid: string, patch: Partial ) { const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); if (!row) return; const config = parseConfig(row.configJson); const idx = config.events.findIndex((e) => e.mediaGuid === mediaGuid || e.guid === mediaGuid); if (idx < 0) return; config.events[idx] = { ...config.events[idx], ...patch }; await prisma.integrationSetting.update({ where: { id: VIAPLAY_ID }, data: { configJson: JSON.stringify(config) }, }); } export async function saveViaplayPreLiveResult(summary: Record) { const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); if (!row) return; const config = parseConfig(row.configJson); config.lastPreLiveAt = new Date().toISOString(); config.lastPreLiveSummary = summary; await prisma.integrationSetting.update({ where: { id: VIAPLAY_ID }, data: { configJson: JSON.stringify(config) }, }); }