mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,4 @@ data class MovieEntity(
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val imageUrlPrefix: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user