From fe4b1210acff0f8502c6193558c962dddf756723 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 2 Sep 2026 00:06:02 +0200 Subject: [PATCH] Prefer classic Xtream TS URLs for live IPTV playback. Match Smarters by serving /live/user/pass/id.ts first from master-api, and tune the Android live player buffer for steadier TS playback. Co-authored-by: Cursor --- apps/android-tv/app/build.gradle.kts | 5 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 4 + .../mediacluster/tv/playback/PlayerFactory.kt | 129 ++++++++++++++++++ .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 65 ++++++--- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 41 +++++- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 34 ++++- apps/master-api/src/iptv/xtream.ts | 65 ++++++--- apps/master-api/src/viewer/iptv-client.ts | 25 +++- 8 files changed, 318 insertions(+), 50 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index b861739..e0f049f 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 = 22 - versionName = "0.9.0" + versionCode = 33 + versionName = "0.11.1" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") @@ -59,6 +59,7 @@ dependencies { // Media3 + FFmpeg-audio (DTS/TrueHD/E-AC3 → PCM) via nextlib val media3 = "1.10.1" implementation("androidx.media3:media3-exoplayer:$media3") + implementation("androidx.media3:media3-exoplayer-hls:$media3") implementation("androidx.media3:media3-ui:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3") implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0") 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 394d0ef..785d322 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 @@ -314,6 +314,8 @@ class ApiClient( logoUrl = o.optString("logoUrl").ifBlank { null }, streamUrl = o.getString("streamUrl"), format = o.optString("format").ifBlank { "hls" }, + fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, + fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, ) } } @@ -487,6 +489,8 @@ data class IptvPlayInfo( val logoUrl: String? = null, val streamUrl: String, val format: String, + val fallbackStreamUrl: String? = null, + val fallbackFormat: String? = null, ) data class EpgProgram( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index 24caa61..554d5d3 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -10,17 +10,22 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.audio.AudioRendererEventListener import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.VideoRendererEventListener import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit /** * Hardware-video + FFmpeg-audio (prefer). @@ -80,6 +85,62 @@ private class McRenderersFactory(context: Context) : NextRenderersFactory(contex } } +/** + * Live IPTV: hardware-video, audio hardware-first met FFmpeg-fallback (MP2/AC3/E-AC3). + * PREFER vermijden — scheelt CPU; ON pakt FFmpeg alleen als MediaCodec faalt. + */ +@OptIn(UnstableApi::class) +private class LiveRenderersFactory(context: Context) : NextRenderersFactory(context) { + init { + setEnableDecoderFallback(true) + setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) + } + + override fun buildAudioRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + audioSink: AudioSink, + eventHandler: Handler, + eventListener: AudioRendererEventListener, + out: ArrayList, + ) { + super.buildAudioRenderers( + context, + EXTENSION_RENDERER_MODE_ON, + mediaCodecSelector, + enableDecoderFallback, + audioSink, + eventHandler, + eventListener, + out, + ) + } + + override fun buildVideoRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + eventHandler: Handler, + eventListener: VideoRendererEventListener, + allowedVideoJoiningTimeMs: Long, + out: ArrayList, + ) { + super.buildVideoRenderers( + context, + EXTENSION_RENDERER_MODE_OFF, + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + allowedVideoJoiningTimeMs, + out, + ) + } +} + @OptIn(UnstableApi::class) fun buildTvPlayer(context: Context): ExoPlayer { val trackSelector = DefaultTrackSelector(context).apply { @@ -110,6 +171,74 @@ fun buildTvPlayer(context: Context): ExoPlayer { .also { it.volume = 1f } } +/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */ +private val liveOkHttp: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() +} + +/** Live IPTV: TS-first, kleine buffer, lage latency. */ +@OptIn(UnstableApi::class) +fun buildLiveTvPlayer(context: Context): ExoPlayer { + val appContext = context.applicationContext + val dataSourceFactory = OkHttpDataSource.Factory(liveOkHttp) + .setUserAgent("VMC-AndroidTV") + + val trackSelector = DefaultTrackSelector(appContext).apply { + parameters = buildUponParameters() + .setExceedRendererCapabilitiesIfNecessary(true) + .setAllowAudioMixedMimeTypeAdaptiveness(true) + .build() + } + + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + /* minBufferMs = */ 8_000, + /* maxBufferMs = */ 20_000, + /* bufferForPlaybackMs = */ 1_800, + /* bufferForPlaybackAfterRebufferMs = */ 2_500, + ) + .setPrioritizeTimeOverSizeThresholds(true) + .build() + + val renderersFactory = LiveRenderersFactory(appContext) + + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build() + + return ExoPlayer.Builder(appContext, renderersFactory) + .setTrackSelector(trackSelector) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setLoadControl(loadControl) + .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .build() + .also { it.volume = 1f } +} + +/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */ +fun liveMediaItem(url: String, format: String): MediaItem { + val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) + val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T + val builder = MediaItem.Builder().setUri(url).setMimeType(mime) + if (isHls) { + builder.setLiveConfiguration( + MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(2_000) + .setMinOffsetMs(1_000) + .setMaxOffsetMs(5_000) + .build(), + ) + } + return builder.build() +} + data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int) fun ExoPlayer.listTracks(type: Int): List { 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 ea02bec..2b2f0a7 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 @@ -42,8 +42,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.MediaItem -import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.ui.AspectRatioFrameLayout @@ -54,8 +52,9 @@ import coil.compose.AsyncImage import kotlinx.coroutines.delay import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.IptvEpg -import nl.vonas.mediacluster.tv.playback.buildTvPlayer +import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer import nl.vonas.mediacluster.tv.playback.ensureAudioSelected +import nl.vonas.mediacluster.tv.playback.liveMediaItem import nl.vonas.mediacluster.tv.ui.theme.McColors @OptIn(ExperimentalTvMaterial3Api::class) @@ -66,7 +65,7 @@ fun LiveTvPlayerScreen( onBack: () -> Unit, ) { val context = LocalContext.current - val player = remember { buildTvPlayer(context) } + val player = remember { buildLiveTvPlayer(context) } val focus = remember { FocusRequester() } var loading by remember { mutableStateOf(true) } @@ -75,28 +74,47 @@ fun LiveTvPlayerScreen( var logoUrl by remember { mutableStateOf(null) } var epg by remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(true) } + var fallbackUrl by remember { mutableStateOf(null) } + var fallbackFormat by remember { mutableStateOf(null) } + var triedFallback by remember { mutableStateOf(false) } + + fun startStream(url: String, format: String) { + player.setMediaItem(liveMediaItem(url, format)) + player.prepare() + player.playWhenReady = true + } LaunchedEffect(streamId) { loading = true error = null + triedFallback = false + epg = null try { val play = api.iptvPlayUrl(streamId) channelName = play.name logoUrl = play.logoUrl - val mime = when { - play.format.equals("hls", true) || play.streamUrl.contains(".m3u8", true) -> - MimeTypes.APPLICATION_M3U8 - else -> MimeTypes.VIDEO_MP2T + // API levert classic TS eerst; lokaal nog TS forceren als fallback in response zit + val tsUrl = when { + play.format.equals("ts", ignoreCase = true) -> play.streamUrl + play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl + else -> null + } + val hlsUrl = when { + play.format.equals("hls", ignoreCase = true) -> play.streamUrl + play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl + else -> null + } + if (!tsUrl.isNullOrBlank()) { + fallbackUrl = hlsUrl ?: play.fallbackStreamUrl + fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let { + if (it.contains("m3u8", true)) "hls" else play.fallbackFormat + } + startStream(tsUrl, "ts") + } else { + fallbackUrl = play.fallbackStreamUrl + fallbackFormat = play.fallbackFormat + startStream(play.streamUrl, play.format) } - player.setMediaItem( - MediaItem.Builder() - .setUri(play.streamUrl) - .setMimeType(mime) - .build(), - ) - player.prepare() - player.playWhenReady = true - runCatching { epg = api.iptvEpg(streamId) } } catch (e: Exception) { error = e.message ?: "Kon zender niet starten" } finally { @@ -104,6 +122,11 @@ fun LiveTvPlayerScreen( } } + LaunchedEffect(streamId) { + delay(2_500) + runCatching { epg = api.iptvEpg(streamId) } + } + LaunchedEffect(overlay) { if (overlay) { delay(5000) @@ -118,6 +141,14 @@ fun LiveTvPlayerScreen( } override fun onPlayerError(e: PlaybackException) { + val fb = fallbackUrl + val fbFmt = fallbackFormat + if (!triedFallback && !fb.isNullOrBlank()) { + triedFallback = true + error = null + startStream(fb, fbFmt ?: "hls") + return + } error = e.message ?: "Afspeelfout" } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index c989193..27c2fce 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -77,6 +78,13 @@ fun McNav() { val scope = rememberCoroutineScope() var ready by remember { mutableStateOf(false) } var authed by remember { mutableStateOf(false) } + // Blijft behouden als main uit compositie gaat (bijv. live player) + var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } + var tvCategoryId by rememberSaveable { mutableStateOf(null) } + var tvLastStreamId by rememberSaveable { mutableStateOf(null) } + val hubTab = remember(hubTabName) { + MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home + } LaunchedEffect(Unit) { val base = store.apiBase.first() @@ -101,6 +109,12 @@ fun McNav() { composable("main") { MainHub( api = api, + tab = hubTab, + onTabChange = { hubTabName = it.name }, + tvCategoryId = tvCategoryId, + onTvCategoryChange = { tvCategoryId = it }, + tvLastStreamId = tvLastStreamId, + onTvLastStreamChange = { tvLastStreamId = it }, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, onBrowse = { catalogId, _title -> @@ -112,7 +126,11 @@ fun McNav() { val s = seriesId?.ifBlank { null } ?: "-" nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs") }, - onPlayLive = { streamId -> nav.navigate("live/$streamId") }, + onPlayLive = { streamId -> + tvLastStreamId = streamId + if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name + nav.navigate("live/$streamId") + }, onLogout = { scope.launch { store.setToken(null) @@ -208,6 +226,12 @@ fun McNav() { @Composable private fun MainHub( api: ApiClient, + tab: MainTab, + onTabChange: (MainTab) -> Unit, + tvCategoryId: String?, + onTvCategoryChange: (String?) -> Unit, + tvLastStreamId: Int?, + onTvLastStreamChange: (Int?) -> Unit, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit, @@ -215,7 +239,6 @@ private fun MainHub( onPlayLive: (Int) -> Unit, onLogout: () -> Unit, ) { - var tab by remember { mutableStateOf(MainTab.Home) } var iptvEnabled by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) } @@ -304,12 +327,12 @@ private fun MainHub( } // Films/Series/Zoeken: BrowseScreen handelt scroll→top af; hier alleen top-nav → Home BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) { - tab = MainTab.Home + onTabChange(MainTab.Home) focusTopNav() } // Settings (geen eigen scroll-back): terug naar home BackHandler(enabled = tab == MainTab.Settings && !navFocused && !exitConfirm) { - tab = MainTab.Home + onTabChange(MainTab.Home) focusTopNav() } // TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav @@ -332,7 +355,7 @@ private fun MainHub( visibleTabs = visibleTabs, onSelect = { if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar - tab = it + onTabChange(it) exitConfirm = false focusTopNav() when (it) { @@ -355,7 +378,7 @@ private fun MainHub( selectedSubId = selectedSubId, onSelectSub = { item -> if (item.id == "__search__") { - tab = MainTab.Search + onTabChange(MainTab.Search) focusTopNav() } else { when (tab) { @@ -401,6 +424,10 @@ private fun MainHub( ) MainTab.Tv -> TvScreen( api = api, + selectedCategoryId = tvCategoryId, + onCategoryChange = onTvCategoryChange, + lastStreamId = tvLastStreamId, + onLastStreamChange = onTvLastStreamChange, onAtTopBack = { focusTopNav() }, onPlayChannel = onPlayLive, onContentFocused = { navFocused = false }, @@ -414,7 +441,7 @@ private fun MainHub( MainTab.Settings -> SettingsScreen( api = api, onBack = { - tab = MainTab.Home + onTabChange(MainTab.Home) focusTopNav() }, onLogout = onLogout, 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 f01a9e8..51c73bc 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 @@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -24,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,18 +58,22 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors @Composable fun TvScreen( api: ApiClient, + selectedCategoryId: String?, + onCategoryChange: (String?) -> Unit, + lastStreamId: Int? = null, + onLastStreamChange: (Int?) -> Unit = {}, onAtTopBack: () -> Unit, onPlayChannel: (streamId: Int) -> Unit, onContentFocused: () -> Unit = {}, ) { var categories by remember { mutableStateOf>(emptyList()) } var channels by remember { mutableStateOf>(emptyList()) } - var selectedCategoryId by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } var loadingChannels by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var focusedEpg by remember { mutableStateOf(null) } var epgJob by remember { mutableStateOf(null) } + val gridState = rememberSaveable(saver = LazyGridState.Saver) { LazyGridState() } val scope = rememberCoroutineScope() LaunchedEffect(Unit) { @@ -75,7 +82,12 @@ fun TvScreen( try { val cats = api.iptvCategories() categories = cats - selectedCategoryId = cats.firstOrNull()?.id + val restored = selectedCategoryId?.takeIf { id -> cats.any { it.id == id } } + if (restored != null) { + onCategoryChange(restored) + } else { + onCategoryChange(cats.firstOrNull()?.id) + } } catch (e: Exception) { error = e.message ?: "Kon TV niet laden" } finally { @@ -97,6 +109,15 @@ fun TvScreen( } } + LaunchedEffect(channels, lastStreamId) { + val target = lastStreamId ?: return@LaunchedEffect + val idx = channels.indexOfFirst { it.streamId == target } + if (idx >= 0) { + gridState.scrollToItem(idx) + runCatching { focusedEpg = api.iptvEpg(target) } + } + } + fun loadEpg(streamId: Int) { epgJob?.cancel() epgJob = scope.launch { @@ -127,7 +148,7 @@ fun TvScreen( CategorySidebar( categories = categories, selectedId = selectedCategoryId, - onSelect = { selectedCategoryId = it }, + onSelect = { onCategoryChange(it) }, onFocused = onContentFocused, ) Column( @@ -166,6 +187,7 @@ fun TvScreen( } else { LazyVerticalGrid( columns = GridCells.Fixed(5), + state = gridState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -174,9 +196,13 @@ fun TvScreen( items(channels, key = { it.streamId }) { ch -> ChannelTile( channel = ch, - onClick = { onPlayChannel(ch.streamId) }, + onClick = { + onLastStreamChange(ch.streamId) + onPlayChannel(ch.streamId) + }, onFocused = { onContentFocused() + onLastStreamChange(ch.streamId) loadEpg(ch.streamId) }, ) diff --git a/apps/master-api/src/iptv/xtream.ts b/apps/master-api/src/iptv/xtream.ts index 86609ce..784e7ec 100644 --- a/apps/master-api/src/iptv/xtream.ts +++ b/apps/master-api/src/iptv/xtream.ts @@ -255,10 +255,10 @@ export class XtreamClient { } /** - * XUI 1.5+ playlists use /play/{token}/m3u8 instead of classic /live/user/pass/id. - * Real live channels often hang on classic URLs; created_live (24/7) still works classically. + * XUI playlists: output=hls | ts | llhls (LLOD low-latency, XUI 1.5+). + * On-demand + LLOD v3 levert HLS-segmenten — geen raw TS continuous stream. */ - async getLivePlayUrlMap(output: "hls" | "ts" = "hls"): Promise> { + async getLivePlayUrlMap(output: "hls" | "ts" | "llhls" = "hls"): Promise> { const cacheKey = `playlist:${output}`; const cached = this.cacheGet>(cacheKey, 5 * 60_000); if (cached) return cached; @@ -279,28 +279,61 @@ export class XtreamClient { return map; } - /** Prefer XUI /play/token URL; fall back to classic Xtream path. */ - async resolveLivePlayUrls(streamId: number): Promise<{ hls?: string; ts?: string; classic: string[] }> { + /** Normaliseer XUI /play/{token} naar expliciet /m3u8 of /ts pad. */ + private normalizePlayUrl(url: string, kind: "hls" | "ts"): string { + const qIdx = url.indexOf("?"); + const path = qIdx >= 0 ? url.slice(0, qIdx) : url; + const query = qIdx >= 0 ? url.slice(qIdx) : ""; + if (kind === "hls") { + if (/\/m3u8$/i.test(path)) return url; + if (/\/play\/[^/]+$/i.test(path)) return `${path}/m3u8${query}`; + return url; + } + if (/\/ts$/i.test(path)) return url; + if (/\/m3u8$/i.test(path)) return `${path.replace(/\/m3u8$/i, "/ts")}${query}`; + if (/\/play\/[^/]+$/i.test(path)) return `${path}/ts${query}`; + return url; + } + + /** XUI on-demand LLOD: llhls → hls playlist → classic m3u8 → ts. */ + async resolveLivePlayUrls(streamId: number): Promise<{ + llhls?: string; + hls?: string; + ts?: string; + classic: string[]; + }> { + let llhls: string | undefined; + try { + const map = await this.getLivePlayUrlMap("llhls"); + const raw = map.get(streamId); + if (raw) llhls = this.normalizePlayUrl(raw, "hls"); + } catch { + // llhls niet beschikbaar op deze XUI-build + } + let hls: string | undefined; try { - const hlsMap = await this.getLivePlayUrlMap("hls"); - hls = hlsMap.get(streamId); + const map = await this.getLivePlayUrlMap("hls"); + const raw = map.get(streamId); + if (raw) hls = this.normalizePlayUrl(raw, "hls"); } catch { - // ignore — fall back + // ignore } let ts: string | undefined; - if (hls) { - if (/\/m3u8$/i.test(hls)) { - ts = hls.replace(/\/m3u8$/i, "/ts"); - } else if (/\/play\/[^/]+$/i.test(hls)) { - const base = hls.replace(/\/$/, ""); - hls = `${base}/m3u8`; - ts = `${base}/ts`; - } + try { + const map = await this.getLivePlayUrlMap("ts"); + const raw = map.get(streamId); + if (raw) ts = this.normalizePlayUrl(raw, "ts"); + } catch { + // ignore + } + if (!ts && hls) { + ts = hls.replace(/\/m3u8(\?.*)?$/i, "/ts$1"); } return { + llhls, hls, ts, classic: [ diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index aa8ef00..426500d 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -147,15 +147,32 @@ export async function getViewerIptvPlayUrl( if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); const resolved = await xt.client.resolveLivePlayUrls(streamId); - const url = resolved.hls || resolved.ts || resolved.classic[0]; - if (!url) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503); + + // Smarters-gedrag: klassiek Xtream-pad eerst (/live/user/pass/id.ts), daarna playlist-fallbacks + const classicTs = resolved.classic.find((u) => /\.ts$/i.test(u)); + const classicHls = resolved.classic.find((u) => /\.m3u8$/i.test(u) || u.includes("m3u8")); + const playlistHls = resolved.hls ?? resolved.llhls; + const playlistTs = resolved.ts; + + const candidates = [classicTs, playlistTs, classicHls, playlistHls, ...resolved.classic].filter( + (u): u is string => Boolean(u) + ); + const unique = [...new Set(candidates)]; + const primary = unique[0]; + if (!primary) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503); + const fallback = unique[1]; + + const formatFor = (url: string): "hls" | "ts" => + url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts"; return { streamId, name: ch.name, logoUrl: ch.stream_icon || null, - streamUrl: url, - format: url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts", + streamUrl: primary, + format: formatFor(primary), + fallbackStreamUrl: fallback ?? null, + fallbackFormat: fallback ? formatFor(fallback) : null, }; }