mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
Fix credentialed HLS playback refresh
This commit is contained in:
parent
5dbf522b7a
commit
88a474a437
10 changed files with 467 additions and 13 deletions
|
|
@ -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)($|[=/_.?&-])")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
}
|
||||
},
|
||||
onError = { message ->
|
||||
if (message != null && tryRefreshCredentialedSourceAfterError(message)) {
|
||||
return@PlatformPlayerSurface
|
||||
}
|
||||
errorMessage = message
|
||||
if (message != null) {
|
||||
controlsVisible = !playerControlsLocked
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue