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.
897 lines
29 KiB
TypeScript
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,
|
|
};
|
|
}
|