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.
This commit is contained in:
Jos Vooges | STH 2026-09-22 04:34:24 +02:00
parent 9ac832545d
commit d8a1c28283
4 changed files with 164 additions and 40 deletions

View file

@ -29,7 +29,9 @@ export function sessionToPublic(
const start = new Date(session.startMs).toISOString(); const start = new Date(session.startMs).toISOString();
const stop = new Date(session.endMs).toISOString(); const stop = new Date(session.endMs).toISOString();
const phase = phaseFor(session.startMs, session.endMs, session.state, nowMs); 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); const image = resolveF1tvImageUrl(session.imageUrl);
return { return {
@ -38,7 +40,7 @@ export function sessionToPublic(
description: session.meetingName, description: session.meetingName,
provider: "f1tv", provider: "f1tv",
category: "racing", category: "racing",
eventType: session.kind === "replay" ? "replay" : "live", eventType: isReplayKind ? "replay" : "live",
subtitle: session.series, subtitle: session.series,
channel: "F1 TV", channel: "F1 TV",
network: "F1 TV", network: "F1 TV",
@ -72,7 +74,12 @@ export async function listF1tvScheduleEvents(
if (session.kind !== "live_event") continue; if (session.kind !== "live_event") continue;
const pub = sessionToPublic(session, nowMs); const pub = sessionToPublic(session, nowMs);
if (pub.phase === "ended") continue; 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; return out;
} }
@ -87,14 +94,33 @@ export async function getF1tvScheduleEvent(
const config = await loadF1tvConfigOnly(); const config = await loadF1tvConfigOnly();
const session = config?.sessions.find((s) => s.contentId === contentId); const session = config?.sessions.find((s) => s.contentId === contentId);
if (!session) return null; 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 = { export type F1tvHomePayload = {
generatedAt: string; generatedAt: string;
live: ScheduleEventPublic[]; live: ScheduleEventPublic[];
upcoming: ScheduleEventPublic[]; upcoming: ScheduleEventPublic[];
recentReplays: ScheduleEventPublic[]; recentReplays: ScheduleEventPublic[];
replayGroups: F1tvReplayGroup[];
meetings: F1tvMeetingSnapshot[]; meetings: F1tvMeetingSnapshot[];
nextMeeting: F1tvMeetingSnapshot | null; nextMeeting: F1tvMeetingSnapshot | null;
}; };
@ -102,7 +128,11 @@ export type F1tvHomePayload = {
export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload> { export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload> {
const config = await loadF1tvConfigOnly(); const config = await loadF1tvConfigOnly();
const sessions = config?.sessions ?? []; 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 live: ScheduleEventPublic[] = [];
const upcoming: ScheduleEventPublic[] = []; const upcoming: ScheduleEventPublic[] = [];
@ -110,10 +140,9 @@ export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload>
for (const session of sessions) { for (const session of sessions) {
const pub = sessionToPublic(session, nowMs); const pub = sessionToPublic(session, nowMs);
if (session.kind === "replay") { if (session.kind === "replay" || pub.phase === "ended") {
if (pub.phase === "ended" || session.startMs < nowMs) { // Terugkijken: echte REPLAY-VODs én afgelopen LIVE_EVENT (2026-sessies).
recentReplays.push(pub); recentReplays.push({ ...pub, eventType: "replay", playable: true });
}
continue; continue;
} }
if (pub.phase === "live") live.push(pub); if (pub.phase === "live") live.push(pub);
@ -123,13 +152,40 @@ export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload>
recentReplays.sort((a, b) => Date.parse(b.start) - Date.parse(a.start)); recentReplays.sort((a, b) => Date.parse(b.start) - Date.parse(a.start));
upcoming.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); upcoming.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
const byMeeting = new Map<string, ScheduleEventPublic[]>();
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; 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) { 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; nextMeeting = meeting;
break; break;
} }
@ -142,7 +198,8 @@ export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload>
generatedAt: new Date(nowMs).toISOString(), generatedAt: new Date(nowMs).toISOString(),
live, live,
upcoming: upcoming.slice(0, 40), upcoming: upcoming.slice(0, 40),
recentReplays: recentReplays.slice(0, 24), recentReplays: recentReplays.slice(0, 60),
replayGroups: replayGroups.slice(0, 20),
meetings, meetings,
nextMeeting, nextMeeting,
}; };
@ -154,6 +211,9 @@ export async function getF1tvMeetingDetail(
): Promise<{ ): Promise<{
meeting: F1tvMeetingSnapshot | null; meeting: F1tvMeetingSnapshot | null;
sessions: ScheduleEventPublic[]; sessions: ScheduleEventPublic[];
replays: ScheduleEventPublic[];
upcoming: ScheduleEventPublic[];
live: ScheduleEventPublic[];
} | null> { } | null> {
const config = await loadF1tvConfigOnly(); const config = await loadF1tvConfigOnly();
if (!config) return null; if (!config) return null;
@ -164,5 +224,11 @@ export async function getF1tvMeetingDetail(
.map((s) => sessionToPublic(s, nowMs)) .map((s) => sessionToPublic(s, nowMs))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); .sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
if (!meeting && !sessions.length) return null; 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 };
} }

View file

@ -41,23 +41,45 @@ export async function resolveF1tvPlay(
const preferredChannel = const preferredChannel =
opts?.channelId ?? session?.defaultChannelId ?? 1033; opts?.channelId ?? session?.defaultChannelId ?? 1033;
const isVod =
session?.kind === "replay" ||
(session != null && Date.now() > session.endMs);
let played: Awaited<ReturnType<typeof fetchContentPlay>>; // Replay/ended: eerst zonder channel (VOD), dan live-feed channel.
try { const channelAttempts: Array<number | null> = isVod
played = await fetchContentPlay({ ? [opts?.channelId ?? null, null, preferredChannel]
contentId, : [preferredChannel, null];
channelId: preferredChannel,
auth: { const auth = {
ascendontoken: account.ascendontoken, ascendontoken: account.ascendontoken,
entitlementtoken: account.entitlementtoken, entitlementtoken: account.entitlementtoken,
sessionId: ensureSessionId(account.sessionId), sessionId: ensureSessionId(account.sessionId),
}, };
let lastErr: unknown = null;
let played: Awaited<ReturnType<typeof fetchContentPlay>> | null = null;
let usedChannel: number | null = preferredChannel;
for (const channelId of channelAttempts) {
try {
played = await fetchContentPlay({
contentId,
channelId,
auth,
locale: config.locale, locale: config.locale,
playPlatform: "WEB_HLS", playPlatform: "WEB_HLS",
timeoutMs: config.syncTimeoutMs, timeoutMs: config.syncTimeoutMs,
}); });
usedChannel = channelId;
if (pickDefaultFeed(played.feeds, channelId)?.url) break;
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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)) { if (/HTTP 4\d\d|NOT_FOUND|UNAVAILABLE|FORBIDDEN/i.test(msg)) {
throw new AppError( throw new AppError(
"NOT_READY", "NOT_READY",
@ -67,10 +89,14 @@ export async function resolveF1tvPlay(
409 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) { if (!feed?.url) {
throw new AppError( throw new AppError(
"NOT_READY", "NOT_READY",

View file

@ -32,6 +32,14 @@ function pageIdFromUri(uri: string | null | undefined): number | null {
return m ? Number(m[1]) : 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[] { function extractMeetings(pageJson: unknown): F1tvMeetingSnapshot[] {
const out: F1tvMeetingSnapshot[] = []; const out: F1tvMeetingSnapshot[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -56,12 +64,21 @@ function extractMeetings(pageJson: unknown): F1tvMeetingSnapshot[] {
break; break;
} }
} }
const meetingNumber = Number(emf.Meeting_Number ?? 0);
out.push({ out.push({
meetingKey, meetingKey,
contentId, contentId,
title: String(meta.title ?? meta.label ?? `Meeting ${meetingKey}`).trim(), title: String(meta.title ?? meta.label ?? `Meeting ${meetingKey}`).trim(),
pageId: pageIdFromUri(pageUri), pageId: pageIdFromUri(pageUri),
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; return out;
@ -149,14 +166,24 @@ function extractSessions(
function selectMeetingsForSync( function selectMeetingsForSync(
meetings: F1tvMeetingSnapshot[], meetings: F1tvMeetingSnapshot[],
limit: number limit: number,
nowMs = Date.now()
): F1tvMeetingSnapshot[] { ): 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); const withPage = meetings.filter((m) => m.pageId != null);
if (withPage.length <= limit) return withPage; 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 = { export type F1tvSyncSummary = {

View file

@ -21,7 +21,7 @@ export const F1TV_TWEAK_DEFAULTS: F1tvTweaks = {
seasonPageId: 12343, seasonPageId: 12343,
locale: "NLD", locale: "NLD",
platform: "WEB_DASH", platform: "WEB_DASH",
syncMeetingLimit: 8, syncMeetingLimit: 24,
syncTimeoutMs: 45_000, syncTimeoutMs: 45_000,
includeFormula2: false, includeFormula2: false,
includeSupportSeries: false, includeSupportSeries: false,
@ -124,6 +124,11 @@ export type F1tvMeetingSnapshot = {
title: string; title: string;
pageId: number | null; pageId: number | null;
pageUri: string | null; pageUri: string | null;
meetingNumber: number | null;
location: string | null;
startMs: number | null;
endMs: number | null;
imageUrl: string | null;
}; };
export function newSessionId(): string { export function newSessionId(): string {