From ee66440bf598a84adad93559a8aa153ca49f13dc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 12 May 2026 12:22:58 +0530 Subject: [PATCH] fix: adjust behaviour logic of streamlink caching fixes #1034 --- .../commonMain/kotlin/com/nuvio/app/App.kt | 40 +++++++++++++++++-- .../nuvio/app/features/player/PlayerScreen.kt | 21 +++++++--- .../streams/StreamLinkCacheRepository.kt | 16 +++++++- .../app/features/streams/StreamModels.kt | 1 + .../app/features/streams/StreamsRepository.kt | 27 +++++++++++-- .../app/features/streams/StreamsScreen.kt | 2 +- .../streams/StreamLinkCacheRepositoryTest.kt | 39 ++++++++++++++++++ gradle.properties | 8 ++-- iosApp/Configuration/Version.xcconfig | 2 +- 9 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 1508344b..2d1fbaad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1338,7 +1338,13 @@ private fun MainAppContent( reuseHandled = true if (launch.manualSelection) return@LaunchedEffect if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect - val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { @@ -1378,17 +1384,37 @@ private fun MainAppContent( } val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() + val expectedStreamsRequestToken = StreamsRepository.requestToken( + type = launch.type, + videoId = effectiveVideoId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } - LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) { + LaunchedEffect( + streamsUiState.autoPlayStream, + streamsUiState.requestToken, + expectedStreamsRequestToken, + reuseHandled, + launch.manualSelection, + ) { if (!reuseHandled) return@LaunchedEffect if (launch.manualSelection) return@LaunchedEffect if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect + if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, @@ -1468,7 +1494,13 @@ private fun MainAppContent( if (sourceUrl != null) { // Persist for Reuse Last Link if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e155ef88..9db6838d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -791,8 +791,11 @@ fun PlayerScreen( flushWatchProgress() if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) { val cacheKey = StreamLinkCacheRepository.contentKey( - contentType ?: parentMetaType, - activeVideoId!!, + type = contentType ?: parentMetaType, + videoId = activeVideoId!!, + parentMetaId = parentMetaId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, ) StreamLinkCacheRepository.save( contentKey = cacheKey, @@ -851,8 +854,11 @@ fun PlayerScreen( val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L if (playerSettingsUiState.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( - contentType ?: parentMetaType, - epVideoId, + type = contentType ?: parentMetaType, + videoId = epVideoId, + parentMetaId = parentMetaId, + season = episode.season, + episode = episode.episode, ) StreamLinkCacheRepository.save( contentKey = cacheKey, @@ -1563,8 +1569,11 @@ fun PlayerScreen( val currentVideoId = activeVideoId if (currentVideoId != null) { val cacheKey = StreamLinkCacheRepository.contentKey( - contentType ?: parentMetaType, - currentVideoId, + type = contentType ?: parentMetaType, + videoId = currentVideoId, + parentMetaId = parentMetaId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, ) StreamLinkCacheRepository.remove(cacheKey) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index 0d497166..648eaa9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt @@ -22,8 +22,20 @@ internal expect fun epochMs(): Long object StreamLinkCacheRepository { private val json = Json { ignoreUnknownKeys = true } - fun contentKey(type: String, videoId: String): String = - "${type.lowercase()}|$videoId" + fun contentKey( + type: String, + videoId: String, + parentMetaId: String? = null, + season: Int? = null, + episode: Int? = null, + ): String { + val normalizedType = type.lowercase() + return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) { + "$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId" + } else { + "$normalizedType|$videoId" + } + } fun save( contentKey: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index c7db8b2d..784dff47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason { } data class StreamsUiState( + val requestToken: String? = null, val groups: List = emptyList(), val activeAddonIds: Set = emptySet(), val selectedFilter: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 674e3352..daa96a7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -36,6 +36,15 @@ object StreamsRepository { private var activeJob: Job? = null private var activeRequestKey: String? = null + fun requestToken( + type: String, + videoId: String, + season: Int? = null, + episode: Int? = null, + manualSelection: Boolean = false, + ): String = + "$type::$videoId::$season::$episode::$manualSelection" + fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, @@ -65,7 +74,14 @@ object StreamsRepository { } else { PluginsUiState(pluginsEnabled = false) } - val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}" + val requestToken = requestToken( + type = type, + videoId = videoId, + season = season, + episode = episode, + manualSelection = manualSelection, + ) + val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}" val currentState = _uiState.value if ( !forceRefresh && @@ -78,7 +94,7 @@ object StreamsRepository { activeRequestKey = requestKey activeJob?.cancel() - _uiState.value = StreamsUiState() + _uiState.value = StreamsUiState(requestToken = requestToken) PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value @@ -90,6 +106,7 @@ object StreamsRepository { if (isDirectAutoPlayFlow) { _uiState.value = StreamsUiState( + requestToken = requestToken, isDirectAutoPlayFlow = true, showDirectAutoPlayOverlay = true, ) @@ -105,6 +122,7 @@ object StreamsRepository { isLoading = false, ) _uiState.value = StreamsUiState( + requestToken = requestToken, groups = listOf(group), activeAddonIds = setOf("embedded"), isAnyLoading = false, @@ -125,6 +143,7 @@ object StreamsRepository { if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( + requestToken = requestToken, isAnyLoading = false, emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled, ) @@ -151,8 +170,9 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( + requestToken = requestToken, isAnyLoading = false, emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons, ) @@ -176,6 +196,7 @@ object StreamsRepository { ) } _uiState.value = StreamsUiState( + requestToken = requestToken, groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index a0cadbc0..22e877bb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -160,7 +160,7 @@ fun StreamsScreen( } } - LaunchedEffect(type, videoId, manualSelection) { + LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) { StreamsRepository.load( type = type, videoId = videoId, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt new file mode 100644 index 00000000..bf43cd42 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class StreamLinkCacheRepositoryTest { + + @Test + fun `movie cache key keeps legacy type and video id shape`() { + val key = StreamLinkCacheRepository.contentKey( + type = "movie", + videoId = "tt123", + ) + + assertEquals("movie|tt123", key) + } + + @Test + fun `episode cache key is scoped to parent show and episode`() { + val firstEpisode = StreamLinkCacheRepository.contentKey( + type = "series", + videoId = "video-id", + parentMetaId = "tt999", + season = 1, + episode = 1, + ) + val secondEpisode = StreamLinkCacheRepository.contentKey( + type = "series", + videoId = "video-id", + parentMetaId = "tt999", + season = 1, + episode = 2, + ) + + assertNotEquals(firstEpisode, secondEpisode) + assertEquals("series|tt999|s1|e1|video-id", firstEpisode) + } +} diff --git a/gradle.properties b/gradle.properties index ddcd9b5f..01e9d962 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,14 +1,14 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx4096M -kotlin.native.jvmArgs=-Xmx6144M +kotlin.daemon.jvmargs=-Xmx6144M +kotlin.native.jvmArgs=-Xmx12288M kotlin.mpp.enableCInteropCommonization=true #Gradle -org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m +org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m org.gradle.configuration-cache=true org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 75fc1867..9b4b9d6b 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=58 -MARKETING_VERSION=0.1.18 +MARKETING_VERSION=0.1.0