From 81d65e96a89d975eb971a2ad97e6888e649dcd00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 07:43:47 +0000 Subject: [PATCH] fix(playback): route HLS URIs to HlsMediaSource via MediaItem mimeType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExoPlayer's URI-based content-type inference only fires for http(s) schemes + known extensions, so HLS playback fails in two cases Amethyst ships regularly: 1. NIP-71 video events with BUD-10 blossom URIs. Hash-based URIs carry no extension and the imeta's application/vnd.apple.mpegurl never reaches the MediaItem — MediaItemCache dropped it. 2. Bare BUD-10 URIs pasted into kind 1 posts, which have no imeta at all. Even though the URI contains `.m3u8` in its path, ExoPlayer's inference skips custom schemes (`blossom:`). Forward a normalized mimeType onto MediaItem.Builder.setMimeType so DefaultMediaSourceFactory routes to HlsMediaSource regardless of scheme or extension: - Normalize IANA Apple HLS variants (application/vnd.apple.mpegurl, application/x-mpegurl, audio/x-mpegurl, audio/mpegurl) to MimeTypes.APPLICATION_M3U8. - Fall back to `.m3u8` URI detection when no imeta mimeType is present. Matches the existing isLiveStreaming(url) heuristic. Adds diagnostic logs at MediaItemCache.compute and CustomMediaSourceFactory.createMediaSource so the routing decision is observable in logcat, matching the diagnostic-logs pattern already used elsewhere in playback. --- .../composable/mediaitem/MediaItemCache.kt | 42 ++++++++ .../playerPool/CustomMediaSourceFactory.kt | 15 +-- .../mediaitem/MediaItemCacheMimeTypeTest.kt | 100 ++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt 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"), + ) + } +}