Add rich IPTV EPG with programme art and 24h guide on Android TV.

Parse Odido/XUI XMLTV for images, descriptions and schedule, and surface them in the Live TV detail panel and player overlay.
This commit is contained in:
Jos Vooges | STH 2026-09-15 18:18:33 +02:00
parent 1cc6fedd95
commit 18bcf43c14
9 changed files with 635 additions and 210 deletions

View file

@ -532,6 +532,7 @@ class ApiClient(
categoryId = c.optString("categoryId").ifBlank { null },
nowTitle = c.optString("nowTitle").ifBlank { null },
nextTitle = c.optString("nextTitle").ifBlank { null },
nowImageUrl = c.optString("nowImageUrl").ifBlank { null },
)
)
}
@ -557,12 +558,21 @@ class ApiClient(
suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build()
parseObject(execute(req)) { o ->
val scheduleArr = o.optJSONArray("schedule")
val schedule = buildList {
if (scheduleArr != null) {
for (i in 0 until scheduleArr.length()) {
scheduleArr.optJSONObject(i)?.toEpgProgram()?.let { add(it) }
}
}
}
IptvEpg(
streamId = o.getInt("streamId"),
channelName = o.getString("channelName"),
logoUrl = o.optString("logoUrl").ifBlank { null },
now = o.optJSONObject("now")?.toEpgProgram(),
next = o.optJSONObject("next")?.toEpgProgram(),
schedule = schedule,
)
}
}
@ -596,6 +606,11 @@ class ApiClient(
val title = optString("title").ifBlank { return null }
return EpgProgram(
title = title,
subTitle = optString("subTitle").ifBlank { null },
description = optString("description").ifBlank { null },
imageUrl = optString("imageUrl").ifBlank { null },
category = optString("category").ifBlank { null },
episode = optString("episode").ifBlank { null },
start = optString("start").ifBlank { null },
end = optString("end").ifBlank { null },
)
@ -794,6 +809,7 @@ data class IptvChannel(
val categoryId: String? = null,
val nowTitle: String? = null,
val nextTitle: String? = null,
val nowImageUrl: String? = null,
)
data class IptvPlayInfo(
@ -808,6 +824,11 @@ data class IptvPlayInfo(
data class EpgProgram(
val title: String,
val subTitle: String? = null,
val description: String? = null,
val imageUrl: String? = null,
val category: String? = null,
val episode: String? = null,
val start: String? = null,
val end: String? = null,
)
@ -818,6 +839,7 @@ data class IptvEpg(
val logoUrl: String? = null,
val now: EpgProgram? = null,
val next: EpgProgram? = null,
val schedule: List<EpgProgram> = emptyList(),
)
private fun JSONArray?.toItems(): List<MediaItem> {

View file

@ -501,11 +501,27 @@ fun LiveTvPlayerScreen(
}
epg?.let { info ->
Column(
Row(
Modifier
.align(Alignment.BottomStart)
.padding(32.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Bottom,
) {
val art = info.now?.imageUrl
if (!art.isNullOrBlank()) {
AsyncImage(
model = art,
contentDescription = null,
modifier = Modifier
.width(140.dp)
.height(84.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0x44000000)),
contentScale = ContentScale.Crop,
)
}
Column {
info.now?.let { now ->
Text(
"Nu · ${now.title}",
@ -515,6 +531,15 @@ fun LiveTvPlayerScreen(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
now.subTitle?.takeIf { it.isNotBlank() }?.let { sub ->
Text(
sub,
color = Color(0xFFCCCCCC),
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
info.next?.let { next ->
Text(
@ -535,6 +560,7 @@ fun LiveTvPlayerScreen(
}
}
}
}
AnimatedVisibility(
visible = channelMenuOpen,

View file

@ -671,12 +671,13 @@ private fun ChannelDetailPanel(
onToggleFavorite: () -> Unit,
modifier: Modifier = Modifier,
) {
val scheduleState = rememberLazyListState()
Column(
modifier
.clip(RoundedCornerShape(6.dp))
.background(TvPanelBg)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
when {
epg == null && loading -> {
@ -708,7 +709,7 @@ private fun ChannelDetailPanel(
model = epg.logoUrl,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.size(44.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)),
contentScale = ContentScale.Fit,
@ -724,13 +725,33 @@ private fun ChannelDetailPanel(
overflow = TextOverflow.Ellipsis,
)
}
Surface(
onClick = onToggleFavorite,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF2A2A2A),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Text(
if (isFavorite) "★" else "☆",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA),
fontSize = 14.sp,
)
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
epg.now?.let { now ->
EpgBlock(label = "Nu", program = now, highlight = true)
val progress = epgProgress(now)
val featured = epg.now ?: epg.next
if (featured != null) {
RichNowBlock(program = featured, isLive = epg.now != null)
if (epg.now != null) {
val progress = epgProgress(epg.now)
Box(
Modifier
.fillMaxWidth()
@ -746,45 +767,40 @@ private fun ChannelDetailPanel(
)
}
}
epg.next?.let { next ->
EpgBlock(label = "Straks", program = next, highlight = false)
}
if (epg.now == null && epg.next == null) {
} else {
Text(
"Geen programmagids voor deze zender",
color = McColors.TextDim,
fontSize = 13.sp,
)
Text(
"Je kunt gewoon kijken — de gids is niet altijd beschikbaar.",
color = Color(0xFF555555),
fontSize = 12.sp,
lineHeight = 16.sp,
)
}
Spacer(Modifier.weight(1f))
Surface(
onClick = onToggleFavorite,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF2A2A2A),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
val guide = remember(epg.schedule, epg.now, epg.next) {
when {
epg.schedule.isNotEmpty() -> epg.schedule
else -> listOfNotNull(epg.now, epg.next)
}
}
if (guide.isNotEmpty()) {
Text(
if (isFavorite) "★ Favoriet" else "☆ Favoriet",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
"Komende 24 uur",
color = McColors.TextDim,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
)
LazyColumn(
state = scheduleState,
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(guide, key = { "${it.start}-${it.title}" }) { prog ->
val isNow = epg.now?.start == prog.start && epg.now?.title == prog.title
ScheduleRow(program = prog, isNow = isNow)
}
}
} else {
Spacer(Modifier.weight(1f))
}
Text("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp)
@ -793,6 +809,126 @@ private fun ChannelDetailPanel(
}
}
@Composable
private fun RichNowBlock(program: EpgProgram, isLive: Boolean) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
val art = program.imageUrl
Box(
Modifier
.width(112.dp)
.height(70.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)),
contentAlignment = Alignment.Center,
) {
if (!art.isNullOrBlank()) {
AsyncImage(
model = art,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text("TV", color = Color(0xFF444444), fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
if (isLive) "NU" else "STRAKS",
color = if (isLive) McColors.Brand else McColors.TextDim,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
)
formatEpgTime(program.start, program.end)?.let { time ->
Text(time, color = McColors.TextDim, fontSize = 12.sp)
}
program.episode?.takeIf { it.isNotBlank() }?.let {
Text(it, color = Color(0xFF888888), fontSize = 11.sp)
}
}
Text(
program.title,
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp,
)
program.subTitle?.takeIf { it.isNotBlank() }?.let {
Text(it, color = Color(0xFFAAAAAA), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
program.category?.takeIf { it.isNotBlank() }?.let {
Text(it, color = Color(0xFF666666), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
program.description?.takeIf { it.isNotBlank() }?.let { desc ->
Text(
desc,
color = Color(0xFF999999),
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ScheduleRow(program: EpgProgram, isNow: Boolean) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(if (isNow) Color(0xFF1A1A1A) else Color.Transparent)
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
formatEpgStart(program.start) ?: "--:--",
color = if (isNow) McColors.Brand else McColors.TextDim,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(42.dp),
)
if (!program.imageUrl.isNullOrBlank()) {
AsyncImage(
model = program.imageUrl,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0xFF0A0A0A)),
contentScale = ContentScale.Crop,
)
}
Column(Modifier.weight(1f)) {
Text(
program.title,
color = if (isNow) Color.White else Color(0xFFCCCCCC),
fontSize = 13.sp,
fontWeight = if (isNow) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val meta = listOfNotNull(
program.episode,
program.subTitle?.takeIf { it.isNotBlank() },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, color = Color(0xFF666666), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
private fun EpgSkeleton() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@ -804,41 +940,19 @@ private fun EpgSkeleton() {
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.width(112.dp).height(70.dp).clip(RoundedCornerShape(4.dp)).background(Color(0xFF1A1A1A)))
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Box(Modifier.width(40.dp).height(10.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.fillMaxWidth(0.85f).height(16.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(1.dp)).background(Color(0xFF222222)))
Box(Modifier.fillMaxWidth(0.9f).height(16.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.fillMaxWidth(0.6f).height(12.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF151515)))
}
}
Spacer(Modifier.weight(1f))
Text("Programma laden…", color = Color(0xFF555555), fontSize = 12.sp)
}
}
@Composable
private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
label.uppercase(),
color = if (highlight) McColors.Brand else McColors.TextDim,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
)
formatEpgTime(program.start, program.end)?.let { time ->
Text(time, color = McColors.TextDim, fontSize = 12.sp)
}
}
Text(
program.title,
color = if (highlight) Color.White else Color(0xFFBBBBBB),
fontSize = if (highlight) 15.sp else 14.sp,
fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp,
)
}
}
private fun formatEpgTime(start: String?, end: String?): String? {
val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
val s = start?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() }
@ -850,6 +964,11 @@ private fun formatEpgTime(start: String?, end: String?): String? {
}
}
private fun formatEpgStart(start: String?): String? {
val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
return start?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() }
}
private fun epgProgress(program: EpgProgram): Float {
val start = program.start?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f
val end = program.end?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f

View file

@ -20,6 +20,9 @@ NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=true
# Rijke IPTV-EPG (icons/desc/24u) — merge op epg_channel_id met XUI
IPTV_EPG_XML_URL=https://epg.faabserver.nl/epg/odido.xml
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
# GOOGLE_CLOUD_PROJECT_ID=
# GOOGLE_SERVICE_ACCOUNT_EMAIL=

View file

@ -20,9 +20,11 @@ import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors";
import { startIptvEpgWarmup } from "./viewer/iptv-client";
import { setExternalEpgXmlUrl } from "./iptv/xtream";
async function main() {
const config = loadConfig();
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
const app = Fastify({
logger: {

View file

@ -33,6 +33,14 @@ const configSchema = z.object({
GOOGLE_PLAY_OPT_IN_URL: z.string().url().optional(),
/** Periodic reconciliation interval (ms); 0 = disabled. Default: 24h */
GOOGLE_PLAY_SYNC_INTERVAL_MS: z.coerce.number().default(86_400_000),
/**
* Optionele rijke XMLTV-feed (icons/desc/episode), bv. Odido.
* Wordt gemerged met XUI xmltv.php op epg_channel_id — voorkomt dat XUI icons weglaat.
*/
IPTV_EPG_XML_URL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().url().optional()
),
});
export type Config = z.infer<typeof configSchema>;

View file

@ -73,11 +73,42 @@ export type XtreamEpgListing = {
now_playing?: number | boolean | string;
};
export type XmltvNowNext = {
now: { title: string; start: string; end: string | null } | null;
next: { title: string; start: string; end: string | null } | null;
/** Rijk programma uit XMLTV (Odido / XUI xmltv.php). */
export type XmltvProgram = {
title: string;
subTitle: string | null;
description: string | null;
imageUrl: string | null;
category: string | null;
episode: string | null;
start: string;
end: string | null;
};
export type XmltvChannelGuide = {
now: XmltvProgram | null;
next: XmltvProgram | null;
/** Programma's van ~nu−2u t/m nu+24u, gesorteerd op start. */
schedule: XmltvProgram[];
};
/** @deprecated thin alias — gebruik XmltvChannelGuide */
export type XmltvNowNext = {
now: XmltvProgram | null;
next: XmltvProgram | null;
};
/** Optionele externe rijke feed (icons/desc), gezet vanuit config. */
let externalEpgXmlUrl: string | null = null;
export function setExternalEpgXmlUrl(url: string | null | undefined) {
externalEpgXmlUrl = url?.trim() || null;
}
export function getExternalEpgXmlUrl(): string | null {
return externalEpgXmlUrl;
}
type CacheEntry<T> = { at: number; data: T };
const LIST_ACTIONS = new Set([
@ -315,47 +346,60 @@ export class XtreamClient {
}
/**
* Bulk EPG via xmltv.php — één download voor alle zenders (veel betrouwbaarder
* dan honderden get_short_epg-calls).
* Bulk EPG via xmltv.php (+ optionele externe rijke feed).
* Geeft now/next per channel id (dunne wrapper om guides).
*/
async getXmltvNowNextByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvNowNext>> {
const cacheKey = "xmltv:now-next";
const cached = this.cacheGet<Map<string, XmltvNowNext>>(cacheKey, ttlMs);
const guides = await this.getXmltvGuidesByChannelId(ttlMs);
const map = new Map<string, XmltvNowNext>();
for (const [id, g] of guides) {
map.set(id, { now: g.now, next: g.next });
}
return map;
}
/**
* Rijke gids per XMLTV-channel id: now/next + 24u schedule.
* Extern IPTV_EPG_XML_URL wint op image/desc; XUI vult ontbrekende zenders.
*/
async getXmltvGuidesByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvChannelGuide>> {
const cacheKey = "xmltv:guides";
const cached = this.cacheGet<Map<string, XmltvChannelGuide>>(cacheKey, ttlMs);
if (cached) return cached;
const user = encodeURIComponent(this.username);
const pass = encodeURIComponent(this.password);
const urls = [
const xuiUrls = [
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}`,
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`,
];
let xml = "";
let lastErr: unknown;
for (const url of urls) {
try {
const res = await fetch(url, {
headers: {
"User-Agent": "MediaCluster/1.0",
Accept: "application/xml,text/xml,*/*",
"Accept-Encoding": "gzip, deflate",
},
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`);
xml = await res.text();
if (xml.includes("<programme") || xml.includes("<tv")) break;
xml = "";
} catch (err) {
lastErr = err;
}
}
if (!xml) {
throw lastErr instanceof Error ? lastErr : new Error("XMLTV niet beschikbaar");
const [externalXml, xuiXml] = await Promise.all([
externalEpgXmlUrl
? fetchXmltvText([externalEpgXmlUrl]).catch((err) => {
console.warn("[iptv-epg] external xmltv:", err);
return "";
})
: Promise.resolve(""),
fetchXmltvText(xuiUrls).catch((err) => {
console.warn("[iptv-epg] xui xmltv:", err);
return "";
}),
]);
if (!externalXml && !xuiXml) {
throw new Error("XMLTV niet beschikbaar");
}
const map = parseXmltvNowNext(xml);
const external = externalXml ? parseXmltvGuides(externalXml) : new Map<string, XmltvChannelGuide>();
const xui = xuiXml ? parseXmltvGuides(xuiXml) : new Map<string, XmltvChannelGuide>();
const map = mergeXmltvGuides(xui, external);
this.cacheSet(cacheKey, map);
// Houd thin now/next-cache synchroon
const thin = new Map<string, XmltvNowNext>();
for (const [id, g] of map) thin.set(id, { now: g.now, next: g.next });
this.cacheSet("xmltv:now-next", thin);
return map;
}
@ -623,16 +667,27 @@ export function epgToIso(listing: XtreamEpgListing, which: "start" | "end"): str
}
/**
* Lightweight XMLTV parser → now/next per channel id.
* Vermijdt zware XML-deps; werkt op typische Xtream xmltv.php output.
* Lightweight XMLTV parser → rijke gids per channel id (now/next + 24u).
* Vermijdt zware XML-deps; werkt op Odido / typische Xtream xmltv.php output.
*/
export function parseXmltvNowNext(xml: string): Map<string, XmltvNowNext> {
export function parseXmltvGuides(xml: string): Map<string, XmltvChannelGuide> {
const now = Date.now();
type Prog = { start: number; end: number; title: string };
const byChannel = new Map<string, Prog[]>();
const windowStart = now - 2 * 60 * 60_000;
const windowEnd = now + 24 * 60 * 60_000;
const progRe =
/<programme\b([^>]*)>([\s\S]*?)<\/programme>/gi;
type Raw = {
start: number;
end: number;
title: string;
subTitle: string | null;
description: string | null;
imageUrl: string | null;
category: string | null;
episode: string | null;
};
const byChannel = new Map<string, Raw[]>();
const progRe = /<programme\b([^>]*)>([\s\S]*?)<\/programme>/gi;
let match: RegExpExecArray | null;
while ((match = progRe.exec(xml)) !== null) {
const attrs = match[1];
@ -645,42 +700,175 @@ export function parseXmltvNowNext(xml: string): Map<string, XmltvNowNext> {
if (start == null) continue;
const end = stopRaw ? parseXmltvTimeMs(stopRaw) : start + 30 * 60_000;
if (end == null) continue;
// Alleen programma's rond nu bewaren (geheugen)
if (end < now - 2 * 60 * 60_000 || start > now + 6 * 60 * 60_000) continue;
// Venster: nog bezig of start binnen 24u
if (end < windowStart || start > windowEnd) continue;
const title = firstXmlTagText(body, "title") || "Programma";
const subTitle = firstXmlTagText(body, "sub-title");
const description = firstXmlTagText(body, "desc");
const category = firstXmlTagText(body, "category");
const imageUrl = firstXmlIconSrc(body);
const episode = formatXmltvEpisode(body);
const titleTag =
body.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ??
body.match(/<title>([\s\S]*?)<\/title>/i)?.[1] ??
"";
const title = decodeEpgText(titleTag.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")) || "Programma";
const list = byChannel.get(channel) ?? [];
list.push({ start, end, title });
list.push({
start,
end,
title,
subTitle: subTitle || null,
description: description || null,
imageUrl: imageUrl || null,
category: category || null,
episode,
});
byChannel.set(channel, list);
}
const result = new Map<string, XmltvNowNext>();
const result = new Map<string, XmltvChannelGuide>();
for (const [channel, progs] of byChannel) {
progs.sort((a, b) => a.start - b.start);
let nowProg: XmltvNowNext["now"] = null;
let nextProg: XmltvNowNext["next"] = null;
for (const p of progs) {
if (p.start <= now && now < p.end) {
nowProg = {
title: p.title,
start: new Date(p.start).toISOString(),
end: new Date(p.end).toISOString(),
};
} else if (p.start > now && !nextProg) {
nextProg = {
title: p.title,
start: new Date(p.start).toISOString(),
end: new Date(p.end).toISOString(),
};
const schedule = progs.map(rawToXmltvProgram);
let nowProg: XmltvProgram | null = null;
let nextProg: XmltvProgram | null = null;
for (const p of schedule) {
const startMs = Date.parse(p.start);
const endMs = p.end ? Date.parse(p.end) : startMs + 30 * 60_000;
if (startMs <= now && now < endMs) {
nowProg = p;
} else if (startMs > now && !nextProg) {
nextProg = p;
}
}
if (nowProg || nextProg) {
result.set(channel, { now: nowProg, next: nextProg });
}
result.set(channel, { now: nowProg, next: nextProg, schedule });
}
return result;
}
/** Thin now/next (backward compat). */
export function parseXmltvNowNext(xml: string): Map<string, XmltvNowNext> {
const guides = parseXmltvGuides(xml);
const map = new Map<string, XmltvNowNext>();
for (const [id, g] of guides) {
map.set(id, { now: g.now, next: g.next });
}
return map;
}
function rawToXmltvProgram(p: {
start: number;
end: number;
title: string;
subTitle: string | null;
description: string | null;
imageUrl: string | null;
category: string | null;
episode: string | null;
}): XmltvProgram {
return {
title: p.title,
subTitle: p.subTitle,
description: p.description,
imageUrl: p.imageUrl,
category: p.category,
episode: p.episode,
start: new Date(p.start).toISOString(),
end: new Date(p.end).toISOString(),
};
}
function firstXmlTagText(body: string, tag: string): string {
const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i");
const m = body.match(re);
if (!m) return "";
const inner = m[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
return decodeEpgText(inner);
}
function firstXmlIconSrc(body: string): string {
const m =
body.match(/<icon\b[^>]*\bsrc="([^"]+)"/i) ||
body.match(/<icon\b[^>]*\bsrc='([^']+)'/i);
const url = m?.[1]?.trim() ?? "";
return /^https?:\/\//i.test(url) ? url : "";
}
/** xmltv_ns is 0-based season.episode — toon als S01E02. Prefer onscreen. */
export function formatXmltvEpisode(body: string): string | null {
const onscreen = body.match(
/<episode-num\b[^>]*system="onscreen"[^>]*>([\s\S]*?)<\/episode-num>/i
);
if (onscreen) {
const t = decodeEpgText(onscreen[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1"));
if (t) return t;
}
const ns = body.match(
/<episode-num\b[^>]*system="xmltv_ns"[^>]*>([\s\S]*?)<\/episode-num>/i
);
if (ns) {
const raw = decodeEpgText(ns[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1"));
const m = raw.match(/^(\d+)\.(\d+)/);
if (m) {
const season = Number(m[1]) + 1;
const episode = Number(m[2]) + 1;
if (Number.isFinite(season) && Number.isFinite(episode)) {
return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`;
}
}
}
return null;
}
/** Prefer richer (image/desc) over thinner for same channel id. */
export function mergeXmltvGuides(
base: Map<string, XmltvChannelGuide>,
richer: Map<string, XmltvChannelGuide>
): Map<string, XmltvChannelGuide> {
const out = new Map(base);
for (const [id, guide] of richer) {
const prev = out.get(id);
if (!prev) {
out.set(id, guide);
continue;
}
// Rijkere feed wint als die images of beschrijvingen heeft, anders merge velden
const richerScore =
(guide.now?.imageUrl ? 2 : 0) +
(guide.now?.description ? 1 : 0) +
(guide.schedule.some((p) => p.imageUrl) ? 2 : 0);
const prevScore =
(prev.now?.imageUrl ? 2 : 0) +
(prev.now?.description ? 1 : 0) +
(prev.schedule.some((p) => p.imageUrl) ? 2 : 0);
if (richerScore >= prevScore) {
out.set(id, guide);
}
}
// Case-insensitive alias keys
for (const [id, guide] of [...out.entries()]) {
const lower = id.toLowerCase();
if (lower !== id && !out.has(lower)) out.set(lower, guide);
}
return out;
}
async function fetchXmltvText(urls: string[]): Promise<string> {
let lastErr: unknown;
for (const url of urls) {
try {
const res = await fetch(url, {
headers: {
"User-Agent": "MediaCluster/1.0",
Accept: "application/xml,text/xml,*/*",
"Accept-Encoding": "gzip, deflate",
},
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`);
const xml = await res.text();
if (xml.includes("<programme") || xml.includes("<tv")) return xml;
} catch (err) {
lastErr = err;
}
}
throw lastErr instanceof Error ? lastErr : new Error("XMLTV niet beschikbaar");
}

View file

@ -5,10 +5,13 @@ import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
import {
decodeEpgText,
epgToIso,
setExternalEpgXmlUrl,
type XtreamClient,
type XtreamEpgListing,
type XtreamLiveStream,
type XmltvChannelGuide,
type XmltvNowNext,
type XmltvProgram,
} from "../iptv/xtream";
const AGGREGATE_LABELS = new Set([
@ -148,6 +151,8 @@ export async function getViewerIptvChannels(
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
let streams =
!categoryId || categoryId === "__all__"
? await xt.client.getLiveStreams()
@ -164,6 +169,7 @@ export async function getViewerIptvChannels(
return {
channels: streams.map((s) => {
const pair = epgTitles?.get(s.stream_id);
const cached = getCachedStreamEpg(xt.cacheKey, s.stream_id);
return {
streamId: s.stream_id,
name: s.name.trim(),
@ -172,6 +178,7 @@ export async function getViewerIptvChannels(
categoryId: s.category_id ? String(s.category_id) : null,
nowTitle: pair?.now ?? null,
nextTitle: pair?.next ?? null,
nowImageUrl: cached?.now?.imageUrl ?? null,
};
}),
};
@ -230,6 +237,8 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
const streams = await xt.client.getLiveStreams();
const ch = streams.find((s) => s.stream_id === streamId);
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
@ -241,30 +250,35 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
.catch(() => undefined);
const cached = getCachedStreamEpg(xt.cacheKey, streamId);
if (cached && (cached.now || cached.next)) {
if (cached && (cached.now || cached.next || cached.schedule.length > 0)) {
return {
streamId,
channelName: ch.name,
logoUrl: ch.stream_icon || null,
now: cached.now,
next: cached.next,
schedule: cached.schedule,
};
}
// Probeer eerst XMLTV (bulk, al warm) via epg_channel_id
// Probeer eerst rijke XMLTV (extern Odido + XUI) via epg_channel_id
const epgId = ch.epg_channel_id?.trim();
if (epgId) {
try {
const xmlMap = await xt.client.getXmltvNowNextByChannelId();
const hit = xmlMap.get(epgId) ?? xmlMap.get(epgId.toLowerCase());
if (hit && (hit.now || hit.next)) {
setCachedStreamEpg(xt.cacheKey, streamId, hit.now, hit.next);
const guides = await xt.client.getXmltvGuidesByChannelId();
const hit =
guides.get(epgId) ??
guides.get(epgId.toLowerCase()) ??
guides.get(epgId.toUpperCase());
if (hit && (hit.now || hit.next || hit.schedule.length > 0)) {
setCachedStreamEpg(xt.cacheKey, streamId, hit.now, hit.next, hit.schedule);
return {
streamId,
channelName: ch.name,
logoUrl: ch.stream_icon || null,
now: hit.now,
next: hit.next,
schedule: hit.schedule,
};
}
} catch {
@ -272,9 +286,9 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
}
}
const epg = await xt.client.getShortEpg(streamId, 12);
const { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg);
setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram);
const epg = await xt.client.getShortEpg(streamId, 48);
const { now: nowProgram, next: nextProgram, schedule } = parseRichShortEpg(epg);
setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram, schedule);
return {
streamId,
@ -282,6 +296,7 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
logoUrl: ch.stream_icon || null,
now: nowProgram,
next: nextProgram,
schedule,
};
}
@ -302,8 +317,9 @@ const xmltvLineRefreshing = new Set<string>();
type StreamEpgCacheEntry = {
at: number;
now: { title: string; start: string; end: string | null } | null;
next: { title: string; start: string; end: string | null } | null;
now: XmltvProgram | null;
next: XmltvProgram | null;
schedule: XmltvProgram[];
};
const epgStreamCache = new Map<string, StreamEpgCacheEntry>();
@ -325,51 +341,86 @@ function getCachedStreamEpg(tokenId: string, streamId: number): StreamEpgCacheEn
function setCachedStreamEpg(
tokenId: string,
streamId: number,
now: StreamEpgCacheEntry["now"],
next: StreamEpgCacheEntry["next"]
now: XmltvProgram | null,
next: XmltvProgram | null,
schedule: XmltvProgram[] = []
) {
epgStreamCache.set(streamEpgKey(tokenId, streamId), { at: Date.now(), now, next });
epgStreamCache.set(streamEpgKey(tokenId, streamId), {
at: Date.now(),
now,
next,
schedule,
});
}
function isNowPlayingFlag(v: XtreamEpgListing["now_playing"]): boolean {
return v === 1 || v === true || v === "1";
}
function listingToProgram(listing: XtreamEpgListing): XmltvProgram | null {
const start = epgToIso(listing, "start");
if (!start) return null;
const end = epgToIso(listing, "end") ?? null;
const title = decodeEpgText(listing.title) || "Programma";
const description = decodeEpgText(listing.description) || null;
return {
title,
subTitle: null,
description: description || null,
imageUrl: null,
category: null,
episode: null,
start,
end,
};
}
export function parseNowNextPrograms(epg: XtreamEpgListing[]): {
now: { title: string; start: string; end: string | null } | null;
next: { title: string; start: string; end: string | null } | null;
now: XmltvProgram | null;
next: XmltvProgram | null;
} {
const now = Date.now();
type Prog = { title: string; start: string; end: string | null };
let nowProgram: Prog | null = null;
let nextProgram: Prog | null = null;
let flaggedNow: Prog | null = null;
const { now, next } = parseRichShortEpg(epg);
return { now, next };
}
export function parseRichShortEpg(epg: XtreamEpgListing[]): {
now: XmltvProgram | null;
next: XmltvProgram | null;
schedule: XmltvProgram[];
} {
const nowMs = Date.now();
const windowEnd = nowMs + 24 * 60 * 60_000;
let nowProgram: XmltvProgram | null = null;
let nextProgram: XmltvProgram | null = null;
let flaggedNow: XmltvProgram | null = null;
const schedule: XmltvProgram[] = [];
for (const listing of epg) {
const start = epgToIso(listing, "start");
const end = epgToIso(listing, "end");
const title = decodeEpgText(listing.title) || "Programma";
if (isNowPlayingFlag(listing.now_playing) && start) {
flaggedNow = { title, start, end: end ?? null };
}
if (!start) continue;
const startMs = Date.parse(start);
const endMs = end ? Date.parse(end) : startMs + 30 * 60_000;
const prog = listingToProgram(listing);
if (!prog) continue;
const startMs = Date.parse(prog.start);
const endMs = prog.end ? Date.parse(prog.end) : startMs + 30 * 60_000;
if (!Number.isFinite(startMs)) continue;
if (startMs <= now && now < endMs) {
nowProgram = { title, start, end: end ?? null };
} else if (startMs > now && !nextProgram) {
nextProgram = { title, start, end: end ?? null };
if (isNowPlayingFlag(listing.now_playing)) {
flaggedNow = prog;
}
if (endMs >= nowMs - 30 * 60_000 && startMs <= windowEnd) {
schedule.push(prog);
}
if (startMs <= nowMs && nowMs < endMs) {
nowProgram = prog;
} else if (startMs > nowMs && !nextProgram) {
nextProgram = prog;
}
}
// Tijd-match heeft voorrang; now_playing als fallback als tijd mist/scheef zit
if (!nowProgram && flaggedNow) nowProgram = flaggedNow;
schedule.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
return { now: nowProgram, next: nextProgram };
return { now: nowProgram, next: nextProgram, schedule };
}
function peekCategoryEpgTitles(
@ -413,7 +464,7 @@ function mergeTitles(
function titlesFromXmltv(
streams: XtreamLiveStream[],
xmlMap: Map<string, XmltvNowNext>,
xmlMap: Map<string, XmltvNowNext | XmltvChannelGuide>,
tokenId: string
): Map<number, EpgTitlePair> {
const titles = new Map<number, EpgTitlePair>();
@ -429,8 +480,10 @@ function titlesFromXmltv(
now: hit.now?.title ?? null,
next: hit.next?.title ?? null,
});
if (hit.now || hit.next) {
setCachedStreamEpg(tokenId, s.stream_id, hit.now, hit.next);
if (hit.now || hit.next || ("schedule" in hit && Array.isArray(hit.schedule) && hit.schedule.length > 0)) {
const schedule: XmltvProgram[] =
"schedule" in hit && Array.isArray(hit.schedule) ? (hit.schedule as XmltvProgram[]) : [];
setCachedStreamEpg(tokenId, s.stream_id, hit.now, hit.next, schedule);
}
}
return titles;
@ -460,11 +513,11 @@ async function resolveCategoryEpgTitles(
// Incomplete of cold: XMLTV bulk laden (hoofdbron)
let titles = new Map<number, EpgTitlePair>(cached?.titles ?? []);
const applyXmltvMap = (xmlMap: Map<string, XmltvNowNext>) => {
const applyXmltvMap = (xmlMap: Map<string, XmltvNowNext | XmltvChannelGuide>) => {
titles = mergeTitles(titles, titlesFromXmltv(streams, xmlMap, tokenId));
};
const xmlPromise = client.getXmltvNowNextByChannelId();
const xmlPromise = client.getXmltvGuidesByChannelId();
if (opts.waitForXmltv) {
// Max ~8s wachten zodat reverse-proxy niet timeout; rest via client-poll
const raced = await Promise.race([
@ -605,7 +658,7 @@ async function fetchNowNextTitlesParallel(
next: next?.title ?? null,
});
if (tokenId) {
setCachedStreamEpg(tokenId, streamId, now, next);
setCachedStreamEpg(tokenId, streamId, now, next, []);
}
} catch {
// zender zonder EPG overslaan
@ -622,9 +675,11 @@ async function fetchNowNextTitlesParallel(
*/
export function startIptvEpgWarmup(config: Config) {
const INTERVAL_MS = 15 * 60_000;
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
const tick = async () => {
try {
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
const lines = await prisma.iptvLine.findMany({
where: {
enableLive: true,
@ -647,9 +702,9 @@ export function startIptvEpgWarmup(config: Config) {
const cacheKey = xt?.cacheKey ?? line.id;
if (!xt?.line.enableLive) continue;
// Primair: hele XMLTV warmen (ttl=0 forceert refresh)
// Primair: hele rijke XMLTV warmen (ttl=0 forceert refresh)
try {
await xt.client.getXmltvNowNextByChannelId(0);
await xt.client.getXmltvGuidesByChannelId(0);
} catch (err) {
console.warn("[iptv-epg-warmup] xmltv", err);
}

View file

@ -59,6 +59,8 @@ services:
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}
NODE_OFFLINE_THRESHOLD_SECONDS: ${NODE_OFFLINE_THRESHOLD_SECONDS:-90}
HIDE_UNAVAILABLE_TITLES: ${HIDE_UNAVAILABLE_TITLES:-true}
# Rijke EPG (programma-images/desc) — los van XUI short_epg
IPTV_EPG_XML_URL: ${IPTV_EPG_XML_URL:-https://epg.faabserver.nl/epg/odido.xml}
depends_on:
postgres:
condition: service_healthy