mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Simplify Jellyfin playback url retrieval
This commit is contained in:
@@ -26,7 +26,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -39,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
@@ -116,10 +116,6 @@ fun PlayerScreen(
|
|||||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = {
|
|
||||||
it.player = viewModel.player
|
|
||||||
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ data class PlaybackReportContext(
|
|||||||
val mediaSourceId: String?,
|
val mediaSourceId: String?,
|
||||||
val audioStreamIndex: Int?,
|
val audioStreamIndex: Int?,
|
||||||
val subtitleStreamIndex: Int?,
|
val subtitleStreamIndex: Int?,
|
||||||
val liveStreamId: String?,
|
|
||||||
val playSessionId: String?,
|
val playSessionId: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||||||
import androidx.media3.exoplayer.SeekParameters
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import androidx.media3.exoplayer.util.EventLogger
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -69,6 +70,7 @@ object VideoPlayerModule {
|
|||||||
.apply {
|
.apply {
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
pauseAtEndOfMediaItems = false
|
pauseAtEndOfMediaItems = false
|
||||||
|
addAnalyticsListener(EventLogger())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
|
|||||||
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
|
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
|
||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
|
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.Episode
|
||||||
import hu.bbara.purefin.model.MediaSegment
|
import hu.bbara.purefin.model.MediaSegment
|
||||||
import hu.bbara.purefin.model.PlayableMedia
|
import hu.bbara.purefin.model.PlayableMedia
|
||||||
@@ -86,14 +84,14 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
downloadedMediaItem: MediaItem?,
|
downloadedMediaItem: MediaItem?,
|
||||||
): PlayableMedia? {
|
): PlayableMedia? {
|
||||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return null
|
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) {
|
val mediaItem = if (downloadedMediaItem == null) {
|
||||||
getMediaItem(baseItem, playbackDecision)
|
getMediaItem(baseItem, playbackSource.directPlayUrl)
|
||||||
} else {
|
} 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) {
|
val preferenceMediaId = when (baseItem.type) {
|
||||||
BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId
|
BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId
|
||||||
else -> mediaId
|
else -> mediaId
|
||||||
@@ -172,7 +170,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
return null
|
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 mediaId = baseItem.id
|
||||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||||
|
|
||||||
@@ -181,11 +179,12 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
|
|
||||||
val mediaItem = createMediaItem(
|
val mediaItem = createMediaItem(
|
||||||
mediaId = mediaId.toString(),
|
mediaId = mediaId.toString(),
|
||||||
playbackDecision = playbackDecision,
|
url = url,
|
||||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
|
title = baseItem?.name ?: "Unknown",
|
||||||
subtitle = seasonEpisodeLabel(baseItem),
|
subtitle = seasonEpisodeLabel(baseItem),
|
||||||
artworkUrl = artworkUrl,
|
artworkUrl = artworkUrl,
|
||||||
playbackReportContext = playbackDecision.reportContext,
|
// TODO
|
||||||
|
playbackReportContext = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
return@withContext mediaItem
|
return@withContext mediaItem
|
||||||
@@ -193,7 +192,6 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun getDownloadedMediaItem(
|
private suspend fun getDownloadedMediaItem(
|
||||||
baseItem: BaseItemDto,
|
baseItem: BaseItemDto,
|
||||||
playbackDecision: PlaybackDecision,
|
|
||||||
downloadedMediaItem: MediaItem,
|
downloadedMediaItem: MediaItem,
|
||||||
): MediaItem = withContext(Dispatchers.IO) {
|
): MediaItem = withContext(Dispatchers.IO) {
|
||||||
val mediaId = baseItem.id
|
val mediaId = baseItem.id
|
||||||
@@ -203,10 +201,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
|
|
||||||
return@withContext downloadedMediaItem.withMetadata(
|
return@withContext downloadedMediaItem.withMetadata(
|
||||||
mediaId = mediaId.toString(),
|
mediaId = mediaId.toString(),
|
||||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
|
title = baseItem?.name ?: "Unknown",
|
||||||
subtitle = seasonEpisodeLabel(baseItem),
|
subtitle = seasonEpisodeLabel(baseItem),
|
||||||
artworkUrl = artworkUrl,
|
artworkUrl = artworkUrl,
|
||||||
playbackReportContext = playbackDecision.reportContext,
|
// TODO
|
||||||
|
playbackReportContext = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +239,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun createMediaItem(
|
private fun createMediaItem(
|
||||||
mediaId: String,
|
mediaId: String,
|
||||||
playbackDecision: PlaybackDecision,
|
url: String,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
artworkUrl: String,
|
artworkUrl: String,
|
||||||
@@ -252,17 +251,10 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
.setArtworkUri(artworkUrl.toUri())
|
.setArtworkUri(artworkUrl.toUri())
|
||||||
.build()
|
.build()
|
||||||
val builder = MediaItem.Builder()
|
val builder = MediaItem.Builder()
|
||||||
.setUri(playbackDecision.url.toUri())
|
.setUri(url.toUri())
|
||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
.setMediaMetadata(metadata)
|
.setMediaMetadata(metadata)
|
||||||
.setTag(playbackReportContext)
|
.setTag(playbackReportContext)
|
||||||
|
|
||||||
playbackCustomCacheKey(
|
|
||||||
mediaId = mediaId,
|
|
||||||
playbackUrl = playbackDecision.url,
|
|
||||||
playMethod = playbackDecision.reportContext.playMethod,
|
|
||||||
)?.let(builder::setCustomCacheKey)
|
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -511,7 +511,6 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
mediaSourceId = reportContext.mediaSourceId,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
audioStreamIndex = reportContext.audioStreamIndex,
|
audioStreamIndex = reportContext.audioStreamIndex,
|
||||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||||
liveStreamId = reportContext.liveStreamId,
|
|
||||||
playSessionId = reportContext.playSessionId,
|
playSessionId = reportContext.playSessionId,
|
||||||
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
@@ -539,7 +538,6 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
mediaSourceId = reportContext.mediaSourceId,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
audioStreamIndex = reportContext.audioStreamIndex,
|
audioStreamIndex = reportContext.audioStreamIndex,
|
||||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||||
liveStreamId = reportContext.liveStreamId,
|
|
||||||
playSessionId = reportContext.playSessionId,
|
playSessionId = reportContext.playSessionId,
|
||||||
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
@@ -561,7 +559,6 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
positionTicks = positionTicks,
|
positionTicks = positionTicks,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
liveStreamId = reportContext.liveStreamId,
|
|
||||||
playSessionId = reportContext.playSessionId,
|
playSessionId = reportContext.playSessionId,
|
||||||
failed = false,
|
failed = false,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package hu.bbara.purefin.data.jellyfin.download
|
|||||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||||
import hu.bbara.purefin.core.data.EpisodeDownloadSource
|
import hu.bbara.purefin.core.data.EpisodeDownloadSource
|
||||||
import hu.bbara.purefin.core.data.MovieDownloadSource
|
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.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.data.converter.toEpisode
|
import hu.bbara.purefin.data.converter.toEpisode
|
||||||
import hu.bbara.purefin.data.converter.toMovie
|
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.converter.toSeries
|
||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
|
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 hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -32,13 +31,13 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
|||||||
return@withContext null
|
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
|
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null
|
||||||
|
|
||||||
MovieDownloadSource(
|
MovieDownloadSource(
|
||||||
movie = itemInfo.toMovie(serverUrl),
|
movie = itemInfo.toMovie(serverUrl),
|
||||||
playbackUrl = playbackDecision.url,
|
playbackUrl = playbackSource.directPlayUrl,
|
||||||
customCacheKey = playbackDecision.downloadCustomCacheKey(movieId),
|
customCacheKey = playbackSource.downloadCustomCacheKey(movieId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
|||||||
return@withContext null
|
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 episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
|
||||||
val episode = episodeDto.toEpisode(serverUrl)
|
val episode = episodeDto.toEpisode(serverUrl)
|
||||||
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
|
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
|
||||||
@@ -58,8 +57,8 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
|||||||
episode = episode,
|
episode = episode,
|
||||||
series = series,
|
series = series,
|
||||||
season = season,
|
season = season,
|
||||||
playbackUrl = playbackDecision.url,
|
playbackUrl = playbackSource.directPlayUrl,
|
||||||
customCacheKey = playbackDecision.downloadCustomCacheKey(episodeId),
|
customCacheKey = playbackSource.downloadCustomCacheKey(episodeId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +90,11 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
|||||||
.map { it.id }
|
.map { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun PlaybackDecision.downloadCustomCacheKey(mediaId: UUID): String? {
|
private fun PlaybackSource.downloadCustomCacheKey(mediaId: UUID): String? {
|
||||||
if (reportContext.playMethod != PlaybackMethod.DIRECT_PLAY) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return playbackCustomCacheKey(
|
return playbackCustomCacheKey(
|
||||||
mediaId = mediaId.toString(),
|
mediaId = mediaId.toString(),
|
||||||
playbackUrl = url,
|
playbackUrl = directPlayUrl,
|
||||||
playMethod = reportContext.playMethod,
|
playMethod = playbackReportContext.playMethod,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.playback
|
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.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -17,7 +19,7 @@ class JellyfinPlaybackResolver @Inject constructor(
|
|||||||
private val playbackProfilePolicy: PlaybackProfilePolicy,
|
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()
|
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||||
if (serverUrl.isBlank()) {
|
if (serverUrl.isBlank()) {
|
||||||
return@withContext null
|
return@withContext null
|
||||||
@@ -33,6 +35,11 @@ class JellyfinPlaybackResolver @Inject constructor(
|
|||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (playbackInfo.mediaSources.isEmpty()) {
|
||||||
|
Timber.tag(TAG).w("No media sources available for $mediaId")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
val directPlayUrl = jellyfinApiClient.getVideoStreamUrl(
|
val directPlayUrl = jellyfinApiClient.getVideoStreamUrl(
|
||||||
itemId = mediaId,
|
itemId = mediaId,
|
||||||
)
|
)
|
||||||
@@ -41,19 +48,18 @@ class JellyfinPlaybackResolver @Inject constructor(
|
|||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
val decision = PlaybackDecisionResolver.resolve(
|
PlaybackSource(
|
||||||
mediaSources = playbackInfo.mediaSources,
|
mediaSource = playbackInfo.mediaSources.first(),
|
||||||
playSessionId = playbackInfo.playSessionId,
|
directPlayUrl = directPlayUrl,
|
||||||
serverUrl = serverUrl,
|
transcodingUrl = playbackInfo.mediaSources.firstOrNull()?.transcodingUrl,
|
||||||
directPlayUrl = directPlayUrl
|
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 {
|
private companion object {
|
||||||
|
|||||||
@@ -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('/')}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user