Agent API accepts X-Tray-Key pushes; Electron tray captures cookies and upserts accounts by email.
517 lines
18 KiB
TypeScript
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) },
|
|
});
|
|
}
|