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 = { 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 = { 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 & { 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(); 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(); 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(); 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(); 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 { 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 { 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 { 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> { const settings = await getIptvSettings(config); return new Set(settings.liveEventsDisabledProviders.map((p) => p.toLowerCase())); } function providerAllowed( provider: string, disabledGlobal: Set, 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, access?: LiveEventsAccessFilter | null ): ScheduleEventPublic[] { const userAllow = access?.allowedProviders ?? null; return events.filter((e) => providerAllowed(e.provider, disabledGlobal, userAllow)); } async function loadRawEvents(config: Config): Promise { 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 { try { const raw = await loadRawEvents(config); const seen = new Set(); 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 { 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, }; }