stremio/apps/master-api/src/viaplay/settings.ts
Jos Vooges | STH 22d46ffac7 Add Windows token-tray MVP for Viaplay session refresh.
Agent API accepts X-Tray-Key pushes; Electron tray captures cookies and upserts accounts by email.
2026-09-22 23:24:44 +02:00

517 lines
18 KiB
TypeScript

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<string, unknown>;
lastPreLiveAt?: string;
lastPreLiveSummary?: Record<string, unknown>;
};
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<string, unknown>;
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<ViaplayConfig> & Record<string, unknown> = {};
if (raw) {
try {
parsed = JSON.parse(raw) as Partial<ViaplayConfig> & Record<string, unknown>;
} 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<string, unknown>)
: undefined,
lastPreLiveAt: typeof parsed.lastPreLiveAt === "string" ? parsed.lastPreLiveAt : undefined,
lastPreLiveSummary:
parsed.lastPreLiveSummary && typeof parsed.lastPreLiveSummary === "object"
? (parsed.lastPreLiveSummary as Record<string, unknown>)
: 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<string> {
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<ViaplayTweaks>;
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);
}
/** Tray/agent: voeg toe of vernieuw tokens op e-mail (case-insensitive). */
export async function upsertViaplayAccountByEmail(
sessionSecret: string,
input: {
email: string;
label?: string;
session: string;
accessToken: string;
profileId?: 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 (!email) throw new AppError("INVALID_REQUEST", "E-mail verplicht", 400);
if (!input.session?.trim() || !input.accessToken?.trim()) {
throw new AppError("INVALID_REQUEST", "session + accessToken verplicht", 400);
}
const idx = config.accounts.findIndex((a) => a.email === email);
if (idx < 0) {
return addViaplayAccount(sessionSecret, {
email,
label: input.label,
session: input.session,
accessToken: input.accessToken,
profileId: input.profileId,
persistentLogin: input.persistentLogin,
enabled: true,
});
}
return updateViaplayAccount(sessionSecret, config.accounts[idx]!.id, {
label: input.label,
session: input.session,
accessToken: input.accessToken,
profileId: input.profileId,
persistentLogin: input.persistentLogin,
});
}
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<LoadedViaplaySettings> {
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<ViaplayConfig | null> {
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<string, unknown>,
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<ViaplayEventSnapshot>
) {
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<string, unknown>) {
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) },
});
}