Simplify Jellyfin playback url retrieval

This commit is contained in:
2026-06-01 20:54:20 +02:00
parent 0eea3165de
commit 80ab591662
10 changed files with 57 additions and 261 deletions

View File

@@ -17,8 +17,6 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia
@@ -86,14 +84,14 @@ class DefaultPlayableMediaRepository @Inject constructor(
downloadedMediaItem: MediaItem?,
): PlayableMedia? {
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return null
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return null
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(mediaId) ?: return null
val mediaItem = if (downloadedMediaItem == null) {
getMediaItem(baseItem, playbackDecision)
getMediaItem(baseItem, playbackSource.directPlayUrl)
} else {
getDownloadedMediaItem(baseItem, playbackDecision, downloadedMediaItem)
getDownloadedMediaItem(baseItem, downloadedMediaItem)
}
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val resumePositionMs = calculateResumePosition(baseItem, playbackSource.mediaSource)
val preferenceMediaId = when (baseItem.type) {
BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId
else -> mediaId
@@ -172,7 +170,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
return null
}
private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {
private suspend fun getMediaItem(baseItem: BaseItemDto, url: String): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -181,11 +179,12 @@ class DefaultPlayableMediaRepository @Inject constructor(
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackDecision = playbackDecision,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
url = url,
title = baseItem?.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
// TODO
playbackReportContext = null,
)
return@withContext mediaItem
@@ -193,7 +192,6 @@ class DefaultPlayableMediaRepository @Inject constructor(
private suspend fun getDownloadedMediaItem(
baseItem: BaseItemDto,
playbackDecision: PlaybackDecision,
downloadedMediaItem: MediaItem,
): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id
@@ -203,10 +201,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
return@withContext downloadedMediaItem.withMetadata(
mediaId = mediaId.toString(),
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
title = baseItem?.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
// TODO
playbackReportContext = null,
)
}
@@ -240,7 +239,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
@OptIn(UnstableApi::class)
private fun createMediaItem(
mediaId: String,
playbackDecision: PlaybackDecision,
url: String,
title: String,
subtitle: String?,
artworkUrl: String,
@@ -252,17 +251,10 @@ class DefaultPlayableMediaRepository @Inject constructor(
.setArtworkUri(artworkUrl.toUri())
.build()
val builder = MediaItem.Builder()
.setUri(playbackDecision.url.toUri())
.setUri(url.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setTag(playbackReportContext)
playbackCustomCacheKey(
mediaId = mediaId,
playbackUrl = playbackDecision.url,
playMethod = playbackDecision.reportContext.playMethod,
)?.let(builder::setCustomCacheKey)
return builder.build()
}

View File

@@ -511,7 +511,6 @@ class JellyfinApiClient @Inject constructor(
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
repeatMode = RepeatMode.REPEAT_NONE,
@@ -539,7 +538,6 @@ class JellyfinApiClient @Inject constructor(
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
repeatMode = RepeatMode.REPEAT_NONE,
@@ -561,7 +559,6 @@ class JellyfinApiClient @Inject constructor(
itemId = itemId,
positionTicks = positionTicks,
mediaSourceId = reportContext.mediaSourceId,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
failed = false,
),

View File

@@ -3,7 +3,6 @@ package hu.bbara.purefin.data.jellyfin.download
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.EpisodeDownloadSource
import hu.bbara.purefin.core.data.MovieDownloadSource
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
@@ -11,7 +10,7 @@ import hu.bbara.purefin.data.converter.toSeason
import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@@ -32,13 +31,13 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
return@withContext null
}
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(movieId) ?: return@withContext null
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(movieId) ?: return@withContext null
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null
MovieDownloadSource(
movie = itemInfo.toMovie(serverUrl),
playbackUrl = playbackDecision.url,
customCacheKey = playbackDecision.downloadCustomCacheKey(movieId),
playbackUrl = playbackSource.directPlayUrl,
customCacheKey = playbackSource.downloadCustomCacheKey(movieId),
)
}
@@ -48,7 +47,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
return@withContext null
}
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(episodeId) ?: return@withContext null
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(episodeId) ?: return@withContext null
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
val episode = episodeDto.toEpisode(serverUrl)
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
@@ -58,8 +57,8 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
episode = episode,
series = series,
season = season,
playbackUrl = playbackDecision.url,
customCacheKey = playbackDecision.downloadCustomCacheKey(episodeId),
playbackUrl = playbackSource.directPlayUrl,
customCacheKey = playbackSource.downloadCustomCacheKey(episodeId),
)
}
@@ -91,14 +90,11 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
.map { it.id }
}
private fun PlaybackDecision.downloadCustomCacheKey(mediaId: UUID): String? {
if (reportContext.playMethod != PlaybackMethod.DIRECT_PLAY) {
return null
}
private fun PlaybackSource.downloadCustomCacheKey(mediaId: UUID): String? {
return playbackCustomCacheKey(
mediaId = mediaId.toString(),
playbackUrl = url,
playMethod = reportContext.playMethod,
playbackUrl = directPlayUrl,
playMethod = playbackReportContext.playMethod,
)
}
}

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.data.jellyfin.playback
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.Dispatchers
@@ -17,7 +19,7 @@ class JellyfinPlaybackResolver @Inject constructor(
private val playbackProfilePolicy: PlaybackProfilePolicy,
) {
suspend fun getPlaybackDecision(mediaId: UUID): PlaybackDecision? = withContext(Dispatchers.IO) {
suspend fun getPlaybackSource(mediaId: UUID): PlaybackSource? = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first().trim()
if (serverUrl.isBlank()) {
return@withContext null
@@ -33,6 +35,11 @@ class JellyfinPlaybackResolver @Inject constructor(
return@withContext null
}
if (playbackInfo.mediaSources.isEmpty()) {
Timber.tag(TAG).w("No media sources available for $mediaId")
return@withContext null
}
val directPlayUrl = jellyfinApiClient.getVideoStreamUrl(
itemId = mediaId,
)
@@ -41,19 +48,18 @@ class JellyfinPlaybackResolver @Inject constructor(
return@withContext null
}
val decision = PlaybackDecisionResolver.resolve(
mediaSources = playbackInfo.mediaSources,
playSessionId = playbackInfo.playSessionId,
serverUrl = serverUrl,
directPlayUrl = directPlayUrl
PlaybackSource(
mediaSource = playbackInfo.mediaSources.first(),
directPlayUrl = directPlayUrl,
transcodingUrl = playbackInfo.mediaSources.firstOrNull()?.transcodingUrl,
playbackReportContext = PlaybackReportContext(
playMethod = PlaybackMethod.DIRECT_PLAY,
mediaSourceId = playbackInfo.mediaSources.firstOrNull()?.id,
audioStreamIndex = playbackInfo.mediaSources.firstOrNull()?.defaultAudioStreamIndex,
subtitleStreamIndex = playbackInfo.mediaSources.firstOrNull()?.defaultSubtitleStreamIndex,
playSessionId = playbackInfo.playSessionId,
),
)
if (decision == null) {
Timber.tag(TAG).w("No compatible playback path for $mediaId")
} else {
Timber.tag(TAG).d("Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}")
}
decision
}
private companion object {

View File

@@ -1,52 +0,0 @@
package hu.bbara.purefin.data.jellyfin.playback
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
internal object PlaybackDecisionResolver {
fun resolve(
mediaSources: List<MediaSourceInfo>,
playSessionId: String?,
serverUrl: String,
directPlayUrl: String,
): PlaybackDecision? {
val mediaSource = mediaSources.firstOrNull { it.protocol == MediaProtocol.FILE && !it.isRemote }
?: return null
val playMethod = when {
mediaSource.supportsDirectPlay -> PlaybackMethod.DIRECT_PLAY
mediaSource.supportsDirectStream && !mediaSource.transcodingUrl.isNullOrBlank() -> PlaybackMethod.DIRECT_STREAM
mediaSource.supportsTranscoding && !mediaSource.transcodingUrl.isNullOrBlank() -> PlaybackMethod.TRANSCODE
else -> return null
}
val url = when (playMethod) {
PlaybackMethod.DIRECT_PLAY -> directPlayUrl
PlaybackMethod.DIRECT_STREAM,
PlaybackMethod.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('/')}"
}
}

View File

@@ -0,0 +1,11 @@
package hu.bbara.purefin.data.jellyfin.playback
import hu.bbara.purefin.core.data.PlaybackReportContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
data class PlaybackSource(
val mediaSource: MediaSourceInfo,
val directPlayUrl: String,
val transcodingUrl: String?,
val playbackReportContext: PlaybackReportContext,
)

View File

@@ -1,151 +0,0 @@
package hu.bbara.purefin.data.jellyfin.playback
import hu.bbara.purefin.core.data.PlaybackMethod
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.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class PlaybackDecisionResolverTest {
@Test
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",
)
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(PlaybackMethod.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 `resolve uses transcoding url for direct stream`() {
val source = mediaSource(
id = "direct-stream",
supportsDirectStream = true,
transcodingUrl = "/Videos/1/master.m3u8",
)
val decision = requireNotNull(
PlaybackDecisionResolver.resolve(
mediaSources = listOf(source),
playSessionId = "session-2",
serverUrl = "https://example.com/jellyfin",
) { error("direct play should not be used") }
)
assertEquals(PlaybackMethod.DIRECT_STREAM, decision.reportContext.playMethod)
assertEquals("https://example.com/jellyfin/Videos/1/master.m3u8", decision.url)
}
@Test
fun `resolve uses transcoding when direct stream is unavailable`() {
val source = mediaSource(
id = "transcode",
supportsTranscoding = true,
transcodingUrl = "Videos/2/master.m3u8",
)
val decision = requireNotNull(
PlaybackDecisionResolver.resolve(
mediaSources = listOf(source),
playSessionId = "session-3",
serverUrl = "https://example.com/jellyfin",
) { error("direct play should not be used") }
)
assertEquals(PlaybackMethod.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,
)
}
}