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:
parent
1cc6fedd95
commit
18bcf43c14
9 changed files with 635 additions and 210 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue