Fix credentialed HLS playback refresh

This commit is contained in:
tapframe 2026-06-09 02:01:23 +05:30
parent 5dbf522b7a
commit 88a474a437
10 changed files with 467 additions and 13 deletions

View file

@ -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<String, String> = 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, String>,
): String? =
inferMimeTypeFromResponseHeaders(responseHeaders)
?: inferMimeTypeFromPath(url)
private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): 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)($|[=/_.?&-])")

View file

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

View file

@ -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<String, String> = 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<String, String>): Map<String, String> =
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<String, String>): Map<String, String> {
val sanitized = sanitizeHeaders(headers)
if (sanitized.headerValue("User-Agent") != null) return sanitized
return DEFAULT_STREAM_HEADERS + sanitized
}
private fun Map<String, String>.headerValue(name: String): String? =
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
}

View file

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

View file

@ -168,6 +168,8 @@ internal class PlayerScreenRuntime(
var nextEpisodeAutoPlayCountdown by mutableStateOf<Int?>(null)
var nextEpisodeAutoPlayJob by mutableStateOf<Job?>(null)
var pendingP2pSwitch by mutableStateOf<PendingPlayerP2pSwitch?>(null)
var credentialRefreshJob by mutableStateOf<Job?>(null)
var credentialRefreshAttemptedSourceUrl by mutableStateOf<String?>(null)
var showAudioModal by mutableStateOf(false)
var showSubtitleModal by mutableStateOf(false)

View file

@ -136,6 +136,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
}
},
onError = { message ->
if (message != null && tryRefreshCredentialedSourceAfterError(message)) {
return@PlatformPlayerSurface
}
errorMessage = message
if (message != null) {
controlsVisible = !playerControlsLocked

View file

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

View file

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

View file

@ -57,6 +57,11 @@ object StreamLinkCacheRepository {
sources: List<String> = 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

View file

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