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"
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")

View file

@ -283,8 +283,12 @@ class ApiClient(
}
}
suspend fun iptvChannels(categoryId: String? = null): List<IptvChannel> = 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<IptvChannel> = 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(

View file

@ -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<Int, String>() }
val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
@ -180,25 +176,6 @@ fun LiveTvPlayerScreen(
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) {
if (!channelMenuOpen) return@LaunchedEffect
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) {
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<Int, FocusRequester>,
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(

View file

@ -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),

View file

@ -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<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();
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<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) => {
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) => {