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:
parent
da6159059d
commit
9377c9fa0e
6 changed files with 115 additions and 61 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue