refactor: update package structure and imports for consistency across UI components

This commit is contained in:
2026-04-14 22:03:36 +02:00
parent 1e6c0e838e
commit b67c8076f1
146 changed files with 1239 additions and 1267 deletions

View File

@@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
@@ -21,10 +20,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
kotlin {
@@ -43,12 +38,7 @@ dependencies {
implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.navigation3.runtime)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
testImplementation(libs.junit)
}

View File

@@ -1,10 +1,11 @@
package hu.bbara.purefin.core.data
import android.util.Log
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -12,41 +13,22 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Switches between [OfflineMediaRepository] and [AppContentRepository] based on
* [UserSessionRepository.isOfflineMode]. When offline mode is enabled, all reads
* and writes go through the offline (downloaded) repository; otherwise the online
* repository is used.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class CompositeMediaRepository @Inject constructor(
private val offlineRepository: OfflineMediaRepository,
private val onlineRepository: InMemoryMediaRepository,
private val networkMonitor: NetworkMonitor,
) : MediaRepository {
) : MediaCatalogReader, MediaProgressWriter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val ready: StateFlow<Boolean> = combine(
offlineRepository.ready,
onlineRepository.ready
) { offlineReady, onlineReady ->
offlineReady && onlineReady
}.stateIn(scope, SharingStarted.Eagerly, false)
private val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(scope, SharingStarted.Eagerly, false)
private val activeRepository: Flow<MediaRepository> =
private val activeRepository: Flow<MediaCatalogReader> =
networkMonitor.isOnline.flatMapLatest { online ->
flowOf(if (online) onlineRepository else offlineRepository)
}
@@ -63,42 +45,12 @@ class CompositeMediaRepository @Inject constructor(
.flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun upsertMovies(movies: List<Movie>) {
if (!isOnline.value) {
Log.e("CompositeMediaRepository", "upsertMovies called in offline mode")
return
}
onlineRepository.upsertMovies(movies)
}
override fun upsertSeries(series: List<Series>) {
if (!isOnline.value) {
Log.e("CompositeMediaRepository", "upsertSeries called in offline mode")
return
}
onlineRepository.upsertSeries(series)
}
override fun upsertEpisodes(episodes: List<Episode>) {
if (!isOnline.value) {
Log.e("CompositeMediaRepository", "upsertEpisodes called in offline mode")
return
}
onlineRepository.upsertEpisodes(episodes)
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
val isOnline = networkMonitor.isOnline.stateIn(scope).value
val repo = if (isOnline) onlineRepository else offlineRepository
repo.updateWatchProgress(mediaId, positionMs, durationMs)
}
override fun setReady() {
onlineRepository.setReady()
offlineRepository.setReady()
val repository = if (networkMonitor.isOnline.first()) onlineRepository else offlineRepository
repository.updateWatchProgress(mediaId, positionMs, durationMs)
}
}

View File

@@ -0,0 +1,14 @@
package hu.bbara.purefin.core.data
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DefaultEpisodeSeriesLookup @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
) : EpisodeSeriesLookup {
override suspend fun preferenceKeyFor(mediaId: UUID): String {
return mediaCatalogReader.episodes.value[mediaId]?.seriesId?.toString() ?: mediaId.toString()
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.player.data
package hu.bbara.purefin.core.data
import android.util.Log
import androidx.annotation.OptIn
@@ -6,29 +6,29 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackDecision
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
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.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
import javax.inject.Inject
@ViewModelScoped
class PlayerMediaRepository @Inject constructor(
@Singleton
class DefaultPlayableMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
) {
private val userSessionRepository: UserSessionRepository,
) : PlayableMediaRepository {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
override suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val playbackDecision = jellyfinApiClient.getPlaybackDecision(mediaId) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -49,7 +49,11 @@ class PlayerMediaRepository @Inject constructor(
Pair(mediaItem, resumePositionMs)
}
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 9): List<MediaItem> = withContext(Dispatchers.IO) {
override suspend fun getNextUpMediaItems(
episodeId: UUID,
existingIds: Set<String>,
count: Int,
): List<MediaItem> = withContext(Dispatchers.IO) {
runCatching {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
@@ -71,7 +75,7 @@ class PlayerMediaRepository @Inject constructor(
)
}
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
@@ -99,7 +103,7 @@ class PlayerMediaRepository @Inject constructor(
playbackCustomCacheKey(
mediaId = mediaId,
playbackUrl = playbackDecision.url,
playMethod = playbackDecision.reportContext.playMethod
playMethod = playbackDecision.reportContext.playMethod,
)?.let(builder::setCustomCacheKey)
return builder.build()
@@ -107,25 +111,17 @@ class PlayerMediaRepository @Inject constructor(
private fun calculateResumePosition(
baseItem: BaseItemDto?,
mediaSource: MediaSourceInfo
mediaSource: MediaSourceInfo,
): Long? {
val userData = baseItem?.userData ?: return null
// Get runtime in ticks
val runtimeTicks = mediaSource.runTimeTicks ?: baseItem.runTimeTicks ?: 0L
if (runtimeTicks == 0L) return null
// Get saved playback position from userData
val playbackPositionTicks = userData.playbackPositionTicks ?: 0L
if (playbackPositionTicks == 0L) return null
// Convert ticks to milliseconds
val positionMs = playbackPositionTicks / 10_000
// Calculate percentage for threshold check
val percentage = (playbackPositionTicks.toDouble() / runtimeTicks.toDouble()) * 100.0
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
return if (percentage in 5.0..95.0) positionMs else null
}

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.data
import java.util.UUID
interface EpisodeSeriesLookup {
suspend fun preferenceKeyFor(mediaId: UUID): String
}

View File

@@ -2,11 +2,10 @@ package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Media
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
import kotlinx.coroutines.flow.StateFlow
interface AppContentRepository : MediaRepository {
interface HomeRepository {
val libraries: StateFlow<List<Library>>
val suggestions: StateFlow<List<Media>>
val continueWatching: StateFlow<List<Media>>

View File

@@ -9,27 +9,11 @@ import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
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 org.jellyfin.sdk.model.api.ImageType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@@ -37,75 +21,114 @@ import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
@Singleton
class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache>,
private val mediaRepository: CompositeMediaRepository,
private val onlineMediaRepository: InMemoryMediaRepository,
private val networkMonitor: NetworkMonitor,
) : AppContentRepository {
) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var loadJob: Job? = null
private val ensureReadyMutex = Mutex()
private var refreshJob: Job? = null
private var cacheHydrated = false
private var initialized = false
private val contentRepositoryReady : MutableStateFlow<Boolean> = MutableStateFlow(false)
override val ready: StateFlow<Boolean> = combine(contentRepositoryReady, mediaRepository.ready) {
contentRepositoryReady, mediaRepositoryReady ->
contentRepositoryReady && mediaRepositoryReady
}.stateIn(scope, SharingStarted.Eagerly, false)
private val librariesState = MutableStateFlow<List<Library>>(emptyList())
override val libraries: StateFlow<List<Library>> = librariesState.asStateFlow()
private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList())
private val suggestionsState = MutableStateFlow<List<Media>>(emptyList())
override val suggestions: StateFlow<List<Media>> = suggestionsState.asStateFlow()
override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow()
private val _suggestions: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
private val continueWatchingState = MutableStateFlow<List<Media>>(emptyList())
override val continueWatching: StateFlow<List<Media>> = continueWatchingState.asStateFlow()
override val suggestions: StateFlow<List<Media>> = _suggestions.asStateFlow()
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
private val nextUpState = MutableStateFlow<List<Media>>(emptyList())
override val nextUp: StateFlow<List<Media>> = nextUpState.asStateFlow()
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
private val _nextUp: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
override val movies: StateFlow<Map<UUID, Movie>> = mediaRepository.movies
override val series: StateFlow<Map<UUID, Series>> = mediaRepository.series
override val episodes: StateFlow<Map<UUID, Episode>> = mediaRepository.episodes
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return mediaRepository.observeSeriesWithContent(seriesId)
}
override suspend fun updateWatchProgress(
mediaId: UUID,
positionMs: Long,
durationMs: Long
) {
mediaRepository.updateWatchProgress(mediaId, positionMs, durationMs)
}
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
init {
scope.launch {
loadFromCache()
ensureReady()
}
}
override suspend fun ensureReady() {
ensureReadyMutex.withLock {
hydrateCacheIfNeeded()
if (initialized) {
return
}
initialized = true
}
refreshHomeData()
}
override suspend fun refreshHomeData() {
ensureReadyMutex.withLock {
hydrateCacheIfNeeded()
}
if (refreshJob?.isActive == true) {
refreshJob?.join()
return
}
val job = scope.launch {
runCatching {
if (!networkMonitor.isOnline.first()) {
return@runCatching
}
loadLibraries()
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
}
refreshJob = job
job.join()
}
private suspend fun hydrateCacheIfNeeded() {
if (cacheHydrated) return
loadFromCache()
cacheHydrated = true
}
private suspend fun loadFromCache() {
val cache = homeCacheDataStore.data.first()
if (cache.suggestions.isNotEmpty()) {
_suggestions.value = cache.suggestions.mapNotNull { it.toMedia() }
suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() }
}
if (cache.continueWatching.isNotEmpty()) {
_continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() }
continueWatchingState.value = cache.continueWatching.mapNotNull { it.toMedia() }
}
if (cache.nextUp.isNotEmpty()) {
_nextUp.value = cache.nextUp.mapNotNull { it.toMedia() }
nextUpState.value = cache.nextUp.mapNotNull { it.toMedia() }
}
if (cache.latestLibraryContent.isNotEmpty()) {
_latestLibraryContent.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
latestLibraryContentState.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null
uuid to items.mapNotNull { it.toMedia() }
}.toMap()
@@ -114,12 +137,12 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun persistHomeCache() {
val cache = HomeCache(
suggestions = _suggestions.value.map { it.toCachedItem() },
continueWatching = _continueWatching.value.map { it.toCachedItem() },
nextUp = _nextUp.value.map { it.toCachedItem() },
latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) ->
suggestions = suggestionsState.value.map { it.toCachedItem() },
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
nextUp = nextUpState.value.map { it.toCachedItem() },
latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) ->
uuid.toString() to items.map { it.toCachedItem() }
}.toMap()
}.toMap(),
)
homeCacheDataStore.updateData { cache }
}
@@ -143,52 +166,26 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
override suspend fun ensureReady() {
// check for combined ready state
if (ready.value) {
return
}
if (loadJob?.isActive == true) {
return
}
if (!contentRepositoryReady.value) {
return
}
contentRepositoryReady.value = true
loadJob?.cancel()
loadJob = scope.launch {
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
loadLibraries()
mediaRepository.setReady()
persistHomeCache()
}
}
suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load libraries", error)
return
}
//TODO add support for playlists
val filteredLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
_libraries.value = emptyLibraries
val filledLibraries = emptyLibraries.map { library ->
return@map loadLibrary(library)
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
_libraries.value = filledLibraries
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
librariesState.value = emptyLibraries
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
mediaRepository.upsertMovies(movies)
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
librariesState.value = filledLibraries
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
mediaRepository.upsertSeries(series)
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
onlineMediaRepository.upsertMovies(movies)
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
onlineMediaRepository.upsertSeries(series)
}
suspend fun loadLibrary(library: Library): Library {
@@ -197,71 +194,33 @@ class InMemoryAppContentRepository @Inject constructor(
Log.w(TAG, "Unable to load library ${library.id}", error)
return library
}
when (library.type) {
CollectionType.MOVIES -> {
val movies = contentItem.map { it.toMovie(serverUrl(), library.id) }
return library.copy(movies = movies)
}
CollectionType.TVSHOWS -> {
val series = contentItem.map { it.toSeries(serverUrl(), library.id) }
return library.copy(series = series)
}
else -> throw UnsupportedOperationException("Unsupported library type: ${library.type}")
return when (library.type) {
LibraryKind.MOVIES -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl(), library.id) },
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl(), library.id) },
)
}
}
suspend fun loadMovie(movieId: UUID): Movie {
val cachedMovie = mediaRepository.movies.value[movieId]
val movieItem = runCatching { jellyfinApiClient.getItemInfo(movieId) }
.getOrElse { error ->
Log.w(TAG, "Unable to load movie $movieId", error)
null
}
?: return cachedMovie ?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movieItem.parentId!!)
mediaRepository.upsertMovies(listOf(updatedMovie))
return updatedMovie
}
suspend fun loadSeries(seriesId: UUID): Series {
val cachedSeries = mediaRepository.series.value[seriesId]
val seriesItem = runCatching { jellyfinApiClient.getItemInfo(seriesId) }
.getOrElse { error ->
Log.w(TAG, "Unable to load series $seriesId", error)
null
}
?: return cachedSeries ?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), seriesItem.parentId!!)
mediaRepository.upsertSeries(listOf(updatedSeries))
return updatedSeries
}
suspend fun loadSuggestions() {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
Log.w(TAG, "Unable to load suggestions", error)
return
}
val items = suggestionsItems.mapNotNull { item ->
suggestionsState.value = suggestionsItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
BaseItemKind.EPISODE -> Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
BaseItemKind.EPISODE -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
}
}
_suggestions.value = items
//Load episodes, Movies are already loaded at this point
suggestionsItems.forEach { item ->
when (item.type) {
BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl())
mediaRepository.upsertEpisodes(listOf(episode))
}
else -> { /* Do nothing */ }
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
}
@@ -272,26 +231,17 @@ class InMemoryAppContentRepository @Inject constructor(
Log.w(TAG, "Unable to load continue watching", error)
return
}
val items = continueWatchingItems.mapNotNull { item ->
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
BaseItemKind.EPISODE -> Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
BaseItemKind.EPISODE -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
}
}
_continueWatching.value = items
//Load episodes, Movies are already loaded at this point.
continueWatchingItems.forEach { item ->
when (item.type) {
BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl())
mediaRepository.upsertEpisodes(listOf(episode))
}
else -> { /* Do nothing */ }
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
}
@@ -302,90 +252,56 @@ class InMemoryAppContentRepository @Inject constructor(
Log.w(TAG, "Unable to load next up", error)
return
}
val items = nextUpItems.map { item ->
Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
}
_nextUp.value = items
// Load episodes
nextUpItems.forEach { item ->
val episode = item.toEpisode(serverUrl())
mediaRepository.upsertEpisodes(listOf(episode))
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
suspend fun loadLatestLibraryContent() {
// TODO Make libraries accessible in a field or something that is not this ugly.
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest library content", error)
return
}
val filterLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val latestLibraryContents = filterLibraries.associate { library ->
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest items for library ${library.id}", error)
emptyList()
}
library.id to when (library.collectionType) {
CollectionType.MOVIES -> {
latestFromLibrary.map {
val movie = it.toMovie(serverUrl(), library.id)
Media.MovieMedia(movieId = movie.id)
}
CollectionType.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(serverUrl(), library.id)
Media.MovieMedia(movieId = movie.id)
}
CollectionType.TVSHOWS -> {
latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl(), library.id)
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
val season = it.toSeason(serverUrl())
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
BaseItemKind.EPISODE -> {
val episode = it.toEpisode(serverUrl())
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
} else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
CollectionType.TVSHOWS -> latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl(), library.id)
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
val season = it.toSeason()
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
BaseItemKind.EPISODE -> {
val episode = it.toEpisode(serverUrl())
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
}
}
_latestLibraryContent.value = latestLibraryContents
//TODO Load seasons and episodes, other types are already loaded at this point.
}
override suspend fun refreshHomeData() {
if(loadJob?.isActive == true) {
loadJob?.join()
return
}
val job = scope.launch {
runCatching {
val isOnline = networkMonitor.isOnline.first()
if (!isOnline) return@runCatching
loadLibraries()
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
}
loadJob = job
job.join()
latestLibraryContentState.value = latestLibraryContents
}
private suspend fun serverUrl(): String {
@@ -393,94 +309,75 @@ class InMemoryAppContentRepository @Inject constructor(
}
private fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (this.collectionType) {
return when (collectionType) {
CollectionType.MOVIES -> Library(
id = this.id,
name = this.name!!,
posterUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
type = CollectionType.MOVIES,
movies = emptyList()
id = id,
name = name!!,
posterUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = id, type = ImageType.PRIMARY),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = this.id,
name = this.name!!,
posterUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
type = CollectionType.TVSHOWS,
series = emptyList()
id = id,
name = name!!,
posterUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = id, type = ImageType.PRIMARY),
type = LibraryKind.SERIES,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: ${this.collectionType}")
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
}
}
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie {
return Movie(
id = this.id,
id = id,
libraryId = libraryId,
title = this.name ?: "Unknown title",
progress = this.userData!!.playedPercentage,
watched = this.userData!!.played,
year = this.productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = this.officialRating
?: "NR",
runtime = formatRuntime(this.runTimeTicks),
synopsis = this.overview ?: "No synopsis available",
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 = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = this.id,
),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = id),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList()
cast = emptyList(),
)
}
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
return Series(
id = this.id,
id = id,
libraryId = libraryId,
name = this.name ?: "Unknown",
synopsis = this.overview ?: "No synopsis available",
year = this.productionYear?.toString()
?: this.premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = this.id
),
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
seasonCount = this.childCount!!,
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = id),
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
seasonCount = childCount!!,
seasons = emptyList(),
cast = emptyList()
cast = emptyList(),
)
}
private fun BaseItemDto.toSeason(serverUrl: String): Season {
private fun BaseItemDto.toSeason(): Season {
return Season(
id = this.id,
seriesId = this.seriesId!!,
name = this.name ?: "Unknown",
index = this.indexNumber ?: 0,
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
episodeCount = this.childCount!!,
episodes = emptyList()
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 ->
JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = itemId
)
JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
@@ -496,7 +393,7 @@ class InMemoryAppContentRepository @Inject constructor(
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList()
cast = emptyList(),
)
}
@@ -513,11 +410,7 @@ class InMemoryAppContentRepository @Inject constructor(
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"
}
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
companion object {

View File

@@ -7,6 +7,13 @@ import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -19,74 +26,41 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
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
@Singleton
class InMemoryMediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
) : MediaRepository {
) : MediaCatalogReader, MediaProgressWriter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val ready: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = moviesState.asStateFlow()
internal val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
private val seriesState = MutableStateFlow<Map<UUID, Series>>(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = seriesState.asStateFlow()
internal val _series: MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
internal val _episodes: MutableStateFlow<Map<UUID, Episode>> = MutableStateFlow(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = _episodes.asStateFlow()
override fun upsertMovies(movies: List<Movie>) {
_movies.update { current -> current + movies.associateBy { it.id } }
fun upsertMovies(movies: List<Movie>) {
moviesState.update { current -> current + movies.associateBy { it.id } }
}
override fun upsertSeries(series: List<Series>) {
_series.update { current -> current + series.associateBy { it.id } }
fun upsertSeries(series: List<Series>) {
seriesState.update { current -> current + series.associateBy { it.id } }
}
override fun upsertEpisodes(episodes: List<Episode>) {
_episodes.update { current -> current + episodes.associateBy { it.id } }
fun upsertEpisodes(episodes: List<Episode>) {
episodesState.update { current -> current + episodes.associateBy { it.id } }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
ensureSeriesContentLoaded(seriesId)
}
return _series.map { it[seriesId] }
}
override fun setReady() {
ready.value = true
}
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
_series.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = _series.value[seriesId] ?: throw RuntimeException("Series not found")
val serverUrl = serverUrl()
val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId)
val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl) }
val filledSeasons = emptySeasons.map { season ->
val episodesItem = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
val episodes = episodesItem.map { it.toEpisode(serverUrl) }
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
_series.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
_episodes.update { current -> current + allEpisodes.associateBy { it.id } }
return seriesState.map { it[seriesId] }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
@@ -94,32 +68,49 @@ class InMemoryMediaRepository @Inject constructor(
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
if (_movies.value.containsKey(mediaId)) {
_movies.update { current ->
if (moviesState.value.containsKey(mediaId)) {
moviesState.update { current ->
val movie = current[mediaId] ?: return@update current
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
}
return
}
if (_episodes.value.containsKey(mediaId)) {
_episodes.update { current ->
if (episodesState.value.containsKey(mediaId)) {
episodesState.update { current ->
val episode = current[mediaId] ?: return@update current
current + (mediaId to episode.copy(progress = progressPercent, watched = watched))
}
}
}
private suspend fun serverUrl(): String = userSessionRepository.serverUrl.first()
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
private fun BaseItemDto.toSeason(serverUrl: String): Season {
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
val serverUrl = userSessionRepository.serverUrl.first()
val emptySeasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
val filledSeasons = emptySeasons.map { season ->
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id).map { it.toEpisode(serverUrl) }
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
episodesState.update { current -> current + allEpisodes.associateBy { it.id } }
}
private fun BaseItemDto.toSeason(): Season {
return Season(
id = this.id,
seriesId = this.seriesId!!,
name = this.name ?: "Unknown",
index = this.indexNumber ?: 0,
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
episodeCount = this.childCount!!,
episodes = emptyList()
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
@@ -142,7 +133,7 @@ class InMemoryMediaRepository @Inject constructor(
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList()
cast = emptyList(),
)
}

View File

@@ -0,0 +1,29 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinPlaybackProgressReporter @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
) : PlaybackProgressReporter {
override suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) {
jellyfinApiClient.reportPlaybackStart(itemId, positionTicks, reportContext)
}
override suspend fun reportPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
reportContext: PlaybackReportContext,
) {
jellyfinApiClient.reportPlaybackProgress(itemId, positionTicks, isPaused, reportContext)
}
override suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) {
jellyfinApiClient.reportPlaybackStopped(itemId, positionTicks, reportContext)
}
}

View File

@@ -3,23 +3,13 @@ package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface MediaRepository {
val ready: StateFlow<Boolean>
interface MediaCatalogReader {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
fun upsertMovies(movies: List<Movie>) {
}
fun upsertSeries(series: List<Series>) {
}
fun upsertEpisodes(episodes: List<Episode>) {
}
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
fun setReady() {
}
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
}
}

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.data
import java.util.UUID
interface MediaProgressWriter {
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
}

View File

@@ -10,9 +10,30 @@ import dagger.hilt.components.SingletonComponent
abstract class MediaRepositoryModule {
@Binds
abstract fun bindAppContentRepository(impl: InMemoryAppContentRepository): AppContentRepository
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
@Binds
abstract fun bindMediaRepository(impl: CompositeMediaRepository): MediaRepository
abstract fun bindMediaCatalogReader(impl: CompositeMediaRepository): MediaCatalogReader
@Binds
abstract fun bindMediaProgressWriter(impl: CompositeMediaRepository): MediaProgressWriter
@Binds
abstract fun bindOfflineCatalogReader(impl: OfflineMediaRepository): OfflineCatalogReader
@Binds
abstract fun bindOfflineCatalogStore(impl: RoomOfflineCatalogStore): OfflineCatalogStore
@Binds
abstract fun bindSmartDownloadStore(impl: RoomSmartDownloadStore): SmartDownloadStore
@Binds
abstract fun bindPlayableMediaRepository(impl: DefaultPlayableMediaRepository): PlayableMediaRepository
@Binds
abstract fun bindEpisodeSeriesLookup(impl: DefaultEpisodeSeriesLookup): EpisodeSeriesLookup
@Binds
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
}

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import kotlinx.coroutines.flow.StateFlow
interface OfflineCatalogReader {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import java.util.UUID
interface OfflineCatalogStore {
suspend fun saveMovies(movies: List<Movie>)
suspend fun saveSeries(series: List<Series>)
suspend fun saveSeason(season: Season)
suspend fun saveEpisode(episode: Episode)
suspend fun getSeriesBasic(seriesId: UUID): Series?
suspend fun getSeason(seasonId: UUID): Season?
suspend fun deleteMovie(movieId: UUID)
suspend fun deleteEpisodeAndCleanup(episodeId: UUID)
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode>
}

View File

@@ -4,28 +4,21 @@ import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Offline media repository for managing downloaded content.
* This repository only accesses the local offline database and does not make network calls.
*/
@Singleton
class OfflineMediaRepository @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource,
) : MediaRepository {
override val ready: StateFlow<Boolean> = MutableStateFlow(false)
) : OfflineCatalogReader, MediaCatalogReader, MediaProgressWriter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
@@ -37,18 +30,6 @@ class OfflineMediaRepository @Inject constructor(
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun upsertMovies(movies: List<Movie>) {
TODO("Not yet implemented")
}
override fun upsertSeries(series: List<Series>) {
TODO("Not yet implemented")
}
override fun upsertEpisodes(episodes: List<Episode>) {
TODO("Not yet implemented")
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId)
}
@@ -57,12 +38,6 @@ class OfflineMediaRepository @Inject constructor(
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
// Write to offline database - the reactive Flows propagate changes to UI automatically
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
}
override fun setReady() {
// OfflineMediaRepository works from the database. So it is ready immediately.
}
}

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.core.data
import androidx.media3.common.MediaItem
import java.util.UUID
interface PlayableMediaRepository {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>?
suspend fun getNextUpMediaItems(
episodeId: UUID,
existingIds: Set<String>,
count: Int = 9,
): List<MediaItem>
}

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import java.util.UUID
interface PlaybackProgressReporter {
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext)
suspend fun reportPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
reportContext: PlaybackReportContext,
)
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext)
}

View File

@@ -0,0 +1,51 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RoomOfflineCatalogStore @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource,
) : OfflineCatalogStore {
override suspend fun saveMovies(movies: List<Movie>) {
localDataSource.saveMovies(movies)
}
override suspend fun saveSeries(series: List<Series>) {
localDataSource.saveSeries(series)
}
override suspend fun saveSeason(season: Season) {
localDataSource.saveSeason(season)
}
override suspend fun saveEpisode(episode: Episode) {
localDataSource.saveEpisode(episode)
}
override suspend fun getSeriesBasic(seriesId: UUID): Series? {
return localDataSource.getSeriesBasic(seriesId)
}
override suspend fun getSeason(seasonId: UUID): Season? {
return localDataSource.getSeason(seasonId)
}
override suspend fun deleteMovie(movieId: UUID) {
localDataSource.deleteMovie(movieId)
}
override suspend fun deleteEpisodeAndCleanup(episodeId: UUID) {
localDataSource.deleteEpisodeAndCleanup(episodeId)
}
override suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
return localDataSource.getEpisodesBySeries(seriesId)
}
}

View File

@@ -0,0 +1,29 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
@Singleton
class RoomSmartDownloadStore @Inject constructor(
private val smartDownloadDao: SmartDownloadDao,
) : SmartDownloadStore {
override suspend fun enable(seriesId: UUID) {
smartDownloadDao.insert(SmartDownloadEntity(seriesId))
}
override suspend fun disable(seriesId: UUID) {
smartDownloadDao.delete(seriesId)
}
override fun observe(seriesId: UUID): Flow<Boolean> {
return smartDownloadDao.observe(seriesId)
}
override suspend fun getEnabledSeriesIds(): List<UUID> {
return smartDownloadDao.getAll().map { it.seriesId }
}
}

View File

@@ -0,0 +1,11 @@
package hu.bbara.purefin.core.data
import java.util.UUID
import kotlinx.coroutines.flow.Flow
interface SmartDownloadStore {
suspend fun enable(seriesId: UUID)
suspend fun disable(seriesId: UUID)
fun observe(seriesId: UUID): Flow<Boolean>
suspend fun getEnabledSeriesIds(): List<UUID>
}

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.core.data.domain.usecase
import hu.bbara.purefin.core.data.AppContentRepository
import hu.bbara.purefin.core.data.HomeRepository
import javax.inject.Inject
class RefreshHomeDataUseCase @Inject constructor(
private val repository: AppContentRepository
private val repository: HomeRepository
) {
suspend operator fun invoke() {
repository.refreshHomeData()

View File

@@ -1,13 +0,0 @@
package hu.bbara.purefin.core.data.domain.usecase
import hu.bbara.purefin.core.data.MediaRepository
import java.util.UUID
import javax.inject.Inject
class UpdateWatchProgressUseCase @Inject constructor(
private val repository: MediaRepository
) {
suspend operator fun invoke(mediaId: UUID, positionMs: Long, durationMs: Long) {
repository.updateWatchProgress(mediaId, positionMs, durationMs)
}
}

View File

@@ -2,7 +2,7 @@ package hu.bbara.purefin.core.data.download
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.model.UUID
import java.util.UUID
interface MediaDownloadController {
fun observeActiveDownloads(): Flow<Map<String, Float>>

View File

@@ -1,6 +1,6 @@
package hu.bbara.purefin.core.data.image
import org.jellyfin.sdk.model.UUID
import java.util.UUID
import org.jellyfin.sdk.model.api.ImageType
class JellyfinImageHelper {

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class EpisodeDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
@Serializable(with = UUIDSerializer::class)
val seasonId: UUID,
@Serializable(with = UUIDSerializer::class)
val seriesId: UUID,
)

View File

@@ -1,12 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@Serializable
data class LibraryDto (
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val name: String
)

View File

@@ -1,11 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class MovieDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID
)

View File

@@ -1,48 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
sealed interface NavigationCommand {
data class Navigate(val route: Route) : NavigationCommand
data class ReplaceAll(val route: Route) : NavigationCommand
data object Pop : NavigationCommand
}
interface NavigationManager {
val commands: SharedFlow<NavigationCommand>
fun navigate(route: Route)
fun replaceAll(route: Route)
fun pop()
}
class DefaultNavigationManager : NavigationManager {
private val _commands =
MutableSharedFlow<NavigationCommand>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val commands: SharedFlow<NavigationCommand> = _commands.asSharedFlow()
override fun navigate(route: Route) {
_commands.tryEmit(NavigationCommand.Navigate(route))
}
override fun replaceAll(route: Route) {
_commands.tryEmit(NavigationCommand.ReplaceAll(route))
}
override fun pop() {
_commands.tryEmit(NavigationCommand.Pop)
}
}
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NavigationManagerModule {
@Provides
@Singleton
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
}

View File

@@ -1,27 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
sealed interface Route : NavKey {
@Serializable
data object Home: Route
@Serializable
data class MovieRoute(val item : MovieDto) : Route
@Serializable
data class SeriesRoute(val item : SeriesDto) : Route
@Serializable
data class EpisodeRoute(val item : EpisodeDto) : Route
@Serializable
data class LibraryRoute(val library : LibraryDto) : Route
@Serializable
data object LoginRoute : Route
@Serializable
data class PlayerRoute(val mediaId: String) : Route
}

View File

@@ -1,11 +0,0 @@
package hu.bbara.purefin.core.data.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class SeriesDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID
)

View File

@@ -155,6 +155,14 @@ class OfflineRoomMediaLocalDataSource(
return seasonEntity.toDomain(episodes)
}
suspend fun getSeason(seasonId: UUID): Season? {
val seasonEntity = seasonDao.getById(seasonId) ?: return null
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
episodeEntity.toDomain()
}
return seasonEntity.toDomain(episodes)
}
suspend fun getSeasons(seriesId: UUID): List<Season> {
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
@@ -197,6 +205,10 @@ class OfflineRoomMediaLocalDataSource(
}
}
suspend fun deleteMovie(movieId: UUID) {
movieDao.deleteById(movieId)
}
private fun Movie.toEntity() = MovieEntity(
id = id,
libraryId = libraryId,

View File

@@ -0,0 +1,77 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.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(),
)
}

View File

@@ -1,30 +1,12 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "hu.bbara.purefin.core.model"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
alias(libs.plugins.kotlin.jvm)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(libs.jellyfin.core)
jvmToolchain(11)
}

View File

@@ -1,12 +1,11 @@
package hu.bbara.purefin.core.model
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID
data class Library(
val id: UUID,
val name: String,
val type: CollectionType,
val type: LibraryKind,
val posterUrl: String,
val series: List<Series>? = null,
val movies: List<Movie>? = null,
@@ -14,6 +13,6 @@ data class Library(
init {
require(series != null || movies != null) { "Either series or movie must be provided" }
require(series == null || movies == null) { "Only one of series or movie can be provided" }
require(type == CollectionType.TVSHOWS || type == CollectionType.MOVIES) { "Invalid type: $type" }
require(type == LibraryKind.SERIES || type == LibraryKind.MOVIES) { "Invalid type: $type" }
}
}

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.core.model
enum class LibraryKind {
MOVIES,
SERIES,
}

View File

@@ -1,14 +1,13 @@
package hu.bbara.purefin.core.model
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
sealed class Media(
val id: UUID,
val type: BaseItemKind
val type: MediaKind,
) {
class MovieMedia(val movieId: UUID) : Media(movieId, BaseItemKind.MOVIE)
class SeriesMedia(val seriesId: UUID) : Media(seriesId, BaseItemKind.SERIES)
class SeasonMedia(val seasonId: UUID, val seriesId: UUID) : Media(seasonId, BaseItemKind.SEASON)
class EpisodeMedia(val episodeId: UUID, val seriesId: UUID) : Media(episodeId, BaseItemKind.EPISODE)
class MovieMedia(val movieId: UUID) : Media(movieId, MediaKind.MOVIE)
class SeriesMedia(val seriesId: UUID) : Media(seriesId, MediaKind.SERIES)
class SeasonMedia(val seasonId: UUID, val seriesId: UUID) : Media(seasonId, MediaKind.SEASON)
class EpisodeMedia(val episodeId: UUID, val seriesId: UUID) : Media(episodeId, MediaKind.EPISODE)
}

View File

@@ -0,0 +1,8 @@
package hu.bbara.purefin.core.model
enum class MediaKind {
MOVIE,
SERIES,
SEASON,
EPISODE,
}

View File

@@ -1,7 +0,0 @@
package hu.bbara.purefin.core.model
sealed interface MediaRepositoryState {
data object Loading : MediaRepositoryState
data object Ready : MediaRepositoryState
data class Error(val throwable: Throwable) : MediaRepositoryState
}

View File

@@ -1,31 +0,0 @@
package hu.bbara.purefin.core.model
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
data class SearchResult(
val id: UUID,
val title: String,
val posterUrl: String,
val type: BaseItemKind,
) {
companion object {
fun create(movie: Movie, imageUrl: String) : SearchResult {
return SearchResult(
id = movie.id,
title = movie.title,
posterUrl = imageUrl,
type = BaseItemKind.MOVIE
)
}
fun create(series: Series, imageUrl: String) : SearchResult {
return SearchResult(
id = series.id,
title = series.name,
posterUrl = imageUrl,
type = BaseItemKind.SERIES
)
}
}
}

View File

@@ -33,12 +33,12 @@ dependencies {
implementation(project(":core:data"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.medi3.exoplayer)
implementation(libs.medi3.exoplayer.hls)
implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore)
implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core)
implementation(libs.okhttp)
testImplementation(libs.junit)
}

View File

@@ -2,9 +2,9 @@ package hu.bbara.purefin.core.player.manager
import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -20,8 +20,8 @@ import javax.inject.Inject
@ViewModelScoped
class ProgressManager @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val updateWatchProgressUseCase: UpdateWatchProgressUseCase
private val playbackProgressReporter: PlaybackProgressReporter,
private val mediaProgressWriter: MediaProgressWriter,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null
@@ -77,7 +77,7 @@ class ProgressManager @Inject constructor(
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
scope.launch(Dispatchers.IO) {
try {
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
mediaProgressWriter.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
} catch (e: Exception) {
Log.e("ProgressManager", "Local cache update failed", e)
}
@@ -102,9 +102,9 @@ class ProgressManager @Inject constructor(
return@launch
}
when {
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
isStart -> playbackProgressReporter.reportPlaybackStart(itemId, ticks, reportContext)
isStop -> playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
else -> playbackProgressReporter.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
}
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) {
@@ -122,9 +122,9 @@ class ProgressManager @Inject constructor(
CoroutineScope(Dispatchers.IO).launch {
try {
activePlaybackReportContext?.let { reportContext ->
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
}
updateWatchProgressUseCase(itemId, posMs, durMs)
mediaProgressWriter.updateWatchProgress(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {
Log.e("ProgressManager", "Report failed", e)

View File

@@ -4,8 +4,8 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
import hu.bbara.purefin.core.data.EpisodeSeriesLookup
import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager
@@ -25,9 +25,9 @@ import javax.inject.Inject
class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager,
private val playerMediaRepository: PlayerMediaRepository,
private val mediaRepository: MediaRepository,
private val progressManager: ProgressManager
private val playableMediaRepository: PlayableMediaRepository,
private val episodeSeriesLookup: EpisodeSeriesLookup,
private val progressManager: ProgressManager,
) : ViewModel() {
companion object {
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
@@ -147,12 +147,11 @@ class PlayerViewModel @Inject constructor(
return
}
viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid)
val result = playableMediaRepository.getMediaItem(uuid)
if (result != null) {
val (mediaItem, resumePositionMs) = result
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val preferenceKey = episodeSeriesLookup.preferenceKeyFor(uuid)
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
playerManager.play(mediaItem, mediaContext)
@@ -175,9 +174,9 @@ class PlayerViewModel @Inject constructor(
val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch {
val queuedIds = uiState.value.queue.map { it.id }.toSet()
val items = playerMediaRepository.getNextUpMediaItems(
val items = playableMediaRepository.getNextUpMediaItems(
episodeId = uuid,
existingIds = queuedIds
existingIds = queuedIds,
)
items.forEach { playerManager.addToQueue(it) }
}