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 },
|
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> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue