From 9377c9fa0e58aac2f3248ee5f330144102bb7bbf Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 01:50:33 +0200 Subject: [PATCH] Batch-fetch IPTV now-playing titles with server-side EPG cache. Add epg=1 to the channels API so the app loads program titles in one request per category instead of one call per channel. Co-authored-by: Cursor --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 10 ++- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 62 ++++--------- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 7 +- apps/master-api/src/viewer/iptv-client.ts | 88 +++++++++++++++++-- apps/master-api/src/viewer/routes.ts | 5 +- 6 files changed, 115 insertions(+), 61 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 5b66057..4b94386 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 38 - versionName = "0.11.6" + versionCode = 39 + versionName = "0.11.7" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 785d322..1046b39 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -283,8 +283,12 @@ class ApiClient( } } - suspend fun iptvChannels(categoryId: String? = null): List = withContext(Dispatchers.IO) { - val q = categoryId?.let { "?categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}" }.orEmpty() + suspend fun iptvChannels(categoryId: String? = null, includeEpg: Boolean = false): List = withContext(Dispatchers.IO) { + val params = buildList { + categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") } + if (includeEpg) add("epg=1") + } + val q = if (params.isEmpty()) "" else "?${params.joinToString("&")}" val req = authed("$baseUrl/api/v1/client/iptv/channels$q").get().build() parseObject(execute(req)) { o -> val arr = o.optJSONArray("channels") ?: JSONArray() @@ -298,6 +302,7 @@ class ApiClient( logoUrl = c.optString("logoUrl").ifBlank { null }, number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, categoryId = c.optString("categoryId").ifBlank { null }, + nowTitle = c.optString("nowTitle").ifBlank { null }, ) ) } @@ -481,6 +486,7 @@ data class IptvChannel( val logoUrl: String? = null, val number: Int? = null, val categoryId: String? = null, + val nowTitle: String? = null, ) data class IptvPlayInfo( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 972dbf0..928d08b 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -33,18 +33,16 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key @@ -69,7 +67,6 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvChannel @@ -92,7 +89,6 @@ fun LiveTvPlayerScreen( val context = LocalContext.current val player = remember { buildLiveTvPlayer(context) } val playerFocus = remember { FocusRequester() } - val menuScope = rememberCoroutineScope() var currentStreamId by remember { mutableIntStateOf(streamId) } var loading by remember { mutableStateOf(true) } @@ -112,8 +108,8 @@ fun LiveTvPlayerScreen( var menuCategoryId by remember { mutableStateOf(categoryId) } var menuCategoryName by remember { mutableStateOf("Zenders") } var menuRefreshing by remember { mutableStateOf(false) } - var epgFetchGen by remember { mutableIntStateOf(0) } var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } + var menuLoadGen by remember { mutableIntStateOf(0) } val menuEpgNow = remember { mutableStateMapOf() } val menuListState = rememberLazyListState() val menuChannelFocus = remember { mutableStateMapOf() } @@ -180,25 +176,6 @@ fun LiveTvPlayerScreen( runCatching { epg = api.iptvEpg(currentStreamId) } } - suspend fun fetchEpgTitles(ids: List, gen: Int) { - for (id in ids) { - if (gen != epgFetchGen) return - if (menuEpgNow.containsKey(id)) continue - runCatching { - api.iptvEpg(id).now?.title?.let { title -> - if (title.isNotBlank()) menuEpgNow[id] = title - } - } - } - } - - fun prefetchMenuEpg(ids: List) { - val gen = epgFetchGen - menuScope.launch { - fetchEpgTitles(ids, gen) - } - } - LaunchedEffect(channelMenuOpen) { if (!channelMenuOpen) return@LaunchedEffect if (menuCategories.isEmpty()) { @@ -206,26 +183,32 @@ fun LiveTvPlayerScreen( } } + fun applyMenuEpg(channels: List) { + for (ch in channels) { + ch.nowTitle?.let { menuEpgNow[ch.streamId] = it } + } + } + LaunchedEffect(channelMenuOpen, menuCategoryId) { if (!channelMenuOpen) return@LaunchedEffect - epgFetchGen++ - val gen = epgFetchGen + menuLoadGen++ + val gen = menuLoadGen menuRefreshing = true try { val catId = menuCategoryId menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" val loaded = if (!catId.isNullOrBlank()) { - api.iptvChannels(catId) + api.iptvChannels(catId, includeEpg = true) } else { - api.iptvChannels() + api.iptvChannels(includeEpg = true) } - if (gen != epgFetchGen) return@LaunchedEffect + if (gen != menuLoadGen) return@LaunchedEffect menuChannels = loaded - prefetchMenuEpg(loaded.take(18).map { it.streamId }) + applyMenuEpg(loaded) } catch (_: Exception) { - if (gen == epgFetchGen) menuChannels = emptyList() + if (gen == menuLoadGen) menuChannels = emptyList() } finally { - if (gen == epgFetchGen) menuRefreshing = false + if (gen == menuLoadGen) menuRefreshing = false } } @@ -241,12 +224,6 @@ fun LiveTvPlayerScreen( onCategoryChange(next.id) } - fun onMenuChannelFocused(index: Int) { - val start = (index - 2).coerceAtLeast(0) - val ids = menuChannels.drop(start).take(6).map { it.streamId } - prefetchMenuEpg(ids) - } - LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) { if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect delay(80) @@ -459,7 +436,6 @@ fun LiveTvPlayerScreen( channelFocus = menuChannelFocus, onSelect = { selectChannel(it) }, onCategoryShift = { shiftMenuCategory(it) }, - onChannelFocused = { onMenuChannelFocused(it) }, modifier = Modifier.align(Alignment.CenterEnd), ) } @@ -503,7 +479,6 @@ private fun LiveChannelSwitcher( channelFocus: MutableMap, onSelect: (Int) -> Unit, onCategoryShift: (Int) -> Unit, - onChannelFocused: (Int) -> Unit, modifier: Modifier = Modifier, ) { val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) @@ -618,10 +593,7 @@ private fun LiveChannelSwitcher( modifier = Modifier .fillMaxWidth() .padding(horizontal = 10.dp) - .focusRequester(fr) - .onFocusChanged { state -> - if (state.isFocused) onChannelFocused(index) - }, + .focusRequester(fr), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 9827fd3..6a6a4ee 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -119,7 +119,10 @@ fun TvScreen( focusedEpg = null focusedStreamId = null try { - channels = api.iptvChannels(catId) + channels = api.iptvChannels(catId, includeEpg = true) + for (ch in channels) { + ch.nowTitle?.let { epgNowTitles[ch.streamId] = it } + } } catch (e: Exception) { error = e.message channels = emptyList() @@ -247,7 +250,7 @@ fun TvScreen( val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } ChannelRow( channel = ch, - nowTitle = epgNowTitles[ch.streamId], + nowTitle = ch.nowTitle ?: epgNowTitles[ch.streamId], isFocused = focusedStreamId == ch.streamId, isLastWatched = ch.streamId == lastStreamId, modifier = Modifier.focusRequester(fr), diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index 426500d..eaf0123 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -5,6 +5,8 @@ import { loadXtreamForToken } from "../iptv/service"; import { decodeEpgText, epgToIso, + type XtreamClient, + type XtreamEpgListing, type XtreamLiveStream, } from "../iptv/xtream"; @@ -106,7 +108,8 @@ function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] { export async function getViewerIptvChannels( viewerId: string, config: Config, - categoryId?: string + categoryId?: string, + includeEpg = false ) { const tokenId = await resolveViewerIptvToken(viewerId); if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); @@ -120,6 +123,10 @@ export async function getViewerIptvChannels( : await xt.client.getLiveStreams(categoryId); streams = sortChannels(streams); + const nowTitles = includeEpg + ? await getCachedCategoryNowTitles(tokenId, categoryId, streams, xt.client) + : null; + return { channels: streams.map((s) => ({ streamId: s.stream_id, @@ -127,6 +134,7 @@ export async function getViewerIptvChannels( logoUrl: s.stream_icon || null, number: s.num ?? null, categoryId: s.category_id ? String(s.category_id) : null, + nowTitle: nowTitles?.get(s.stream_id) ?? null, })), }; } @@ -188,6 +196,28 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); const epg = await xt.client.getShortEpg(streamId, 8); + const { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg); + + return { + streamId, + channelName: ch.name, + logoUrl: ch.stream_icon || null, + now: nowProgram, + next: nextProgram, + }; +} + +const EPG_NOW_CACHE_TTL_MS = 90_000; +const epgNowCache = new Map }>(); + +function epgCacheKey(tokenId: string, categoryId?: string): string { + return `${tokenId}:${categoryId?.trim() || "__all__"}`; +} + +export function parseNowNextPrograms(epg: XtreamEpgListing[]): { + now: { title: string; start: string; end: string | null } | null; + next: { title: string; start: string; end: string | null } | null; +} { const now = Date.now(); let nowProgram: { title: string; start: string; end: string | null } | null = null; let nextProgram: { title: string; start: string; end: string | null } | null = null; @@ -207,11 +237,53 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI } } - return { - streamId, - channelName: ch.name, - logoUrl: ch.stream_icon || null, - now: nowProgram, - next: nextProgram, - }; + return { now: nowProgram, next: nextProgram }; +} + +async function getCachedCategoryNowTitles( + tokenId: string, + categoryId: string | undefined, + streams: XtreamLiveStream[], + client: XtreamClient +): Promise> { + const key = epgCacheKey(tokenId, categoryId); + const cached = epgNowCache.get(key); + if (cached && Date.now() - cached.at < EPG_NOW_CACHE_TTL_MS) { + return cached.titles; + } + + const titles = await fetchNowTitlesParallel( + client, + streams.map((s) => s.stream_id), + 14 + ); + epgNowCache.set(key, { at: Date.now(), titles }); + return titles; +} + +async function fetchNowTitlesParallel( + client: XtreamClient, + streamIds: number[], + concurrency: number +): Promise> { + const result = new Map(); + if (streamIds.length === 0) return result; + + const queue = [...streamIds]; + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { + while (queue.length > 0) { + const streamId = queue.shift(); + if (streamId == null) return; + try { + const epg = await client.getShortEpg(streamId, 4); + const { now } = parseNowNextPrograms(epg); + if (now?.title) result.set(streamId, now.title); + } catch { + // zender zonder EPG overslaan + } + } + }); + + await Promise.all(workers); + return result; } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 2c002b5..732eb70 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -240,9 +240,10 @@ export function registerViewerRoutes( app.get("/api/v1/client/iptv/channels", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); - const q = request.query as { categoryId?: string }; + const q = request.query as { categoryId?: string; epg?: string }; const { getViewerIptvChannels } = await import("./iptv-client"); - return getViewerIptvChannels(auth.viewerId, config, q.categoryId); + const includeEpg = q.epg === "1" || q.epg === "true"; + return getViewerIptvChannels(auth.viewerId, config, q.categoryId, includeEpg); }); app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => {