diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackProfileFamilyModule.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackProfileFamilyModule.kt new file mode 100644 index 00000000..4c1ea36a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackProfileFamilyModule.kt @@ -0,0 +1,15 @@ +package hu.bbara.purefin.tv.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.core.data.client.PlaybackProfileFamily + +@Module +@InstallIn(SingletonComponent::class) +object TvPlaybackProfileFamilyModule { + + @Provides + fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.TV +} diff --git a/app/src/main/java/hu/bbara/purefin/di/PlaybackProfileFamilyModule.kt b/app/src/main/java/hu/bbara/purefin/di/PlaybackProfileFamilyModule.kt new file mode 100644 index 00000000..7e2cca97 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/di/PlaybackProfileFamilyModule.kt @@ -0,0 +1,15 @@ +package hu.bbara.purefin.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.core.data.client.PlaybackProfileFamily + +@Module +@InstallIn(SingletonComponent::class) +object PlaybackProfileFamilyModule { + + @Provides + fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.MOBILE +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index e6b2a45e..b4cc865b 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -36,6 +36,7 @@ kotlin { dependencies { implementation(project(":core:model")) implementation(libs.jellyfin.core) + implementation(libs.media3.common) implementation(libs.kotlinx.serialization.json) implementation(libs.hilt) ksp(libs.hilt.compiler) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidCodecCatalog.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidCodecCatalog.kt new file mode 100644 index 00000000..5987cae0 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidCodecCatalog.kt @@ -0,0 +1,169 @@ +package hu.bbara.purefin.core.data.client + +import android.media.MediaCodecInfo +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaFormat + +internal data class AndroidCodecCatalog( + val supportedVideoCodecs: Set, + val supportedAudioCodecs: Set, + val videoCodecProfiles: Map>, +) { + companion object { + fun create(codecInfos: Array): AndroidCodecCatalog { + val supportedVideoCodecs = linkedSetOf() + val supportedAudioCodecs = linkedSetOf() + val videoCodecProfiles = linkedMapOf>() + + for (codecInfo in codecInfos) { + if (codecInfo.isEncoder) continue + + for (mimeType in codecInfo.supportedTypes) { + val capabilities = try { + codecInfo.getCapabilitiesForType(mimeType) + } catch (_: IllegalArgumentException) { + continue + } + + androidVideoCodecName(mimeType)?.let { codec -> + supportedVideoCodecs += codec + val profiles = videoCodecProfiles.getOrPut(codec) { linkedSetOf() } + capabilities.profileLevels + .mapNotNull { androidVideoProfileName(codec, it.profile) } + .forEach(profiles::add) + } + + androidAudioCodecName(mimeType)?.let(supportedAudioCodecs::add) + } + } + + return AndroidCodecCatalog( + supportedVideoCodecs = supportedVideoCodecs, + supportedAudioCodecs = supportedAudioCodecs, + videoCodecProfiles = videoCodecProfiles.mapValues { it.value.toSet() }, + ) + } + } +} + +private fun androidVideoCodecName(mimeType: String): String? = when (mimeType) { + MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video" + MediaFormat.MIMETYPE_VIDEO_H263 -> "h263" + MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4" + MediaFormat.MIMETYPE_VIDEO_AVC -> "h264" + MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc" + MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8" + MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9" + MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1" + else -> null +} + +private fun androidAudioCodecName(mimeType: String): String? = when (mimeType) { + MediaFormat.MIMETYPE_AUDIO_AAC -> "aac" + MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3" + MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp" + MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3" + MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac" + MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3" + MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus" + MediaFormat.MIMETYPE_AUDIO_RAW -> "raw" + MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis" + MediaFormat.MIMETYPE_AUDIO_QCELP, + MediaFormat.MIMETYPE_AUDIO_MSGSM, + MediaFormat.MIMETYPE_AUDIO_G711_MLAW, + MediaFormat.MIMETYPE_AUDIO_G711_ALAW, + -> null + else -> null +} + +private fun androidVideoProfileName(codec: String, profile: Int): String? = when (codec) { + "mpeg2video" -> mpeg2ProfileName(profile) + "h263" -> h263ProfileName(profile) + "mpeg4" -> mpeg4ProfileName(profile) + "h264" -> avcProfileName(profile) + "hevc" -> hevcProfileName(profile) + "vp8" -> vp8ProfileName(profile) + "vp9" -> vp9ProfileName(profile) + else -> null +} + +private fun mpeg2ProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.MPEG2ProfileSimple -> "simple profile" + CodecProfileLevel.MPEG2ProfileMain -> "main profile" + CodecProfileLevel.MPEG2Profile422 -> "422 profile" + CodecProfileLevel.MPEG2ProfileSNR -> "snr profile" + CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile" + CodecProfileLevel.MPEG2ProfileHigh -> "high profile" + else -> null +} + +private fun h263ProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.H263ProfileBaseline -> "baseline" + CodecProfileLevel.H263ProfileH320Coding -> "h320 coding" + CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible" + CodecProfileLevel.H263ProfileISWV2 -> "isw v2" + CodecProfileLevel.H263ProfileISWV3 -> "isw v3" + CodecProfileLevel.H263ProfileHighCompression -> "high compression" + CodecProfileLevel.H263ProfileInternet -> "internet" + CodecProfileLevel.H263ProfileInterlace -> "interlace" + CodecProfileLevel.H263ProfileHighLatency -> "high latency" + else -> null +} + +private fun mpeg4ProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile" + CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile" + CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile" + CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile" + CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile" + CodecProfileLevel.MPEG4ProfileCore -> "core profile" + CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile" + CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile" + CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile" + CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile" + CodecProfileLevel.MPEG4ProfileSimple -> "simple profile" + CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile" + CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile" + CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile" + CodecProfileLevel.MPEG4ProfileMain -> "main profile" + else -> null +} + +private fun avcProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.AVCProfileBaseline -> "baseline" + CodecProfileLevel.AVCProfileMain -> "main" + CodecProfileLevel.AVCProfileExtended -> "extended" + CodecProfileLevel.AVCProfileHigh -> "high" + CodecProfileLevel.AVCProfileHigh10 -> "high 10" + CodecProfileLevel.AVCProfileHigh422 -> "high 422" + CodecProfileLevel.AVCProfileHigh444 -> "high 444" + CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline" + CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high" + else -> null +} + +private fun hevcProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.HEVCProfileMain -> "Main" + CodecProfileLevel.HEVCProfileMain10 -> "Main 10" + CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10" + CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus" + CodecProfileLevel.HEVCProfileMainStill -> "Main Still" + else -> null +} + +private fun vp8ProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.VP8ProfileMain -> "main" + else -> null +} + +private fun vp9ProfileName(profile: Int): String? = when (profile) { + CodecProfileLevel.VP9Profile0 -> "Profile 0" + CodecProfileLevel.VP9Profile1 -> "Profile 1" + CodecProfileLevel.VP9Profile2, + CodecProfileLevel.VP9Profile2HDR, + -> "Profile 2" + CodecProfileLevel.VP9Profile3, + CodecProfileLevel.VP9Profile3HDR, + -> "Profile 3" + else -> null +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileCapabilities.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileCapabilities.kt new file mode 100644 index 00000000..2fcdff14 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileCapabilities.kt @@ -0,0 +1,315 @@ +package hu.bbara.purefin.core.data.client + +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build +import androidx.media3.common.MimeTypes + +data class ProfileResolution( + val width: Int, + val height: Int, +) + +interface DeviceProfileCapabilities { + fun supportsAv1(): Boolean + fun supportsAv1Main10(): Boolean + fun supportsAv1DolbyVision(): Boolean + fun supportsAv1Hdr10(): Boolean + fun supportsAv1Hdr10Plus(): Boolean + fun supportsAvc(): Boolean + fun supportsAvcHigh10(): Boolean + fun avcMainLevel(): Int + fun avcHigh10Level(): Int + fun supportsHevc(): Boolean + fun supportsHevcMain10(): Boolean + fun supportsHevcDolbyVision(): Boolean + fun supportsHevcDolbyVisionEl(): Boolean + fun supportsHevcHdr10(): Boolean + fun supportsHevcHdr10Plus(): Boolean + fun hevcMainLevel(): Int + fun hevcMain10Level(): Int + fun supportsVc1(): Boolean + fun maxResolution(mimeType: String): ProfileResolution + fun supportsVideoCodec(codec: String): Boolean + fun supportsAudioCodec(codec: String): Boolean + fun videoCodecProfiles(codec: String): Set + val hevcDoviHdr10PlusBug: Boolean +} + +internal class AndroidDeviceProfileCapabilities : DeviceProfileCapabilities { + private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) } + private val codecCatalog by lazy { AndroidCodecCatalog.create(mediaCodecList.codecInfos) } + + private object DolbyVisionProfiles { + val profile7: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + CodecProfileLevel.DolbyVisionProfileDvheDtb + } else { + -1 + } + } + } + + private object Av1ProfileLevels { + val profileMain10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10 + } else { + 0x2 + } + } + val profileMain10Hdr10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10HDR10 + } else { + 0x1000 + } + } + val profileMain10Hdr10Plus: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10HDR10Plus + } else { + 0x2000 + } + } + val dolbyVisionProfile10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + CodecProfileLevel.DolbyVisionProfileDvav110 + } else { + 0x400 + } + } + val level5: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1Level5 + } else { + 0x1000 + } + } + } + + private val avcLevels = listOf( + CodecProfileLevel.AVCLevel1b to 9, + CodecProfileLevel.AVCLevel1 to 10, + CodecProfileLevel.AVCLevel11 to 11, + CodecProfileLevel.AVCLevel12 to 12, + CodecProfileLevel.AVCLevel13 to 13, + CodecProfileLevel.AVCLevel2 to 20, + CodecProfileLevel.AVCLevel21 to 21, + CodecProfileLevel.AVCLevel22 to 22, + CodecProfileLevel.AVCLevel3 to 30, + CodecProfileLevel.AVCLevel31 to 31, + CodecProfileLevel.AVCLevel32 to 32, + CodecProfileLevel.AVCLevel4 to 40, + CodecProfileLevel.AVCLevel41 to 41, + CodecProfileLevel.AVCLevel42 to 42, + CodecProfileLevel.AVCLevel5 to 50, + CodecProfileLevel.AVCLevel51 to 51, + CodecProfileLevel.AVCLevel52 to 52, + ) + + private val hevcLevels = listOf( + CodecProfileLevel.HEVCMainTierLevel1 to 30, + CodecProfileLevel.HEVCMainTierLevel2 to 60, + CodecProfileLevel.HEVCMainTierLevel21 to 63, + CodecProfileLevel.HEVCMainTierLevel3 to 90, + CodecProfileLevel.HEVCMainTierLevel31 to 93, + CodecProfileLevel.HEVCMainTierLevel4 to 120, + CodecProfileLevel.HEVCMainTierLevel41 to 123, + CodecProfileLevel.HEVCMainTierLevel5 to 150, + CodecProfileLevel.HEVCMainTierLevel51 to 153, + CodecProfileLevel.HEVCMainTierLevel52 to 156, + CodecProfileLevel.HEVCMainTierLevel6 to 180, + CodecProfileLevel.HEVCMainTierLevel61 to 183, + CodecProfileLevel.HEVCMainTierLevel62 to 186, + ) + + override fun supportsAv1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1) + + override fun supportsAv1Main10(): Boolean = hasDecoder( + MimeTypes.VIDEO_AV1, + Av1ProfileLevels.profileMain10, + Av1ProfileLevels.level5, + ) + + override fun supportsAv1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + hasDecoder( + MimeTypes.VIDEO_DOLBY_VISION, + Av1ProfileLevels.dolbyVisionProfile10, + CodecProfileLevel.DolbyVisionLevelHd24, + ) + + override fun supportsAv1Hdr10(): Boolean = hasDecoder( + MimeTypes.VIDEO_AV1, + Av1ProfileLevels.profileMain10Hdr10, + Av1ProfileLevels.level5, + ) + + override fun supportsAv1Hdr10Plus(): Boolean = hasDecoder( + MimeTypes.VIDEO_AV1, + Av1ProfileLevels.profileMain10Hdr10Plus, + Av1ProfileLevels.level5, + ) + + override fun supportsAvc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC) + + override fun supportsAvcHigh10(): Boolean = hasDecoder( + MediaFormat.MIMETYPE_VIDEO_AVC, + CodecProfileLevel.AVCProfileHigh10, + CodecProfileLevel.AVCLevel4, + ) + + override fun avcMainLevel(): Int = avcLevel(CodecProfileLevel.AVCProfileMain) + + override fun avcHigh10Level(): Int = avcLevel(CodecProfileLevel.AVCProfileHigh10) + + override fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC) + + override fun supportsHevcMain10(): Boolean = hasDecoder( + MediaFormat.MIMETYPE_VIDEO_HEVC, + CodecProfileLevel.HEVCProfileMain10, + CodecProfileLevel.HEVCMainTierLevel4, + ) + + override fun supportsHevcDolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION) + + override fun supportsHevcDolbyVisionEl(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, + DolbyVisionProfiles.profile7, + CodecProfileLevel.DolbyVisionLevelHd24, + ) && + supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) + + override fun supportsHevcHdr10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_HEVC, + CodecProfileLevel.HEVCProfileMain10HDR10, + CodecProfileLevel.HEVCMainTierLevel4, + ) + + override fun supportsHevcHdr10Plus(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_HEVC, + CodecProfileLevel.HEVCProfileMain10HDR10Plus, + CodecProfileLevel.HEVCMainTierLevel4, + ) + + override fun hevcMainLevel(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain) + + override fun hevcMain10Level(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain10) + + override fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1) + + override fun maxResolution(mimeType: String): ProfileResolution { + var maxWidth = 0 + var maxHeight = 0 + + for (info in mediaCodecList.codecInfos) { + if (info.isEncoder) continue + + try { + val videoCapabilities = info.getCapabilitiesForType(mimeType).videoCapabilities ?: continue + val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue + val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue + + maxWidth = maxOf(maxWidth, supportedWidth) + maxHeight = maxOf(maxHeight, supportedHeight) + } catch (_: IllegalArgumentException) { + // Codec not supported. + } + } + + return ProfileResolution(maxWidth, maxHeight) + } + + override fun supportsVideoCodec(codec: String): Boolean = codecCatalog.supportedVideoCodecs.contains(codec) + + override fun supportsAudioCodec(codec: String): Boolean = codecCatalog.supportedAudioCodecs.contains(codec) + + override fun videoCodecProfiles(codec: String): Set = codecCatalog.videoCodecProfiles[codec].orEmpty() + + override val hevcDoviHdr10PlusBug: Boolean = Build.MODEL in modelsWithDoviHdr10PlusBug + + private fun avcLevel(profile: Int): Int { + val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile) + return avcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0 + } + + private fun hevcLevel(profile: Int): Int { + val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile) + return hevcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0 + } + + private fun decoderLevel(mimeType: String, profile: Int): Int { + var maxLevel = 0 + for (info in mediaCodecList.codecInfos) { + if (info.isEncoder) continue + + try { + val capabilities = info.getCapabilitiesForType(mimeType) + for (profileLevel in capabilities.profileLevels) { + if (profileLevel.profile == profile) { + maxLevel = maxOf(maxLevel, profileLevel.level) + } + } + } catch (_: IllegalArgumentException) { + // Codec not supported. + } + } + return maxLevel + } + + private fun hasDecoder(mimeType: String, profile: Int, level: Int): Boolean { + for (info in mediaCodecList.codecInfos) { + if (info.isEncoder) continue + + try { + val capabilities = info.getCapabilitiesForType(mimeType) + for (profileLevel in capabilities.profileLevels) { + if (profileLevel.profile != profile) continue + if (profileLevel.level >= level) return true + } + } catch (_: IllegalArgumentException) { + // Codec not supported. + } + } + return false + } + + private fun hasCodecForMime(mimeType: String): Boolean { + for (info in mediaCodecList.codecInfos) { + if (info.isEncoder) continue + if (info.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) { + return true + } + } + return false + } + + private fun supportsMultiInstance(mimeType: String): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false + + for (info in mediaCodecList.codecInfos) { + if (info.isEncoder) continue + + try { + if (!info.supportedTypes.contains(mimeType)) continue + if (info.getCapabilitiesForType(mimeType).maxSupportedInstances > 1) { + return true + } + } catch (_: IllegalArgumentException) { + // Codec not supported. + } + } + return false + } +} + +private val modelsWithDoviHdr10PlusBug = setOf( + "AFTKRT", + "AFTKA", + "AFTKM", +) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinAndroidMobileDeviceProfile.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinAndroidMobileDeviceProfile.kt new file mode 100644 index 00000000..55a315f5 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinAndroidMobileDeviceProfile.kt @@ -0,0 +1,213 @@ +package hu.bbara.purefin.core.data.client + +import org.jellyfin.sdk.model.api.CodecProfile +import org.jellyfin.sdk.model.api.CodecType +import org.jellyfin.sdk.model.api.ContainerProfile +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.MediaStreamProtocol +import org.jellyfin.sdk.model.api.ProfileCondition +import org.jellyfin.sdk.model.api.ProfileConditionType +import org.jellyfin.sdk.model.api.ProfileConditionValue +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import org.jellyfin.sdk.model.api.SubtitleProfile +import org.jellyfin.sdk.model.api.TranscodingProfile + +internal data class JellyfinAndroidMobileProfileConfig( + val assDirectPlay: Boolean = false, +) + +internal object JellyfinAndroidMobileDeviceProfile { + val fixedConfig = JellyfinAndroidMobileProfileConfig() + + private const val profileName = "Purefin" + private const val maxStreamingBitrate = 120_000_000 + private const val maxStaticBitrate = 100_000_000 + private const val maxMusicTranscodingBitrate = 384_000 + + private val supportedContainerFormats = arrayOf( + "mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp", + ) + + private val availableVideoCodecs = arrayOf( + arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"), + arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"), + arrayOf("vp8", "vp9", "av1"), + arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9"), + emptyArray(), + emptyArray(), + emptyArray(), + arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"), + arrayOf("mpeg4", "h264"), + emptyArray(), + emptyArray(), + arrayOf("h263", "mpeg4", "h264", "hevc"), + ) + + private val pcmCodecs = arrayOf( + "pcm_s8", + "pcm_s16be", + "pcm_s16le", + "pcm_s24le", + "pcm_s32le", + "pcm_f32le", + "pcm_alaw", + "pcm_mulaw", + ) + + private val availableAudioCodecs = arrayOf( + arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3", "opus"), + arrayOf("mp3", "aac", "ac3", "eac3"), + arrayOf("vorbis", "opus"), + arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"), + arrayOf("mp3"), + arrayOf("vorbis", "opus", "flac"), + pcmCodecs, + arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"), + arrayOf("mp3", "aac"), + arrayOf("aac"), + arrayOf("flac"), + arrayOf("3gpp", "aac", "flac"), + ) + + private val forcedAudioCodecs = setOf(*pcmCodecs, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd") + + private val exoEmbeddedSubtitles = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml") + private val exoExternalSubtitles = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt") + private val ssaSubtitles = arrayOf("ssa", "ass") + + private val transcodingProfiles = listOf( + TranscodingProfile( + type = DlnaProfileType.VIDEO, + container = "ts", + videoCodec = "h264", + audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd", + protocol = MediaStreamProtocol.HLS, + conditions = emptyList(), + ), + TranscodingProfile( + type = DlnaProfileType.VIDEO, + container = "mkv", + videoCodec = "h264", + audioCodec = availableAudioCodecs[supportedContainerFormats.indexOf("mkv")].joinToString(","), + protocol = MediaStreamProtocol.HLS, + conditions = emptyList(), + ), + TranscodingProfile( + type = DlnaProfileType.AUDIO, + container = "mp3", + videoCodec = "", + audioCodec = "mp3", + protocol = MediaStreamProtocol.HTTP, + conditions = emptyList(), + ), + ) + + fun create( + capabilities: DeviceProfileCapabilities, + config: JellyfinAndroidMobileProfileConfig = fixedConfig, + ): DeviceProfile { + val containerProfiles = mutableListOf() + val directPlayProfiles = mutableListOf() + val codecProfiles = mutableListOf() + + for (i in supportedContainerFormats.indices) { + val container = supportedContainerFormats[i] + val supportedVideoCodecs = availableVideoCodecs[i] + .filter(capabilities::supportsVideoCodec) + .toTypedArray() + val supportedAudioCodecs = availableAudioCodecs[i] + .filter { capabilities.supportsAudioCodec(it) || it in forcedAudioCodecs } + .toTypedArray() + + if (supportedVideoCodecs.isNotEmpty()) { + containerProfiles += ContainerProfile( + type = DlnaProfileType.VIDEO, + container = container, + conditions = emptyList(), + ) + directPlayProfiles += DirectPlayProfile( + type = DlnaProfileType.VIDEO, + container = container, + videoCodec = supportedVideoCodecs.joinToString(","), + audioCodec = supportedAudioCodecs.joinToString(","), + ) + supportedVideoCodecs.mapNotNullTo(codecProfiles) { videoCodec -> + generateCodecProfile(container, videoCodec, capabilities.videoCodecProfiles(videoCodec)) + } + } + + if (supportedAudioCodecs.isNotEmpty()) { + containerProfiles += ContainerProfile( + type = DlnaProfileType.AUDIO, + container = container, + conditions = emptyList(), + ) + directPlayProfiles += DirectPlayProfile( + type = DlnaProfileType.AUDIO, + container = container, + audioCodec = supportedAudioCodecs.joinToString(","), + ) + } + } + + val subtitleProfiles = when { + config.assDirectPlay -> subtitleProfiles( + embedded = exoEmbeddedSubtitles + ssaSubtitles, + external = exoExternalSubtitles + ssaSubtitles, + ) + else -> subtitleProfiles( + embedded = exoEmbeddedSubtitles, + external = exoExternalSubtitles, + ) + } + + return DeviceProfile( + name = profileName, + directPlayProfiles = directPlayProfiles, + transcodingProfiles = transcodingProfiles, + containerProfiles = containerProfiles, + codecProfiles = codecProfiles, + subtitleProfiles = subtitleProfiles, + maxStreamingBitrate = maxStreamingBitrate, + maxStaticBitrate = maxStaticBitrate, + musicStreamingTranscodingBitrate = maxMusicTranscodingBitrate, + ) + } + + private fun generateCodecProfile( + container: String, + videoCodec: String, + profiles: Set, + ): CodecProfile? { + if (profiles.isEmpty()) return null + + return CodecProfile( + type = CodecType.VIDEO, + container = container, + codec = videoCodec, + applyConditions = emptyList(), + conditions = listOf( + ProfileCondition( + condition = ProfileConditionType.EQUALS_ANY, + property = ProfileConditionValue.VIDEO_PROFILE, + value = profiles.joinToString("|"), + isRequired = false, + ), + ), + ) + } + + private fun subtitleProfiles( + embedded: Array, + external: Array, + ): List = buildList { + embedded.forEach { format -> + add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED)) + } + external.forEach { format -> + add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL)) + } + } +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinAndroidTvDeviceProfile.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinAndroidTvDeviceProfile.kt new file mode 100644 index 00000000..99371d9a --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinAndroidTvDeviceProfile.kt @@ -0,0 +1,544 @@ +package hu.bbara.purefin.core.data.client + +import androidx.media3.common.MimeTypes +import org.jellyfin.sdk.model.ServerVersion +import org.jellyfin.sdk.model.api.CodecType +import org.jellyfin.sdk.model.api.DeviceProfile +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.ProfileConditionValue +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import org.jellyfin.sdk.model.api.VideoRangeType +import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder +import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile + +internal data class JellyfinAndroidTvProfileConfig( + val maxBitrate: Int = 100_000_000, + val isAc3Enabled: Boolean = true, + val downMixAudio: Boolean = false, + val assDirectPlay: Boolean = false, + val pgsDirectPlay: Boolean = true, +) + +internal object JellyfinAndroidTvDeviceProfile { + val fixedConfig = JellyfinAndroidTvProfileConfig() + private val extendedRangeTypesServerVersion = ServerVersion(10, 11, 0) + + private val downmixSupportedAudioCodecs = arrayOf( + Codec.Audio.AAC, + Codec.Audio.MP2, + Codec.Audio.MP3, + ) + + private val supportedAudioCodecs = arrayOf( + Codec.Audio.AAC, + Codec.Audio.AAC_LATM, + Codec.Audio.AC3, + Codec.Audio.ALAC, + Codec.Audio.DCA, + Codec.Audio.DTS, + Codec.Audio.EAC3, + Codec.Audio.FLAC, + Codec.Audio.MLP, + Codec.Audio.MP2, + Codec.Audio.MP3, + Codec.Audio.OPUS, + Codec.Audio.PCM_ALAW, + Codec.Audio.PCM_MULAW, + Codec.Audio.PCM_S16LE, + Codec.Audio.PCM_S20LE, + Codec.Audio.PCM_S24LE, + Codec.Audio.TRUEHD, + Codec.Audio.VORBIS, + ) + + private val hlsMpegTsAudioCodecs = arrayOf( + Codec.Audio.AAC, + Codec.Audio.AC3, + Codec.Audio.EAC3, + Codec.Audio.MP3, + ) + + private val hlsFmp4AudioCodecs = arrayOf( + Codec.Audio.AAC, + Codec.Audio.AC3, + Codec.Audio.EAC3, + Codec.Audio.MP3, + Codec.Audio.ALAC, + Codec.Audio.FLAC, + Codec.Audio.OPUS, + Codec.Audio.DTS, + Codec.Audio.TRUEHD, + ) + + fun create( + capabilities: DeviceProfileCapabilities, + serverVersion: ServerVersion, + config: JellyfinAndroidTvProfileConfig = fixedConfig, + ): DeviceProfile = buildDeviceProfile { + val allowedAudioCodecs = when { + config.downMixAudio -> downmixSupportedAudioCodecs + !config.isAc3Enabled -> supportedAudioCodecs + .filterNot { it == Codec.Audio.AC3 || it == Codec.Audio.EAC3 } + .toTypedArray() + else -> supportedAudioCodecs + } + + val supportsHevc = capabilities.supportsHevc() + val supportsHevcMain10 = capabilities.supportsHevcMain10() + val hevcMainLevel = capabilities.hevcMainLevel() + val hevcMain10Level = capabilities.hevcMain10Level() + val supportsAvc = capabilities.supportsAvc() + val supportsAvcHigh10 = capabilities.supportsAvcHigh10() + val avcMainLevel = capabilities.avcMainLevel() + val avcHigh10Level = capabilities.avcHigh10Level() + val supportsAv1 = capabilities.supportsAv1() + val supportsAv1Main10 = capabilities.supportsAv1Main10() + val supportsVc1 = capabilities.supportsVc1() + val maxResolutionAvc = capabilities.maxResolution(MimeTypes.VIDEO_H264) + val maxResolutionHevc = capabilities.maxResolution(MimeTypes.VIDEO_H265) + val maxResolutionAv1 = capabilities.maxResolution(MimeTypes.VIDEO_AV1) + val maxResolutionVc1 = capabilities.maxResolution(MimeTypes.VIDEO_VC1) + + val supportsAv1DolbyVision = capabilities.supportsAv1DolbyVision() + val supportsAv1Hdr10 = capabilities.supportsAv1Hdr10() + val supportsAv1Hdr10Plus = capabilities.supportsAv1Hdr10Plus() + val supportsHevcDolbyVision = capabilities.supportsHevcDolbyVision() + val supportsHevcDolbyVisionEl = capabilities.supportsHevcDolbyVisionEl() + val supportsHevcHdr10 = capabilities.supportsHevcHdr10() + val supportsHevcHdr10Plus = capabilities.supportsHevcHdr10Plus() + val supportsExtendedRangeTypes = serverVersion >= extendedRangeTypesServerVersion + + name = "AndroidTV-Default" + maxStaticBitrate = config.maxBitrate + maxStreamingBitrate = config.maxBitrate + + val hlsVideoCodecs = listOfNotNull( + if (supportsHevc) Codec.Video.HEVC else null, + Codec.Video.H264, + ).toTypedArray() + + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + videoCodec(*hlsVideoCodecs) + audioCodec(*hlsMpegTsAudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray()) + copyTimestamps = false + enableSubtitlesInManifest = true + } + + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING + container = Codec.Container.MP4 + protocol = MediaStreamProtocol.HLS + videoCodec(*hlsVideoCodecs) + audioCodec(*hlsFmp4AudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray()) + copyTimestamps = false + enableSubtitlesInManifest = true + } + + transcodingProfile { + type = DlnaProfileType.AUDIO + context = EncodingContext.STREAMING + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + audioCodec(Codec.Audio.AAC) + } + + directPlayProfile { + type = DlnaProfileType.VIDEO + container( + Codec.Container.ASF, + Codec.Container.HLS, + Codec.Container.M4V, + Codec.Container.MKV, + Codec.Container.MOV, + Codec.Container.MP4, + Codec.Container.OGM, + Codec.Container.OGV, + Codec.Container.TS, + Codec.Container.VOB, + Codec.Container.WEBM, + Codec.Container.WMV, + Codec.Container.XVID, + ) + videoCodec( + Codec.Video.AV1, + Codec.Video.H264, + Codec.Video.HEVC, + Codec.Video.MPEG, + Codec.Video.MPEG2VIDEO, + Codec.Video.VC1, + Codec.Video.VP8, + Codec.Video.VP9, + ) + audioCodec(*allowedAudioCodecs) + } + + directPlayProfile { + type = DlnaProfileType.AUDIO + audioCodec(*allowedAudioCodecs) + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.H264 + conditions { + when { + !supportsAvc -> ProfileConditionValue.VIDEO_PROFILE equals "none" + else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull( + "high", + "main", + "baseline", + "constrained baseline", + if (supportsAvcHigh10) "high 10" else null, + ) + } + } + } + if (supportsAvc) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.H264 + conditions { + ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcMainLevel + } + applyConditions { + ProfileConditionValue.VIDEO_PROFILE inCollection listOf( + "high", + "main", + "baseline", + "constrained baseline", + ) + } + } + } + if (supportsAvcHigh10) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.H264 + conditions { + ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcHigh10Level + } + applyConditions { + ProfileConditionValue.VIDEO_PROFILE equals "high 10" + } + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.H264 + conditions { + ProfileConditionValue.REF_FRAMES lowerThanOrEquals 12 + } + applyConditions { + ProfileConditionValue.WIDTH greaterThanOrEquals 1200 + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.H264 + conditions { + ProfileConditionValue.REF_FRAMES lowerThanOrEquals 4 + } + applyConditions { + ProfileConditionValue.WIDTH greaterThanOrEquals 1900 + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.HEVC + conditions { + when { + !supportsHevc -> ProfileConditionValue.VIDEO_PROFILE equals "none" + else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull( + "main", + if (supportsHevcMain10) "main 10" else null, + ) + } + } + } + if (supportsHevc) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.HEVC + conditions { + ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMainLevel + } + applyConditions { + ProfileConditionValue.VIDEO_PROFILE equals "main" + } + } + } + if (supportsHevcMain10) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.HEVC + conditions { + ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMain10Level + } + applyConditions { + ProfileConditionValue.VIDEO_PROFILE equals "main 10" + } + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.AV1 + conditions { + when { + !supportsAv1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" + !supportsAv1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10" + else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" + } + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.VC1 + conditions { + when { + !supportsVc1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" + else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" + } + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.H264 + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAvc.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAvc.height + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.HEVC + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionHevc.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionHevc.height + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.AV1 + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAv1.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAv1.height + } + } + + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.VC1 + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionVc1.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionVc1.height + } + } + + val unsupportedRangeTypesAv1 = buildSet { + addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes) + if (!supportsAv1DolbyVision) { + add(VideoRangeType.DOVI) + if (!supportsAv1Hdr10) add(VideoRangeType.DOVI_WITH_HDR10) + if (!supportsAv1Hdr10Plus) addRangeTypeIfSupported( + VideoRangeType.DOVI_WITH_HDR10_PLUS, + supportsExtendedRangeTypes, + ) + } + if (!supportsAv1Hdr10Plus) { + add(VideoRangeType.HDR10_PLUS) + if (!supportsAv1Hdr10) add(VideoRangeType.HDR10) + } + } + + val unsupportedRangeTypesHevc = buildSet { + addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes) + if (!supportsHevcDolbyVisionEl) { + addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_EL, supportsExtendedRangeTypes) + if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) { + addRangeTypeIfSupported( + VideoRangeType.DOVI_WITH_ELHDR10_PLUS, + supportsExtendedRangeTypes, + ) + } + + if (!supportsHevcDolbyVision) { + add(VideoRangeType.DOVI) + if (!supportsHevcHdr10) add(VideoRangeType.DOVI_WITH_HDR10) + if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) { + addRangeTypeIfSupported( + VideoRangeType.DOVI_WITH_HDR10_PLUS, + supportsExtendedRangeTypes, + ) + } + } + } + + if (!supportsHevcHdr10Plus) { + add(VideoRangeType.HDR10_PLUS) + if (!supportsHevcHdr10) add(VideoRangeType.HDR10) + } + + if (capabilities.hevcDoviHdr10PlusBug) { + addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_HDR10_PLUS, supportsExtendedRangeTypes) + addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_ELHDR10_PLUS, supportsExtendedRangeTypes) + } + } + + if (unsupportedRangeTypesAv1.isNotEmpty()) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.AV1 + conditions { + ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesAv1.joinToString("|") { it.serialName } + } + applyConditions { + ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesAv1.map { it.serialName } + } + } + } + + if (unsupportedRangeTypesHevc.isNotEmpty()) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.HEVC + conditions { + ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesHevc.joinToString("|") { it.serialName } + } + applyConditions { + ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesHevc.map { it.serialName } + } + } + } + + codecProfile { + type = CodecType.VIDEO_AUDIO + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (config.downMixAudio) 2 else 8 + } + } + + subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true) + subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true) + subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true) + subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true) + subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true) + subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true) + subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true) + subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true) + subtitleProfile(Codec.Subtitle.PGS, embedded = config.pgsDirectPlay, encode = true) + subtitleProfile(Codec.Subtitle.PGSSUB, embedded = config.pgsDirectPlay, encode = true) + subtitleProfile( + Codec.Subtitle.ASS, + encode = true, + embedded = config.assDirectPlay, + external = config.assDirectPlay, + ) + subtitleProfile( + Codec.Subtitle.SSA, + encode = true, + embedded = config.assDirectPlay, + external = config.assDirectPlay, + ) + } +} + +private fun MutableSet.addRangeTypeIfSupported( + type: VideoRangeType, + supportedByServer: Boolean, +) { + if (supportedByServer) add(type) +} + +private fun DeviceProfileBuilder.subtitleProfile( + format: String, + embedded: Boolean = false, + external: Boolean = false, + hls: Boolean = false, + encode: Boolean = false, +) { + if (embedded) subtitleProfile(format, SubtitleDeliveryMethod.EMBED) + if (external) subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL) + if (hls) subtitleProfile(format, SubtitleDeliveryMethod.HLS) + if (encode) subtitleProfile(format, SubtitleDeliveryMethod.ENCODE) +} + +private object Codec { + object Container { + const val ASF = "asf" + const val HLS = "hls" + const val M4V = "m4v" + const val MKV = "mkv" + const val MOV = "mov" + const val MP4 = "mp4" + const val OGM = "ogm" + const val OGV = "ogv" + const val TS = "ts" + const val VOB = "vob" + const val WEBM = "webm" + const val WMV = "wmv" + const val XVID = "xvid" + } + + object Audio { + const val AAC = "aac" + const val AAC_LATM = "aac_latm" + const val AC3 = "ac3" + const val ALAC = "alac" + const val DCA = "dca" + const val DTS = "dts" + const val EAC3 = "eac3" + const val FLAC = "flac" + const val MLP = "mlp" + const val MP2 = "mp2" + const val MP3 = "mp3" + const val OPUS = "opus" + const val PCM_ALAW = "pcm_alaw" + const val PCM_MULAW = "pcm_mulaw" + const val PCM_S16LE = "pcm_s16le" + const val PCM_S20LE = "pcm_s20le" + const val PCM_S24LE = "pcm_s24le" + const val TRUEHD = "truehd" + const val VORBIS = "vorbis" + } + + object Video { + const val AV1 = "av1" + const val H264 = "h264" + const val HEVC = "hevc" + const val MPEG = "mpeg" + const val MPEG2VIDEO = "mpeg2video" + const val VC1 = "vc1" + const val VP8 = "vp8" + const val VP9 = "vp9" + } + + object Subtitle { + const val ASS = "ass" + const val DVBSUB = "dvbsub" + const val DVDSUB = "dvdsub" + const val IDX = "idx" + const val PGS = "pgs" + const val PGSSUB = "pgssub" + const val SRT = "srt" + const val SSA = "ssa" + const val SUBRIP = "subrip" + const val TTML = "ttml" + const val VTT = "vtt" + const val WEBVTT = "webvtt" + } +} 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 4867ee2a..4aeb2c6a 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 @@ -18,8 +18,10 @@ import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.SystemApi import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemKind @@ -27,7 +29,6 @@ import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo @@ -38,6 +39,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -45,6 +47,7 @@ import javax.inject.Singleton class JellyfinApiClient @Inject constructor( @ApplicationContext private val applicationContext: Context, private val userSessionRepository: UserSessionRepository, + private val playbackProfilePolicy: PlaybackProfilePolicy, ) { private val jellyfin = createJellyfin { context = applicationContext @@ -52,7 +55,8 @@ class JellyfinApiClient @Inject constructor( } private val api = jellyfin.createApi() - + private val serverVersionCache = ConcurrentHashMap() + private suspend fun getUserId(): UUID? = userSessionRepository.userId.first() private suspend fun ensureConfigured(): Boolean { @@ -269,12 +273,14 @@ class JellyfinApiClient @Inject constructor( } suspend fun getMediaSources(mediaId: UUID): List = withContext(Dispatchers.IO) { + if (!ensureConfigured()) { + return@withContext emptyList() + } val result = api.mediaInfoApi .getPostedPlaybackInfo( mediaId, PlaybackInfoDto( userId = getUserId(), - // TODO add supportedContainers deviceProfile = null, maxStreamingBitrate = 100_000_000, ), @@ -283,21 +289,72 @@ class JellyfinApiClient @Inject constructor( result.content.mediaSources } + suspend fun getPlaybackDecision(mediaId: UUID): PlaybackDecision? = withContext(Dispatchers.IO) { + if (!ensureConfigured()) { + return@withContext null + } + + val serverUrl = userSessionRepository.serverUrl.first().trim() + val serverVersion = getServerVersion(serverUrl) + val deviceProfile = playbackProfilePolicy.create(serverVersion) + + val response = api.mediaInfoApi.getPostedPlaybackInfo( + mediaId, + PlaybackInfoDto( + userId = getUserId(), + deviceProfile = deviceProfile, + enableDirectPlay = true, + enableDirectStream = true, + enableTranscoding = true, + allowVideoStreamCopy = true, + allowAudioStreamCopy = true, + autoOpenLiveStream = false, + ), + ) + + val playbackInfo = response.content + if (playbackInfo.errorCode != null) { + Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}") + return@withContext null + } + + val decision = PlaybackDecisionResolver.resolve( + mediaSources = playbackInfo.mediaSources, + playSessionId = playbackInfo.playSessionId, + serverUrl = serverUrl, + directPlayUrl = { mediaSource -> + api.videosApi.getVideoStreamUrl( + itemId = mediaId, + container = mediaSource.container, + mediaSourceId = mediaSource.id, + static = true, + tag = mediaSource.eTag, + playSessionId = playbackInfo.playSessionId, + liveStreamId = mediaSource.liveStreamId, + ) + }, + ) + + if (decision == null) { + Log.w(TAG, "No compatible playback path for $mediaId") + } else { + Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}") + } + decision + } + suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext null } - // Check if transcoding is required based on the MediaSourceInfo from getMediaSources val shouldTranscode = mediaSource.supportsTranscoding == true && - (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) + (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) { - // Use transcoding URL val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/') "$baseUrl${mediaSource.transcodingUrl}" } else { - // Use direct play URL api.videosApi.getVideoStreamUrl( itemId = mediaId, static = true, @@ -309,7 +366,11 @@ class JellyfinApiClient @Inject constructor( url } - suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) { + suspend fun reportPlaybackStart( + itemId: UUID, + positionTicks: Long = 0L, + reportContext: PlaybackReportContext, + ) = withContext(Dispatchers.IO) { if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackStart( PlaybackStartInfo( @@ -318,15 +379,24 @@ class JellyfinApiClient @Inject constructor( canSeek = true, isPaused = false, isMuted = false, - // TODO Send correct play method - playMethod = PlayMethod.DIRECT_PLAY, + mediaSourceId = reportContext.mediaSourceId, + audioStreamIndex = reportContext.audioStreamIndex, + subtitleStreamIndex = reportContext.subtitleStreamIndex, + liveStreamId = reportContext.liveStreamId, + playSessionId = reportContext.playSessionId, + playMethod = reportContext.playMethod, repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT + playbackOrder = PlaybackOrder.DEFAULT, ) ) } - suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) { + suspend fun reportPlaybackProgress( + itemId: UUID, + positionTicks: Long, + isPaused: Boolean, + reportContext: PlaybackReportContext, + ) = withContext(Dispatchers.IO) { if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( @@ -335,21 +405,52 @@ class JellyfinApiClient @Inject constructor( canSeek = true, isPaused = isPaused, isMuted = false, - playMethod = PlayMethod.DIRECT_PLAY, + mediaSourceId = reportContext.mediaSourceId, + audioStreamIndex = reportContext.audioStreamIndex, + subtitleStreamIndex = reportContext.subtitleStreamIndex, + liveStreamId = reportContext.liveStreamId, + playSessionId = reportContext.playSessionId, + playMethod = reportContext.playMethod, repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT + playbackOrder = PlaybackOrder.DEFAULT, ) ) } - suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) { + suspend fun reportPlaybackStopped( + itemId: UUID, + positionTicks: Long, + reportContext: PlaybackReportContext, + ) = withContext(Dispatchers.IO) { if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemId, positionTicks = positionTicks, - failed = false + mediaSourceId = reportContext.mediaSourceId, + liveStreamId = reportContext.liveStreamId, + playSessionId = reportContext.playSessionId, + failed = false, ) ) } + + private suspend fun getServerVersion(serverUrl: String): ServerVersion { + serverVersionCache[serverUrl]?.let { return it } + + val parsedVersion = runCatching { + val versionString = SystemApi(api).getPublicSystemInfo().content.version + versionString?.let(ServerVersion::fromString) + }.onFailure { error -> + Log.w(TAG, "Unable to fetch server version for $serverUrl", error) + }.getOrNull() + + val resolvedVersion = parsedVersion ?: PlaybackProfileDefaults.fallbackServerVersion + serverVersionCache[serverUrl] = resolvedVersion + return resolvedVersion + } + + private companion object { + private const val TAG = "JellyfinApiClient" + } } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt new file mode 100644 index 00000000..1a7ddff4 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt @@ -0,0 +1,19 @@ +package hu.bbara.purefin.core.data.client + +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.PlayMethod + +data class PlaybackDecision( + val url: String, + val mediaSource: MediaSourceInfo, + val reportContext: PlaybackReportContext +) + +data class PlaybackReportContext( + val playMethod: PlayMethod, + val mediaSourceId: String?, + val audioStreamIndex: Int?, + val subtitleStreamIndex: Int?, + val liveStreamId: String?, + val playSessionId: String?, +) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecisionResolver.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecisionResolver.kt new file mode 100644 index 00000000..4c779429 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecisionResolver.kt @@ -0,0 +1,51 @@ +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.PlayMethod + +internal object PlaybackDecisionResolver { + fun resolve( + mediaSources: List, + playSessionId: String?, + serverUrl: String, + directPlayUrl: (MediaSourceInfo) -> String, + ): PlaybackDecision? { + val mediaSource = mediaSources.firstOrNull { it.protocol == MediaProtocol.FILE && !it.isRemote } + ?: return null + + val playMethod = when { + mediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY + mediaSource.supportsDirectStream && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.DIRECT_STREAM + mediaSource.supportsTranscoding && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.TRANSCODE + else -> return null + } + + val url = when (playMethod) { + PlayMethod.DIRECT_PLAY -> directPlayUrl(mediaSource) + PlayMethod.DIRECT_STREAM, + PlayMethod.TRANSCODE, + -> absolutePlaybackUrl(serverUrl, requireNotNull(mediaSource.transcodingUrl)) + } + + return PlaybackDecision( + url = url, + mediaSource = mediaSource, + reportContext = PlaybackReportContext( + playMethod = playMethod, + mediaSourceId = mediaSource.id, + audioStreamIndex = mediaSource.defaultAudioStreamIndex, + subtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex, + liveStreamId = mediaSource.liveStreamId, + playSessionId = playSessionId, + ), + ) + } + + fun absolutePlaybackUrl(serverUrl: String, playbackUrl: String): String = when { + playbackUrl.startsWith("http://", ignoreCase = true) -> playbackUrl + playbackUrl.startsWith("https://", ignoreCase = true) -> playbackUrl + playbackUrl.startsWith("/") -> "${serverUrl.trimEnd('/')}$playbackUrl" + else -> "${serverUrl.trimEnd('/')}/${playbackUrl.trimStart('/')}" + } +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackProfileModule.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackProfileModule.kt new file mode 100644 index 00000000..bc443383 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackProfileModule.kt @@ -0,0 +1,26 @@ +package hu.bbara.purefin.core.data.client + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PlaybackProfileModule { + + @Provides + @Singleton + fun provideDeviceProfileCapabilities(): DeviceProfileCapabilities = AndroidDeviceProfileCapabilities() + + @Provides + @Singleton + fun providePlaybackProfilePolicy( + family: PlaybackProfileFamily, + capabilities: DeviceProfileCapabilities, + ): PlaybackProfilePolicy = when (family) { + PlaybackProfileFamily.MOBILE -> MobilePlaybackProfilePolicy(capabilities) + PlaybackProfileFamily.TV -> TvPlaybackProfilePolicy(capabilities) + } +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackProfilePolicy.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackProfilePolicy.kt new file mode 100644 index 00000000..9cb080c1 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackProfilePolicy.kt @@ -0,0 +1,34 @@ +package hu.bbara.purefin.core.data.client + +import org.jellyfin.sdk.model.ServerVersion +import org.jellyfin.sdk.model.api.DeviceProfile + +enum class PlaybackProfileFamily { + MOBILE, + TV, +} + +interface PlaybackProfilePolicy { + fun create(serverVersion: ServerVersion): DeviceProfile +} + +internal object PlaybackProfileDefaults { + val fallbackServerVersion = ServerVersion(10, 10, 0) +} + +internal class MobilePlaybackProfilePolicy( + private val capabilities: DeviceProfileCapabilities, +) : PlaybackProfilePolicy { + override fun create(serverVersion: ServerVersion): DeviceProfile = + JellyfinAndroidMobileDeviceProfile.create(capabilities = capabilities) +} + +internal class TvPlaybackProfilePolicy( + private val capabilities: DeviceProfileCapabilities, +) : PlaybackProfilePolicy { + override fun create(serverVersion: ServerVersion): DeviceProfile = + JellyfinAndroidTvDeviceProfile.create( + capabilities = capabilities, + serverVersion = serverVersion, + ) +} 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 index b304ab29..c79dedb3 100644 --- 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 @@ -1,34 +1,371 @@ package hu.bbara.purefin.core.data.client +import androidx.media3.common.MimeTypes +import org.jellyfin.sdk.model.ServerVersion +import org.jellyfin.sdk.model.api.CodecType 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.ProfileCondition +import org.jellyfin.sdk.model.api.ProfileConditionType +import org.jellyfin.sdk.model.api.ProfileConditionValue +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class AndroidDeviceProfileTest { +class JellyfinAndroidTvDeviceProfileTest { @Test - fun `device profile includes explicit video transcoding support`() { - val profile = AndroidDeviceProfile.createDeviceProfile( - audioCodecs = listOf("aac", "ac3"), - videoCodecs = listOf("h264", "hevc"), - maxAudioChannels = 6 + fun `official defaults build expected profile structure`() { + val profile = JellyfinAndroidTvDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(), + serverVersion = ServerVersion(10, 11, 0), ) - val transcodingProfile = profile.transcodingProfiles.single() + assertEquals("AndroidTV-Default", profile.name) + assertEquals(100_000_000, profile.maxStreamingBitrate) + assertEquals(100_000_000, profile.maxStaticBitrate) + assertEquals(3, profile.transcodingProfiles.size) - 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(3, transcodingProfile.minSegments) - assertEquals(6, transcodingProfile.segmentLength) - assertFalse(transcodingProfile.breakOnNonKeyFrames) - assertTrue(transcodingProfile.copyTimestamps) + val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO } + assertEquals(setOf("mp4", "ts"), videoProfiles.map { it.container }.toSet()) + assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS }) + assertTrue(videoProfiles.all { it.enableSubtitlesInManifest }) + + val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO } + assertEquals("ts", audioProfile.container) + assertEquals("aac", audioProfile.audioCodec) + + val videoDirectPlay = profile.directPlayProfiles.single { it.type == DlnaProfileType.VIDEO } + assertTrue(videoDirectPlay.videoCodec.orEmpty().split(",").contains("vc1")) + assertTrue(videoDirectPlay.audioCodec.orEmpty().split(",").contains("ac3")) + + assertTrue( + profile.subtitleProfiles.any { + it.format == "pgs" && it.method == SubtitleDeliveryMethod.EMBED + } + ) + assertTrue( + profile.subtitleProfiles.any { + it.format == "webvtt" && it.method == SubtitleDeliveryMethod.HLS + } + ) + } + + @Test + fun `ac3 disabled removes ac3 and eac3 from advertised codecs`() { + val profile = JellyfinAndroidTvDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(), + serverVersion = ServerVersion(10, 11, 0), + config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(isAc3Enabled = false), + ) + + val directAudioCodecs = profile.directPlayProfiles + .single { it.type == DlnaProfileType.AUDIO } + .audioCodec + .orEmpty() + .split(",") + + val transcodingAudioCodecs = profile.transcodingProfiles + .filter { it.type == DlnaProfileType.VIDEO } + .flatMap { it.audioCodec.orEmpty().split(",") } + + assertFalse(directAudioCodecs.contains("ac3")) + assertFalse(directAudioCodecs.contains("eac3")) + assertFalse(transcodingAudioCodecs.contains("ac3")) + assertFalse(transcodingAudioCodecs.contains("eac3")) + } + + @Test + fun `downmix mode limits audio codecs and channel profile`() { + val profile = JellyfinAndroidTvDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(), + serverVersion = ServerVersion(10, 11, 0), + config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(downMixAudio = true), + ) + + val directAudioCodecs = profile.directPlayProfiles + .single { it.type == DlnaProfileType.AUDIO } + .audioCodec + .orEmpty() + .split(",") + + assertEquals(listOf("aac", "mp2", "mp3"), directAudioCodecs) + + val audioChannelProfile = profile.codecProfiles.single { it.type == CodecType.VIDEO_AUDIO } + assertEquals("2", audioChannelProfile.conditions.single().value) + } + + @Test + fun `older servers omit extended dolby vision range types`() { + val capabilities = FakeDeviceProfileCapabilities( + supportsAv1DolbyVision = false, + supportsAv1Hdr10 = false, + supportsAv1Hdr10Plus = false, + supportsHevcDolbyVision = false, + supportsHevcDolbyVisionEl = false, + supportsHevcHdr10 = false, + supportsHevcHdr10Plus = false, + ) + + val olderServerProfile = JellyfinAndroidTvDeviceProfile.create( + capabilities = capabilities, + serverVersion = ServerVersion(10, 10, 0), + ) + val newerServerProfile = JellyfinAndroidTvDeviceProfile.create( + capabilities = capabilities, + serverVersion = ServerVersion(10, 11, 0), + ) + + val olderRangeTypes = codecRangeTypes(olderServerProfile.codecProfiles) + val newerRangeTypes = codecRangeTypes(newerServerProfile.codecProfiles) + + assertFalse(olderRangeTypes.contains("DOVIWithEL")) + assertFalse(olderRangeTypes.contains("DOVIWithHDR10Plus")) + assertFalse(olderRangeTypes.contains("DOVIWithELHDR10Plus")) + assertFalse(olderRangeTypes.contains("DOVIInvalid")) + + assertTrue(newerRangeTypes.contains("DOVIWithEL")) + assertTrue(newerRangeTypes.contains("DOVIWithHDR10Plus")) + assertTrue(newerRangeTypes.contains("DOVIWithELHDR10Plus")) + assertTrue(newerRangeTypes.contains("DOVIInvalid")) + } + + @Test + fun `h264 high 10 is advertised as high 10`() { + val profile = JellyfinAndroidTvDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(supportsAvcHigh10 = true), + serverVersion = ServerVersion(10, 11, 0), + ) + + val h264Values = profile.codecProfiles + .filter { it.codec == "h264" } + .flatMap { it.conditions + it.applyConditions } + .mapNotNull(ProfileCondition::value) + + assertTrue(h264Values.any { "high 10" in it }) + assertFalse(h264Values.any { "main 10" in it }) + } + + @Test + fun `vc1 support condition changes with codec support`() { + val unsupportedProfile = JellyfinAndroidTvDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(supportsVc1 = false), + serverVersion = ServerVersion(10, 11, 0), + ) + val supportedProfile = JellyfinAndroidTvDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(supportsVc1 = true), + serverVersion = ServerVersion(10, 11, 0), + ) + + val unsupportedCondition = vc1SupportCondition(unsupportedProfile.codecProfiles) + val supportedCondition = vc1SupportCondition(supportedProfile.codecProfiles) + + assertEquals(ProfileConditionType.EQUALS, unsupportedCondition.condition) + assertEquals("none", unsupportedCondition.value) + assertEquals(ProfileConditionType.NOT_EQUALS, supportedCondition.condition) + assertEquals("none", supportedCondition.value) } } + +class JellyfinAndroidMobileDeviceProfileTest { + @Test + fun `official mobile defaults build expected profile structure`() { + val profile = JellyfinAndroidMobileDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(), + ) + + assertEquals("Purefin", profile.name) + assertEquals(120_000_000, profile.maxStreamingBitrate) + assertEquals(100_000_000, profile.maxStaticBitrate) + assertEquals(384_000, profile.musicStreamingTranscodingBitrate) + + val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO } + assertEquals(setOf("mkv", "ts"), videoProfiles.map { it.container }.toSet()) + assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS }) + + val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO } + assertEquals("mp3", audioProfile.container) + assertEquals(MediaStreamProtocol.HTTP, audioProfile.protocol) + assertEquals("mp3", audioProfile.audioCodec) + + assertTrue( + profile.directPlayProfiles.any { + it.type == DlnaProfileType.VIDEO && it.container == "mp4" + } + ) + assertTrue( + profile.subtitleProfiles.any { + it.format == "pgssub" && it.method == SubtitleDeliveryMethod.EMBED + } + ) + assertFalse( + profile.subtitleProfiles.any { + it.format == "ass" + } + ) + } + + @Test + fun `mobile profile advertises forced audio codecs even without codec support`() { + val profile = JellyfinAndroidMobileDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities( + supportedAudioCodecs = emptySet(), + supportedVideoCodecs = setOf("h264"), + videoProfiles = mapOf("h264" to setOf("high")), + ), + ) + + val mkvAudioProfile = profile.directPlayProfiles.single { + it.type == DlnaProfileType.AUDIO && it.container == "mkv" + } + + val codecs = mkvAudioProfile.audioCodec.orEmpty().split(",") + assertTrue(codecs.contains("aac")) + assertTrue(codecs.contains("ac3")) + assertTrue(codecs.contains("truehd")) + assertTrue(codecs.contains("pcm_s16le")) + } + + @Test + fun `mobile ass direct play opt in adds ass and ssa subtitles`() { + val profile = JellyfinAndroidMobileDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(), + config = JellyfinAndroidMobileDeviceProfile.fixedConfig.copy(assDirectPlay = true), + ) + + assertTrue( + profile.subtitleProfiles.any { + it.format == "ass" && it.method == SubtitleDeliveryMethod.EMBED + } + ) + assertTrue( + profile.subtitleProfiles.any { + it.format == "ssa" && it.method == SubtitleDeliveryMethod.EXTERNAL + } + ) + } + + @Test + fun `mobile profile omits tv hdr range conditions`() { + val profile = JellyfinAndroidMobileDeviceProfile.create( + capabilities = FakeDeviceProfileCapabilities(), + ) + + assertFalse( + profile.codecProfiles.any { codecProfile -> + (codecProfile.conditions + codecProfile.applyConditions).any { + it.property == ProfileConditionValue.VIDEO_RANGE_TYPE + } + } + ) + } +} + +private fun codecRangeTypes(codecProfiles: List): Set = + codecProfiles + .filter { it.propertyValuesContain(ProfileConditionValue.VIDEO_RANGE_TYPE) } + .flatMap { (it.conditions + it.applyConditions).mapNotNull(ProfileCondition::value) } + .flatMap { it.split("|") } + .toSet() + +private fun vc1SupportCondition(codecProfiles: List): ProfileCondition = + codecProfiles + .single { + it.codec == "vc1" && it.conditions.any { condition -> + condition.property == ProfileConditionValue.VIDEO_PROFILE + } + } + .conditions + .single() + +private fun org.jellyfin.sdk.model.api.CodecProfile.propertyValuesContain(value: ProfileConditionValue): Boolean = + (conditions + applyConditions).any { it.property == value } + +private data class FakeDeviceProfileCapabilities( + val supportsAv1: Boolean = true, + val supportsAv1Main10: Boolean = true, + val supportsAv1DolbyVision: Boolean = true, + val supportsAv1Hdr10: Boolean = true, + val supportsAv1Hdr10Plus: Boolean = true, + val supportsAvc: Boolean = true, + val supportsAvcHigh10: Boolean = false, + val avcMainLevel: Int = 41, + val avcHigh10Level: Int = 41, + val supportsHevc: Boolean = true, + val supportsHevcMain10: Boolean = true, + val supportsHevcDolbyVision: Boolean = true, + val supportsHevcDolbyVisionEl: Boolean = true, + val supportsHevcHdr10: Boolean = true, + val supportsHevcHdr10Plus: Boolean = true, + val hevcMainLevel: Int = 123, + val hevcMain10Level: Int = 123, + val supportsVc1: Boolean = true, + val maxResolutionAvc: ProfileResolution = ProfileResolution(3840, 2160), + val maxResolutionHevc: ProfileResolution = ProfileResolution(3840, 2160), + val maxResolutionAv1: ProfileResolution = ProfileResolution(3840, 2160), + val maxResolutionVc1: ProfileResolution = ProfileResolution(1920, 1080), + val supportedVideoCodecs: Set = setOf( + "h263", + "mpeg2video", + "mpeg4", + "h264", + "hevc", + "vp8", + "vp9", + "av1", + ), + val supportedAudioCodecs: Set = setOf( + "aac", + "ac3", + "eac3", + "flac", + "mp3", + "opus", + "vorbis", + "3gpp", + ), + val videoProfiles: Map> = mapOf( + "h263" to setOf("baseline"), + "mpeg2video" to setOf("main profile"), + "mpeg4" to setOf("simple profile"), + "h264" to setOf("high", "main", "baseline"), + "hevc" to setOf("Main", "Main 10"), + "vp8" to setOf("main"), + "vp9" to setOf("Profile 0"), + ), + override val hevcDoviHdr10PlusBug: Boolean = false, +) : DeviceProfileCapabilities { + override fun supportsAv1(): Boolean = supportsAv1 + override fun supportsAv1Main10(): Boolean = supportsAv1Main10 + override fun supportsAv1DolbyVision(): Boolean = supportsAv1DolbyVision + override fun supportsAv1Hdr10(): Boolean = supportsAv1Hdr10 + override fun supportsAv1Hdr10Plus(): Boolean = supportsAv1Hdr10Plus + override fun supportsAvc(): Boolean = supportsAvc + override fun supportsAvcHigh10(): Boolean = supportsAvcHigh10 + override fun avcMainLevel(): Int = avcMainLevel + override fun avcHigh10Level(): Int = avcHigh10Level + override fun supportsHevc(): Boolean = supportsHevc + override fun supportsHevcMain10(): Boolean = supportsHevcMain10 + override fun supportsHevcDolbyVision(): Boolean = supportsHevcDolbyVision + override fun supportsHevcDolbyVisionEl(): Boolean = supportsHevcDolbyVisionEl + override fun supportsHevcHdr10(): Boolean = supportsHevcHdr10 + override fun supportsHevcHdr10Plus(): Boolean = supportsHevcHdr10Plus + override fun hevcMainLevel(): Int = hevcMainLevel + override fun hevcMain10Level(): Int = hevcMain10Level + override fun supportsVc1(): Boolean = supportsVc1 + + override fun maxResolution(mimeType: String): ProfileResolution = when (mimeType) { + MimeTypes.VIDEO_H264 -> maxResolutionAvc + MimeTypes.VIDEO_H265 -> maxResolutionHevc + MimeTypes.VIDEO_AV1 -> maxResolutionAv1 + MimeTypes.VIDEO_VC1 -> maxResolutionVc1 + else -> ProfileResolution(0, 0) + } + + override fun supportsVideoCodec(codec: String): Boolean = supportedVideoCodecs.contains(codec) + + override fun supportsAudioCodec(codec: String): Boolean = supportedAudioCodecs.contains(codec) + + override fun videoCodecProfiles(codec: String): Set = videoProfiles[codec].orEmpty() +} diff --git a/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt b/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt index 2a281cfa..cc041aec 100644 --- a/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt +++ b/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt @@ -1,29 +1,151 @@ 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.assertNull +import org.junit.Assert.assertSame import org.junit.Test -class JellyfinApiClientTest { +class PlaybackDecisionResolverTest { @Test - fun `direct play requests do not apply HLS stability options`() { - val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY) + fun `resolve prefers local file-backed source for direct play`() { + val remoteSource = mediaSource( + id = "remote", + protocol = MediaProtocol.HTTP, + isRemote = true, + supportsDirectPlay = true, + ) + val localSource = mediaSource( + id = "local", + supportsDirectPlay = true, + defaultAudioStreamIndex = 2, + defaultSubtitleStreamIndex = 4, + liveStreamId = "live-1", + ) - assertNull(options) + val decision = requireNotNull( + PlaybackDecisionResolver.resolve( + mediaSources = listOf(remoteSource, localSource), + playSessionId = "session-1", + serverUrl = "https://example.com/jellyfin", + ) { mediaSource -> + "direct://${mediaSource.id}" + } + ) + + assertSame(localSource, decision.mediaSource) + assertEquals("direct://local", decision.url) + assertEquals(PlayMethod.DIRECT_PLAY, decision.reportContext.playMethod) + assertEquals("local", decision.reportContext.mediaSourceId) + assertEquals(2, decision.reportContext.audioStreamIndex) + assertEquals(4, decision.reportContext.subtitleStreamIndex) + assertEquals("live-1", decision.reportContext.liveStreamId) + assertEquals("session-1", decision.reportContext.playSessionId) } @Test - fun `direct stream requests use conservative HLS stability options`() { - val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM) + fun `resolve uses transcoding url for direct stream`() { + val source = mediaSource( + id = "direct-stream", + supportsDirectStream = true, + transcodingUrl = "/Videos/1/master.m3u8", + ) - assertEquals(HlsPlaybackStability.conservativeHlsOptions, options) + val decision = requireNotNull( + PlaybackDecisionResolver.resolve( + mediaSources = listOf(source), + playSessionId = "session-2", + serverUrl = "https://example.com/jellyfin", + ) { error("direct play should not be used") } + ) + + assertEquals(PlayMethod.DIRECT_STREAM, decision.reportContext.playMethod) + assertEquals("https://example.com/jellyfin/Videos/1/master.m3u8", decision.url) } @Test - fun `transcode requests use the same HLS stability options as direct stream`() { - val directStreamOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM) - val transcodeOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE) + fun `resolve uses transcoding when direct stream is unavailable`() { + val source = mediaSource( + id = "transcode", + supportsTranscoding = true, + transcodingUrl = "Videos/2/master.m3u8", + ) - assertEquals(directStreamOptions, transcodeOptions) + val decision = requireNotNull( + PlaybackDecisionResolver.resolve( + mediaSources = listOf(source), + playSessionId = "session-3", + serverUrl = "https://example.com/jellyfin", + ) { error("direct play should not be used") } + ) + + assertEquals(PlayMethod.TRANSCODE, decision.reportContext.playMethod) + assertEquals("https://example.com/jellyfin/Videos/2/master.m3u8", decision.url) + } + + @Test + fun `resolve returns null when no usable source exists`() { + val source = mediaSource(id = "unusable") + + val decision = PlaybackDecisionResolver.resolve( + mediaSources = listOf(source), + playSessionId = "session-4", + serverUrl = "https://example.com/jellyfin", + ) { error("direct play should not be used") } + + assertNull(decision) + } + + @Test + fun `absolute playback url preserves absolute urls`() { + val url = PlaybackDecisionResolver.absolutePlaybackUrl( + serverUrl = "https://example.com/jellyfin", + playbackUrl = "https://cdn.example.com/master.m3u8", + ) + + assertEquals("https://cdn.example.com/master.m3u8", url) + } + + private fun mediaSource( + id: String, + protocol: MediaProtocol = MediaProtocol.FILE, + isRemote: Boolean = false, + supportsDirectPlay: Boolean = false, + supportsDirectStream: Boolean = false, + supportsTranscoding: Boolean = false, + transcodingUrl: String? = null, + defaultAudioStreamIndex: Int? = null, + defaultSubtitleStreamIndex: Int? = null, + liveStreamId: String? = null, + ): MediaSourceInfo { + return MediaSourceInfo( + protocol = protocol, + id = id, + type = MediaSourceType.DEFAULT, + container = "mkv", + isRemote = isRemote, + readAtNativeFramerate = true, + ignoreDts = false, + ignoreIndex = false, + genPtsInput = false, + supportsTranscoding = supportsTranscoding, + supportsDirectStream = supportsDirectStream, + supportsDirectPlay = supportsDirectPlay, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + liveStreamId = liveStreamId, + requiresLooping = false, + supportsProbing = true, + transcodingUrl = transcodingUrl, + defaultAudioStreamIndex = defaultAudioStreamIndex, + defaultSubtitleStreamIndex = defaultSubtitleStreamIndex, + transcodingSubProtocol = MediaStreamProtocol.HLS, + hasSegments = false, + ) } } 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 deleted file mode 100644 index c0caa490..00000000 --- a/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlannerTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -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 - ) - } -} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt index ab41283f..34e60d06 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt @@ -6,6 +6,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.client.JellyfinApiClient +import hu.bbara.purefin.core.data.client.PlaybackReportContext import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.session.UserSessionRepository import kotlinx.coroutines.Dispatchers @@ -24,26 +25,21 @@ class PlayerMediaRepository @Inject constructor( ) { suspend fun getMediaItem(mediaId: UUID): Pair? = withContext(Dispatchers.IO) { - val mediaSources = jellyfinApiClient.getMediaSources(mediaId) - val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null - val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( - mediaId = mediaId, - mediaSource = selectedMediaSource - ) ?: return@withContext null + val playbackDecision = jellyfinApiClient.getPlaybackDecision(mediaId) ?: return@withContext null val baseItem = jellyfinApiClient.getItemInfo(mediaId) - // Calculate resume position - val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource) + val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) val serverUrl = userSessionRepository.serverUrl.first() val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) val mediaItem = createMediaItem( mediaId = mediaId.toString(), - playbackUrl = playbackUrl, - title = baseItem?.name ?: selectedMediaSource.name!!, - subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}", + playbackUrl = playbackDecision.url, + title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null, + subtitle = seasonEpisodeLabel(baseItem), artworkUrl = artworkUrl, + playbackReportContext = playbackDecision.reportContext, ) Pair(mediaItem, resumePositionMs) @@ -59,19 +55,15 @@ class PlayerMediaRepository @Inject constructor( if (existingIds.contains(stringId)) { return@mapNotNull null } - val mediaSources = jellyfinApiClient.getMediaSources(id) - val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null - val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( - mediaId = id, - mediaSource = selectedMediaSource - ) ?: return@mapNotNull null + val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) createMediaItem( mediaId = stringId, - playbackUrl = playbackUrl, - title = episode.name ?: selectedMediaSource.name!!, - subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", + playbackUrl = playbackDecision.url, + title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null, + subtitle = seasonEpisodeLabel(episode), artworkUrl = artworkUrl, + playbackReportContext = playbackDecision.reportContext, ) } }.getOrElse { error -> @@ -86,6 +78,7 @@ class PlayerMediaRepository @Inject constructor( title: String, subtitle: String?, artworkUrl: String, + playbackReportContext: PlaybackReportContext?, ): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(title) @@ -96,6 +89,7 @@ class PlayerMediaRepository @Inject constructor( .setUri(playbackUrl.toUri()) .setMediaId(mediaId) .setMediaMetadata(metadata) + .setTag(playbackReportContext) .build() } @@ -122,4 +116,10 @@ class PlayerMediaRepository @Inject constructor( // Apply thresholds: resume only if 5% ≤ progress ≤ 95% return if (percentage in 5.0..95.0) positionMs else null } + + private fun seasonEpisodeLabel(item: BaseItemDto?): String? { + val seasonNumber = item?.parentIndexNumber ?: return null + val episodeNumber = item.indexNumber ?: return null + return "S$seasonNumber:E$episodeNumber" + } } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index 6ff4ea9b..5ad9ceb4 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -9,6 +9,7 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import dagger.hilt.android.scopes.ViewModelScoped +import hu.bbara.purefin.core.data.client.PlaybackReportContext import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackType @@ -311,10 +312,12 @@ class PlayerManager @Inject constructor( } private fun refreshMetadata(mediaItem: MediaItem?) { + val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext _metadata.value = MetadataState( mediaId = mediaItem?.mediaId, title = mediaItem?.mediaMetadata?.title?.toString(), subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(), + playbackReportContext = playbackReportContext, ) } @@ -341,6 +344,7 @@ data class MetadataState( val mediaId: String? = null, val title: String? = null, val subtitle: String? = null, + val playbackReportContext: PlaybackReportContext? = null, ) data class MediaContext( diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt index 4ef203d1..d0a236c4 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt @@ -3,6 +3,7 @@ package hu.bbara.purefin.core.player.manager import android.util.Log import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.client.JellyfinApiClient +import hu.bbara.purefin.core.data.client.PlaybackReportContext import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,6 +26,7 @@ class ProgressManager @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var progressJob: Job? = null private var activeItemId: UUID? = null + private var activePlaybackReportContext: PlaybackReportContext? = null private var lastPositionMs: Long = 0L private var lastDurationMs: Long = 0L private var isPaused: Boolean = false @@ -42,27 +44,29 @@ class ProgressManager @Inject constructor( lastDurationMs = prog.durationMs isPaused = !state.isPlaying val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() } + val nextPlaybackReportContext = meta.playbackReportContext - // Media changed or ended - stop session if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) { stopSession() } - // Start session when we have a media item and none is active if (activeItemId == null && mediaId != null && !state.isEnded) { - startSession(mediaId, prog.positionMs) + startSession(mediaId, prog.positionMs, nextPlaybackReportContext) + } else if (activeItemId == mediaId) { + activePlaybackReportContext = nextPlaybackReportContext } } } } - private fun startSession(itemId: UUID, positionMs: Long) { + private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) { activeItemId = itemId - report(itemId, positionMs, isStart = true) + activePlaybackReportContext = reportContext + report(itemId, positionMs, reportContext = reportContext, isStart = true) progressJob = scope.launch { while (isActive) { delay(5000) - report(itemId, lastPositionMs, isPaused = isPaused) + report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused) } } } @@ -70,7 +74,7 @@ class ProgressManager @Inject constructor( private fun stopSession() { progressJob?.cancel() activeItemId?.let { itemId -> - report(itemId, lastPositionMs, isStop = true) + report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true) scope.launch(Dispatchers.IO) { try { updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs) @@ -80,11 +84,13 @@ class ProgressManager @Inject constructor( } } activeItemId = null + activePlaybackReportContext = null } private fun report( itemId: UUID, positionMs: Long, + reportContext: PlaybackReportContext?, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false @@ -92,10 +98,13 @@ class ProgressManager @Inject constructor( val ticks = positionMs * 10_000 scope.launch(Dispatchers.IO) { try { + if (reportContext == null) { + return@launch + } when { - isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks) - isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks) - else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused) + isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext) + isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext) + else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext) } Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused") } catch (e: Exception) { @@ -112,7 +121,9 @@ class ProgressManager @Inject constructor( val durMs = lastDurationMs CoroutineScope(Dispatchers.IO).launch { try { - jellyfinApiClient.reportPlaybackStopped(itemId, ticks) + activePlaybackReportContext?.let { reportContext -> + jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext) + } updateWatchProgressUseCase(itemId, posMs, durMs) Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms") } catch (e: Exception) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6ebd1a2..51bccc50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls" medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"} medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"} medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"} +media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }