stremio/apps/master-api/src/f1tv/events.ts
Jos Vooges | STH d8a1c28283 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.
2026-09-22 04:34:24 +02:00

234 lines
7.5 KiB
TypeScript

import type { ScheduleEventPublic } from "../viewer/schedule-events";
import { resolveF1tvImageUrl } from "./client";
import { loadF1tvConfigOnly } from "./settings";
import type { F1tvMeetingSnapshot, F1tvSessionSnapshot } from "./tweaks";
function phaseFor(
startMs: number,
endMs: number,
state: string | null,
nowMs: number
): "live" | "upcoming" | "ended" {
const st = (state || "").toLowerCase();
if (st === "live" || (st.includes("live") && !st.includes("pre"))) {
if (nowMs <= endMs + 15 * 60_000) return "live";
}
if (nowMs < startMs) return "upcoming";
if (nowMs > endMs) return "ended";
return "live";
}
function toEventId(contentId: number): string {
return `f1tv:${contentId}`;
}
export function sessionToPublic(
session: F1tvSessionSnapshot,
nowMs: number
): ScheduleEventPublic {
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 isReplayKind = session.kind === "replay" || phase === "ended";
const playable =
(session.kind === "live_event" && phase === "live") || isReplayKind;
const image = resolveF1tvImageUrl(session.imageUrl);
return {
id: toEventId(session.contentId),
name: session.title,
description: session.meetingName,
provider: "f1tv",
category: "racing",
eventType: isReplayKind ? "replay" : "live",
subtitle: session.series,
channel: "F1 TV",
network: "F1 TV",
isLive: phase === "live",
phase,
playable,
status: session.state,
start,
stop,
startLocal: null,
stopLocal: null,
sportBucket: "racing",
sportLabel: "Racing",
imageLandscape: image,
imageBackdrop: image,
imagePortrait: null,
logoUrl: null,
images: image ? [image] : [],
audioLanguages: [],
};
}
/** Upcoming + live F1 sessions for the main Events tab (no replays). */
export async function listF1tvScheduleEvents(
nowMs = Date.now()
): Promise<ScheduleEventPublic[]> {
const config = await loadF1tvConfigOnly();
if (!config?.sessions?.length) return [];
const out: ScheduleEventPublic[] = [];
for (const session of config.sessions) {
if (session.kind !== "live_event") continue;
const pub = sessionToPublic(session, nowMs);
if (pub.phase === "ended") continue;
// Events-tab: alleen echte live speelbaar, niet ended-as-replay.
out.push({
...pub,
playable: pub.phase === "live",
eventType: "live",
});
}
return out;
}
export async function getF1tvScheduleEvent(
eventId: string,
nowMs = Date.now()
): Promise<ScheduleEventPublic | null> {
const m = /^f1tv:(\d+)$/i.exec(eventId.trim());
if (!m) return null;
const contentId = Number(m[1]);
const config = await loadF1tvConfigOnly();
const session = config?.sessions.find((s) => s.contentId === contentId);
if (!session) return null;
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;
};
export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload> {
const config = await loadF1tvConfigOnly();
const sessions = config?.sessions ?? [];
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[] = [];
const recentReplays: ScheduleEventPublic[] = [];
for (const session of sessions) {
const pub = sessionToPublic(session, nowMs);
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);
else if (pub.phase === "upcoming") upcoming.push(pub);
}
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;
for (const meeting of meetings) {
const start = meeting.startMs ?? 0;
const end = meeting.endMs ?? start;
if (end + 86_400_000 >= nowMs) {
nextMeeting = meeting;
break;
}
}
if (!nextMeeting && meetings.length) {
nextMeeting = meetings[meetings.length - 1] ?? null;
}
return {
generatedAt: new Date(nowMs).toISOString(),
live,
upcoming: upcoming.slice(0, 40),
recentReplays: recentReplays.slice(0, 60),
replayGroups: replayGroups.slice(0, 20),
meetings,
nextMeeting,
};
}
export async function getF1tvMeetingDetail(
meetingKey: string,
nowMs = Date.now()
): Promise<{
meeting: F1tvMeetingSnapshot | null;
sessions: ScheduleEventPublic[];
replays: ScheduleEventPublic[];
upcoming: ScheduleEventPublic[];
live: ScheduleEventPublic[];
} | null> {
const config = await loadF1tvConfigOnly();
if (!config) return null;
const key = meetingKey.trim();
const meeting = config.meetings.find((m) => m.meetingKey === key) ?? null;
const sessions = config.sessions
.filter((s) => s.meetingKey === key)
.map((s) => sessionToPublic(s, nowMs))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
if (!meeting && !sessions.length) return null;
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 };
}