import { AppError } from "../security/errors"; import type { F1tvContentOwners } from "./accounts"; import { decryptAccount } from "./accounts"; import { ensureSessionId, fetchPage, resolveF1tvImageUrl } from "./client"; import { loadF1tvSettings, saveF1tvSyncResult, type F1tvConfig, } from "./settings"; import type { F1tvMeetingSnapshot, F1tvSessionSnapshot } from "./tweaks"; function asRecord(v: unknown): Record | null { return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; } function walk(node: unknown, visit: (n: Record) => void) { if (!node || typeof node !== "object") return; if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; } const rec = node as Record; visit(rec); for (const value of Object.values(rec)) walk(value, visit); } function pageIdFromUri(uri: string | null | undefined): number | null { if (!uri) return null; const m = uri.match(/\/PAGE\/(\d+)/i); return m ? Number(m[1]) : null; } function parseMeetingMs(raw: unknown): number | null { if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return raw; const t = String(raw ?? "").trim(); if (!t) return null; const ms = Date.parse(t); return Number.isFinite(ms) ? ms : null; } function extractMeetings(pageJson: unknown): F1tvMeetingSnapshot[] { const out: F1tvMeetingSnapshot[] = []; const seen = new Set(); walk(pageJson, (node) => { const meta = asRecord(node.metadata) ?? node; const contentType = String(meta.contentType ?? "").toUpperCase(); const contentSubtype = String(meta.contentSubtype ?? "").toUpperCase(); if (contentType !== "BUNDLE" || contentSubtype !== "MEETING") return; const emf = asRecord(meta.emfAttributes) ?? {}; const meetingKey = String(emf.MeetingKey ?? meta.MeetingKey ?? "").trim(); const contentId = Number(meta.contentId ?? node.contentId ?? 0); if (!meetingKey || !contentId) return; if (seen.has(meetingKey)) return; seen.add(meetingKey); const actions = Array.isArray(node.actions) ? node.actions : []; let pageUri: string | null = null; for (const action of actions) { const a = asRecord(action); const uri = String(a?.uri ?? "").trim(); if (/\/PAGE\/\d+/i.test(uri)) { pageUri = uri; break; } } const meetingNumber = Number(emf.Meeting_Number ?? 0); out.push({ meetingKey, contentId, title: String(meta.title ?? meta.label ?? `Meeting ${meetingKey}`).trim(), pageId: pageIdFromUri(pageUri), pageUri, meetingNumber: Number.isFinite(meetingNumber) && meetingNumber > 0 ? meetingNumber : null, location: String(emf.Meeting_Location ?? "").trim() || null, startMs: parseMeetingMs(emf.Meeting_Start_Date ?? emf.sessionStartDate), endMs: parseMeetingMs(emf.Meeting_End_Date ?? emf.sessionEndDate), imageUrl: resolveF1tvImageUrl( String(meta.pictureUrl ?? meta.imageUrl ?? "").trim() || null, { width: 1280, height: 720 } ), }); }); return out; } function seriesAllowed( series: string, includeFormula2: boolean, includeSupportSeries: boolean ): boolean { const s = series.toUpperCase(); if (!s || s.includes("FORMULA 1") || s === "F1") return true; if (s.includes("FORMULA 2") || s === "F2") return includeFormula2; return includeSupportSeries; } function extractSessions( pageJson: unknown, meeting: F1tvMeetingSnapshot, accountId: string, opts: { includeFormula2: boolean; includeSupportSeries: boolean } ): F1tvSessionSnapshot[] { const out: F1tvSessionSnapshot[] = []; const seen = new Set(); walk(pageJson, (node) => { const meta = asRecord(node.metadata); if (!meta) return; const contentType = String(meta.contentType ?? "").toUpperCase(); if (contentType !== "VIDEO") return; const contentSubtype = String(meta.contentSubtype ?? "").toUpperCase(); const kind = contentSubtype === "LIVE_EVENT" ? "live_event" : contentSubtype === "REPLAY" ? "replay" : null; if (!kind) return; const contentId = Number(meta.contentId ?? node.contentId ?? 0); if (!contentId || seen.has(contentId)) return; const emf = asRecord(meta.emfAttributes) ?? {}; const series = String(emf.Series ?? meta.series ?? "").trim(); if (!seriesAllowed(series, opts.includeFormula2, opts.includeSupportSeries)) return; const startMs = Number(emf.sessionStartDate ?? meta.sessionStartDate ?? 0); const endMs = Number(emf.sessionEndDate ?? meta.sessionEndDate ?? 0); if (!Number.isFinite(startMs) || startMs <= 0) return; seen.add(contentId); const additional = Array.isArray(meta.additionalStreams) ? meta.additionalStreams : []; let defaultChannelId: number | null = null; for (const stream of additional) { const s = asRecord(stream); const channelId = Number(s?.channelId ?? 0); const title = String(s?.title ?? s?.identifier ?? "").toUpperCase(); if (channelId === 1033 || title.includes("F1 LIVE") || title === "PRES") { defaultChannelId = channelId || 1033; break; } if (defaultChannelId == null && channelId) defaultChannelId = channelId; } out.push({ contentId, kind, title: String(meta.title ?? meta.label ?? `Content ${contentId}`).trim(), meetingKey: String(emf.MeetingKey ?? meeting.meetingKey).trim() || meeting.meetingKey, meetingName: String(emf.Meeting_Name ?? meeting.title).trim() || meeting.title, meetingPageId: meeting.pageId, series: series || "FORMULA 1", state: String(emf.state ?? meta.state ?? "").trim() || null, startMs, endMs: Number.isFinite(endMs) && endMs > 0 ? endMs : startMs + 2 * 3600_000, imageUrl: resolveF1tvImageUrl( String(meta.pictureUrl ?? meta.imageUrl ?? meta.embargoImage ?? "").trim() || null ), defaultChannelId, accountIds: [accountId], }); }); return out; } function selectMeetingsForSync( meetings: F1tvMeetingSnapshot[], limit: number, nowMs = Date.now() ): F1tvMeetingSnapshot[] { const withPage = meetings.filter((m) => m.pageId != null); if (withPage.length <= limit) return withPage; // Rank by closeness to "now" (past weekend + upcoming first), not calendar tail. const scored = withPage.map((m) => { const start = m.startMs ?? 0; const end = m.endMs ?? start; let score: number; if (start <= nowMs && nowMs <= end + 2 * 86_400_000) score = 0; // current / just finished else if (start > nowMs) score = start - nowMs; // upcoming else score = nowMs - end + 86_400_000; // past — slight penalty vs upcoming return { m, score }; }); scored.sort((a, b) => a.score - b.score || (b.m.startMs ?? 0) - (a.m.startMs ?? 0)); return scored.slice(0, limit).map((x) => x.m); } export type F1tvSyncSummary = { accounts: number; meetings: number; sessions: number; liveEvents: number; replays: number; durationMs: number; errors: string[]; }; export async function syncF1tvCatalog(sessionSecret: string): Promise { const started = Date.now(); const { accounts, config } = await loadF1tvSettings(sessionSecret); const errors: string[] = []; const meetingsByKey = new Map(); const sessionsById = new Map(); const contentOwners: F1tvContentOwners = {}; for (const account of accounts) { const auth = { ascendontoken: account.ascendontoken, entitlementtoken: account.entitlementtoken, sessionId: ensureSessionId(account.sessionId), }; try { const season = await fetchPage({ pageId: config.seasonPageId, auth, locale: config.locale, platform: config.platform, timeoutMs: config.syncTimeoutMs, }); for (const meeting of extractMeetings(season)) { const prev = meetingsByKey.get(meeting.meetingKey); if (!prev || (!prev.pageId && meeting.pageId)) { meetingsByKey.set(meeting.meetingKey, meeting); } } } catch (err) { errors.push(`${account.email}: kalender — ${(err as Error).message}`); continue; } const selected = selectMeetingsForSync( [...meetingsByKey.values()], config.syncMeetingLimit ); for (const meeting of selected) { if (!meeting.pageId) continue; try { const page = await fetchPage({ pageId: meeting.pageId, auth, locale: config.locale, platform: config.platform, timeoutMs: config.syncTimeoutMs, }); const sessions = extractSessions(page, meeting, account.id, { includeFormula2: config.includeFormula2, includeSupportSeries: config.includeSupportSeries, }); for (const session of sessions) { const existing = sessionsById.get(session.contentId); if (existing) { if (!existing.accountIds.includes(account.id)) { existing.accountIds.push(account.id); } } else { sessionsById.set(session.contentId, session); } const owners = contentOwners[String(session.contentId)] ?? []; if (!owners.includes(account.id)) owners.push(account.id); contentOwners[String(session.contentId)] = owners; } } catch (err) { errors.push( `${account.email}: meeting ${meeting.meetingKey} — ${(err as Error).message}` ); } } } const meetings = [...meetingsByKey.values()]; const sessions = [...sessionsById.values()].sort((a, b) => a.startMs - b.startMs); const summary: F1tvSyncSummary = { accounts: accounts.length, meetings: meetings.length, sessions: sessions.length, liveEvents: sessions.filter((s) => s.kind === "live_event").length, replays: sessions.filter((s) => s.kind === "replay").length, durationMs: Date.now() - started, errors: errors.slice(0, 20), }; await saveF1tvSyncResult(summary, { meetings, sessions, contentOwners }); return summary; } export async function getF1tvSnapshot(): Promise<{ config: F1tvConfig; } | null> { const { loadF1tvConfigOnly } = await import("./settings"); const config = await loadF1tvConfigOnly(); if (!config) return null; return { config }; } export function requireSyncReady(config: F1tvConfig) { if (!config.sessions.length) { throw new AppError( "NOT_READY", "Nog geen F1TV-sessies — voer Sync uit onder Scripts → F1TV", 409 ); } }