refactor: centralize playable media state

Move playback-related media state into shared model types and make the player consume a single PlayableMedia object instead of separately loading MediaItems, track preferences, resume positions, and media segments.

This updates PlayableMediaRepository to return playable media bundles, seeds the player playlist with the current item, keeps next-up entries in the same model, restores resume/segment handling, and fixes current queue highlighting.

The change also moves track preference models into core-model, centralizes Jellyfin BaseItemDto conversion, removes placeholder movie audio/subtitle fields, bumps the offline Room schema, and updates mobile/TV queue UI code for the playlist model.

Verification:
./gradlew --no-daemon :core-model:compileDebugKotlin :core:compileDebugKotlin :data:compileDebugKotlin :app:compileDebugKotlin
This commit is contained in:
2026-04-25 18:08:23 +02:00
parent 2433ececac
commit f7f9fc1058
36 changed files with 440 additions and 549 deletions

View File

@@ -5,15 +5,11 @@ import androidx.datastore.core.DataStore
import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.NetworkMonitor
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toLibrary
import hu.bbara.purefin.data.converter.toMovie
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.offline.cache.HomeCache
import hu.bbara.purefin.data.offline.cache.toCachedEpisode
@@ -26,6 +22,9 @@ import hu.bbara.purefin.data.offline.cache.toLibrary
import hu.bbara.purefin.data.offline.cache.toMedia
import hu.bbara.purefin.data.offline.cache.toMovie
import hu.bbara.purefin.data.offline.cache.toSeries
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -35,14 +34,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicBoolean
@@ -235,10 +229,10 @@ class InMemoryAppContentRepository @Inject constructor(
}
return when (library.type) {
LibraryKind.MOVIES -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl(), library.id) },
movies = contentItem.map { it.toMovie(serverUrl()) },
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl(), library.id) },
series = contentItem.map { it.toSeries(serverUrl()) },
)
}
}
@@ -317,13 +311,13 @@ class InMemoryAppContentRepository @Inject constructor(
}
library.id to when (library.collectionType) {
CollectionType.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(serverUrl(), library.id)
val movie = it.toMovie(serverUrl())
Media.MovieMedia(movieId = movie.id)
}
CollectionType.TVSHOWS -> latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl(), library.id)
val series = it.toSeries(serverUrl())
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
@@ -347,111 +341,6 @@ class InMemoryAppContentRepository @Inject constructor(
return userSessionRepository.serverUrl.first()
}
private fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (collectionType) {
CollectionType.MOVIES -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY),
type = LibraryKind.SERIES,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
}
}
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie {
return Movie(
id = id,
libraryId = libraryId,
title = name ?: "Unknown title",
progress = userData!!.playedPercentage,
watched = userData!!.played,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
synopsis = overview ?: "No synopsis available",
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList(),
)
}
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
return Series(
id = id,
libraryId = libraryId,
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
seasonCount = childCount!!,
seasons = emptyList(),
cast = emptyList(),
)
}
private fun BaseItemDto.toSeason(): Season {
return Season(
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList(),
)
}
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) {
return fallbackYear?.toString() ?: ""
}
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
companion object {
private const val TAG = "InMemoryAppContentRepo"
}

View File

@@ -2,11 +2,13 @@ package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
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.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -19,12 +21,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -36,6 +33,8 @@ class InMemoryMediaRepository @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val serverUrl = userSessionRepository.serverUrl
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = moviesState.asStateFlow()
@@ -45,6 +44,39 @@ class InMemoryMediaRepository @Inject constructor(
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
override suspend fun getMovie(id: UUID): Flow<Movie?> {
if (!moviesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
val movie = item.toMovie(serverUrl.first())
moviesState.update { current -> current + (movie.id to movie) }
}
}
return moviesState.map { it[id] }
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
if (!seriesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
val series = item.toSeries(serverUrl.first())
seriesState.update { current -> current + (series.id to series) }
}
}
return seriesState.map { it[id] }
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
if (!episodesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
val episode = item.toEpisode(serverUrl.first())
episodesState.update { current -> current + (episode.id to episode) }
}
}
val episodeFlow = episodesState.map { it[id] }
val seriesId = episodeFlow.first()!!.seriesId
observeSeriesWithContent(seriesId = seriesId)
return episodeFlow
}
fun upsertMovies(movies: List<Movie>) {
moviesState.update { current -> current + movies.associateBy { it.id } }
}
@@ -102,53 +134,4 @@ class InMemoryMediaRepository @Inject constructor(
val allEpisodes = filledSeasons.flatMap { it.episodes }
episodesState.update { current -> current + allEpisodes.associateBy { it.id } }
}
private fun BaseItemDto.toSeason(): Season {
return Season(
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList(),
)
}
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) return fallbackYear?.toString() ?: ""
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
}

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
@@ -31,6 +32,18 @@ class OfflineMediaRepository @Inject constructor(
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return movies.map { it[id] }
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return series.map { it[id] }
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return episodes.map { it[id] }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId)
}

View File

@@ -0,0 +1,127 @@
package hu.bbara.purefin.data.converter
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit
fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (collectionType) {
CollectionType.MOVIES -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = serverUrl,
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = serverUrl,
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = LibraryKind.SERIES,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
}
}
fun BaseItemDto.toMovie(serverUrl: String): Movie {
return Movie(
id = id,
libraryId = parentId!!,
title = name ?: "Unknown title",
progress = userData!!.playedPercentage,
watched = userData!!.played,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
synopsis = overview ?: "No synopsis available",
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
cast = emptyList(),
)
}
fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId!!,
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
seasonCount = childCount!!,
seasons = emptyList(),
cast = emptyList(),
)
}
fun BaseItemDto.toSeason(): Season {
return Season(
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList(),
)
}
fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) {
return fallbackYear?.toString() ?: ""
}
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}

View File

@@ -9,14 +9,16 @@ import androidx.media3.common.util.UnstableApi
import hu.bbara.purefin.data.PlayableMediaRepository
import hu.bbara.purefin.data.PlaybackReportContext
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.SegmentType
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.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.player.preference.TrackPreferencesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -35,14 +37,30 @@ import javax.inject.Singleton
class DefaultPlayableMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinPlaybackResolver: JellyfinPlaybackResolver,
private val trackPreferencesRepository: TrackPreferencesRepository,
private val userSessionRepository: UserSessionRepository,
) : PlayableMediaRepository {
override suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) {
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return@withContext null
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val mediaItem = getMediaItem(baseItem, playbackDecision)
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(mediaId.toString()).first()
val mediaSegments = getMediaSegments(mediaId)
PlayableMedia(
id = mediaId,
mediaItem = mediaItem,
resumePositionMs = resumePositionMs?.toFloat() ?: 0f,
preferences = mediaTrackPreferences,
mediaSegments = mediaSegments
)
}
private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY)
@@ -50,46 +68,35 @@ class DefaultPlayableMediaRepository @Inject constructor(
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackDecision = playbackDecision,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
)
Pair(mediaItem, resumePositionMs)
return@withContext mediaItem
}
override suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> {
private suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> {
val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId)
return mediaSegments.mapNotNull {
it.toMediaSegment()
}
}
override suspend fun getNextUpMediaItems(
override suspend fun getNextUpPlayableMedias(
episodeId: UUID,
existingIds: Set<String>,
existingIds: Set<UUID>,
count: Int,
): List<MediaItem> = withContext(Dispatchers.IO) {
): List<PlayableMedia> = withContext(Dispatchers.IO) {
runCatching {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
episodes.mapNotNull { episode ->
val id = episode.id ?: return@mapNotNull null
val stringId = id.toString()
if (existingIds.contains(stringId)) {
if (existingIds.contains(id)) {
return@mapNotNull null
}
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(id) ?: return@mapNotNull null
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, id, ArtworkKind.PRIMARY)
createMediaItem(
mediaId = stringId,
playbackDecision = playbackDecision,
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
subtitle = seasonEpisodeLabel(episode),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
)
getPlayableMedia(id)
}
}.getOrElse { error ->
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)

View File

@@ -244,9 +244,9 @@ class JellyfinApiClient @Inject constructor(
seriesId = seriesId,
enableUserData = true,
startItemId = episodeId,
limit = count + 1,
limit = count,
)
val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1)
val nextUpEpisodes = nextUpEpisodesResult.content.items
Log.d("getNextEpisodes", nextUpEpisodes.toString())
nextUpEpisodes
}

View File

@@ -1,83 +0,0 @@
package hu.bbara.purefin.data.jellyfin.download
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import java.util.UUID
import java.util.concurrent.TimeUnit
import org.jellyfin.sdk.model.api.BaseItemDto
internal fun BaseItemDto.toMovie(serverUrl: String): Movie {
return Movie(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
title = name ?: "Unknown title",
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
audioTrack = "ENG",
subtitles = "ENG",
cast = emptyList(),
)
}
internal fun BaseItemDto.toEpisode(serverUrl: String): Episode {
return Episode(
id = id,
seriesId = seriesId ?: UUID.randomUUID(),
seasonId = parentId ?: UUID.randomUUID(),
title = name ?: "Unknown title",
index = indexNumber ?: 0,
synopsis = overview ?: "No synopsis available.",
releaseDate = productionYear?.toString() ?: "",
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
cast = emptyList(),
)
}
internal fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
seasonCount = childCount ?: 0,
seasons = emptyList(),
cast = emptyList(),
)
}
internal fun BaseItemDto.toSeason(seriesId: UUID): Season {
return Season(
id = id,
seriesId = this.seriesId ?: seriesId,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
episodeCount = childCount ?: 0,
episodes = emptyList(),
)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}

View File

@@ -5,16 +5,20 @@ import hu.bbara.purefin.data.EpisodeDownloadSource
import hu.bbara.purefin.data.MovieDownloadSource
import hu.bbara.purefin.data.PlaybackMethod
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
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.PlaybackDecisionResolver
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinDownloadMediaSourceResolver @Inject constructor(
@@ -49,7 +53,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
val episode = episodeDto.toEpisode(serverUrl)
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: return@withContext null
val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason() ?: return@withContext null
EpisodeDownloadSource(
episode = episode,

View File

@@ -30,8 +30,6 @@ data class CachedMovie(
val format: String,
val synopsis: String,
val imageUrlPrefix: String,
val audioTrack: String,
val subtitles: String,
val cast: List<CachedCastMember> = emptyList()
)
@@ -156,8 +154,6 @@ fun Movie.toCachedMovie() = CachedMovie(
format = format,
synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast.map { it.toCachedCastMember() },
)
@@ -176,8 +172,6 @@ fun CachedMovie.toMovie(): Movie? {
format = format,
synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast.map { it.toCastMember() },
)
}

View File

@@ -21,6 +21,4 @@ data class MovieEntity(
val format: String,
val synopsis: String,
val imageUrlPrefix: String,
val audioTrack: String,
val subtitles: String
)

View File

@@ -23,7 +23,7 @@ import hu.bbara.purefin.data.offline.room.entity.SmartDownloadEntity
EpisodeEntity::class,
SmartDownloadEntity::class,
],
version = 9,
version = 10,
exportSchema = false
)
@TypeConverters(UuidConverters::class)

View File

@@ -220,9 +220,7 @@ class OfflineRoomMediaLocalDataSource(
runtime = runtime,
format = format,
synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles
imageUrlPrefix = imageUrlPrefix
)
private fun Series.toEntity() = SeriesEntity(
@@ -273,8 +271,6 @@ class OfflineRoomMediaLocalDataSource(
format = format,
synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = emptyList()
)

View File

@@ -1,78 +0,0 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class DefaultEpisodeSeriesLookupTest {
@Test
fun `returns series id for known episode`() = runBlocking {
val episodeId = UUID.randomUUID()
val seriesId = UUID.randomUUID()
val lookup = DefaultEpisodeSeriesLookup(
mediaCatalogReader = FakeMediaCatalogReader(
episodes = mapOf(
episodeId to episode(
id = episodeId,
seriesId = seriesId,
)
)
)
)
val result = lookup.preferenceKeyFor(episodeId)
assertEquals(seriesId.toString(), result)
}
@Test
fun `falls back to media id when episode is missing`() = runBlocking {
val mediaId = UUID.randomUUID()
val lookup = DefaultEpisodeSeriesLookup(
mediaCatalogReader = FakeMediaCatalogReader()
)
val result = lookup.preferenceKeyFor(mediaId)
assertEquals(mediaId.toString(), result)
}
private class FakeMediaCatalogReader(
movies: Map<UUID, Movie> = emptyMap(),
series: Map<UUID, Series> = emptyMap(),
episodes: Map<UUID, Episode> = emptyMap(),
) : MediaCatalogReader {
override val movies: StateFlow<Map<UUID, Movie>> = MutableStateFlow(movies)
override val series: StateFlow<Map<UUID, Series>> = MutableStateFlow(series)
override val episodes: StateFlow<Map<UUID, Episode>> = MutableStateFlow(episodes)
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> = flowOf(series.value[seriesId])
}
private fun episode(id: UUID, seriesId: UUID) = Episode(
id = id,
seriesId = seriesId,
seasonId = UUID.randomUUID(),
index = 1,
title = "Episode",
synopsis = "Synopsis",
releaseDate = "2026",
rating = "PG",
runtime = "42m",
progress = null,
watched = false,
format = "VIDEO",
imageUrlPrefix = "",
cast = emptyList(),
)
}