Merge pull request #2520 from davotoula/claude/fix-hls-uri-extension-dJ5rR
fix(playback): route HLS URIs to HlsMediaSource via MediaItem mimeType
This commit is contained in:
+42
@@ -24,7 +24,10 @@ import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -33,16 +36,55 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
|
||||
companion object {
|
||||
const val EXTRA_CALLBACK_URI = "callbackUri"
|
||||
const val EXTRA_IS_LIVE_STREAM = "isLiveStream"
|
||||
|
||||
// ExoPlayer's URI-based content-type inference only fires for http(s)
|
||||
// schemes + known extensions — BUD-10 blossom URIs have neither, so
|
||||
// without an explicit mimeType HLS playlists get routed to
|
||||
// ProgressiveMediaSource and fail.
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun toExoPlayerMimeType(
|
||||
mimeType: String?,
|
||||
videoUri: String? = null,
|
||||
): String? {
|
||||
if (!mimeType.isNullOrBlank()) {
|
||||
return when (mimeType.lowercase()) {
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"audio/mpegurl",
|
||||
-> MimeTypes.APPLICATION_M3U8
|
||||
|
||||
else -> mimeType
|
||||
}
|
||||
}
|
||||
if (videoUri != null && hasM3u8PathExtension(videoUri)) {
|
||||
return MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Restrict `.m3u8` matching to the path component so a query param or
|
||||
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
|
||||
// MP4 URI doesn't misroute to HlsMediaSource.
|
||||
private fun hasM3u8PathExtension(uri: String): Boolean {
|
||||
val path = uri.substringBefore('?').substringBefore('#')
|
||||
return path.endsWith(".m3u8", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun compute(key: MediaItemData): LoadedMediaItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val normalizedMime = toExoPlayerMimeType(key.mimeType, key.videoUri)
|
||||
Log.d("MediaItemCache") {
|
||||
"compute: videoUri=${key.videoUri} imetaMime=${key.mimeType} -> exoMime=$normalizedMime isLive=${key.isLiveStream}"
|
||||
}
|
||||
LoadedMediaItem(
|
||||
key,
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setMediaId(key.videoUri)
|
||||
.setUri(key.videoUri)
|
||||
.apply { normalizedMime?.let { setMimeType(it) } }
|
||||
.setMediaMetadata(
|
||||
MediaMetadata
|
||||
.Builder()
|
||||
|
||||
+9
-6
@@ -68,14 +68,17 @@ class CustomMediaSourceFactory(
|
||||
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val live = isLiveStream(mediaItem)
|
||||
val itemMime = mediaItem.localConfiguration?.mimeType
|
||||
val source =
|
||||
if (live) {
|
||||
nonCachingFactory.createMediaSource(mediaItem)
|
||||
} else {
|
||||
cachingFactory.createMediaSource(mediaItem)
|
||||
}
|
||||
Log.d("CustomMediaSourceFactory") {
|
||||
"createMediaSource(${if (live) "BYPASS" else "CACHE"}): ${mediaItem.mediaId}"
|
||||
}
|
||||
return if (live) {
|
||||
nonCachingFactory.createMediaSource(mediaItem)
|
||||
} else {
|
||||
cachingFactory.createMediaSource(mediaItem)
|
||||
"createMediaSource(${if (live) "BYPASS" else "CACHE"}): id=${mediaItem.mediaId} mime=$itemMime -> ${source::class.java.simpleName}"
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
private fun isLiveStream(mediaItem: MediaItem): Boolean {
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class MediaItemCacheMimeTypeTest {
|
||||
@Test
|
||||
fun appleHlsPlaylistMimeIsNormalizedForExoPlayer() {
|
||||
assertEquals(
|
||||
MimeTypes.APPLICATION_M3U8,
|
||||
MediaItemCache.toExoPlayerMimeType("application/vnd.apple.mpegurl"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun xMpegUrlVariantsNormalize() {
|
||||
assertEquals(MimeTypes.APPLICATION_M3U8, MediaItemCache.toExoPlayerMimeType("application/x-mpegurl"))
|
||||
assertEquals(MimeTypes.APPLICATION_M3U8, MediaItemCache.toExoPlayerMimeType("APPLICATION/X-MPEGURL"))
|
||||
assertEquals(MimeTypes.APPLICATION_M3U8, MediaItemCache.toExoPlayerMimeType("audio/x-mpegurl"))
|
||||
assertEquals(MimeTypes.APPLICATION_M3U8, MediaItemCache.toExoPlayerMimeType("audio/mpegurl"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonHlsMimeIsForwardedUnchanged() {
|
||||
assertEquals("video/mp4", MediaItemCache.toExoPlayerMimeType("video/mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullOrBlankYieldsNull() {
|
||||
assertNull(MediaItemCache.toExoPlayerMimeType(null))
|
||||
assertNull(MediaItemCache.toExoPlayerMimeType(""))
|
||||
assertNull(MediaItemCache.toExoPlayerMimeType(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareBlossomM3u8UriInfersHlsWhenMimeIsMissing() {
|
||||
val uri = "blossom:ce7cad1ad75f26b5ebbd72d1048cb627a1d34529a0eeea087454c96aad8fc3f4.m3u8?xs=cdn.hzrd149.com"
|
||||
assertEquals(MimeTypes.APPLICATION_M3U8, MediaItemCache.toExoPlayerMimeType(null, uri))
|
||||
assertEquals(MimeTypes.APPLICATION_M3U8, MediaItemCache.toExoPlayerMimeType("", uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpsM3u8UriInfersHlsWhenMimeIsMissing() {
|
||||
assertEquals(
|
||||
MimeTypes.APPLICATION_M3U8,
|
||||
MediaItemCache.toExoPlayerMimeType(null, "https://cdn.example.com/video/master.m3u8"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonHlsUriYieldsNullWhenMimeIsMissing() {
|
||||
assertNull(MediaItemCache.toExoPlayerMimeType(null, "https://cdn.example.com/video.mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun m3u8InQueryOrFragmentDoesNotMisrouteMp4Playback() {
|
||||
assertNull(MediaItemCache.toExoPlayerMimeType(null, "https://cdn.example.com/video.mp4?ref=other.m3u8"))
|
||||
assertNull(MediaItemCache.toExoPlayerMimeType(null, "https://cdn.example.com/video.mp4#section=a.m3u8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpsM3u8UriWithQueryStringStillInfersHls() {
|
||||
assertEquals(
|
||||
MimeTypes.APPLICATION_M3U8,
|
||||
MediaItemCache.toExoPlayerMimeType(null, "https://cdn.example.com/master.m3u8?token=abc"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imetaMimeTakesPrecedenceOverUriInference() {
|
||||
assertEquals(
|
||||
"video/mp4",
|
||||
MediaItemCache.toExoPlayerMimeType("video/mp4", "https://cdn.example.com/playlist.m3u8"),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user