From d8a1c28283cf3e5d065b75c0420351006e11c836 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 22 Sep 2026 04:34:24 +0200 Subject: [PATCH] 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. --- apps/master-api/src/f1tv/events.ts | 98 +++++++++++++++++++++++++----- apps/master-api/src/f1tv/play.ts | 62 +++++++++++++------ apps/master-api/src/f1tv/sync.ts | 37 +++++++++-- apps/master-api/src/f1tv/tweaks.ts | 7 ++- 4 files changed, 164 insertions(+), 40 deletions(-) diff --git a/apps/master-api/src/f1tv/events.ts b/apps/master-api/src/f1tv/events.ts index 29a97fe..a73cde0 100644 --- a/apps/master-api/src/f1tv/events.ts +++ b/apps/master-api/src/f1tv/events.ts @@ -29,7 +29,9 @@ export function sessionToPublic( const start = new Date(session.startMs).toISOString(); const stop = new Date(session.endMs).toISOString(); const phase = phaseFor(session.startMs, session.endMs, session.state, nowMs); - const playable = session.kind === "live_event" && phase === "live"; + const isReplayKind = session.kind === "replay" || phase === "ended"; + const playable = + (session.kind === "live_event" && phase === "live") || isReplayKind; const image = resolveF1tvImageUrl(session.imageUrl); return { @@ -38,7 +40,7 @@ export function sessionToPublic( description: session.meetingName, provider: "f1tv", category: "racing", - eventType: session.kind === "replay" ? "replay" : "live", + eventType: isReplayKind ? "replay" : "live", subtitle: session.series, channel: "F1 TV", network: "F1 TV", @@ -72,7 +74,12 @@ export async function listF1tvScheduleEvents( if (session.kind !== "live_event") continue; const pub = sessionToPublic(session, nowMs); if (pub.phase === "ended") continue; - out.push(pub); + // Events-tab: alleen echte live speelbaar, niet ended-as-replay. + out.push({ + ...pub, + playable: pub.phase === "live", + eventType: "live", + }); } return out; } @@ -87,14 +94,33 @@ export async function getF1tvScheduleEvent( const config = await loadF1tvConfigOnly(); const session = config?.sessions.find((s) => s.contentId === contentId); if (!session) return null; - return sessionToPublic(session, nowMs); + const pub = sessionToPublic(session, nowMs); + // Events detail: geen replay-play via schedule-play pad. + if (session.kind === "live_event" && pub.phase !== "ended") { + return { ...pub, playable: pub.phase === "live", eventType: "live" }; + } + if (session.kind === "live_event" && pub.phase === "ended") { + return { ...pub, playable: false, eventType: "live" }; + } + return pub; } +export type F1tvReplayGroup = { + meetingKey: string; + title: string; + location: string | null; + meetingNumber: number | null; + imageUrl: string | null; + latestStart: string; + sessions: ScheduleEventPublic[]; +}; + export type F1tvHomePayload = { generatedAt: string; live: ScheduleEventPublic[]; upcoming: ScheduleEventPublic[]; recentReplays: ScheduleEventPublic[]; + replayGroups: F1tvReplayGroup[]; meetings: F1tvMeetingSnapshot[]; nextMeeting: F1tvMeetingSnapshot | null; }; @@ -102,7 +128,11 @@ export type F1tvHomePayload = { export async function getF1tvHome(nowMs = Date.now()): Promise { const config = await loadF1tvConfigOnly(); const sessions = config?.sessions ?? []; - const meetings = config?.meetings ?? []; + const meetings = [...(config?.meetings ?? [])].sort((a, b) => { + const am = a.startMs ?? 0; + const bm = b.startMs ?? 0; + return am - bm; + }); const live: ScheduleEventPublic[] = []; const upcoming: ScheduleEventPublic[] = []; @@ -110,10 +140,9 @@ export async function getF1tvHome(nowMs = Date.now()): Promise for (const session of sessions) { const pub = sessionToPublic(session, nowMs); - if (session.kind === "replay") { - if (pub.phase === "ended" || session.startMs < nowMs) { - recentReplays.push(pub); - } + if (session.kind === "replay" || pub.phase === "ended") { + // Terugkijken: echte REPLAY-VODs én afgelopen LIVE_EVENT (2026-sessies). + recentReplays.push({ ...pub, eventType: "replay", playable: true }); continue; } if (pub.phase === "live") live.push(pub); @@ -123,13 +152,40 @@ export async function getF1tvHome(nowMs = Date.now()): Promise recentReplays.sort((a, b) => Date.parse(b.start) - Date.parse(a.start)); upcoming.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); + const byMeeting = new Map(); + for (const r of recentReplays) { + const session = sessions.find((s) => toEventId(s.contentId) === r.id); + const key = session?.meetingKey || r.description || "other"; + const list = byMeeting.get(key) ?? []; + list.push(r); + byMeeting.set(key, list); + } + const replayGroups: F1tvReplayGroup[] = [...byMeeting.entries()] + .map(([meetingKey, groupSessions]) => { + const meeting = meetings.find((m) => m.meetingKey === meetingKey); + const sorted = [...groupSessions].sort( + (a, b) => Date.parse(b.start) - Date.parse(a.start) + ); + return { + meetingKey, + title: meeting?.title || sorted[0]?.description || meetingKey, + location: meeting?.location ?? null, + meetingNumber: meeting?.meetingNumber ?? null, + imageUrl: + resolveF1tvImageUrl(meeting?.imageUrl) || + sorted[0]?.imageLandscape || + null, + latestStart: sorted[0]?.start || "", + sessions: sorted, + }; + }) + .sort((a, b) => Date.parse(b.latestStart) - Date.parse(a.latestStart)); + let nextMeeting: F1tvMeetingSnapshot | null = null; - const upcomingMeetingKeys = new Set(upcoming.map((e) => { - const s = sessions.find((x) => toEventId(x.contentId) === e.id); - return s?.meetingKey; - })); for (const meeting of meetings) { - if (upcomingMeetingKeys.has(meeting.meetingKey)) { + const start = meeting.startMs ?? 0; + const end = meeting.endMs ?? start; + if (end + 86_400_000 >= nowMs) { nextMeeting = meeting; break; } @@ -142,7 +198,8 @@ export async function getF1tvHome(nowMs = Date.now()): Promise generatedAt: new Date(nowMs).toISOString(), live, upcoming: upcoming.slice(0, 40), - recentReplays: recentReplays.slice(0, 24), + recentReplays: recentReplays.slice(0, 60), + replayGroups: replayGroups.slice(0, 20), meetings, nextMeeting, }; @@ -154,6 +211,9 @@ export async function getF1tvMeetingDetail( ): Promise<{ meeting: F1tvMeetingSnapshot | null; sessions: ScheduleEventPublic[]; + replays: ScheduleEventPublic[]; + upcoming: ScheduleEventPublic[]; + live: ScheduleEventPublic[]; } | null> { const config = await loadF1tvConfigOnly(); if (!config) return null; @@ -164,5 +224,11 @@ export async function getF1tvMeetingDetail( .map((s) => sessionToPublic(s, nowMs)) .sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); if (!meeting && !sessions.length) return null; - return { meeting, sessions }; + const live = sessions.filter((s) => s.phase === "live"); + const upcoming = sessions.filter((s) => s.phase === "upcoming"); + const replays = sessions + .filter((s) => s.phase === "ended" || s.eventType === "replay") + .map((s) => ({ ...s, eventType: "replay", playable: true })) + .sort((a, b) => Date.parse(b.start) - Date.parse(a.start)); + return { meeting, sessions, replays, upcoming, live }; } diff --git a/apps/master-api/src/f1tv/play.ts b/apps/master-api/src/f1tv/play.ts index 24d1a34..19683fb 100644 --- a/apps/master-api/src/f1tv/play.ts +++ b/apps/master-api/src/f1tv/play.ts @@ -41,23 +41,45 @@ export async function resolveF1tvPlay( const preferredChannel = opts?.channelId ?? session?.defaultChannelId ?? 1033; + const isVod = + session?.kind === "replay" || + (session != null && Date.now() > session.endMs); - let played: Awaited>; - try { - played = await fetchContentPlay({ - contentId, - channelId: preferredChannel, - auth: { - ascendontoken: account.ascendontoken, - entitlementtoken: account.entitlementtoken, - sessionId: ensureSessionId(account.sessionId), - }, - locale: config.locale, - playPlatform: "WEB_HLS", - timeoutMs: config.syncTimeoutMs, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + // Replay/ended: eerst zonder channel (VOD), dan live-feed channel. + const channelAttempts: Array = isVod + ? [opts?.channelId ?? null, null, preferredChannel] + : [preferredChannel, null]; + + const auth = { + ascendontoken: account.ascendontoken, + entitlementtoken: account.entitlementtoken, + sessionId: ensureSessionId(account.sessionId), + }; + + let lastErr: unknown = null; + let played: Awaited> | null = null; + let usedChannel: number | null = preferredChannel; + + for (const channelId of channelAttempts) { + try { + played = await fetchContentPlay({ + contentId, + channelId, + auth, + locale: config.locale, + playPlatform: "WEB_HLS", + timeoutMs: config.syncTimeoutMs, + }); + usedChannel = channelId; + if (pickDefaultFeed(played.feeds, channelId)?.url) break; + } catch (err) { + lastErr = err; + played = null; + } + } + + if (!played) { + const msg = lastErr instanceof Error ? lastErr.message : String(lastErr ?? ""); if (/HTTP 4\d\d|NOT_FOUND|UNAVAILABLE|FORBIDDEN/i.test(msg)) { throw new AppError( "NOT_READY", @@ -67,10 +89,14 @@ export async function resolveF1tvPlay( 409 ); } - throw new AppError("UPSTREAM_ERROR", `F1TV play mislukt: ${msg.slice(0, 160)}`, 502); + throw new AppError( + "UPSTREAM_ERROR", + `F1TV play mislukt: ${msg.slice(0, 160) || "onbekend"}`, + 502 + ); } - const feed = pickDefaultFeed(played.feeds, preferredChannel); + const feed = pickDefaultFeed(played.feeds, usedChannel); if (!feed?.url) { throw new AppError( "NOT_READY", diff --git a/apps/master-api/src/f1tv/sync.ts b/apps/master-api/src/f1tv/sync.ts index d56be34..e450d44 100644 --- a/apps/master-api/src/f1tv/sync.ts +++ b/apps/master-api/src/f1tv/sync.ts @@ -32,6 +32,14 @@ function pageIdFromUri(uri: string | null | undefined): number | null { 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(); @@ -56,12 +64,21 @@ function extractMeetings(pageJson: unknown): F1tvMeetingSnapshot[] { 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; @@ -149,14 +166,24 @@ function extractSessions( function selectMeetingsForSync( meetings: F1tvMeetingSnapshot[], - limit: number + limit: number, + nowMs = Date.now() ): F1tvMeetingSnapshot[] { - // Prefer meetings with pageId; keep calendar order, take up to limit from end - // (recent/upcoming often at the end) plus any without dates. const withPage = meetings.filter((m) => m.pageId != null); if (withPage.length <= limit) return withPage; - // Take a window around "now": last `limit` entries (season progresses left→right). - return withPage.slice(-limit); + + // 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 = { diff --git a/apps/master-api/src/f1tv/tweaks.ts b/apps/master-api/src/f1tv/tweaks.ts index 3e48494..32ee1b2 100644 --- a/apps/master-api/src/f1tv/tweaks.ts +++ b/apps/master-api/src/f1tv/tweaks.ts @@ -21,7 +21,7 @@ export const F1TV_TWEAK_DEFAULTS: F1tvTweaks = { seasonPageId: 12343, locale: "NLD", platform: "WEB_DASH", - syncMeetingLimit: 8, + syncMeetingLimit: 24, syncTimeoutMs: 45_000, includeFormula2: false, includeSupportSeries: false, @@ -124,6 +124,11 @@ export type F1tvMeetingSnapshot = { title: string; pageId: number | null; pageUri: string | null; + meetingNumber: number | null; + location: string | null; + startMs: number | null; + endMs: number | null; + imageUrl: string | null; }; export function newSessionId(): string {