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 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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue