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 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<F1tvHomePayload> {
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<F1tvHomePayload>
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<F1tvHomePayload>
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<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;
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<F1tvHomePayload>
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 };
}

View file

@ -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<ReturnType<typeof fetchContentPlay>>;
try {
played = await fetchContentPlay({
contentId,
channelId: preferredChannel,
auth: {
// Replay/ended: eerst zonder channel (VOD), dan live-feed channel.
const channelAttempts: Array<number | null> = 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<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,
playPlatform: "WEB_HLS",
timeoutMs: config.syncTimeoutMs,
});
usedChannel = channelId;
if (pickDefaultFeed(played.feeds, channelId)?.url) break;
} 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)) {
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",

View file

@ -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<string>();
@ -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 = {

View file

@ -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 {