Treat finished LIVE_EVENTs as hub replays, select meetings near now (not season tail), enrich meeting metadata, and retry VOD play without channelId.
304 lines
10 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|