stremio/apps/master-api/src/viewer/schedule-events.ts
Jos Vooges | STH 796edf36db Add Events sport chips and shelves with sportBucket (TV 0.14.65).
Normalize FAAB events into stable sport buckets on master-api, group the TV Events tab by sport (with client fallback), and bump Android TV to 128/0.14.65.
2026-09-19 14:26:43 +02:00

897 lines
29 KiB
TypeScript

import type { Config } from "../config";
import { AppError } from "../security/errors";
import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
import { prisma } from "../database/client";
const CACHE_TTL_MS = 60_000;
const FETCH_TIMEOUT_MS = 20_000;
export type EventPhase = "live" | "upcoming" | "ended";
export type ScheduleEventPublic = {
id: string;
name: string;
description: string | null;
provider: string;
category: string | null;
eventType: string | null;
subtitle: string | null;
channel: string | null;
network: string | null;
isLive: boolean;
phase: EventPhase;
playable: boolean;
status: string | null;
start: string;
stop: string;
startLocal: string | null;
stopLocal: string | null;
/** Stable sport id for shelves/chips (`football`, `racing`, …, `other`). */
sportBucket: string;
/** Dutch label for sportBucket (Voetbal, Racing, Overig, …). */
sportLabel: string;
/** 16:9 card/cover art (Prime: BOXART-16X9 preferred). */
imageLandscape: string | null;
/** Full-bleed detail/hero backdrop (Prime: HERO-16X9); fall back to landscape on clients. */
imageBackdrop: string | null;
imagePortrait: string | null;
logoUrl: string | null;
images: string[];
audioLanguages: string[];
};
/** Canonical shelf/chip order (Overig last). */
export const SPORT_BUCKET_ORDER = [
"football",
"racing",
"tennis",
"golf",
"darts",
"snooker",
"basketball",
"waterpolo",
"sailing",
"hockey",
"cycling",
"other",
] as const;
export type SportBucketId = (typeof SPORT_BUCKET_ORDER)[number];
const SPORT_LABELS: Record<SportBucketId, string> = {
football: "Voetbal",
racing: "Racing",
tennis: "Tennis",
golf: "Golf",
darts: "Darts",
snooker: "Snooker",
basketball: "Basketbal",
waterpolo: "Waterpolo",
sailing: "Zeilen",
hockey: "Hockey",
cycling: "Wielrennen",
other: "Overig",
};
/** Category first-token → bucket (NL/EN). Noise tokens ignored. */
const CATEGORY_TOKEN_BUCKET: Record<string, SportBucketId> = {
voetbal: "football",
football: "football",
soccer: "football",
racing: "racing",
motorsport: "racing",
motorsports: "racing",
formule: "racing",
formula: "racing",
tennis: "tennis",
golf: "golf",
darts: "darts",
snooker: "snooker",
basketbal: "basketball",
basketball: "basketball",
nba: "basketball",
waterpolo: "waterpolo",
zeilen: "sailing",
sailing: "sailing",
sail: "sailing",
hockey: "hockey",
ijshockey: "hockey",
wielrennen: "cycling",
cycling: "cycling",
sport: "other", // generic — refine via type/name
};
const CATEGORY_NOISE = new Set([
"all",
"upcoming",
"live",
"overig",
"other",
"vod",
"documentaire",
"documentary",
"talkshow",
"talk",
"instant",
"restart",
]);
/** eventType / league → bucket (specific sports before broad "championship") */
const EVENT_TYPE_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
{ re: /\b(formula\s*[1-4e]|f1|f2|f3|f4|motogp|indycar|imsa|nascar|wec|dtm|rally|ferrari\s*challenge|goodwood|festival\s*of\s*speed)\b/i, bucket: "racing" },
{ re: /\b(atp|wta|davis\s*cup|billie\s*jean|grand\s*slam|wimbledon|roland\s*garros|us\s*open|australian\s*open)\b/i, bucket: "tennis" },
{ re: /\b(pga|lpga|european\s*(pga\s*)?tour|masters|ryder\s*cup|dp\s*world|liv\s*golf)\b/i, bucket: "golf" },
{ re: /\b(pdc|darts|world\s*matchplay|uk\s*open)\b/i, bucket: "darts" },
{ re: /\b(snooker|world\s*snooker|uk\s*championship)\b/i, bucket: "snooker" },
{ re: /\b(nba|ncaa|euroleague|basketball|basketbal)\b/i, bucket: "basketball" },
{ re: /\b(waterpolo|water\s*polo)\b/i, bucket: "waterpolo" },
{ re: /\b(sail\s*gp|sailing|zeilen)\b/i, bucket: "sailing" },
{ re: /\b(nhl|hockey|ijshockey|field\s*hockey)\b/i, bucket: "hockey" },
{ re: /\b(tour\s*de\s*france|giro|vuelta|uci|wielrennen|cycling)\b/i, bucket: "cycling" },
{ re: /\b(premier\s*league|bundesliga|ligue\s*[12]|la\s*liga|serie\s*a|eredivisie|fa\s*cup|uefa|champions\s*league|europa\s*league|conference\s*league|copa|mls|nwsl|womens?\s*super\s*league|damallsvenskan|frauen[- ]?bundesliga|2\.\s*bundesliga|championship)\b/i, bucket: "football" },
];
const NAME_KEYWORD_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
{ re: /\b(voetbal|football|soccer|eredivisie|premier\s*league|bundesliga|champions\s*league|europa\s*league)\b/i, bucket: "football" },
{ re: /\b(formule\s*1|formula\s*[1-4]|f1\b|motogp|nascar|indycar|imsa|race\s*caf|racing|motorsport|grand\s*prix)\b/i, bucket: "racing" },
{ re: /\b(tennis|atp|wta|davis\s*cup)\b/i, bucket: "tennis" },
{ re: /\b(golf|pga\b|lpga)\b/i, bucket: "golf" },
{ re: /\b(darts|pdc)\b/i, bucket: "darts" },
{ re: /\b(snooker)\b/i, bucket: "snooker" },
{ re: /\b(nba|basketbal|basketball)\b/i, bucket: "basketball" },
{ re: /\b(waterpolo)\b/i, bucket: "waterpolo" },
{ re: /\b(sail\s*gp|zeilen)\b/i, bucket: "sailing" },
{ re: /\b(hockey|ijshockey)\b/i, bucket: "hockey" },
{ re: /\b(wielrennen|tour\s*de\s*france|cycling)\b/i, bucket: "cycling" },
];
export function sportLabelFor(bucket: string): string {
return SPORT_LABELS[(bucket as SportBucketId)] ?? SPORT_LABELS.other;
}
function firstCategoryToken(category: string | null | undefined): string | null {
const raw = str(category);
if (!raw) return null;
const token = raw.split(/[,/|·•]/)[0]?.trim().toLowerCase() ?? "";
if (!token || CATEGORY_NOISE.has(token)) return null;
// "Voetbal, UEFA…" → voetbal
return token;
}
/**
* Resolve a stable sport bucket from category → eventType/leagues → name keywords.
* Documentaire/Talkshow and unknown → `other` / Overig.
*/
export function resolveSportBucket(input: {
category?: string | null;
eventType?: string | null;
name?: string | null;
subtitle?: string | null;
}): { sportBucket: SportBucketId; sportLabel: string } {
const category = str(input.category) || null;
const eventType = str(input.eventType) || null;
const name = str(input.name) || null;
const subtitle = str(input.subtitle) || null;
const hayName = [name, subtitle].filter(Boolean).join(" ");
// Explicit non-sport categories
const catLower = (category ?? "").toLowerCase();
if (
/\b(documentaire|documentary|talkshow|talk\s*show)\b/i.test(catLower) ||
firstCategoryToken(category) === "documentaire" ||
firstCategoryToken(category) === "talkshow"
) {
return { sportBucket: "other", sportLabel: SPORT_LABELS.other };
}
// 1) category first token
const token = firstCategoryToken(category);
if (token) {
const mapped = CATEGORY_TOKEN_BUCKET[token];
if (mapped && mapped !== "other") {
return { sportBucket: mapped, sportLabel: SPORT_LABELS[mapped] };
}
// Unknown but specific token (not noise) — keep scanning type/name; may still be a sport
}
// 2) eventType / leagues
if (eventType && !/\binstant\s*restart\b/i.test(eventType)) {
for (const { re, bucket } of EVENT_TYPE_BUCKET) {
if (re.test(eventType)) {
return { sportBucket: bucket, sportLabel: SPORT_LABELS[bucket] };
}
}
}
// 3) name / subtitle keywords
if (hayName) {
for (const { re, bucket } of NAME_KEYWORD_BUCKET) {
if (re.test(hayName)) {
return { sportBucket: bucket, sportLabel: SPORT_LABELS[bucket] };
}
}
// Match-style: "Ligue 1: Monaco - Lens" / "A vs B" without talk/doc noise
if (
(/^[A-Za-z0-9 .'+]+:\s+.+\s[-–—]\s+.+/i.test(hayName) ||
/\b(vs\.?|versus)\b/i.test(hayName)) &&
!/\b(documentaire|talkshow|show|hoogtepunten|highlights|vandaag|today|stories)\b/i.test(
hayName
)
) {
return { sportBucket: "football", sportLabel: SPORT_LABELS.football };
}
}
return { sportBucket: "other", sportLabel: SPORT_LABELS.other };
}
/** Viewer + globale provider-filter voor list/detail/play. */
export type LiveEventsAccessFilter = {
/** false → 403 op client-endpoints */
enabled: boolean;
/**
* null/empty = alle globaal-ingeschakelde providers.
* Anders: subset (lowercase ids).
*/
allowedProviders: string[] | null;
};
type RawKid = { kid?: unknown; key?: unknown };
type RawEvent = {
id?: unknown;
name?: unknown;
description?: unknown;
provider?: unknown;
category?: unknown;
is_live?: unknown;
status?: unknown;
start?: unknown;
stop?: unknown;
start_time?: unknown;
end_time?: unknown;
start_local?: unknown;
stop_local?: unknown;
manifest_url?: unknown;
mpd?: unknown;
has_manifest?: unknown;
has_keys?: unknown;
keys?: unknown;
kids?: unknown;
images?: unknown;
epg?: {
title?: unknown;
subtitle?: unknown;
description?: unknown;
category?: unknown;
channel?: unknown;
network?: unknown;
images?: unknown;
};
metadata?: Record<string, unknown> & {
type?: unknown;
images?: unknown;
audio_langs?: unknown;
zender?: unknown;
"originele titel"?: unknown;
};
};
type CacheEntry = {
at: number;
url: string;
events: RawEvent[];
generatedAt: string | null;
};
let cache: CacheEntry | null = null;
function str(v: unknown): string {
return String(v ?? "").trim();
}
function asStringList(v: unknown): string[] {
if (!Array.isArray(v)) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const item of v) {
const s = str(item);
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
function pickImages(raw: RawEvent): string[] {
const fromEvent = asStringList(raw.images);
const fromEpg = asStringList(raw.epg?.images);
const fromMeta = asStringList(raw.metadata?.images);
const merged = [...fromEvent, ...fromEpg, ...fromMeta];
const seen = new Set<string>();
const out: string[] = [];
for (const u of merged) {
if (seen.has(u)) continue;
seen.add(u);
out.push(u);
}
return out;
}
/** Standalone channel/brand logo (not composite boxart that embeds a logo overlay). */
function isPureLogoUrl(url: string): boolean {
const u = url.toLowerCase();
if (u.includes("boxart-") || u.includes("hero-") || u.includes("poster")) return false;
if (u.includes("channels-logo") || u.includes("/logos/")) return true;
if (u.includes("assets/channels")) return true;
// Bare logo PNG without Amazon content-art path segments
if (u.includes("logo") && u.includes(".png") && !u.includes("le-target-images")) return true;
return false;
}
function scoreLandscape(url: string): number {
const u = url.toLowerCase();
let score = 0;
// Prime/Amazon: card cover = BOXART-16X9 (composite with corner logo overlay is OK)
if (u.includes("boxart-16x9") || u.includes("boxart_16x9")) score += 90;
if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 35;
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 25;
// Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
if (u.includes("thumb")) score += 55;
if (u.includes("akamaized.net/live")) score += 50;
if (u.includes("scale=")) score += 35;
if (u.includes("640x360") || u.includes("scale=640")) score += 30;
if (u.includes("_l.") || u.includes("_l?")) score += 50;
if (u.includes("width=960") && u.includes("height=540")) score += 40;
if (u.includes("width=1280") || u.includes("height=720")) score += 30;
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
// Pure logos are never card art
if (isPureLogoUrl(url)) score -= 120;
else if (u.includes("assets/channels")) score -= 70;
else if (u.includes("/channels/") && u.includes(".png") && !u.includes("boxart")) score -= 50;
// Only penalize "logo" when it is not part of a BOXART/HERO content composite
if (u.includes("logo") && !u.includes("boxart") && !u.includes("hero-") && !u.includes("hero_")) {
score -= 40;
if (u.includes(".png")) score -= 50;
}
if (u.includes("_c.") || u.includes("_c?")) score -= 20;
return score;
}
/** Detail/hero full-bleed: prefer clean HERO-16X9 over card BOXART. */
function scoreBackdrop(url: string): number {
const u = url.toLowerCase();
let score = 0;
if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 95;
if (u.includes("boxart-16x9") || u.includes("boxart_16x9")) score += 40;
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 20;
if (u.includes("thumb")) score += 45;
if (u.includes("akamaized.net/live")) score += 40;
if (u.includes("scale=")) score += 25;
if (u.includes("_l.") || u.includes("_l?")) score += 40;
if (u.includes("width=1280") || u.includes("height=720") || u.includes("1920")) score += 15;
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
if (isPureLogoUrl(url)) score -= 120;
else if (u.includes("logo") && !u.includes("boxart") && !u.includes("hero-") && !u.includes("hero_")) {
score -= 50;
}
return score;
}
function scorePortrait(url: string): number {
const u = url.toLowerCase();
let score = 0;
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score += 80;
if (u.includes("_c.") || u.includes("_c?")) score += 50;
if (u.includes("width=199") || u.includes("height=298")) score += 30;
if (u.includes("boxart-16x9") || u.includes("hero-16x9")) score -= 30;
if (isPureLogoUrl(url) || u.includes("assets/channels")) score -= 80;
else if (u.includes("logo") && !u.includes("boxart")) score -= 40;
if (u.includes("thumb") || u.includes("akamaized.net/live")) score -= 20;
if (u.includes("_l.")) score -= 10;
return score;
}
function scoreLogo(url: string): number {
const u = url.toLowerCase();
let score = 0;
// Content art must never win as logo (BOXART composites embed logo overlay paths)
if (u.includes("boxart-") || u.includes("hero-") || u.includes("le-target-images")) score -= 200;
if (isPureLogoUrl(url)) score += 100;
if (u.includes("channels-logo")) score += 40;
if (u.includes("assets/channels")) score += 80;
if (u.includes("ziggo_sport")) score += 45;
if (u.includes("/channels/") && u.includes(".png")) score += 55;
if (u.includes("logo")) score += 60;
if (u.includes(".png")) score += 20;
if (u.includes("cropped")) score += 10;
// Live thumbs are not logos
if (u.includes("thumb") || u.includes("akamaized.net/live") || u.includes("scale=")) score -= 50;
if (u.includes("_l.") || u.includes("_c.")) score -= 30;
return score;
}
/** Viaplay labels like "Stream 10" / "Stream 22 (NEW)" — hide from public meta. */
function isStreamLabel(value: string | null | undefined): boolean {
const s = str(value);
if (!s) return false;
return /^stream\s*\d+/i.test(s);
}
function cleanChannelLabel(value: string | null | undefined): string | null {
const s = str(value);
if (!s || isStreamLabel(s)) return null;
return s;
}
function normalizeChannelKey(value: string | null | undefined): string {
return str(value).toLowerCase().replace(/\s+/g, " ");
}
function donorImageScore(e: ScheduleEventPublic): number {
let s = 0;
if (e.imageLandscape) s += 20;
if (e.imageBackdrop) s += 10;
if (e.images.some((u) => /thumb|akamaized\.net\/live|scale=/i.test(u))) s += 30;
if (e.logoUrl) s += 5;
if (e.images.length) s += Math.min(e.images.length, 5);
if (e.provider === "ziggosport") s += 8;
if (e.playable) s += 3;
return s;
}
/**
* Odido (and others) often publish the same channel without images while
* ziggosport/FAAB has thumbs for that channel. Fill gaps by channel/network key.
*/
function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[] {
const donors = new Map<string, ScheduleEventPublic>();
for (const e of events) {
if (!e.imageLandscape && !e.logoUrl && e.images.length === 0) continue;
const keys = [e.channel, e.network]
.map(normalizeChannelKey)
.filter((k) => k.length > 0);
for (const key of keys) {
const existing = donors.get(key);
if (!existing || donorImageScore(e) > donorImageScore(existing)) {
donors.set(key, e);
}
}
}
return events.map((e) => {
if (e.imageLandscape || e.images.length > 0) return e;
const keys = [e.channel, e.network]
.map(normalizeChannelKey)
.filter((k) => k.length > 0);
let donor: ScheduleEventPublic | undefined;
for (const key of keys) {
const hit = donors.get(key);
if (hit && hit.id !== e.id) {
donor = hit;
break;
}
}
if (!donor) return e;
return {
...e,
imageLandscape: e.imageLandscape || donor.imageLandscape,
imageBackdrop: e.imageBackdrop || donor.imageBackdrop,
imagePortrait: e.imagePortrait || donor.imagePortrait,
logoUrl: e.logoUrl || donor.logoUrl,
images: e.images.length ? e.images : [...donor.images],
};
});
}
function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
let best: string | null = null;
let bestScore = -Infinity;
for (const u of urls) {
const s = scorer(u);
if (s > bestScore) {
bestScore = s;
best = u;
}
}
return bestScore > 0 ? best : urls[0] ?? null;
}
function parseIsoMs(raw: string): number | null {
if (!raw) return null;
const t = Date.parse(raw);
return Number.isFinite(t) ? t : null;
}
function phaseFor(start: string, stop: string, isLiveFlag: boolean, nowMs: number): EventPhase {
const startMs = parseIsoMs(start);
const stopMs = parseIsoMs(stop);
if (startMs != null && stopMs != null) {
if (nowMs >= startMs && nowMs < stopMs) return "live";
if (nowMs < startMs) return "upcoming";
return "ended";
}
if (isLiveFlag) return "live";
if (startMs != null && nowMs < startMs) return "upcoming";
return "ended";
}
function extractKids(raw: RawEvent): Array<{ kid: string; key: string }> {
const fromKids = Array.isArray(raw.kids) ? raw.kids : [];
const out: Array<{ kid: string; key: string }> = [];
const seen = new Set<string>();
for (const item of fromKids) {
const k = item as RawKid;
const kid = str(k.kid).toLowerCase();
const key = str(k.key).toLowerCase();
if (!/^[0-9a-f]{32}$/.test(kid) || !/^[0-9a-f]{32}$/.test(key)) continue;
if (seen.has(kid)) continue;
seen.add(kid);
out.push({ kid, key });
}
if (out.length) return out;
const keyPairs = Array.isArray(raw.keys) ? raw.keys : [];
for (const item of keyPairs) {
const s = str(item);
const m = s.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
if (!m) continue;
const kid = m[1].toLowerCase();
const key = m[2].toLowerCase();
if (seen.has(kid)) continue;
seen.add(kid);
out.push({ kid, key });
}
return out;
}
function isPlayable(raw: RawEvent): boolean {
const mpd = str(raw.mpd) || str(raw.manifest_url);
if (!mpd) return false;
if (raw.has_manifest === false || raw.has_keys === false) {
// still allow if kids present
}
return extractKids(raw).length > 0;
}
function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
const id = str(raw.id);
const name = str(raw.name) || str(raw.epg?.title);
if (!id || !name) return null;
const start = str(raw.start) || str(raw.start_time);
const stop = str(raw.stop) || str(raw.end_time);
if (!start || !stop) return null;
const images = pickImages(raw);
const meta = raw.metadata ?? {};
const audioLangs = asStringList(meta.audio_langs);
const isLiveFlag = raw.is_live === true;
const phase = phaseFor(start, stop, isLiveFlag, nowMs);
const channel = cleanChannelLabel(str(raw.epg?.channel) || str(meta.zender));
const network = cleanChannelLabel(str(raw.epg?.network));
const category = str(raw.category) || str(raw.epg?.category) || null;
const eventType = str(meta.type) || null;
const subtitle = str(meta["originele titel"]) || str(raw.epg?.subtitle) || null;
const { sportBucket, sportLabel } = resolveSportBucket({
category,
eventType,
name,
subtitle,
});
return {
id,
name,
description:
str(raw.description) ||
str(raw.epg?.description) ||
null,
provider: (str(raw.provider) || "unknown").toLowerCase(),
category,
eventType,
subtitle,
channel,
network,
isLive: phase === "live",
phase,
playable: isPlayable(raw) && phase === "live",
status: str(raw.status) || null,
start,
stop,
startLocal: str(raw.start_local) || null,
stopLocal: str(raw.stop_local) || null,
sportBucket,
sportLabel,
imageLandscape: bestByScore(images, scoreLandscape),
imageBackdrop: bestByScore(images, scoreBackdrop),
imagePortrait: bestByScore(images, scorePortrait),
logoUrl: bestByScore(images, scoreLogo),
images,
audioLanguages: audioLangs,
};
}
export async function resolveLiveEventsUrl(config: Config): Promise<string> {
const settings = await getIptvSettings(config);
const fromSettings = settings.liveEventsUrl?.trim();
if (!fromSettings) {
throw new AppError("NOT_CONFIGURED", "Live events feed niet geconfigureerd", 404);
}
return fromSettings;
}
export async function eventsFeedEnabled(config: Config): Promise<boolean> {
const settings = await getIptvSettings(config);
return !!settings.liveEventsUrl?.trim();
}
/** Parse JSON allowlist; null/empty → null (= alle globaal-enabled). */
export function parseLiveEventsProvidersJson(raw: string | null | undefined): string[] | null {
if (!raw?.trim()) return null;
try {
const parsed = JSON.parse(raw) as unknown;
const list = normalizeProviderIdList(parsed);
return list.length ? list : null;
} catch {
return null;
}
}
export function serializeLiveEventsProviders(providers: string[] | null | undefined): string | null {
if (providers == null) return null;
const list = normalizeProviderIdList(providers);
return list.length ? JSON.stringify(list) : null;
}
export async function resolveViewerLiveEventsAccess(
viewerId: string,
config: Config
): Promise<LiveEventsAccessFilter> {
const feedOn = await eventsFeedEnabled(config);
const viewer = await prisma.viewerUser.findUnique({
where: { id: viewerId },
select: {
enabled: true,
appAccess: true,
liveEventsEnabled: true,
liveEventsProviders: true,
},
});
if (!viewer || !viewer.enabled || !viewer.appAccess || !viewer.liveEventsEnabled || !feedOn) {
return { enabled: false, allowedProviders: null };
}
return {
enabled: true,
allowedProviders: parseLiveEventsProvidersJson(viewer.liveEventsProviders),
};
}
async function globalDisabledProviders(config: Config): Promise<Set<string>> {
const settings = await getIptvSettings(config);
return new Set(settings.liveEventsDisabledProviders.map((p) => p.toLowerCase()));
}
function providerAllowed(
provider: string,
disabledGlobal: Set<string>,
userAllow: string[] | null
): boolean {
const p = provider.trim().toLowerCase() || "unknown";
if (disabledGlobal.has(p)) return false;
if (!userAllow || userAllow.length === 0) return true;
return userAllow.includes(p);
}
function assertAccessEnabled(access?: LiveEventsAccessFilter | null) {
if (access && !access.enabled) {
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
}
}
function filterByAccess(
events: ScheduleEventPublic[],
disabledGlobal: Set<string>,
access?: LiveEventsAccessFilter | null
): ScheduleEventPublic[] {
const userAllow = access?.allowedProviders ?? null;
return events.filter((e) => providerAllowed(e.provider, disabledGlobal, userAllow));
}
async function loadRawEvents(config: Config): Promise<CacheEntry> {
const url = await resolveLiveEventsUrl(config);
if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) {
return cache;
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ac.signal,
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new AppError(
"UPSTREAM",
`Live events feed HTTP ${res.status}`,
502
);
}
const json = (await res.json()) as {
Meta?: { generated_at?: string };
events?: unknown;
};
const events = Array.isArray(json.events) ? (json.events as RawEvent[]) : [];
cache = {
at: Date.now(),
url,
events,
generatedAt: str(json.Meta?.generated_at) || null,
};
return cache;
} catch (err) {
if (cache && cache.url === url) return cache;
if (err instanceof AppError) throw err;
throw new AppError(
"UPSTREAM",
err instanceof Error ? err.message : "Live events feed onbereikbaar",
502
);
} finally {
clearTimeout(timer);
}
}
export function invalidateScheduleEventsCache() {
cache = null;
}
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
export async function discoverScheduleEventProviders(config: Config): Promise<string[]> {
try {
const raw = await loadRawEvents(config);
const seen = new Set<string>();
const out: string[] = [];
for (const e of raw.events) {
const p = (str(e.provider) || "unknown").toLowerCase();
if (seen.has(p)) continue;
seen.add(p);
out.push(p);
}
return out.sort((a, b) => a.localeCompare(b, "nl"));
} catch {
return [];
}
}
export async function listScheduleEvents(
config: Config,
access?: LiveEventsAccessFilter | null
): Promise<{
generatedAt: string | null;
events: ScheduleEventPublic[];
providers: string[];
}> {
assertAccessEnabled(access);
const raw = await loadRawEvents(config);
const nowMs = Date.now();
const mapped = raw.events
.map((e) => toPublic(e, nowMs))
.filter((e): e is ScheduleEventPublic => e != null)
.filter((e) => e.phase !== "ended")
.sort((a, b) => {
if (a.phase === "live" && b.phase !== "live") return -1;
if (b.phase === "live" && a.phase !== "live") return 1;
return Date.parse(a.start) - Date.parse(b.start);
});
const disabledGlobal = await globalDisabledProviders(config);
const events = filterByAccess(fillMissingImages(mapped), disabledGlobal, access);
const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) =>
a.localeCompare(b, "nl")
);
return {
generatedAt: raw.generatedAt,
events,
providers,
};
}
export async function getScheduleEvent(
config: Config,
eventId: string,
access?: LiveEventsAccessFilter | null
): Promise<ScheduleEventPublic> {
assertAccessEnabled(access);
const id = str(eventId);
const listed = await listScheduleEvents(config, access);
const hit = listed.events.find((e) => e.id === id);
if (hit) return hit;
// Ended events still resolvable for detail (no play) if provider allowed
const raw = await loadRawEvents(config);
const nowMs = Date.now();
const found = raw.events.find((e) => str(e.id) === id);
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const disabledGlobal = await globalDisabledProviders(config);
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
return pub;
}
export async function getScheduleEventPlay(
config: Config,
eventId: string,
access?: LiveEventsAccessFilter | null,
viewerUserId?: string | null
) {
assertAccessEnabled(access);
const id = str(eventId);
const raw = await loadRawEvents(config);
const found = raw.events.find((e) => str(e.id) === id);
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const nowMs = Date.now();
const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const disabledGlobal = await globalDisabledProviders(config);
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
if (pub.phase === "ended") {
throw new AppError("GONE", "Dit event is afgelopen", 410);
}
if (pub.phase === "upcoming") {
throw new AppError("NOT_READY", "Dit event is nog niet begonnen", 409);
}
const mpd = str(found.mpd) || str(found.manifest_url);
const keys = extractKids(found);
if (!mpd || !keys.length) {
throw new AppError(
"NOT_READY",
"Stream start binnenkort",
409
);
}
let watchSessionId: string | null = null;
if (viewerUserId) {
try {
const { touchClientWatch } = await import("./client-watches");
const watch = touchClientWatch({
viewerUserId,
kind: "event",
resourceId: pub.id,
title: pub.name,
provider: pub.provider,
});
watchSessionId = watch.id;
} catch {
// Presence is best-effort; play must not fail.
}
}
return {
eventId: pub.id,
name: pub.name,
logoUrl: pub.logoUrl ?? pub.imagePortrait,
streamUrl: mpd,
format: "dash" as const,
fallbackStreamUrl: null,
fallbackFormat: null,
drm: {
type: "clearkey" as const,
keys,
},
watchSessionId,
};
}