Fix live tv & recorded tv playback (#1770)

## Description
#1654 broke playback for both live TV and recorded programs in `.ts`
format.

This PR fixes both issues! Jellyfin returns `NoCompatibleStream` for
live tv if the source ID is passed in, so that is now skipped for live
tv. `.ts` files may have IDs formatted with a slash instead of colon, so
the logic is updated to handle that plus fallbacks for potentially
unknown future formats.

### Related issues
Fixes #1767

### Testing
Emulator & nvidia shield, plus unit tests

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-02 11:11:32 -04:00 • committed by GitHub
parent 7c6ab39436
commit 6ad96865e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 116 additions and 33 deletions

View file

@ -466,10 +466,16 @@ class PlaybackViewModel
} }
} }
} }
val mediaSource = streamChoiceService.chooseSource(base, itemPlayback) val mediaSource =
if (!isLiveTv) {
streamChoiceService.chooseSource(base, itemPlayback)
} else {
null
}
val plc = streamChoiceService.getPlaybackLanguageChoice(base) val plc = streamChoiceService.getPlaybackLanguageChoice(base)
if (mediaSource == null) { if (mediaSource == null && !isLiveTv) {
showToast( showToast(
context, context,
"Item has no media sources, skipping...", "Item has no media sources, skipping...",
@ -479,7 +485,8 @@ class PlaybackViewModel
} }
val videoStream = val videoStream =
mediaSource.mediaStreams mediaSource
?.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO } ?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let { ?.let {
val isHdr = val isHdr =
@ -493,37 +500,48 @@ class PlaybackViewModel
// Create the correct player for the media // Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
val subtitleStreams = getSubtitleStreams(mediaSource) val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
val audioStreams = getAudioStreams(mediaSource) val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
val audioStream = val audioStream =
streamChoiceService mediaSource?.let {
.chooseAudioStream( streamChoiceService
source = mediaSource, .chooseAudioStream(
seriesId = base.seriesId, source = mediaSource,
itemPlayback = itemPlayback, seriesId = base.seriesId,
plc = plc, itemPlayback = itemPlayback,
prefs = preferences, plc = plc,
) prefs = preferences,
)
}
val audioIndex = audioStream?.index val audioIndex = audioStream?.index
val subtitleIndex = val subtitleIndex =
streamChoiceService mediaSource?.let {
.chooseSubtitleStream( streamChoiceService
source = mediaSource, .chooseSubtitleStream(
audioStream = audioStream, source = mediaSource,
seriesId = base.seriesId, audioStream = audioStream,
itemPlayback = itemPlayback, seriesId = base.seriesId,
plc = plc, itemPlayback = itemPlayback,
prefs = preferences, plc = plc,
)?.index prefs = preferences,
)?.index
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") }
Timber.d(
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
mediaSource?.id,
audioIndex,
subtitleIndex,
)
val trickPlayInfo = val trickPlayInfo =
item.data.trickplay mediaSource?.id?.let { sourceId ->
?.get(mediaSource.id) item.data.trickplay
?.values ?.get(sourceId)
?.firstOrNull() ?.values
?.firstOrNull()
}
trickPlayInfo?.let { trickplayInfo -> trickPlayInfo?.let { trickplayInfo ->
mediaSource.runTimeTicks?.ticks?.let { duration -> mediaSource.runTimeTicks?.ticks?.let { duration ->
viewModelScope.launchIO { viewModelScope.launchIO {
@ -545,7 +563,7 @@ class PlaybackViewModel
} }
updateCurrentMedia { updateCurrentMedia {
CurrentMediaInfo( CurrentMediaInfo(
sourceId = mediaSource.id, sourceId = mediaSource?.id,
videoStream = videoStream, videoStream = videoStream,
audioStreams = audioStreams, audioStreams = audioStreams,
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
@ -559,7 +577,7 @@ class PlaybackViewModel
audioIndex = audioIndex, audioIndex = audioIndex,
subtitleIndex = subtitleIndex, subtitleIndex = subtitleIndex,
positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET,
sourceId = mediaSource.id, sourceId = mediaSource?.id,
enableDirectPlay = !forceTranscoding, enableDirectPlay = !forceTranscoding,
enableDirectStream = !forceTranscoding, enableDirectStream = !forceTranscoding,
) )

View file

@ -158,10 +158,16 @@ object TrackSelectionUtils {
} else { } else {
track.trackFormats[0] track.trackFormats[0]
.id .id
?.split(":") ?.split(":", "/")
?.map { it.toInt() } ?.map { it.toIntOrNull() ?: Int.MAX_VALUE }
?.let { ?.let {
track to it if (it.size >= 2) {
track to it
} else if (it.isEmpty()) {
track to listOf(Int.MAX_VALUE, Int.MAX_VALUE)
} else {
track to listOf(it[0], Int.MAX_VALUE)
}
} }
} }
}.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] }) }.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] })

View file

@ -296,4 +296,61 @@ class TestSelectionTrackExamples {
Assert.assertFalse(result.bothSelected) Assert.assertFalse(result.bothSelected)
} }
} }
@Test
fun `test exo track IDs with slashes`() {
val testTracks =
TestTracks(
listOf(
// .ts files (eg recorded TV) report IDs like this in ExoPlayer
TestTrack("3/49", 0, MediaStreamType.VIDEO, false),
TestTrack("3/52", 1, MediaStreamType.AUDIO, false),
TestTrack("", 2, MediaStreamType.DATA, false),
TestTrack("3/53", 3, MediaStreamType.AUDIO, false),
),
)
val mediaSource =
TestTracks
.Builder()
.addVideo()
.addAudio()
.addTrack(1, MediaStreamType.DATA, false, C.FORMAT_HANDLED)
.addAudio()
.buildMediaSourceInfo()
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = testTracks.toTracks(),
audioIndex = 1,
subtitleIndex = null,
source = mediaSource,
).let { result ->
Assert.assertEquals(TrackSelected.SELECTED, result.audio)
Assert.assertTrue(result.bothSelected)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = testTracks.toTracks(),
audioIndex = 2,
subtitleIndex = null,
source = mediaSource,
).let { result ->
Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio)
Assert.assertFalse(result.bothSelected)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = testTracks.toTracks(),
audioIndex = 3,
subtitleIndex = null,
source = mediaSource,
).let { result ->
Assert.assertEquals(TrackSelected.SELECTED, result.audio)
Assert.assertTrue(result.bothSelected)
}
}
} }

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.test
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackGroup import androidx.media3.common.TrackGroup
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import org.jellyfin.sdk.model.api.MediaProtocol import org.jellyfin.sdk.model.api.MediaProtocol
@ -35,6 +36,7 @@ class TestTracks(
MediaStreamType.VIDEO -> "video/sample" MediaStreamType.VIDEO -> "video/sample"
MediaStreamType.AUDIO -> "audio/default" MediaStreamType.AUDIO -> "audio/default"
MediaStreamType.SUBTITLE -> "text/sample" MediaStreamType.SUBTITLE -> "text/sample"
MediaStreamType.DATA -> MimeTypes.APPLICATION_ID3
else -> throw UnsupportedOperationException("${it.type}") else -> throw UnsupportedOperationException("${it.type}")
} }
val format = val format =