diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt index a690ee159..22aa18267 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt @@ -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(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index 43212d657..f6561c9a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -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 { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt new file mode 100644 index 000000000..c2a2935ee --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt @@ -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"), + ) + } +}