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:
Vitor Pamplona
2026-04-23 08:43:24 -04:00
committed by GitHub
3 changed files with 151 additions and 6 deletions
@@ -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()
@@ -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 {
@@ -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"),
)
}
}