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 }, categoryId = c.optString("categoryId").ifBlank { null },
nowTitle = c.optString("nowTitle").ifBlank { null }, nowTitle = c.optString("nowTitle").ifBlank { null },
nextTitle = c.optString("nextTitle").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) { suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build() val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build()
parseObject(execute(req)) { o -> 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( IptvEpg(
streamId = o.getInt("streamId"), streamId = o.getInt("streamId"),
channelName = o.getString("channelName"), channelName = o.getString("channelName"),
logoUrl = o.optString("logoUrl").ifBlank { null }, logoUrl = o.optString("logoUrl").ifBlank { null },
now = o.optJSONObject("now")?.toEpgProgram(), now = o.optJSONObject("now")?.toEpgProgram(),
next = o.optJSONObject("next")?.toEpgProgram(), next = o.optJSONObject("next")?.toEpgProgram(),
schedule = schedule,
) )
} }
} }
@ -596,6 +606,11 @@ class ApiClient(
val title = optString("title").ifBlank { return null } val title = optString("title").ifBlank { return null }
return EpgProgram( return EpgProgram(
title = title, 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 }, start = optString("start").ifBlank { null },
end = optString("end").ifBlank { null }, end = optString("end").ifBlank { null },
) )
@ -794,6 +809,7 @@ data class IptvChannel(
val categoryId: String? = null, val categoryId: String? = null,
val nowTitle: String? = null, val nowTitle: String? = null,
val nextTitle: String? = null, val nextTitle: String? = null,
val nowImageUrl: String? = null,
) )
data class IptvPlayInfo( data class IptvPlayInfo(
@ -808,6 +824,11 @@ data class IptvPlayInfo(
data class EpgProgram( data class EpgProgram(
val title: String, 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 start: String? = null,
val end: String? = null, val end: String? = null,
) )
@ -818,6 +839,7 @@ data class IptvEpg(
val logoUrl: String? = null, val logoUrl: String? = null,
val now: EpgProgram? = null, val now: EpgProgram? = null,
val next: EpgProgram? = null, val next: EpgProgram? = null,
val schedule: List<EpgProgram> = emptyList(),
) )
private fun JSONArray?.toItems(): List<MediaItem> { private fun JSONArray?.toItems(): List<MediaItem> {

View file

@ -501,36 +501,62 @@ fun LiveTvPlayerScreen(
} }
epg?.let { info -> epg?.let { info ->
Column( Row(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(32.dp), .padding(32.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Bottom,
) { ) {
info.now?.let { now -> val art = info.now?.imageUrl
Text( if (!art.isNullOrBlank()) {
"Nu · ${now.title}", AsyncImage(
color = Color.White, model = art,
fontSize = 18.sp, contentDescription = null,
fontWeight = FontWeight.Medium, modifier = Modifier
maxLines = 1, .width(140.dp)
overflow = TextOverflow.Ellipsis, .height(84.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0x44000000)),
contentScale = ContentScale.Crop,
) )
} }
info.next?.let { next -> Column {
info.now?.let { now ->
Text(
"Nu · ${now.title}",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
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(
"Straks · ${next.title}",
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(8.dp))
Text( Text(
"Straks · ${next.title}", "↑↓ zappen · ← zenderlijst · OK info",
color = McColors.TextDim, color = Color(0xFF888888),
fontSize = 15.sp, fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
Spacer(Modifier.height(8.dp))
Text(
"↑↓ zappen · ← zenderlijst · OK info",
color = Color(0xFF888888),
fontSize = 12.sp,
)
} }
} }
} }

View file

@ -671,12 +671,13 @@ private fun ChannelDetailPanel(
onToggleFavorite: () -> Unit, onToggleFavorite: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheduleState = rememberLazyListState()
Column( Column(
modifier modifier
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(TvPanelBg) .background(TvPanelBg)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
when { when {
epg == null && loading -> { epg == null && loading -> {
@ -708,7 +709,7 @@ private fun ChannelDetailPanel(
model = epg.logoUrl, model = epg.logoUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(44.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)), .background(Color(0xFF0A0A0A)),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
@ -724,67 +725,82 @@ private fun ChannelDetailPanel(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
} Surface(
onClick = onToggleFavorite,
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
epg.now?.let { now -> colors = ClickableSurfaceDefaults.colors(
EpgBlock(label = "Nu", program = now, highlight = true) containerColor = Color(0xFF1A1A1A),
val progress = epgProgress(now) focusedContainerColor = Color(0xFF2A2A2A),
Box( contentColor = Color.White,
Modifier focusedContentColor = Color.White,
.fillMaxWidth() ),
.height(3.dp)
.clip(RoundedCornerShape(1.dp))
.background(Color(0xFF333333)),
) { ) {
Box( Text(
Modifier if (isFavorite) "★" else "☆",
.fillMaxWidth(progress.coerceAtLeast(0.02f)) modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
.height(3.dp) color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA),
.background(McColors.Brand), fontSize = 14.sp,
) )
} }
} }
epg.next?.let { next -> Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
EpgBlock(label = "Straks", program = next, highlight = false)
}
if (epg.now == null && epg.next == null) { 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()
.height(3.dp)
.clip(RoundedCornerShape(1.dp))
.background(Color(0xFF333333)),
) {
Box(
Modifier
.fillMaxWidth(progress.coerceAtLeast(0.02f))
.height(3.dp)
.background(McColors.Brand),
)
}
}
} else {
Text( Text(
"Geen programmagids voor deze zender", "Geen programmagids voor deze zender",
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 13.sp, 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)) val guide = remember(epg.schedule, epg.now, epg.next) {
when {
Surface( epg.schedule.isNotEmpty() -> epg.schedule
onClick = onToggleFavorite, else -> listOfNotNull(epg.now, epg.next)
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), }
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), }
colors = ClickableSurfaceDefaults.colors( if (guide.isNotEmpty()) {
containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF2A2A2A),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Text( Text(
if (isFavorite) "★ Favoriet" else "☆ Favoriet", "Komende 24 uur",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), color = McColors.TextDim,
color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA), fontSize = 11.sp,
fontSize = 13.sp, fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium, 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) 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 @Composable
private fun EpgSkeleton() { private fun EpgSkeleton() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@ -804,38 +940,16 @@ private fun EpgSkeleton() {
} }
} }
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
Box(Modifier.width(40.dp).height(10.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A))) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.fillMaxWidth(0.85f).height(16.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A))) Box(Modifier.width(112.dp).height(70.dp).clip(RoundedCornerShape(4.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(1.dp)).background(Color(0xFF222222))) Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Spacer(Modifier.weight(1f)) Box(Modifier.width(40.dp).height(10.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Text("Programma laden…", color = Color(0xFF555555), fontSize = 12.sp) 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)))
}
@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( Spacer(Modifier.weight(1f))
program.title, Text("Programma laden…", color = Color(0xFF555555), fontSize = 12.sp)
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,
)
} }
} }
@ -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 { private fun epgProgress(program: EpgProgram): Float {
val start = program.start?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f 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 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 NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=true 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 Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
# GOOGLE_CLOUD_PROJECT_ID= # GOOGLE_CLOUD_PROJECT_ID=
# GOOGLE_SERVICE_ACCOUNT_EMAIL= # GOOGLE_SERVICE_ACCOUNT_EMAIL=

View file

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

View file

@ -33,6 +33,14 @@ const configSchema = z.object({
GOOGLE_PLAY_OPT_IN_URL: z.string().url().optional(), GOOGLE_PLAY_OPT_IN_URL: z.string().url().optional(),
/** Periodic reconciliation interval (ms); 0 = disabled. Default: 24h */ /** Periodic reconciliation interval (ms); 0 = disabled. Default: 24h */
GOOGLE_PLAY_SYNC_INTERVAL_MS: z.coerce.number().default(86_400_000), 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>; export type Config = z.infer<typeof configSchema>;

View file

@ -73,11 +73,42 @@ export type XtreamEpgListing = {
now_playing?: number | boolean | string; now_playing?: number | boolean | string;
}; };
export type XmltvNowNext = { /** Rijk programma uit XMLTV (Odido / XUI xmltv.php). */
now: { title: string; start: string; end: string | null } | null; export type XmltvProgram = {
next: { title: string; start: string; end: string | null } | null; 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 }; type CacheEntry<T> = { at: number; data: T };
const LIST_ACTIONS = new Set([ const LIST_ACTIONS = new Set([
@ -315,47 +346,60 @@ export class XtreamClient {
} }
/** /**
* Bulk EPG via xmltv.php — één download voor alle zenders (veel betrouwbaarder * Bulk EPG via xmltv.php (+ optionele externe rijke feed).
* dan honderden get_short_epg-calls). * Geeft now/next per channel id (dunne wrapper om guides).
*/ */
async getXmltvNowNextByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvNowNext>> { async getXmltvNowNextByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvNowNext>> {
const cacheKey = "xmltv:now-next"; const guides = await this.getXmltvGuidesByChannelId(ttlMs);
const cached = this.cacheGet<Map<string, XmltvNowNext>>(cacheKey, 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; if (cached) return cached;
const user = encodeURIComponent(this.username); const user = encodeURIComponent(this.username);
const pass = encodeURIComponent(this.password); 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}`,
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`, `${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`,
]; ];
let xml = ""; const [externalXml, xuiXml] = await Promise.all([
let lastErr: unknown; externalEpgXmlUrl
for (const url of urls) { ? fetchXmltvText([externalEpgXmlUrl]).catch((err) => {
try { console.warn("[iptv-epg] external xmltv:", err);
const res = await fetch(url, { return "";
headers: { })
"User-Agent": "MediaCluster/1.0", : Promise.resolve(""),
Accept: "application/xml,text/xml,*/*", fetchXmltvText(xuiUrls).catch((err) => {
"Accept-Encoding": "gzip, deflate", console.warn("[iptv-epg] xui xmltv:", err);
}, return "";
signal: AbortSignal.timeout(120_000), }),
}); ]);
if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`);
xml = await res.text(); if (!externalXml && !xuiXml) {
if (xml.includes("<programme") || xml.includes("<tv")) break; throw new Error("XMLTV niet beschikbaar");
xml = "";
} catch (err) {
lastErr = err;
}
}
if (!xml) {
throw lastErr instanceof Error ? lastErr : 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); 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; return map;
} }
@ -623,16 +667,27 @@ export function epgToIso(listing: XtreamEpgListing, which: "start" | "end"): str
} }
/** /**
* Lightweight XMLTV parser → now/next per channel id. * Lightweight XMLTV parser → rijke gids per channel id (now/next + 24u).
* Vermijdt zware XML-deps; werkt op typische Xtream xmltv.php output. * 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(); const now = Date.now();
type Prog = { start: number; end: number; title: string }; const windowStart = now - 2 * 60 * 60_000;
const byChannel = new Map<string, Prog[]>(); const windowEnd = now + 24 * 60 * 60_000;
const progRe = type Raw = {
/<programme\b([^>]*)>([\s\S]*?)<\/programme>/gi; 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; let match: RegExpExecArray | null;
while ((match = progRe.exec(xml)) !== null) { while ((match = progRe.exec(xml)) !== null) {
const attrs = match[1]; const attrs = match[1];
@ -645,42 +700,175 @@ export function parseXmltvNowNext(xml: string): Map<string, XmltvNowNext> {
if (start == null) continue; if (start == null) continue;
const end = stopRaw ? parseXmltvTimeMs(stopRaw) : start + 30 * 60_000; const end = stopRaw ? parseXmltvTimeMs(stopRaw) : start + 30 * 60_000;
if (end == null) continue; if (end == null) continue;
// Alleen programma's rond nu bewaren (geheugen) // Venster: nog bezig of start binnen 24u
if (end < now - 2 * 60 * 60_000 || start > now + 6 * 60 * 60_000) continue; 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) ?? []; 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); byChannel.set(channel, list);
} }
const result = new Map<string, XmltvNowNext>(); const result = new Map<string, XmltvChannelGuide>();
for (const [channel, progs] of byChannel) { for (const [channel, progs] of byChannel) {
progs.sort((a, b) => a.start - b.start); progs.sort((a, b) => a.start - b.start);
let nowProg: XmltvNowNext["now"] = null; const schedule = progs.map(rawToXmltvProgram);
let nextProg: XmltvNowNext["next"] = null; let nowProg: XmltvProgram | null = null;
for (const p of progs) { let nextProg: XmltvProgram | null = null;
if (p.start <= now && now < p.end) { for (const p of schedule) {
nowProg = { const startMs = Date.parse(p.start);
title: p.title, const endMs = p.end ? Date.parse(p.end) : startMs + 30 * 60_000;
start: new Date(p.start).toISOString(), if (startMs <= now && now < endMs) {
end: new Date(p.end).toISOString(), nowProg = p;
}; } else if (startMs > now && !nextProg) {
} else if (p.start > now && !nextProg) { nextProg = p;
nextProg = {
title: p.title,
start: new Date(p.start).toISOString(),
end: new Date(p.end).toISOString(),
};
} }
} }
if (nowProg || nextProg) { result.set(channel, { now: nowProg, next: nextProg, schedule });
result.set(channel, { now: nowProg, next: nextProg });
}
} }
return result; 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 { import {
decodeEpgText, decodeEpgText,
epgToIso, epgToIso,
setExternalEpgXmlUrl,
type XtreamClient, type XtreamClient,
type XtreamEpgListing, type XtreamEpgListing,
type XtreamLiveStream, type XtreamLiveStream,
type XmltvChannelGuide,
type XmltvNowNext, type XmltvNowNext,
type XmltvProgram,
} from "../iptv/xtream"; } from "../iptv/xtream";
const AGGREGATE_LABELS = new Set([ 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) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403); if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
let streams = let streams =
!categoryId || categoryId === "__all__" !categoryId || categoryId === "__all__"
? await xt.client.getLiveStreams() ? await xt.client.getLiveStreams()
@ -164,6 +169,7 @@ export async function getViewerIptvChannels(
return { return {
channels: streams.map((s) => { channels: streams.map((s) => {
const pair = epgTitles?.get(s.stream_id); const pair = epgTitles?.get(s.stream_id);
const cached = getCachedStreamEpg(xt.cacheKey, s.stream_id);
return { return {
streamId: s.stream_id, streamId: s.stream_id,
name: s.name.trim(), name: s.name.trim(),
@ -172,6 +178,7 @@ export async function getViewerIptvChannels(
categoryId: s.category_id ? String(s.category_id) : null, categoryId: s.category_id ? String(s.category_id) : null,
nowTitle: pair?.now ?? null, nowTitle: pair?.now ?? null,
nextTitle: pair?.next ?? 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) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 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 streams = await xt.client.getLiveStreams();
const ch = streams.find((s) => s.stream_id === streamId); const ch = streams.find((s) => s.stream_id === streamId);
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); 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); .catch(() => undefined);
const cached = getCachedStreamEpg(xt.cacheKey, streamId); const cached = getCachedStreamEpg(xt.cacheKey, streamId);
if (cached && (cached.now || cached.next)) { if (cached && (cached.now || cached.next || cached.schedule.length > 0)) {
return { return {
streamId, streamId,
channelName: ch.name, channelName: ch.name,
logoUrl: ch.stream_icon || null, logoUrl: ch.stream_icon || null,
now: cached.now, now: cached.now,
next: cached.next, 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(); const epgId = ch.epg_channel_id?.trim();
if (epgId) { if (epgId) {
try { try {
const xmlMap = await xt.client.getXmltvNowNextByChannelId(); const guides = await xt.client.getXmltvGuidesByChannelId();
const hit = xmlMap.get(epgId) ?? xmlMap.get(epgId.toLowerCase()); const hit =
if (hit && (hit.now || hit.next)) { guides.get(epgId) ??
setCachedStreamEpg(xt.cacheKey, streamId, hit.now, hit.next); 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 { return {
streamId, streamId,
channelName: ch.name, channelName: ch.name,
logoUrl: ch.stream_icon || null, logoUrl: ch.stream_icon || null,
now: hit.now, now: hit.now,
next: hit.next, next: hit.next,
schedule: hit.schedule,
}; };
} }
} catch { } catch {
@ -272,9 +286,9 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
} }
} }
const epg = await xt.client.getShortEpg(streamId, 12); const epg = await xt.client.getShortEpg(streamId, 48);
const { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg); const { now: nowProgram, next: nextProgram, schedule } = parseRichShortEpg(epg);
setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram); setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram, schedule);
return { return {
streamId, streamId,
@ -282,6 +296,7 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
logoUrl: ch.stream_icon || null, logoUrl: ch.stream_icon || null,
now: nowProgram, now: nowProgram,
next: nextProgram, next: nextProgram,
schedule,
}; };
} }
@ -302,8 +317,9 @@ const xmltvLineRefreshing = new Set<string>();
type StreamEpgCacheEntry = { type StreamEpgCacheEntry = {
at: number; at: number;
now: { title: string; start: string; end: string | null } | null; now: XmltvProgram | null;
next: { title: string; start: string; end: string | null } | null; next: XmltvProgram | null;
schedule: XmltvProgram[];
}; };
const epgStreamCache = new Map<string, StreamEpgCacheEntry>(); const epgStreamCache = new Map<string, StreamEpgCacheEntry>();
@ -325,51 +341,86 @@ function getCachedStreamEpg(tokenId: string, streamId: number): StreamEpgCacheEn
function setCachedStreamEpg( function setCachedStreamEpg(
tokenId: string, tokenId: string,
streamId: number, streamId: number,
now: StreamEpgCacheEntry["now"], now: XmltvProgram | null,
next: StreamEpgCacheEntry["next"] 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 { function isNowPlayingFlag(v: XtreamEpgListing["now_playing"]): boolean {
return v === 1 || v === true || v === "1"; 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[]): { export function parseNowNextPrograms(epg: XtreamEpgListing[]): {
now: { title: string; start: string; end: string | null } | null; now: XmltvProgram | null;
next: { title: string; start: string; end: string | null } | null; next: XmltvProgram | null;
} { } {
const now = Date.now(); const { now, next } = parseRichShortEpg(epg);
type Prog = { title: string; start: string; end: string | null }; return { now, next };
let nowProgram: Prog | null = null; }
let nextProgram: Prog | null = null;
let flaggedNow: Prog | null = null; 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) { for (const listing of epg) {
const start = epgToIso(listing, "start"); const prog = listingToProgram(listing);
const end = epgToIso(listing, "end"); if (!prog) continue;
const title = decodeEpgText(listing.title) || "Programma"; const startMs = Date.parse(prog.start);
const endMs = prog.end ? Date.parse(prog.end) : startMs + 30 * 60_000;
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;
if (!Number.isFinite(startMs)) continue; if (!Number.isFinite(startMs)) continue;
if (startMs <= now && now < endMs) { if (isNowPlayingFlag(listing.now_playing)) {
nowProgram = { title, start, end: end ?? null }; flaggedNow = prog;
} else if (startMs > now && !nextProgram) { }
nextProgram = { title, start, end: end ?? null };
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; 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( function peekCategoryEpgTitles(
@ -413,7 +464,7 @@ function mergeTitles(
function titlesFromXmltv( function titlesFromXmltv(
streams: XtreamLiveStream[], streams: XtreamLiveStream[],
xmlMap: Map<string, XmltvNowNext>, xmlMap: Map<string, XmltvNowNext | XmltvChannelGuide>,
tokenId: string tokenId: string
): Map<number, EpgTitlePair> { ): Map<number, EpgTitlePair> {
const titles = new Map<number, EpgTitlePair>(); const titles = new Map<number, EpgTitlePair>();
@ -429,8 +480,10 @@ function titlesFromXmltv(
now: hit.now?.title ?? null, now: hit.now?.title ?? null,
next: hit.next?.title ?? null, next: hit.next?.title ?? null,
}); });
if (hit.now || hit.next) { if (hit.now || hit.next || ("schedule" in hit && Array.isArray(hit.schedule) && hit.schedule.length > 0)) {
setCachedStreamEpg(tokenId, s.stream_id, hit.now, hit.next); 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; return titles;
@ -460,11 +513,11 @@ async function resolveCategoryEpgTitles(
// Incomplete of cold: XMLTV bulk laden (hoofdbron) // Incomplete of cold: XMLTV bulk laden (hoofdbron)
let titles = new Map<number, EpgTitlePair>(cached?.titles ?? []); 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)); titles = mergeTitles(titles, titlesFromXmltv(streams, xmlMap, tokenId));
}; };
const xmlPromise = client.getXmltvNowNextByChannelId(); const xmlPromise = client.getXmltvGuidesByChannelId();
if (opts.waitForXmltv) { if (opts.waitForXmltv) {
// Max ~8s wachten zodat reverse-proxy niet timeout; rest via client-poll // Max ~8s wachten zodat reverse-proxy niet timeout; rest via client-poll
const raced = await Promise.race([ const raced = await Promise.race([
@ -605,7 +658,7 @@ async function fetchNowNextTitlesParallel(
next: next?.title ?? null, next: next?.title ?? null,
}); });
if (tokenId) { if (tokenId) {
setCachedStreamEpg(tokenId, streamId, now, next); setCachedStreamEpg(tokenId, streamId, now, next, []);
} }
} catch { } catch {
// zender zonder EPG overslaan // zender zonder EPG overslaan
@ -622,9 +675,11 @@ async function fetchNowNextTitlesParallel(
*/ */
export function startIptvEpgWarmup(config: Config) { export function startIptvEpgWarmup(config: Config) {
const INTERVAL_MS = 15 * 60_000; const INTERVAL_MS = 15 * 60_000;
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
const tick = async () => { const tick = async () => {
try { try {
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
const lines = await prisma.iptvLine.findMany({ const lines = await prisma.iptvLine.findMany({
where: { where: {
enableLive: true, enableLive: true,
@ -647,9 +702,9 @@ export function startIptvEpgWarmup(config: Config) {
const cacheKey = xt?.cacheKey ?? line.id; const cacheKey = xt?.cacheKey ?? line.id;
if (!xt?.line.enableLive) continue; if (!xt?.line.enableLive) continue;
// Primair: hele XMLTV warmen (ttl=0 forceert refresh) // Primair: hele rijke XMLTV warmen (ttl=0 forceert refresh)
try { try {
await xt.client.getXmltvNowNextByChannelId(0); await xt.client.getXmltvGuidesByChannelId(0);
} catch (err) { } catch (err) {
console.warn("[iptv-epg-warmup] xmltv", 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_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}
NODE_OFFLINE_THRESHOLD_SECONDS: ${NODE_OFFLINE_THRESHOLD_SECONDS:-90} NODE_OFFLINE_THRESHOLD_SECONDS: ${NODE_OFFLINE_THRESHOLD_SECONDS:-90}
HIDE_UNAVAILABLE_TITLES: ${HIDE_UNAVAILABLE_TITLES:-true} 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy