diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index cb3cb62..82b73b2 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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 = emptyList(), ) private fun JSONArray?.toItems(): List { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 3fa1984..5e0b52c 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -501,36 +501,62 @@ fun LiveTvPlayerScreen( } epg?.let { info -> - Column( + Row( Modifier .align(Alignment.BottomStart) .padding(32.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Bottom, ) { - info.now?.let { now -> - Text( - "Nu · ${now.title}", - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + 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, ) } - 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( - "Straks · ${next.title}", - color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + "↑↓ zappen · ← zenderlijst · OK info", + color = Color(0xFF888888), + fontSize = 12.sp, ) } - Spacer(Modifier.height(8.dp)) - Text( - "↑↓ zappen · ← zenderlijst · OK info", - color = Color(0xFF888888), - fontSize = 12.sp, - ) } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 04d6f4c..c482798 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -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,67 +725,82 @@ private fun ChannelDetailPanel( overflow = TextOverflow.Ellipsis, ) } - } - - Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) - - epg.now?.let { now -> - EpgBlock(label = "Nu", program = now, highlight = true) - val progress = epgProgress(now) - Box( - Modifier - .fillMaxWidth() - .height(3.dp) - .clip(RoundedCornerShape(1.dp)) - .background(Color(0xFF333333)), + 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, + ), ) { - Box( - Modifier - .fillMaxWidth(progress.coerceAtLeast(0.02f)) - .height(3.dp) - .background(McColors.Brand), + Text( + if (isFavorite) "★" else "☆", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA), + fontSize = 14.sp, ) } } - epg.next?.let { next -> - EpgBlock(label = "Straks", program = next, highlight = false) - } + Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) - 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( "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,38 +940,16 @@ private fun EpgSkeleton() { } } Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) - 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))) - 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) + 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.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))) } } - 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, - ) + Spacer(Modifier.weight(1f)) + Text("Programma laden…", color = Color(0xFF555555), fontSize = 12.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 { 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 diff --git a/apps/master-api/.env.example b/apps/master-api/.env.example index ed11d43..ba92247 100644 --- a/apps/master-api/.env.example +++ b/apps/master-api/.env.example @@ -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= diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 584dab9..752fa46 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -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: { diff --git a/apps/master-api/src/config.ts b/apps/master-api/src/config.ts index c2c0727..dc74b66 100644 --- a/apps/master-api/src/config.ts +++ b/apps/master-api/src/config.ts @@ -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; diff --git a/apps/master-api/src/iptv/xtream.ts b/apps/master-api/src/iptv/xtream.ts index 998c89a..d19b451 100644 --- a/apps/master-api/src/iptv/xtream.ts +++ b/apps/master-api/src/iptv/xtream.ts @@ -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 = { 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> { - const cacheKey = "xmltv:now-next"; - const cached = this.cacheGet>(cacheKey, ttlMs); + const guides = await this.getXmltvGuidesByChannelId(ttlMs); + const map = new Map(); + 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> { + const cacheKey = "xmltv:guides"; + const cached = this.cacheGet>(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(" { + 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(); + const xui = xuiXml ? parseXmltvGuides(xuiXml) : new Map(); + const map = mergeXmltvGuides(xui, external); + this.cacheSet(cacheKey, map); + // Houd thin now/next-cache synchroon + const thin = new Map(); + 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 { +export function parseXmltvGuides(xml: string): Map { const now = Date.now(); - type Prog = { start: number; end: number; title: string }; - const byChannel = new Map(); + const windowStart = now - 2 * 60 * 60_000; + const windowEnd = now + 24 * 60 * 60_000; - const progRe = - /]*)>([\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(); + + const progRe = /]*)>([\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 { 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(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? - body.match(/([\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"); +} diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index 9e2612a..c849a0f 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -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); } diff --git a/deploy/docker/docker-compose.dokploy.yml b/deploy/docker/docker-compose.dokploy.yml index 1d6b172..10a6b33 100644 --- a/deploy/docker/docker-compose.dokploy.yml +++ b/deploy/docker/docker-compose.dokploy.yml @@ -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