mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat: implement playback source planning and enhance device profile transcoding support
This commit is contained in:
@@ -49,4 +49,5 @@ dependencies {
|
|||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import android.util.Log
|
|||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||||
|
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a DeviceProfile for Android devices with proper codec support detection.
|
* Creates a DeviceProfile for Android devices with proper codec support detection.
|
||||||
@@ -45,7 +48,7 @@ object AndroidDeviceProfile {
|
|||||||
Log.d(TAG, "Max audio channels: $maxAudioChannels")
|
Log.d(TAG, "Max audio channels: $maxAudioChannels")
|
||||||
|
|
||||||
val snapshot = CapabilitySnapshot(
|
val snapshot = CapabilitySnapshot(
|
||||||
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs),
|
deviceProfile = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels),
|
||||||
maxAudioChannels = maxAudioChannels
|
maxAudioChannels = maxAudioChannels
|
||||||
)
|
)
|
||||||
cachedSnapshot = snapshot
|
cachedSnapshot = snapshot
|
||||||
@@ -55,9 +58,10 @@ object AndroidDeviceProfile {
|
|||||||
|
|
||||||
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
|
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
|
||||||
|
|
||||||
private fun buildDeviceProfile(
|
internal fun createDeviceProfile(
|
||||||
audioCodecs: List<String>,
|
audioCodecs: List<String>,
|
||||||
videoCodecs: List<String>
|
videoCodecs: List<String>,
|
||||||
|
maxAudioChannels: Int
|
||||||
): DeviceProfile {
|
): DeviceProfile {
|
||||||
return DeviceProfile(
|
return DeviceProfile(
|
||||||
name = "Android Media3",
|
name = "Android Media3",
|
||||||
@@ -74,8 +78,22 @@ object AndroidDeviceProfile {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Empty transcoding profiles - Jellyfin will use its defaults
|
// Explicit video transcoding support keeps Jellyfin 10.10/10.11 from
|
||||||
transcodingProfiles = emptyList(),
|
// returning sources that exist but are not marked usable for playback.
|
||||||
|
transcodingProfiles = listOf(
|
||||||
|
TranscodingProfile(
|
||||||
|
container = "ts",
|
||||||
|
type = DlnaProfileType.VIDEO,
|
||||||
|
videoCodec = "h264",
|
||||||
|
audioCodec = "aac",
|
||||||
|
protocol = MediaStreamProtocol.HLS,
|
||||||
|
context = EncodingContext.STREAMING,
|
||||||
|
maxAudioChannels = maxAudioChannels.toString(),
|
||||||
|
minSegments = 2,
|
||||||
|
breakOnNonKeyFrames = true,
|
||||||
|
conditions = emptyList()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
codecProfiles = emptyList(),
|
codecProfiles = emptyList(),
|
||||||
|
|
||||||
|
|||||||
@@ -281,76 +281,32 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
|
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
|
||||||
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
|
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
|
||||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||||
val selectedMediaSource = selectMediaSource(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
|
val playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
|
||||||
|
val selectedMediaSource = playbackPlan.mediaSource
|
||||||
|
|
||||||
val url = when {
|
val url = resolvePlaybackUrl(
|
||||||
forceTranscode && selectedMediaSource.supportsTranscoding -> {
|
mediaId = mediaId,
|
||||||
resolveTranscodeUrl(
|
mediaSource = selectedMediaSource,
|
||||||
mediaId = mediaId,
|
urlStrategy = playbackPlan.urlStrategy,
|
||||||
mediaSource = selectedMediaSource,
|
playSessionId = playbackInfo.playSessionId,
|
||||||
playSessionId = playbackInfo.playSessionId,
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
) ?: return@withContext null
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
!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(
|
val reportContext = PlaybackReportContext(
|
||||||
playMethod = playMethod,
|
playMethod = playbackPlan.playMethod,
|
||||||
mediaSourceId = selectedMediaSource.id,
|
mediaSourceId = selectedMediaSource.id,
|
||||||
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
|
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
|
||||||
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
|
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
|
||||||
liveStreamId = selectedMediaSource.liveStreamId,
|
liveStreamId = selectedMediaSource.liveStreamId,
|
||||||
playSessionId = playbackInfo.playSessionId,
|
playSessionId = playbackInfo.playSessionId,
|
||||||
canRetryWithTranscoding = !forceTranscode &&
|
canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding
|
||||||
playMethod != PlayMethod.TRANSCODE &&
|
|
||||||
selectedMediaSource.supportsTranscoding
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "Playback decision for $mediaId: $playMethod using ${selectedMediaSource.id}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Playback decision for $mediaId: ${playbackPlan.playMethod} using ${selectedMediaSource.id}" +
|
||||||
|
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
|
||||||
|
)
|
||||||
PlaybackDecision(
|
PlaybackDecision(
|
||||||
url = url,
|
url = url,
|
||||||
mediaSource = selectedMediaSource,
|
mediaSource = selectedMediaSource,
|
||||||
@@ -365,37 +321,20 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
|
|
||||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||||
|
|
||||||
val url = when {
|
val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
|
||||||
mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl(
|
val url = resolvePlaybackUrl(
|
||||||
itemId = mediaId,
|
mediaId = mediaId,
|
||||||
static = true,
|
mediaSource = playbackPlan.mediaSource,
|
||||||
mediaSourceId = mediaSource.id,
|
urlStrategy = playbackPlan.urlStrategy,
|
||||||
liveStreamId = mediaSource.liveStreamId
|
playSessionId = null,
|
||||||
)
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
|
)
|
||||||
|
|
||||||
mediaSource.supportsDirectStream -> api.videosApi.getVideoStreamUrl(
|
Log.d(
|
||||||
itemId = mediaId,
|
TAG,
|
||||||
static = false,
|
"Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
|
||||||
mediaSourceId = mediaSource.id,
|
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
|
||||||
liveStreamId = mediaSource.liveStreamId,
|
)
|
||||||
container = mediaSource.transcodingContainer ?: mediaSource.container,
|
|
||||||
enableAutoStreamCopy = true,
|
|
||||||
allowVideoStreamCopy = true,
|
|
||||||
allowAudioStreamCopy = true,
|
|
||||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
|
||||||
)
|
|
||||||
|
|
||||||
mediaSource.supportsTranscoding -> resolveTranscodeUrl(
|
|
||||||
mediaId = mediaId,
|
|
||||||
mediaSource = mediaSource,
|
|
||||||
playSessionId = null,
|
|
||||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
|
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,20 +421,66 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "PlaybackInfo for $mediaId -> sources=${response.content.mediaSources.size}, session=${response.content.playSessionId}")
|
logPlaybackInfo(
|
||||||
|
mediaId = mediaId,
|
||||||
|
playbackInfo = response.content,
|
||||||
|
forceTranscode = forceTranscode
|
||||||
|
)
|
||||||
return response.content
|
return response.content
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? {
|
private suspend fun resolvePlaybackUrl(
|
||||||
return when {
|
mediaId: UUID,
|
||||||
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }
|
mediaSource: MediaSourceInfo,
|
||||||
else -> mediaSources.firstOrNull { it.supportsDirectPlay }
|
urlStrategy: PlaybackSourcePlanner.UrlStrategy,
|
||||||
?: mediaSources.firstOrNull { it.supportsDirectStream }
|
playSessionId: String?,
|
||||||
?: mediaSources.firstOrNull { it.supportsTranscoding }
|
maxAudioChannels: Int
|
||||||
?: mediaSources.firstOrNull()
|
): String? {
|
||||||
|
return when (urlStrategy) {
|
||||||
|
PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = true,
|
||||||
|
mediaSourceId = mediaSource.id,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
liveStreamId = mediaSource.liveStreamId
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM -> resolveDirectStreamUrl(
|
||||||
|
mediaId = mediaId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
maxAudioChannels = maxAudioChannels
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> resolveTranscodeUrl(
|
||||||
|
mediaId = mediaId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
maxAudioChannels = maxAudioChannels
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveDirectStreamUrl(
|
||||||
|
mediaId: UUID,
|
||||||
|
mediaSource: MediaSourceInfo,
|
||||||
|
playSessionId: String?,
|
||||||
|
maxAudioChannels: Int
|
||||||
|
): String {
|
||||||
|
return api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = false,
|
||||||
|
mediaSourceId = mediaSource.id,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
liveStreamId = mediaSource.liveStreamId,
|
||||||
|
container = mediaSource.transcodingContainer ?: mediaSource.container,
|
||||||
|
enableAutoStreamCopy = true,
|
||||||
|
allowVideoStreamCopy = true,
|
||||||
|
allowAudioStreamCopy = true,
|
||||||
|
maxAudioChannels = maxAudioChannels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun resolveTranscodeUrl(
|
private suspend fun resolveTranscodeUrl(
|
||||||
mediaId: UUID,
|
mediaId: UUID,
|
||||||
mediaSource: MediaSourceInfo,
|
mediaSource: MediaSourceInfo,
|
||||||
@@ -523,4 +508,28 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
maxAudioChannels = maxAudioChannels
|
maxAudioChannels = maxAudioChannels
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun logPlaybackInfo(
|
||||||
|
mediaId: UUID,
|
||||||
|
playbackInfo: PlaybackInfoResponse,
|
||||||
|
forceTranscode: Boolean
|
||||||
|
) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"PlaybackInfo for $mediaId (forceTranscode=$forceTranscode) -> " +
|
||||||
|
"sources=${playbackInfo.mediaSources.size}, session=${playbackInfo.playSessionId}, error=${playbackInfo.errorCode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
playbackInfo.mediaSources.forEachIndexed { index, mediaSource ->
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"PlaybackInfo[$index] id=${mediaSource.id}, container=${mediaSource.container}, " +
|
||||||
|
"transcodingContainer=${mediaSource.transcodingContainer}, protocol=${mediaSource.protocol}, " +
|
||||||
|
"supportsDirectPlay=${mediaSource.supportsDirectPlay}, " +
|
||||||
|
"supportsDirectStream=${mediaSource.supportsDirectStream}, " +
|
||||||
|
"supportsTranscoding=${mediaSource.supportsTranscoding}, " +
|
||||||
|
"hasTranscodingUrl=${!mediaSource.transcodingUrl.isNullOrBlank()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package hu.bbara.purefin.core.data.client
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
|
||||||
|
internal object PlaybackSourcePlanner {
|
||||||
|
data class Plan(
|
||||||
|
val mediaSource: MediaSourceInfo,
|
||||||
|
val urlStrategy: UrlStrategy,
|
||||||
|
val playMethod: PlayMethod,
|
||||||
|
val canRetryWithTranscoding: Boolean,
|
||||||
|
val usedFallback: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class UrlStrategy {
|
||||||
|
DIRECT_PLAY,
|
||||||
|
DIRECT_STREAM,
|
||||||
|
TRANSCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun plan(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): Plan? {
|
||||||
|
val selected = when {
|
||||||
|
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
|
||||||
|
PlannedSelection(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
urlStrategy = UrlStrategy.TRANSCODE,
|
||||||
|
playMethod = PlayMethod.TRANSCODE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> mediaSources.firstOrNull { it.supportsDirectPlay }?.let { mediaSource ->
|
||||||
|
PlannedSelection(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
urlStrategy = UrlStrategy.DIRECT_PLAY,
|
||||||
|
playMethod = PlayMethod.DIRECT_PLAY
|
||||||
|
)
|
||||||
|
} ?: mediaSources.firstOrNull { it.supportsDirectStream }?.let { mediaSource ->
|
||||||
|
PlannedSelection(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
urlStrategy = UrlStrategy.DIRECT_STREAM,
|
||||||
|
playMethod = PlayMethod.DIRECT_STREAM
|
||||||
|
)
|
||||||
|
} ?: mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
|
||||||
|
PlannedSelection(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
urlStrategy = UrlStrategy.TRANSCODE,
|
||||||
|
playMethod = PlayMethod.TRANSCODE
|
||||||
|
)
|
||||||
|
} ?: mediaSources.firstOrNull()?.let { mediaSource ->
|
||||||
|
PlannedSelection(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
urlStrategy = UrlStrategy.DIRECT_STREAM,
|
||||||
|
playMethod = PlayMethod.DIRECT_STREAM,
|
||||||
|
usedFallback = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
return Plan(
|
||||||
|
mediaSource = selected.mediaSource,
|
||||||
|
urlStrategy = selected.urlStrategy,
|
||||||
|
playMethod = selected.playMethod,
|
||||||
|
canRetryWithTranscoding = !forceTranscode &&
|
||||||
|
selected.playMethod != PlayMethod.TRANSCODE &&
|
||||||
|
mediaSources.any { it.supportsTranscoding },
|
||||||
|
usedFallback = selected.usedFallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun plan(mediaSource: MediaSourceInfo): Plan? = plan(listOf(mediaSource), forceTranscode = false)
|
||||||
|
|
||||||
|
private data class PlannedSelection(
|
||||||
|
val mediaSource: MediaSourceInfo,
|
||||||
|
val urlStrategy: UrlStrategy,
|
||||||
|
val playMethod: PlayMethod,
|
||||||
|
val usedFallback: Boolean = false
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package hu.bbara.purefin.core.data.client
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AndroidDeviceProfileTest {
|
||||||
|
@Test
|
||||||
|
fun `device profile includes explicit video transcoding support`() {
|
||||||
|
val profile = AndroidDeviceProfile.createDeviceProfile(
|
||||||
|
audioCodecs = listOf("aac", "ac3"),
|
||||||
|
videoCodecs = listOf("h264", "hevc"),
|
||||||
|
maxAudioChannels = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
val transcodingProfile = profile.transcodingProfiles.single()
|
||||||
|
|
||||||
|
assertEquals(DlnaProfileType.VIDEO, transcodingProfile.type)
|
||||||
|
assertEquals("ts", transcodingProfile.container)
|
||||||
|
assertEquals("h264", transcodingProfile.videoCodec)
|
||||||
|
assertEquals("aac", transcodingProfile.audioCodec)
|
||||||
|
assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol)
|
||||||
|
assertEquals(EncodingContext.STREAMING, transcodingProfile.context)
|
||||||
|
assertEquals("6", transcodingProfile.maxAudioChannels)
|
||||||
|
assertEquals(2, transcodingProfile.minSegments)
|
||||||
|
assertTrue(transcodingProfile.breakOnNonKeyFrames)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package hu.bbara.purefin.core.data.client
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PlaybackSourcePlannerTest {
|
||||||
|
@Test
|
||||||
|
fun `transcode-only source is selected for playback`() {
|
||||||
|
val mediaSource = mediaSource(id = "transcode-only", supportsTranscoding = true)
|
||||||
|
|
||||||
|
val plan = requireNotNull(
|
||||||
|
PlaybackSourcePlanner.plan(
|
||||||
|
mediaSources = listOf(mediaSource),
|
||||||
|
forceTranscode = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSame(mediaSource, plan.mediaSource)
|
||||||
|
assertEquals(PlaybackSourcePlanner.UrlStrategy.TRANSCODE, plan.urlStrategy)
|
||||||
|
assertEquals(PlayMethod.TRANSCODE, plan.playMethod)
|
||||||
|
assertFalse(plan.canRetryWithTranscoding)
|
||||||
|
assertFalse(plan.usedFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first source falls back to direct stream when Jellyfin flags are inconsistent`() {
|
||||||
|
val mediaSource = mediaSource(id = "flagless")
|
||||||
|
|
||||||
|
val plan = requireNotNull(
|
||||||
|
PlaybackSourcePlanner.plan(
|
||||||
|
mediaSources = listOf(mediaSource),
|
||||||
|
forceTranscode = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSame(mediaSource, plan.mediaSource)
|
||||||
|
assertEquals(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM, plan.urlStrategy)
|
||||||
|
assertEquals(PlayMethod.DIRECT_STREAM, plan.playMethod)
|
||||||
|
assertFalse(plan.canRetryWithTranscoding)
|
||||||
|
assertTrue(plan.usedFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retry with transcoding stays enabled when another source can transcode`() {
|
||||||
|
val directPlaySource = mediaSource(id = "direct-play", supportsDirectPlay = true)
|
||||||
|
val transcodeSource = mediaSource(id = "transcode", supportsTranscoding = true)
|
||||||
|
|
||||||
|
val plan = requireNotNull(
|
||||||
|
PlaybackSourcePlanner.plan(
|
||||||
|
mediaSources = listOf(directPlaySource, transcodeSource),
|
||||||
|
forceTranscode = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSame(directPlaySource, plan.mediaSource)
|
||||||
|
assertEquals(PlayMethod.DIRECT_PLAY, plan.playMethod)
|
||||||
|
assertTrue(plan.canRetryWithTranscoding)
|
||||||
|
assertFalse(plan.usedFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `force transcode does not fall back to unusable source`() {
|
||||||
|
val mediaSource = mediaSource(id = "flagless")
|
||||||
|
|
||||||
|
val plan = PlaybackSourcePlanner.plan(
|
||||||
|
mediaSources = listOf(mediaSource),
|
||||||
|
forceTranscode = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaSource(
|
||||||
|
id: String,
|
||||||
|
supportsDirectPlay: Boolean = false,
|
||||||
|
supportsDirectStream: Boolean = false,
|
||||||
|
supportsTranscoding: Boolean = false,
|
||||||
|
container: String? = "mkv"
|
||||||
|
): MediaSourceInfo {
|
||||||
|
return MediaSourceInfo(
|
||||||
|
protocol = MediaProtocol.FILE,
|
||||||
|
id = id,
|
||||||
|
type = MediaSourceType.DEFAULT,
|
||||||
|
container = container,
|
||||||
|
isRemote = false,
|
||||||
|
readAtNativeFramerate = true,
|
||||||
|
ignoreDts = false,
|
||||||
|
ignoreIndex = false,
|
||||||
|
genPtsInput = false,
|
||||||
|
supportsTranscoding = supportsTranscoding,
|
||||||
|
supportsDirectStream = supportsDirectStream,
|
||||||
|
supportsDirectPlay = supportsDirectPlay,
|
||||||
|
isInfiniteStream = false,
|
||||||
|
requiresOpening = false,
|
||||||
|
requiresClosing = false,
|
||||||
|
requiresLooping = false,
|
||||||
|
supportsProbing = true,
|
||||||
|
transcodingSubProtocol = MediaStreamProtocol.HTTP,
|
||||||
|
hasSegments = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user