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:
parent
9ac832545d
commit
d8a1c28283
4 changed files with 164 additions and 40 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
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<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) {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue