Implement Jellyfin playback decision flow

This commit is contained in:
2026-03-29 16:38:44 +02:00
parent a187192013
commit 4b92af26ba
7 changed files with 480 additions and 94 deletions

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.core.data.client
import android.content.Context
import android.media.AudioManager
import android.media.MediaCodecList
import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile
@@ -14,32 +16,59 @@ import org.jellyfin.sdk.model.api.SubtitleProfile
*/
object AndroidDeviceProfile {
fun create(): DeviceProfile {
// Debug: Log all available decoders
CodecDebugHelper.logAvailableDecoders()
private const val TAG = "AndroidDeviceProfile"
private const val DEFAULT_MAX_AUDIO_CHANNELS = 8
val audioCodecs = getAudioCodecs()
val videoCodecs = getVideoCodecs()
@Volatile
private var cachedSnapshot: CapabilitySnapshot? = null
Log.d("AndroidDeviceProfile", "Supported audio codecs: ${audioCodecs.joinToString()}")
Log.d("AndroidDeviceProfile", "Supported video codecs: ${videoCodecs.joinToString()}")
data class CapabilitySnapshot(
val deviceProfile: DeviceProfile,
val maxAudioChannels: Int
)
// Check specifically for DTS
val hasDTS = CodecDebugHelper.hasDecoderFor("audio/vnd.dts")
val hasDTSHD = CodecDebugHelper.hasDecoderFor("audio/vnd.dts.hd")
Log.d("AndroidDeviceProfile", "Has DTS decoder: $hasDTS, Has DTS-HD decoder: $hasDTSHD")
fun getSnapshot(context: Context): CapabilitySnapshot {
cachedSnapshot?.let { return it }
return synchronized(this) {
cachedSnapshot?.let { return@synchronized it }
val applicationContext = context.applicationContext
// Debug logging is noisy and expensive, so keep it to debug builds/devices only when needed.
val audioCodecs = getAudioCodecs()
val videoCodecs = getVideoCodecs()
val maxAudioChannels = resolveMaxAudioChannels(applicationContext)
Log.d(TAG, "Supported audio codecs: ${audioCodecs.joinToString()}")
Log.d(TAG, "Supported video codecs: ${videoCodecs.joinToString()}")
Log.d(TAG, "Max audio channels: $maxAudioChannels")
val snapshot = CapabilitySnapshot(
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs),
maxAudioChannels = maxAudioChannels
)
cachedSnapshot = snapshot
snapshot
}
}
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
private fun buildDeviceProfile(
audioCodecs: List<String>,
videoCodecs: List<String>
): DeviceProfile {
return DeviceProfile(
name = "Android",
name = "Android Media3",
maxStaticBitrate = 100_000_000,
maxStreamingBitrate = 100_000_000,
// Direct play profiles - what we can play natively
// By specifying supported codecs, Jellyfin will transcode unsupported formats like DTS-HD
directPlayProfiles = listOf(
DirectPlayProfile(
type = DlnaProfileType.VIDEO,
container = "mp4,m4v,mkv,webm",
container = "mp4,m4v,mkv,webm,ts,mov",
videoCodec = videoCodecs.joinToString(","),
audioCodec = audioCodecs.joinToString(",")
)
@@ -77,15 +106,18 @@ object AndroidDeviceProfile {
private fun getAudioCodecs(): List<String> {
val supportedCodecs = mutableListOf<String>()
// Common codecs supported on most Android devices
val commonCodecs = listOf(
"aac" to android.media.MediaFormat.MIMETYPE_AUDIO_AAC,
"mp3" to android.media.MediaFormat.MIMETYPE_AUDIO_MPEG,
"ac3" to android.media.MediaFormat.MIMETYPE_AUDIO_AC3,
"eac3" to android.media.MediaFormat.MIMETYPE_AUDIO_EAC3,
"flac" to android.media.MediaFormat.MIMETYPE_AUDIO_FLAC,
"vorbis" to android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS,
"opus" to android.media.MediaFormat.MIMETYPE_AUDIO_OPUS
"aac" to "audio/mp4a-latm",
"mp3" to "audio/mpeg",
"ac3" to "audio/ac3",
"eac3" to "audio/eac3",
"dts" to "audio/vnd.dts",
"dtshd_ma" to "audio/vnd.dts.hd",
"truehd" to "audio/true-hd",
"flac" to "audio/flac",
"vorbis" to "audio/vorbis",
"opus" to "audio/opus",
"alac" to "audio/alac"
)
for ((codecName, mimeType) in commonCodecs) {
@@ -109,12 +141,12 @@ object AndroidDeviceProfile {
val supportedCodecs = mutableListOf<String>()
val commonCodecs = listOf(
"h264" to android.media.MediaFormat.MIMETYPE_VIDEO_AVC,
"hevc" to android.media.MediaFormat.MIMETYPE_VIDEO_HEVC,
"vp9" to android.media.MediaFormat.MIMETYPE_VIDEO_VP9,
"vp8" to android.media.MediaFormat.MIMETYPE_VIDEO_VP8,
"mpeg4" to android.media.MediaFormat.MIMETYPE_VIDEO_MPEG4,
"av1" to android.media.MediaFormat.MIMETYPE_VIDEO_AV1
"h264" to "video/avc",
"hevc" to "video/hevc",
"vp9" to "video/x-vnd.on2.vp9",
"vp8" to "video/x-vnd.on2.vp8",
"mpeg4" to "video/mp4v-es",
"av1" to "video/av01"
)
for ((codecName, mimeType) in commonCodecs) {
@@ -135,15 +167,58 @@ object AndroidDeviceProfile {
* Check if a specific decoder (not encoder) is supported on this device.
*/
private fun isCodecSupported(mimeType: String): Boolean {
return try {
val platformSupport = try {
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
codecList.codecInfos.any { codecInfo ->
!codecInfo.isEncoder &&
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
}
} catch (_: Exception) {
// If we can't determine, assume not supported
false
}
if (platformSupport) {
return true
}
return isCodecSupportedByFfmpeg(mimeType)
}
private fun isCodecSupportedByFfmpeg(mimeType: String): Boolean {
return runCatching {
val ffmpegLibraryClass = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary")
val supportsFormat = ffmpegLibraryClass.getMethod("supportsFormat", String::class.java)
supportsFormat.invoke(null, mimeType) as? Boolean ?: false
}.getOrElse { false }
}
private fun resolveMaxAudioChannels(context: Context): Int {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val maxDeviceChannels = devices
.flatMap { device ->
buildList {
addAll(device.channelCounts.toList())
addAll(device.channelMasks.map(::channelMaskToCount))
addAll(device.channelIndexMasks.map { Integer.bitCount(it) })
}
}
.maxOrNull()
maxDeviceChannels
?.takeIf { it > 0 }
?: DEFAULT_MAX_AUDIO_CHANNELS
}.getOrElse {
DEFAULT_MAX_AUDIO_CHANNELS
}
}
private fun channelMaskToCount(mask: Int): Int {
return if (mask == 0) {
0
} else {
Integer.bitCount(mask)
}
}
}

View File

@@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
@@ -44,6 +45,11 @@ class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
) {
companion object {
private const val TAG = "JellyfinApiClient"
private const val MAX_STREAMING_BITRATE = 100_000_000
}
private val jellyfin = createJellyfin {
context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
@@ -248,17 +254,87 @@ class JellyfinApiClient @Inject constructor(
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
val result = api.mediaInfoApi
.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
deviceProfile = AndroidDeviceProfile.create(),
maxStreamingBitrate = 100_000_000,
),
)
Log.d("getMediaSources", result.toString())
result.content.mediaSources
requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList()
}
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 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 reportContext = PlaybackReportContext(
playMethod = playMethod,
mediaSourceId = selectedMediaSource.id,
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
liveStreamId = selectedMediaSource.liveStreamId,
playSessionId = playbackInfo.playSessionId,
canRetryWithTranscoding = !forceTranscode &&
playMethod != PlayMethod.TRANSCODE &&
selectedMediaSource.supportsTranscoding
)
Log.d(TAG, "Playback decision for $mediaId: $playMethod using ${selectedMediaSource.id}")
PlaybackDecision(
url = url,
mediaSource = selectedMediaSource,
reportContext = reportContext
)
}
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
@@ -266,28 +342,43 @@ class JellyfinApiClient @Inject constructor(
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)
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
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(
val url = when {
mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId
)
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("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url")
Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
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(
@@ -296,14 +387,24 @@ class JellyfinApiClient @Inject constructor(
canSeek = true,
isPaused = false,
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
)
)
}
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(
@@ -312,21 +413,93 @@ 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
)
)
}
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,
mediaSourceId = reportContext.mediaSourceId,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
failed = false
)
)
}
private suspend fun requestPlaybackInfo(mediaId: UUID, forceTranscode: Boolean = false): PlaybackInfoResponse? {
if (!ensureConfigured()) {
return null
}
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
deviceProfile = capabilitySnapshot.deviceProfile,
maxStreamingBitrate = MAX_STREAMING_BITRATE,
maxAudioChannels = capabilitySnapshot.maxAudioChannels,
enableDirectPlay = !forceTranscode,
enableDirectStream = !forceTranscode,
enableTranscoding = true,
allowVideoStreamCopy = !forceTranscode,
allowAudioStreamCopy = !forceTranscode,
alwaysBurnInSubtitleWhenTranscoding = false
)
)
Log.d(TAG, "PlaybackInfo for $mediaId -> sources=${response.content.mediaSources.size}, session=${response.content.playSessionId}")
return response.content
}
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? {
return when {
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }
else -> mediaSources.firstOrNull { it.supportsDirectPlay }
?: mediaSources.firstOrNull { it.supportsDirectStream }
?: mediaSources.firstOrNull { it.supportsTranscoding }
?: mediaSources.firstOrNull()
}
}
private suspend fun resolveTranscodeUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
playSessionId: String?,
maxAudioChannels: Int
): String? {
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl ->
if (transcodingUrl.startsWith("http", ignoreCase = true)) {
return transcodingUrl
}
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
return "$baseUrl$transcodingUrl"
}
return api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
enableAutoStreamCopy = false,
allowVideoStreamCopy = false,
allowAudioStreamCopy = false,
maxAudioChannels = maxAudioChannels
)
}
}

View File

@@ -0,0 +1,20 @@
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?,
val canRetryWithTranscoding: Boolean
)