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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 01:50:33 +02:00
parent da6159059d
commit 9377c9fa0e
6 changed files with 115 additions and 61 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 38 versionCode = 39
versionName = "0.11.6" versionName = "0.11.7"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -283,8 +283,12 @@ class ApiClient(
} }
} }
suspend fun iptvChannels(categoryId: String? = null): List<IptvChannel> = withContext(Dispatchers.IO) { suspend fun iptvChannels(categoryId: String? = null, includeEpg: Boolean = false): List<IptvChannel> = withContext(Dispatchers.IO) {
val q = categoryId?.let { "?categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}" }.orEmpty() 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() val req = authed("$baseUrl/api/v1/client/iptv/channels$q").get().build()
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->
val arr = o.optJSONArray("channels") ?: JSONArray() val arr = o.optJSONArray("channels") ?: JSONArray()
@ -298,6 +302,7 @@ class ApiClient(
logoUrl = c.optString("logoUrl").ifBlank { null }, logoUrl = c.optString("logoUrl").ifBlank { null },
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
categoryId = c.optString("categoryId").ifBlank { null }, categoryId = c.optString("categoryId").ifBlank { null },
nowTitle = c.optString("nowTitle").ifBlank { null },
) )
) )
} }
@ -481,6 +486,7 @@ data class IptvChannel(
val logoUrl: String? = null, val logoUrl: String? = null,
val number: Int? = null, val number: Int? = null,
val categoryId: String? = null, val categoryId: String? = null,
val nowTitle: String? = null,
) )
data class IptvPlayInfo( data class IptvPlayInfo(

View file

@ -33,18 +33,16 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -69,7 +67,6 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvChannel
@ -92,7 +89,6 @@ fun LiveTvPlayerScreen(
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) } val player = remember { buildLiveTvPlayer(context) }
val playerFocus = remember { FocusRequester() } val playerFocus = remember { FocusRequester() }
val menuScope = rememberCoroutineScope()
var currentStreamId by remember { mutableIntStateOf(streamId) } var currentStreamId by remember { mutableIntStateOf(streamId) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
@ -112,8 +108,8 @@ fun LiveTvPlayerScreen(
var menuCategoryId by remember { mutableStateOf(categoryId) } var menuCategoryId by remember { mutableStateOf(categoryId) }
var menuCategoryName by remember { mutableStateOf("Zenders") } var menuCategoryName by remember { mutableStateOf("Zenders") }
var menuRefreshing by remember { mutableStateOf(false) } var menuRefreshing by remember { mutableStateOf(false) }
var epgFetchGen by remember { mutableIntStateOf(0) }
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
var menuLoadGen by remember { mutableIntStateOf(0) }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() } val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -180,25 +176,6 @@ fun LiveTvPlayerScreen(
runCatching { epg = api.iptvEpg(currentStreamId) } runCatching { epg = api.iptvEpg(currentStreamId) }
} }
suspend fun fetchEpgTitles(ids: List<Int>, 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<Int>) {
val gen = epgFetchGen
menuScope.launch {
fetchEpgTitles(ids, gen)
}
}
LaunchedEffect(channelMenuOpen) { LaunchedEffect(channelMenuOpen) {
if (!channelMenuOpen) return@LaunchedEffect if (!channelMenuOpen) return@LaunchedEffect
if (menuCategories.isEmpty()) { if (menuCategories.isEmpty()) {
@ -206,26 +183,32 @@ fun LiveTvPlayerScreen(
} }
} }
fun applyMenuEpg(channels: List<IptvChannel>) {
for (ch in channels) {
ch.nowTitle?.let { menuEpgNow[ch.streamId] = it }
}
}
LaunchedEffect(channelMenuOpen, menuCategoryId) { LaunchedEffect(channelMenuOpen, menuCategoryId) {
if (!channelMenuOpen) return@LaunchedEffect if (!channelMenuOpen) return@LaunchedEffect
epgFetchGen++ menuLoadGen++
val gen = epgFetchGen val gen = menuLoadGen
menuRefreshing = true menuRefreshing = true
try { try {
val catId = menuCategoryId val catId = menuCategoryId
menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders" menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
val loaded = if (!catId.isNullOrBlank()) { val loaded = if (!catId.isNullOrBlank()) {
api.iptvChannels(catId) api.iptvChannels(catId, includeEpg = true)
} else { } else {
api.iptvChannels() api.iptvChannels(includeEpg = true)
} }
if (gen != epgFetchGen) return@LaunchedEffect if (gen != menuLoadGen) return@LaunchedEffect
menuChannels = loaded menuChannels = loaded
prefetchMenuEpg(loaded.take(18).map { it.streamId }) applyMenuEpg(loaded)
} catch (_: Exception) { } catch (_: Exception) {
if (gen == epgFetchGen) menuChannels = emptyList() if (gen == menuLoadGen) menuChannels = emptyList()
} finally { } finally {
if (gen == epgFetchGen) menuRefreshing = false if (gen == menuLoadGen) menuRefreshing = false
} }
} }
@ -241,12 +224,6 @@ fun LiveTvPlayerScreen(
onCategoryChange(next.id) 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) { LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) {
if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
delay(80) delay(80)
@ -459,7 +436,6 @@ fun LiveTvPlayerScreen(
channelFocus = menuChannelFocus, channelFocus = menuChannelFocus,
onSelect = { selectChannel(it) }, onSelect = { selectChannel(it) },
onCategoryShift = { shiftMenuCategory(it) }, onCategoryShift = { shiftMenuCategory(it) },
onChannelFocused = { onMenuChannelFocused(it) },
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterEnd),
) )
} }
@ -503,7 +479,6 @@ private fun LiveChannelSwitcher(
channelFocus: MutableMap<Int, FocusRequester>, channelFocus: MutableMap<Int, FocusRequester>,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
onCategoryShift: (Int) -> Unit, onCategoryShift: (Int) -> Unit,
onChannelFocused: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
@ -618,10 +593,7 @@ private fun LiveChannelSwitcher(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 10.dp) .padding(horizontal = 10.dp)
.focusRequester(fr) .focusRequester(fr),
.onFocusChanged { state ->
if (state.isFocused) onChannelFocused(index)
},
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(

View file

@ -119,7 +119,10 @@ fun TvScreen(
focusedEpg = null focusedEpg = null
focusedStreamId = null focusedStreamId = null
try { 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) { } catch (e: Exception) {
error = e.message error = e.message
channels = emptyList() channels = emptyList()
@ -247,7 +250,7 @@ fun TvScreen(
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
ChannelRow( ChannelRow(
channel = ch, channel = ch,
nowTitle = epgNowTitles[ch.streamId], nowTitle = ch.nowTitle ?: epgNowTitles[ch.streamId],
isFocused = focusedStreamId == ch.streamId, isFocused = focusedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId, isLastWatched = ch.streamId == lastStreamId,
modifier = Modifier.focusRequester(fr), modifier = Modifier.focusRequester(fr),

View file

@ -5,6 +5,8 @@ import { loadXtreamForToken } from "../iptv/service";
import { import {
decodeEpgText, decodeEpgText,
epgToIso, epgToIso,
type XtreamClient,
type XtreamEpgListing,
type XtreamLiveStream, type XtreamLiveStream,
} from "../iptv/xtream"; } from "../iptv/xtream";
@ -106,7 +108,8 @@ function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] {
export async function getViewerIptvChannels( export async function getViewerIptvChannels(
viewerId: string, viewerId: string,
config: Config, config: Config,
categoryId?: string categoryId?: string,
includeEpg = false
) { ) {
const tokenId = await resolveViewerIptvToken(viewerId); const tokenId = await resolveViewerIptvToken(viewerId);
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
@ -120,6 +123,10 @@ export async function getViewerIptvChannels(
: await xt.client.getLiveStreams(categoryId); : await xt.client.getLiveStreams(categoryId);
streams = sortChannels(streams); streams = sortChannels(streams);
const nowTitles = includeEpg
? await getCachedCategoryNowTitles(tokenId, categoryId, streams, xt.client)
: null;
return { return {
channels: streams.map((s) => ({ channels: streams.map((s) => ({
streamId: s.stream_id, streamId: s.stream_id,
@ -127,6 +134,7 @@ export async function getViewerIptvChannels(
logoUrl: s.stream_icon || null, logoUrl: s.stream_icon || null,
number: s.num ?? null, number: s.num ?? null,
categoryId: s.category_id ? String(s.category_id) : 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); if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
const epg = await xt.client.getShortEpg(streamId, 8); 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<string, { at: number; titles: Map<number, string> }>();
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(); const now = Date.now();
let nowProgram: { title: string; start: string; end: string | null } | null = null; let nowProgram: { title: string; start: string; end: string | null } | null = null;
let nextProgram: { 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 { return { now: nowProgram, next: nextProgram };
streamId, }
channelName: ch.name,
logoUrl: ch.stream_icon || null, async function getCachedCategoryNowTitles(
now: nowProgram, tokenId: string,
next: nextProgram, categoryId: string | undefined,
}; streams: XtreamLiveStream[],
client: XtreamClient
): Promise<Map<number, string>> {
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<Map<number, string>> {
const result = new Map<number, string>();
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;
} }

View file

@ -240,9 +240,10 @@ export function registerViewerRoutes(
app.get("/api/v1/client/iptv/channels", async (request) => { app.get("/api/v1/client/iptv/channels", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); 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"); 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) => { app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => {