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.core.net.toUri
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
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.amethyst.commons.compose.GenericBaseCache
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
@@ -33,16 +36,55 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
|
|||||||
companion object {
|
companion object {
|
||||||
const val EXTRA_CALLBACK_URI = "callbackUri"
|
const val EXTRA_CALLBACK_URI = "callbackUri"
|
||||||
const val EXTRA_IS_LIVE_STREAM = "isLiveStream"
|
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 =
|
override suspend fun compute(key: MediaItemData): LoadedMediaItem =
|
||||||
withContext(Dispatchers.IO) {
|
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(
|
LoadedMediaItem(
|
||||||
key,
|
key,
|
||||||
MediaItem
|
MediaItem
|
||||||
.Builder()
|
.Builder()
|
||||||
.setMediaId(key.videoUri)
|
.setMediaId(key.videoUri)
|
||||||
.setUri(key.videoUri)
|
.setUri(key.videoUri)
|
||||||
|
.apply { normalizedMime?.let { setMimeType(it) } }
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata
|
MediaMetadata
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|||||||
+9
-6
@@ -68,14 +68,17 @@ class CustomMediaSourceFactory(
|
|||||||
|
|
||||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||||
val live = isLiveStream(mediaItem)
|
val live = isLiveStream(mediaItem)
|
||||||
|
val itemMime = mediaItem.localConfiguration?.mimeType
|
||||||
|
val source =
|
||||||
|
if (live) {
|
||||||
|
nonCachingFactory.createMediaSource(mediaItem)
|
||||||
|
} else {
|
||||||
|
cachingFactory.createMediaSource(mediaItem)
|
||||||
|
}
|
||||||
Log.d("CustomMediaSourceFactory") {
|
Log.d("CustomMediaSourceFactory") {
|
||||||
"createMediaSource(${if (live) "BYPASS" else "CACHE"}): ${mediaItem.mediaId}"
|
"createMediaSource(${if (live) "BYPASS" else "CACHE"}): id=${mediaItem.mediaId} mime=$itemMime -> ${source::class.java.simpleName}"
|
||||||
}
|
|
||||||
return if (live) {
|
|
||||||
nonCachingFactory.createMediaSource(mediaItem)
|
|
||||||
} else {
|
|
||||||
cachingFactory.createMediaSource(mediaItem)
|
|
||||||
}
|
}
|
||||||
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isLiveStream(mediaItem: MediaItem): Boolean {
|
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