diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 9afea195..e6b2a45e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -49,4 +49,5 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) + testImplementation(libs.junit) } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt index a9eb331a..8daeccdc 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt @@ -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, - videoCodecs: List + videoCodecs: List, + 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(), diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt index e464014e..e8691a58 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt @@ -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, 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()}" + ) + } + } } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt new file mode 100644 index 00000000..96361a56 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt @@ -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, 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 + ) +} diff --git a/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt b/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt new file mode 100644 index 00000000..e02f328d --- /dev/null +++ b/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt @@ -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) + } +} diff --git a/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlannerTest.kt b/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlannerTest.kt new file mode 100644 index 00000000..c0caa490 --- /dev/null +++ b/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlannerTest.kt @@ -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 + ) + } +}