stremio/apps/master-api/src/f1tv/sync.ts
Jos Vooges | STH d8a1c28283 Fix F1TV 2026 terugkijken: ended live sessions + smarter meeting sync.
Treat finished LIVE_EVENTs as hub replays, select meetings near now (not season tail), enrich meeting metadata, and retry VOD play without channelId.
2026-09-22 04:34:24 +02:00

304 lines
10 KiB
TypeScript

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<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
function walk(node: unknown, visit: (n: Record<string, unknown>) => 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<string, unknown>;
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<string>();
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<number>();
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<F1tvSyncSummary> {
const started = Date.now();
const { accounts, config } = await loadF1tvSettings(sessionSecret);
const errors: string[] = [];
const meetingsByKey = new Map<string, F1tvMeetingSnapshot>();
const sessionsById = new Map<number, F1tvSessionSnapshot>();
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
);
}
}