mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
refactor: update package structure and imports for consistency across UI components
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
interface EpisodeSeriesLookup {
|
||||
suspend fun preferenceKeyFor(mediaId: UUID): String
|
||||
}
|
||||
@@ -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>>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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() }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package hu.bbara.purefin.core.model
|
||||
|
||||
enum class LibraryKind {
|
||||
MOVIES,
|
||||
SERIES,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package hu.bbara.purefin.core.model
|
||||
|
||||
enum class MediaKind {
|
||||
MOVIE,
|
||||
SERIES,
|
||||
SEASON,
|
||||
EPISODE,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user