diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt new file mode 100644 index 000000000..038a472a2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt @@ -0,0 +1,180 @@ +package com.nuvio.app.features.player + +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import java.util.Locale + +internal fun playbackMediaItemFromUrl( + url: String, + responseHeaders: Map = emptyMap(), +): MediaItem { + val builder = MediaItem.Builder().setUri(url) + inferPlaybackMimeType( + url = url, + responseHeaders = responseHeaders, + )?.let(builder::setMimeType) + return builder.build() +} + +private fun inferPlaybackMimeType( + url: String, + responseHeaders: Map, +): String? = + inferMimeTypeFromResponseHeaders(responseHeaders) + ?: inferMimeTypeFromPath(url) + +private fun inferMimeTypeFromResponseHeaders(headers: Map): String? { + if (headers.isEmpty()) return null + + headers.entries + .firstOrNull { (key, _) -> key.equals("Content-Type", ignoreCase = true) } + ?.value + ?.let(::normalizeMimeType) + ?.let { return it } + + val contentDisposition = headers.entries + .firstOrNull { (key, _) -> key.equals("Content-Disposition", ignoreCase = true) } + ?.value + ?: return null + + val filename = contentDisposition + .substringAfter("filename*=", missingDelimiterValue = "") + .substringAfterLast("''", missingDelimiterValue = "") + .ifBlank { + contentDisposition.substringAfter("filename=", missingDelimiterValue = "") + } + .trim() + .trim('"', '\'') + .takeIf { it.isNotBlank() } + + return inferMimeTypeFromPath(filename) +} + +private fun normalizeMimeType(contentType: String?): String? { + val normalized = contentType + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.US) + ?: return null + + return when (normalized) { + "application/vnd.apple.mpegurl", + "application/mpegurl", + "application/x-mpegurl", + "audio/mpegurl", + "audio/x-mpegurl", + "application/m3u8" -> MimeTypes.APPLICATION_M3U8 + + "application/dash+xml", + "video/vnd.mpeg.dash.mpd" -> MimeTypes.APPLICATION_MPD + + "application/vnd.ms-sstr+xml" -> MimeTypes.APPLICATION_SS + + "video/mp4", + "application/mp4", + "video/x-m4v" -> MimeTypes.VIDEO_MP4 + + "video/webm", + "audio/webm" -> MimeTypes.VIDEO_WEBM + + "video/x-matroska", + "audio/x-matroska", + "video/mkv", + "audio/mkv" -> MimeTypes.VIDEO_MATROSKA + + else -> null + } +} + +private fun inferMimeTypeFromPath(path: String?): String? { + val normalized = path + ?.trim() + ?.lowercase(Locale.US) + ?.takeIf { it.isNotBlank() } + ?: return null + val pathWithoutFragment = normalized.substringBefore('#') + val pathPart = pathWithoutFragment.substringBefore('?') + val queryPart = pathWithoutFragment.substringAfter('?', missingDelimiterValue = "") + val fileName = pathPart.substringAfterLast('/') + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") + + return when { + extension == "m3u8" -> MimeTypes.APPLICATION_M3U8 + extension == "mpd" -> MimeTypes.APPLICATION_MPD + extension == "ism" || extension == "isml" -> MimeTypes.APPLICATION_SS + extension == "mkv" -> MimeTypes.VIDEO_MATROSKA + extension == "webm" -> MimeTypes.VIDEO_WEBM + extension == "mp4" || extension == "m4v" -> MimeTypes.VIDEO_MP4 + extension == "ts" || extension == "mts" || extension == "m2ts" -> MimeTypes.VIDEO_MP2T + extension == "mov" -> MIME_VIDEO_QUICK_TIME + extension == "avi" -> MimeTypes.VIDEO_AVI + extension == "mpeg" || extension == "mpg" -> MimeTypes.VIDEO_MPEG + else -> inferMimeTypeFromQuery(queryPart) + ?: inferMimeTypeFromDelimitedToken(pathPart) + ?: inferMimeTypeFromDelimitedToken(queryPart) + } +} + +private fun inferMimeTypeFromQuery(query: String): String? { + if (query.isBlank()) return null + + query.split('&').forEach { parameter -> + val key = parameter.substringBefore('=', missingDelimiterValue = "").trim() + val value = parameter.substringAfter('=', missingDelimiterValue = "").trim() + if (key.isBlank() || value.isBlank()) return@forEach + + when (key) { + "format", + "mime", + "mime_type", + "contenttype", + "content_type", + "type", + "ext", + "extension", + "output" -> when (value.substringAfterLast('/').substringAfterLast('.')) { + "m3u8" -> return MimeTypes.APPLICATION_M3U8 + "mpd" -> return MimeTypes.APPLICATION_MPD + "ism", "isml" -> return MimeTypes.APPLICATION_SS + "mkv" -> return MimeTypes.VIDEO_MATROSKA + "webm" -> return MimeTypes.VIDEO_WEBM + "mp4", "m4v" -> return MimeTypes.VIDEO_MP4 + "ts", "mts", "m2ts" -> return MimeTypes.VIDEO_MP2T + "mov" -> return MIME_VIDEO_QUICK_TIME + "avi" -> return MimeTypes.VIDEO_AVI + "mpeg", "mpg" -> return MimeTypes.VIDEO_MPEG + } + } + + when (value) { + "application/vnd.apple.mpegurl", + "application/mpegurl", + "application/x-mpegurl", + "audio/mpegurl", + "audio/x-mpegurl", + "application/m3u8", + "hls" -> return MimeTypes.APPLICATION_M3U8 + "application/dash+xml", + "video/vnd.mpeg.dash.mpd", + "dash" -> return MimeTypes.APPLICATION_MPD + "application/vnd.ms-sstr+xml", + "smoothstreaming", + "ss" -> return MimeTypes.APPLICATION_SS + } + } + + return null +} + +private fun inferMimeTypeFromDelimitedToken(value: String): String? = + when { + DELIMITED_M3U8_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8 + DELIMITED_MPD_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_MPD + DELIMITED_SS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_SS + else -> null + } + +private const val MIME_VIDEO_QUICK_TIME = "video/quicktime" +private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&-])m3u8($|[=/_.?&-])") +private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&-])mpd($|[=/_.?&-])") +private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&-])(ism|isml)($|[=/_.?&-])") diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 9d2c97bd5..c938387f0 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -143,7 +143,7 @@ actual fun PlatformPlayerSurface( if (!sourceAudioUrl.isNullOrBlank()) { val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory) val videoSource = mediaSourceFactory.createMediaSource(videoMediaItem) - val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl)) + val audioSource = mediaSourceFactory.createMediaSource(playbackMediaItemFromUrl(sourceAudioUrl)) val mergedSource = MergingMediaSource(videoSource, audioSource) if (startPositionMs != null) { setMediaSource(mergedSource, startPositionMs.coerceAtLeast(0L)) @@ -223,7 +223,10 @@ actual fun PlatformPlayerSurface( player.apply { setPlaybackMediaItem( - videoMediaItem = MediaItem.fromUri(sourceUrl), + videoMediaItem = playbackMediaItemFromUrl( + url = sourceUrl, + responseHeaders = sanitizedSourceResponseHeaders, + ), startPositionMs = fallbackStartPositionMs, ) prepare() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt index 01653e477..09cc98668 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt @@ -22,7 +22,6 @@ internal object PlayerPlaybackNetworking { "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36", - ) internal const val DEFAULT_USER_AGENT = @@ -60,10 +59,29 @@ internal object PlayerPlaybackNetworking { } fun createHttpDataSourceFactory(defaultHeaders: Map = emptyMap()): DataSource.Factory { - val mergedHeaders = DEFAULT_STREAM_HEADERS + defaultHeaders - return OkHttpDataSource.Factory(playbackHttpClient).apply { - setDefaultRequestProperties(mergedHeaders) - setUserAgent(DEFAULT_USER_AGENT) + val requestHeaders = sanitizeHeaders(defaultHeaders) + val client = requestHeaders.headerValue("Authorization")?.let { authorization -> + playbackHttpClient.newBuilder() + .addNetworkInterceptor { chain -> + val request = chain.request() + if (request.header("Authorization") == null) { + chain.proceed( + request.newBuilder() + .header("Authorization", authorization) + .build() + ) + } else { + chain.proceed(request) + } + } + .build() + } ?: playbackHttpClient + + return OkHttpDataSource.Factory(client).apply { + setDefaultRequestProperties(requestHeaders) + if (requestHeaders.headerValue("User-Agent") == null) { + setUserAgent(DEFAULT_USER_AGENT) + } } } @@ -82,7 +100,7 @@ internal object PlayerPlaybackNetworking { readTimeoutMs: Int, range: String? = null, ): HttpURLConnection { - val mergedHeaders = DEFAULT_STREAM_HEADERS + headers + val mergedHeaders = withDefaultUserAgent(headers) return (URL(url).openConnection() as HttpURLConnection).apply { if (this is HttpsURLConnection) { sslSocketFactory = sslContext.socketFactory @@ -92,7 +110,7 @@ internal object PlayerPlaybackNetworking { connectTimeout = connectTimeoutMs readTimeout = readTimeoutMs requestMethod = method - setRequestProperty("User-Agent", mergedHeaders["User-Agent"] ?: DEFAULT_USER_AGENT) + setRequestProperty("User-Agent", mergedHeaders.headerValue("User-Agent") ?: DEFAULT_USER_AGENT) mergedHeaders.forEach { (key, value) -> if (key.equals("Range", ignoreCase = true)) return@forEach if (key.equals("User-Agent", ignoreCase = true)) return@forEach @@ -101,4 +119,24 @@ internal object PlayerPlaybackNetworking { range?.let { setRequestProperty("Range", it) } } } + + private fun sanitizeHeaders(headers: Map): Map = + headers.mapNotNull { (rawKey, rawValue) -> + val key = rawKey.trim() + val value = rawValue.trim() + if (key.isBlank() || value.isBlank() || key.equals("Range", ignoreCase = true)) { + null + } else { + key to value + } + }.toMap() + + private fun withDefaultUserAgent(headers: Map): Map { + val sanitized = sanitizeHeaders(headers) + if (sanitized.headerValue("User-Agent") != null) return sanitized + return DEFAULT_STREAM_HEADERS + sanitized + } + + private fun Map.headerValue(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 9c539efaa..08ffa55aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -13,6 +13,8 @@ import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules import com.nuvio.app.features.player.skip.SkipIntroRepository import com.nuvio.app.features.streams.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamLinkCacheRepository +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -60,6 +62,9 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { liveGestureFeedback = null renderedGestureFeedback = null lockedOverlayVisible = false + credentialRefreshJob?.cancel() + credentialRefreshJob = null + credentialRefreshAttemptedSourceUrl = null initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false @@ -469,3 +474,124 @@ internal fun PlayerScreenRuntime.removeFailedStreamFromCache() { ) StreamLinkCacheRepository.remove(cacheKey) } + +internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message: String?): Boolean { + val failedUrl = activeSourceUrl + if (!failedUrl.hasLikelyExpiringPlaybackCredentials()) return false + if (credentialRefreshJob?.isActive == true) return true + if (credentialRefreshAttemptedSourceUrl == failedUrl) return false + + val currentVideoId = activeVideoId ?: return false + credentialRefreshAttemptedSourceUrl = failedUrl + removeFailedStreamFromCache() + + val savedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + val expectedProviderAddonId = activeProviderAddonId + val expectedProviderName = activeProviderName + val expectedStreamTitle = activeStreamTitle + val expectedBingeGroup = currentStreamBingeGroup + val type = contentType ?: parentMetaType + val season = activeSeasonNumber + val episode = activeEpisodeNumber + + errorMessage = null + controlsVisible = !playerControlsLocked + + credentialRefreshJob = scope.launch { + PlayerStreamsRepository.loadSources( + type = type, + videoId = currentVideoId, + season = season, + episode = episode, + forceRefresh = true, + ) + + var refreshedStream: StreamItem? = null + var pollCount = 0 + while (pollCount < CREDENTIAL_REFRESH_POLL_COUNT && refreshedStream == null) { + val state = PlayerStreamsRepository.sourceState.value + refreshedStream = findCredentialRefreshCandidate( + streams = state.groups.flatMap { it.streams }, + failedUrl = failedUrl, + expectedProviderAddonId = expectedProviderAddonId, + expectedProviderName = expectedProviderName, + expectedStreamTitle = expectedStreamTitle, + expectedBingeGroup = expectedBingeGroup, + ) + if ( + refreshedStream != null || + state.emptyStateReason != null || + (!state.isAnyLoading && state.groups.isNotEmpty()) + ) { + break + } + delay(CREDENTIAL_REFRESH_POLL_INTERVAL_MS) + pollCount++ + } + + val stream = refreshedStream + if (stream == null) { + errorMessage = message + controlsVisible = !playerControlsLocked + return@launch + } + + val refreshedUrl = stream.playableDirectUrl + if (refreshedUrl.isNullOrBlank() || refreshedUrl == failedUrl) { + errorMessage = message + controlsVisible = !playerControlsLocked + return@launch + } + + flushWatchProgress() + stopActiveP2pStream() + activeSourceUrl = refreshedUrl + activeSourceAudioUrl = null + activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeInitialPositionMs = savedPositionMs + activeInitialProgressFraction = null + showSourcesPanel = false + controlsVisible = true + } + return true +} + +private fun findCredentialRefreshCandidate( + streams: List, + failedUrl: String, + expectedProviderAddonId: String?, + expectedProviderName: String, + expectedStreamTitle: String, + expectedBingeGroup: String?, +): StreamItem? = + streams + .asSequence() + .mapNotNull { stream -> + val refreshedUrl = stream.playableDirectUrl?.takeIf { it.isNotBlank() && it != failedUrl } + ?: return@mapNotNull null + val providerMatches = if (!expectedProviderAddonId.isNullOrBlank()) { + stream.addonId == expectedProviderAddonId + } else { + stream.addonName == expectedProviderName + } + if (!providerMatches) return@mapNotNull null + + var score = 100 + if (stream.streamLabel == expectedStreamTitle) score += 40 + if (!expectedBingeGroup.isNullOrBlank() && stream.behaviorHints.bingeGroup == expectedBingeGroup) { + score += 20 + } + if (refreshedUrl.hasLikelyExpiringPlaybackCredentials()) score += 5 + score to stream + } + .maxByOrNull { (score, _) -> score } + ?.second + +private const val CREDENTIAL_REFRESH_POLL_COUNT = 30 +private const val CREDENTIAL_REFRESH_POLL_INTERVAL_MS = 500L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index cd000586e..3be4b9f2c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -168,6 +168,8 @@ internal class PlayerScreenRuntime( var nextEpisodeAutoPlayCountdown by mutableStateOf(null) var nextEpisodeAutoPlayJob by mutableStateOf(null) var pendingP2pSwitch by mutableStateOf(null) + var credentialRefreshJob by mutableStateOf(null) + var credentialRefreshAttemptedSourceUrl by mutableStateOf(null) var showAudioModal by mutableStateOf(false) var showSubtitleModal by mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 6319676f4..fbfc91d02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -136,6 +136,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { } }, onError = { message -> + if (message != null && tryRefreshCredentialedSourceAfterError(message)) { + return@PlatformPlayerSurface + } errorMessage = message if (message != null) { controlsVisible = !playerControlsLocked diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 6d9079919..1a733fcdb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -213,10 +213,14 @@ object PlayerStreamsRepository { } val installedAddonOrder = streamAddons.map { it.addonName } - val warmedAddonGroups = AddonStreamWarmupRepository - .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) - .orEmpty() - .associateBy { it.addonId } + val warmedAddonGroups = if (forceRefresh) { + emptyMap() + } else { + AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + } val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentials.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentials.kt new file mode 100644 index 000000000..6d8b90026 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentials.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.streams + +private val credentialQueryKeys = setOf( + "accesskey", + "accesssignature", + "accesssig", + "access_token", + "accesstoken", + "auth", + "authkey", + "authsig", + "authsignature", + "auth_token", + "authtoken", + "e", + "exp", + "expiration", + "expire", + "expires", + "expiresat", + "expiresin", + "expires_in", + "expiry", + "hmac", + "jwt", + "keypairid", + "policy", + "sig", + "signature", + "signed", + "st", + "t", + "token", +) + +private val credentialKeyFragments = listOf( + "token", + "signature", + "expires", + "expiry", +) + +internal fun String.hasLikelyExpiringPlaybackCredentials(): Boolean { + val query = substringAfter('?', missingDelimiterValue = "") + .substringBefore('#') + .takeIf { it.isNotBlank() } + ?: return false + + return query + .split('&', ';') + .any { rawParameter -> + val rawKey = rawParameter + .substringBefore('=', missingDelimiterValue = "") + .trim() + .lowercase() + if (rawKey.isBlank()) return@any false + + val compactKey = rawKey + .replace("-", "") + .replace("_", "") + .replace(".", "") + + rawKey in credentialQueryKeys || + compactKey in credentialQueryKeys || + credentialKeyFragments.any { fragment -> + rawKey.contains(fragment) || compactKey.contains(fragment) + } + } +} 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 ac2140f5c..63f1d4151 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 @@ -57,6 +57,11 @@ object StreamLinkCacheRepository { sources: List = emptyList(), bingeGroup: String? = null, ) { + if (url.isNotBlank() && url.hasLikelyExpiringPlaybackCredentials()) { + remove(contentKey) + return + } + val entry = CachedStreamLink( url = url, streamName = streamName, @@ -95,6 +100,10 @@ object StreamLinkCacheRepository { StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) return null } + if (entry.url.isNotBlank() && entry.url.hasLikelyExpiringPlaybackCredentials()) { + StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) + return null + } if (entry.url.isBlank() && entry.infoHash.isNullOrBlank() && entry.magnetUri.isNullOrBlank()) { StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) return null diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentialsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentialsTest.kt new file mode 100644 index 000000000..a7a808e55 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentialsTest.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlaybackUrlCredentialsTest { + @Test + fun detectsCommonExpiringCredentialParameters() { + assertTrue("https://example.com/playlist/1234.m3u8?token=abc&expires=1234".hasLikelyExpiringPlaybackCredentials()) + assertTrue("https://example.com/proxy?ext=m3u8&t=abc".hasLikelyExpiringPlaybackCredentials()) + assertTrue("https://example.com/video.mp4?expiresIn=300&signature=sig".hasLikelyExpiringPlaybackCredentials()) + } + + @Test + fun ignoresStableFormatHints() { + assertFalse("https://example.com/proxy?ext=m3u8".hasLikelyExpiringPlaybackCredentials()) + assertFalse("https://example.com/video.mp4?quality=1080p&format=mp4".hasLikelyExpiringPlaybackCredentials()) + } +}