feat: implement playback source planning and enhance device profile transcoding support

This commit is contained in:
2026-04-07 15:18:39 +02:00
parent b91a8a81e8
commit 59c9e31ca5
6 changed files with 351 additions and 103 deletions

View File

@@ -49,4 +49,5 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
testImplementation(libs.junit)
}

View File

@@ -7,8 +7,11 @@ import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodingProfile
/**
* Creates a DeviceProfile for Android devices with proper codec support detection.
@@ -45,7 +48,7 @@ object AndroidDeviceProfile {
Log.d(TAG, "Max audio channels: $maxAudioChannels")
val snapshot = CapabilitySnapshot(
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs),
deviceProfile = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels),
maxAudioChannels = maxAudioChannels
)
cachedSnapshot = snapshot
@@ -55,9 +58,10 @@ object AndroidDeviceProfile {
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
private fun buildDeviceProfile(
internal fun createDeviceProfile(
audioCodecs: List<String>,
videoCodecs: List<String>
videoCodecs: List<String>,
maxAudioChannels: Int
): DeviceProfile {
return DeviceProfile(
name = "Android Media3",
@@ -74,8 +78,22 @@ object AndroidDeviceProfile {
)
),
// Empty transcoding profiles - Jellyfin will use its defaults
transcodingProfiles = emptyList(),
// Explicit video transcoding support keeps Jellyfin 10.10/10.11 from
// returning sources that exist but are not marked usable for playback.
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
type = DlnaProfileType.VIDEO,
videoCodec = "h264",
audioCodec = "aac",
protocol = MediaStreamProtocol.HLS,
context = EncodingContext.STREAMING,
maxAudioChannels = maxAudioChannels.toString(),
minSegments = 2,
breakOnNonKeyFrames = true,
conditions = emptyList()
)
),
codecProfiles = emptyList(),

View File

@@ -281,76 +281,32 @@ class JellyfinApiClient @Inject constructor(
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val selectedMediaSource = selectMediaSource(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
val playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
val selectedMediaSource = playbackPlan.mediaSource
val url = when {
forceTranscode && selectedMediaSource.supportsTranscoding -> {
resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
!forceTranscode && selectedMediaSource.supportsDirectPlay -> {
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = selectedMediaSource.id,
playSessionId = playbackInfo.playSessionId,
liveStreamId = selectedMediaSource.liveStreamId
)
}
!forceTranscode && selectedMediaSource.supportsDirectStream -> {
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = selectedMediaSource.id,
playSessionId = playbackInfo.playSessionId,
liveStreamId = selectedMediaSource.liveStreamId,
container = selectedMediaSource.transcodingContainer ?: selectedMediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
selectedMediaSource.supportsTranscoding -> {
resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
else -> null
} ?: return@withContext null
val playMethod = when {
forceTranscode -> PlayMethod.TRANSCODE
selectedMediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
selectedMediaSource.supportsDirectStream -> PlayMethod.DIRECT_STREAM
selectedMediaSource.supportsTranscoding -> PlayMethod.TRANSCODE
else -> return@withContext null
}
val url = resolvePlaybackUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
urlStrategy = playbackPlan.urlStrategy,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
) ?: return@withContext null
val reportContext = PlaybackReportContext(
playMethod = playMethod,
playMethod = playbackPlan.playMethod,
mediaSourceId = selectedMediaSource.id,
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
liveStreamId = selectedMediaSource.liveStreamId,
playSessionId = playbackInfo.playSessionId,
canRetryWithTranscoding = !forceTranscode &&
playMethod != PlayMethod.TRANSCODE &&
selectedMediaSource.supportsTranscoding
canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding
)
Log.d(TAG, "Playback decision for $mediaId: $playMethod using ${selectedMediaSource.id}")
Log.d(
TAG,
"Playback decision for $mediaId: ${playbackPlan.playMethod} using ${selectedMediaSource.id}" +
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
)
PlaybackDecision(
url = url,
mediaSource = selectedMediaSource,
@@ -365,37 +321,20 @@ class JellyfinApiClient @Inject constructor(
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val url = when {
mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId
)
val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
val url = resolvePlaybackUrl(
mediaId = mediaId,
mediaSource = playbackPlan.mediaSource,
urlStrategy = playbackPlan.urlStrategy,
playSessionId = null,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
mediaSource.supportsDirectStream -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
mediaSource.supportsTranscoding -> resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = null,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
else -> null
}
Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
Log.d(
TAG,
"Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
)
url
}
@@ -482,20 +421,66 @@ class JellyfinApiClient @Inject constructor(
)
)
Log.d(TAG, "PlaybackInfo for $mediaId -> sources=${response.content.mediaSources.size}, session=${response.content.playSessionId}")
logPlaybackInfo(
mediaId = mediaId,
playbackInfo = response.content,
forceTranscode = forceTranscode
)
return response.content
}
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? {
return when {
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }
else -> mediaSources.firstOrNull { it.supportsDirectPlay }
?: mediaSources.firstOrNull { it.supportsDirectStream }
?: mediaSources.firstOrNull { it.supportsTranscoding }
?: mediaSources.firstOrNull()
private suspend fun resolvePlaybackUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
urlStrategy: PlaybackSourcePlanner.UrlStrategy,
playSessionId: String?,
maxAudioChannels: Int
): String? {
return when (urlStrategy) {
PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId
)
PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM -> resolveDirectStreamUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = playSessionId,
maxAudioChannels = maxAudioChannels
)
PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = playSessionId,
maxAudioChannels = maxAudioChannels
)
}
}
private suspend fun resolveDirectStreamUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
playSessionId: String?,
maxAudioChannels: Int
): String {
return api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = maxAudioChannels
)
}
private suspend fun resolveTranscodeUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
@@ -523,4 +508,28 @@ class JellyfinApiClient @Inject constructor(
maxAudioChannels = maxAudioChannels
)
}
private fun logPlaybackInfo(
mediaId: UUID,
playbackInfo: PlaybackInfoResponse,
forceTranscode: Boolean
) {
Log.d(
TAG,
"PlaybackInfo for $mediaId (forceTranscode=$forceTranscode) -> " +
"sources=${playbackInfo.mediaSources.size}, session=${playbackInfo.playSessionId}, error=${playbackInfo.errorCode}"
)
playbackInfo.mediaSources.forEachIndexed { index, mediaSource ->
Log.d(
TAG,
"PlaybackInfo[$index] id=${mediaSource.id}, container=${mediaSource.container}, " +
"transcodingContainer=${mediaSource.transcodingContainer}, protocol=${mediaSource.protocol}, " +
"supportsDirectPlay=${mediaSource.supportsDirectPlay}, " +
"supportsDirectStream=${mediaSource.supportsDirectStream}, " +
"supportsTranscoding=${mediaSource.supportsTranscoding}, " +
"hasTranscodingUrl=${!mediaSource.transcodingUrl.isNullOrBlank()}"
)
}
}
}

View File

@@ -0,0 +1,78 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
internal object PlaybackSourcePlanner {
data class Plan(
val mediaSource: MediaSourceInfo,
val urlStrategy: UrlStrategy,
val playMethod: PlayMethod,
val canRetryWithTranscoding: Boolean,
val usedFallback: Boolean
)
enum class UrlStrategy {
DIRECT_PLAY,
DIRECT_STREAM,
TRANSCODE
}
fun plan(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): Plan? {
val selected = when {
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.TRANSCODE,
playMethod = PlayMethod.TRANSCODE
)
}
else -> mediaSources.firstOrNull { it.supportsDirectPlay }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.DIRECT_PLAY,
playMethod = PlayMethod.DIRECT_PLAY
)
} ?: mediaSources.firstOrNull { it.supportsDirectStream }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.DIRECT_STREAM,
playMethod = PlayMethod.DIRECT_STREAM
)
} ?: mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.TRANSCODE,
playMethod = PlayMethod.TRANSCODE
)
} ?: mediaSources.firstOrNull()?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.DIRECT_STREAM,
playMethod = PlayMethod.DIRECT_STREAM,
usedFallback = true
)
}
} ?: return null
return Plan(
mediaSource = selected.mediaSource,
urlStrategy = selected.urlStrategy,
playMethod = selected.playMethod,
canRetryWithTranscoding = !forceTranscode &&
selected.playMethod != PlayMethod.TRANSCODE &&
mediaSources.any { it.supportsTranscoding },
usedFallback = selected.usedFallback
)
}
fun plan(mediaSource: MediaSourceInfo): Plan? = plan(listOf(mediaSource), forceTranscode = false)
private data class PlannedSelection(
val mediaSource: MediaSourceInfo,
val urlStrategy: UrlStrategy,
val playMethod: PlayMethod,
val usedFallback: Boolean = false
)
}

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidDeviceProfileTest {
@Test
fun `device profile includes explicit video transcoding support`() {
val profile = AndroidDeviceProfile.createDeviceProfile(
audioCodecs = listOf("aac", "ac3"),
videoCodecs = listOf("h264", "hevc"),
maxAudioChannels = 6
)
val transcodingProfile = profile.transcodingProfiles.single()
assertEquals(DlnaProfileType.VIDEO, transcodingProfile.type)
assertEquals("ts", transcodingProfile.container)
assertEquals("h264", transcodingProfile.videoCodec)
assertEquals("aac", transcodingProfile.audioCodec)
assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol)
assertEquals(EncodingContext.STREAMING, transcodingProfile.context)
assertEquals("6", transcodingProfile.maxAudioChannels)
assertEquals(2, transcodingProfile.minSegments)
assertTrue(transcodingProfile.breakOnNonKeyFrames)
}
}

View File

@@ -0,0 +1,111 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaSourceType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackSourcePlannerTest {
@Test
fun `transcode-only source is selected for playback`() {
val mediaSource = mediaSource(id = "transcode-only", supportsTranscoding = true)
val plan = requireNotNull(
PlaybackSourcePlanner.plan(
mediaSources = listOf(mediaSource),
forceTranscode = false
)
)
assertSame(mediaSource, plan.mediaSource)
assertEquals(PlaybackSourcePlanner.UrlStrategy.TRANSCODE, plan.urlStrategy)
assertEquals(PlayMethod.TRANSCODE, plan.playMethod)
assertFalse(plan.canRetryWithTranscoding)
assertFalse(plan.usedFallback)
}
@Test
fun `first source falls back to direct stream when Jellyfin flags are inconsistent`() {
val mediaSource = mediaSource(id = "flagless")
val plan = requireNotNull(
PlaybackSourcePlanner.plan(
mediaSources = listOf(mediaSource),
forceTranscode = false
)
)
assertSame(mediaSource, plan.mediaSource)
assertEquals(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM, plan.urlStrategy)
assertEquals(PlayMethod.DIRECT_STREAM, plan.playMethod)
assertFalse(plan.canRetryWithTranscoding)
assertTrue(plan.usedFallback)
}
@Test
fun `retry with transcoding stays enabled when another source can transcode`() {
val directPlaySource = mediaSource(id = "direct-play", supportsDirectPlay = true)
val transcodeSource = mediaSource(id = "transcode", supportsTranscoding = true)
val plan = requireNotNull(
PlaybackSourcePlanner.plan(
mediaSources = listOf(directPlaySource, transcodeSource),
forceTranscode = false
)
)
assertSame(directPlaySource, plan.mediaSource)
assertEquals(PlayMethod.DIRECT_PLAY, plan.playMethod)
assertTrue(plan.canRetryWithTranscoding)
assertFalse(plan.usedFallback)
}
@Test
fun `force transcode does not fall back to unusable source`() {
val mediaSource = mediaSource(id = "flagless")
val plan = PlaybackSourcePlanner.plan(
mediaSources = listOf(mediaSource),
forceTranscode = true
)
assertNull(plan)
}
private fun mediaSource(
id: String,
supportsDirectPlay: Boolean = false,
supportsDirectStream: Boolean = false,
supportsTranscoding: Boolean = false,
container: String? = "mkv"
): MediaSourceInfo {
return MediaSourceInfo(
protocol = MediaProtocol.FILE,
id = id,
type = MediaSourceType.DEFAULT,
container = container,
isRemote = false,
readAtNativeFramerate = true,
ignoreDts = false,
ignoreIndex = false,
genPtsInput = false,
supportsTranscoding = supportsTranscoding,
supportsDirectStream = supportsDirectStream,
supportsDirectPlay = supportsDirectPlay,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
requiresLooping = false,
supportsProbing = true,
transcodingSubProtocol = MediaStreamProtocol.HTTP,
hasSegments = false
)
}
}