fix: adjust behaviour logic of streamlink caching

fixes #1034
This commit is contained in:
tapframe 2026-05-12 12:22:58 +05:30
parent 17c747e5c1
commit ee66440bf5
9 changed files with 135 additions and 21 deletions

View file

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

View file

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

View file

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

View file

@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
}
data class StreamsUiState(
val requestToken: String? = null,
val groups: List<AddonStreamGroup> = emptyList(),
val activeAddonIds: Set<String> = emptySet(),
val selectedFilter: String? = null,

View file

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

View file

@ -160,7 +160,7 @@ fun StreamsScreen(
}
}
LaunchedEffect(type, videoId, manualSelection) {
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
StreamsRepository.load(
type = type,
videoId = videoId,

View file

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

View file

@ -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
android.useAndroidX=true

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=58
MARKETING_VERSION=0.1.18
MARKETING_VERSION=0.1.0