refactor: replace Room backing store in InMemoryMediaRepository with MutableStateFlows

- Remove RoomMediaLocalDataSource dependency from InMemoryMediaRepository;
  libraries, movies, series, and episodes are now held in MutableStateFlows
- observeSeriesWithContent derives from _series flow via map {}
- updateWatchProgress mutates _movies / _episodes in-memory
- ensureSeriesContentLoaded checks and updates _series in-memory
- Reorganize Room package: move entities/DAOs from local/room to room/entity and room/dao
This commit is contained in:
2026-02-22 10:50:17 +01:00
parent c72283c566
commit 781d36cc99
27 changed files with 198 additions and 918 deletions

View File

@@ -1,95 +0,0 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.local.room.OfflineRepository
import hu.bbara.purefin.core.data.local.room.OnlineRepository
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.Media
import hu.bbara.purefin.core.model.MediaRepositoryState
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Active media repository that delegates to either online or offline repository
* based on user preference.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class ActiveMediaRepository @Inject constructor(
@OnlineRepository private val onlineRepository: MediaRepository,
@OfflineRepository private val offlineRepository: MediaRepository,
private val userSessionRepository: UserSessionRepository
) : MediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Switch between repositories based on offline mode preference
private val activeRepository: StateFlow<MediaRepository> =
userSessionRepository.isOfflineMode
.map { isOffline ->
if (isOffline) offlineRepository else onlineRepository
}
.stateIn(scope, SharingStarted.Eagerly, onlineRepository)
// Delegate all MediaRepository interface methods to the active repository
override val libraries: StateFlow<List<Library>> =
activeRepository.flatMapLatest { it.libraries }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val movies: StateFlow<Map<UUID, Movie>> =
activeRepository.flatMapLatest { it.movies }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val series: StateFlow<Map<UUID, Series>> =
activeRepository.flatMapLatest { it.series }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> =
activeRepository.flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val state: StateFlow<MediaRepositoryState> =
activeRepository.flatMapLatest { it.state }
.stateIn(scope, SharingStarted.Eagerly, MediaRepositoryState.Loading)
override val continueWatching: StateFlow<List<Media>> =
activeRepository.flatMapLatest { it.continueWatching }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val nextUp: StateFlow<List<Media>> =
activeRepository.flatMapLatest { it.nextUp }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> =
activeRepository.flatMapLatest { it.latestLibraryContent }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
override suspend fun ensureReady() {
activeRepository.value.ensureReady()
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
activeRepository.value.updateWatchProgress(mediaId, positionMs, durationMs)
}
override suspend fun refreshHomeData() {
activeRepository.value.refreshHomeData()
}
}

View File

@@ -5,9 +5,6 @@ import hu.bbara.purefin.core.data.cache.CachedMediaItem
import hu.bbara.purefin.core.data.cache.HomeCache
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.local.room.OfflineDatabase
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.local.room.RoomMediaLocalDataSource
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
@@ -22,11 +19,11 @@ 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.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import org.jellyfin.sdk.model.api.BaseItemDto
@@ -45,8 +42,6 @@ import javax.inject.Singleton
class InMemoryMediaRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient,
private val localDataSource: RoomMediaLocalDataSource,
@OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
private val homeCacheDataStore: DataStore<HomeCache>
) : MediaRepository {
@@ -58,23 +53,24 @@ class InMemoryMediaRepository @Inject constructor(
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
override val libraries: StateFlow<List<Library>> = localDataSource.librariesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList())
override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow()
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
private val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
private val _series: MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
private val _episodes: MutableStateFlow<Map<UUID, Episode>> = MutableStateFlow(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = _episodes.asStateFlow()
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
awaitReady()
ensureSeriesContentLoaded(seriesId)
}
return localDataSource.observeSeriesWithContent(seriesId)
return _series.map { it[seriesId] }
}
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
@@ -181,20 +177,19 @@ class InMemoryMediaRepository @Inject constructor(
//TODO add support for playlists
val filteredLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val emptyLibraries = filteredLibraries.map {
it.toLibrary()
}
localDataSource.saveLibraries(emptyLibraries)
offlineDataSource.saveLibraries(emptyLibraries)
val emptyLibraries = filteredLibraries.map { it.toLibrary() }
_libraries.value = emptyLibraries
val filledLibraries = emptyLibraries.map { library ->
return@map loadLibrary(library)
}
_libraries.value = filledLibraries
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
localDataSource.saveMovies(movies)
_movies.update { current -> current + movies.associateBy { it.id } }
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
localDataSource.saveSeries(series)
_series.update { current -> current + series.associateBy { it.id } }
}
suspend fun loadLibrary(library: Library): Library {
@@ -216,7 +211,7 @@ class InMemoryMediaRepository @Inject constructor(
val movieItem = jellyfinApiClient.getItemInfo(movie.id)
?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
localDataSource.saveMovies(listOf(updatedMovie))
_movies.update { it + (updatedMovie.id to updatedMovie) }
return updatedMovie
}
@@ -224,7 +219,7 @@ class InMemoryMediaRepository @Inject constructor(
val seriesItem = jellyfinApiClient.getItemInfo(series.id)
?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
localDataSource.saveSeries(listOf(updatedSeries))
_series.update { it + (updatedSeries.id to updatedSeries) }
return updatedSeries
}
@@ -247,7 +242,7 @@ class InMemoryMediaRepository @Inject constructor(
when (item.type) {
BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl())
localDataSource.saveEpisode(episode)
_episodes.update { it + (episode.id to episode) }
}
else -> { /* Do nothing */ }
}
@@ -267,7 +262,7 @@ class InMemoryMediaRepository @Inject constructor(
// Load episodes
nextUpItems.forEach { item ->
val episode = item.toEpisode(serverUrl())
localDataSource.saveEpisode(episode)
_episodes.update { it + (episode.id to episode) }
}
}
@@ -313,10 +308,8 @@ class InMemoryMediaRepository @Inject constructor(
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
awaitReady()
// Skip if content is already cached in Room
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
return
}
// Skip if content is already loaded in-memory
_series.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found")
@@ -328,16 +321,30 @@ class InMemoryMediaRepository @Inject constructor(
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
localDataSource.saveSeries(listOf(updatedSeries))
localDataSource.saveSeriesContent(updatedSeries)
_series.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
_episodes.update { current -> current + allEpisodes.associateBy { it.id } }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
// Write to Room — the reactive Flows propagate changes to UI automatically
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
if (_movies.value.containsKey(mediaId)) {
_movies.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 ->
val episode = current[mediaId] ?: return@update current
current + (mediaId to episode.copy(progress = progressPercent, watched = watched))
}
}
}
companion object {

View File

@@ -4,22 +4,12 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.local.room.OfflineRepository
import hu.bbara.purefin.core.data.local.room.OnlineRepository
@Module
@InstallIn(SingletonComponent::class)
abstract class MediaRepositoryModule {
@Binds
@OnlineRepository
abstract fun bindOnlineMediaRepository(impl: InMemoryMediaRepository): MediaRepository
abstract fun bindInMemoryMediaRepository(impl: InMemoryMediaRepository): MediaRepository
@Binds
@OfflineRepository
abstract fun bindOfflineMediaRepository(impl: OfflineMediaRepository): MediaRepository
// Default binding delegates to online/offline based on user preference
@Binds
abstract fun bindDefaultMediaRepository(impl: ActiveMediaRepository): MediaRepository
}

View File

@@ -1,21 +1,15 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.local.room.OfflineDatabase
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.model.MediaRepositoryState
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
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.asStateFlow
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
@@ -27,41 +21,24 @@ import javax.inject.Singleton
*/
@Singleton
class OfflineMediaRepository @Inject constructor(
@OfflineDatabase private val localDataSource: OfflineRoomMediaLocalDataSource
) : MediaRepository {
private val localDataSource: OfflineRoomMediaLocalDataSource
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Offline repository is always ready (no network loading required)
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Ready)
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
override val libraries: StateFlow<List<Library>> = localDataSource.librariesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
// Offline mode doesn't support these server-side features
override val continueWatching: StateFlow<List<Media>> = MutableStateFlow(emptyList())
override val nextUp: StateFlow<List<Media>> = MutableStateFlow(emptyList())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId)
}
override suspend fun ensureReady() {
// Offline repository is always ready - no initialization needed
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
@@ -69,7 +46,4 @@ class OfflineMediaRepository @Inject constructor(
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
}
override suspend fun refreshHomeData() {
// No-op for offline repository - no network refresh available
}
}

View File

@@ -1,35 +0,0 @@
package hu.bbara.purefin.core.data.local.room
import javax.inject.Qualifier
/**
* Qualifier for online database and its components.
* Used for the primary MediaDatabase that syncs with the Jellyfin server.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OnlineDatabase
/**
* Qualifier for offline database and its components.
* Used for the OfflineMediaDatabase that stores downloaded content for offline viewing.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OfflineDatabase
/**
* Qualifier for the online media repository.
* Provides access to media synced from the Jellyfin server.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OnlineRepository
/**
* Qualifier for the offline media repository.
* Provides access to media downloaded for offline viewing.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OfflineRepository

View File

@@ -1,33 +0,0 @@
package hu.bbara.purefin.core.data.local.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao
@Database(
entities = [
MovieEntity::class,
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
LibraryEntity::class,
CastMemberEntity::class
],
version = 3,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
abstract class MediaDatabase : RoomDatabase() {
abstract fun movieDao(): MovieDao
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
abstract fun libraryDao(): LibraryDao
abstract fun castMemberDao(): CastMemberDao
}

View File

@@ -1,125 +0,0 @@
package hu.bbara.purefin.core.data.local.room
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object MediaDatabaseModule {
// Online Database and DAOs
@Provides
@Singleton
@OnlineDatabase
fun provideOnlineDatabase(@ApplicationContext context: Context): MediaDatabase =
Room.inMemoryDatabaseBuilder(context, MediaDatabase::class.java)
.fallbackToDestructiveMigration()
.build()
@Provides
@OnlineDatabase
fun provideOnlineMovieDao(@OnlineDatabase db: MediaDatabase) = db.movieDao()
@Provides
@OnlineDatabase
fun provideOnlineSeriesDao(@OnlineDatabase db: MediaDatabase) = db.seriesDao()
@Provides
@OnlineDatabase
fun provideOnlineSeasonDao(@OnlineDatabase db: MediaDatabase) = db.seasonDao()
@Provides
@OnlineDatabase
fun provideOnlineEpisodeDao(@OnlineDatabase db: MediaDatabase) = db.episodeDao()
@Provides
@OnlineDatabase
fun provideOnlineCastMemberDao(@OnlineDatabase db: MediaDatabase) = db.castMemberDao()
@Provides
@OnlineDatabase
fun provideOnlineLibraryDao(@OnlineDatabase db: MediaDatabase) = db.libraryDao()
// Offline Database and DAOs
@Provides
@Singleton
@OfflineDatabase
fun provideOfflineDatabase(@ApplicationContext context: Context): OfflineMediaDatabase =
Room.databaseBuilder(context, OfflineMediaDatabase::class.java, "offline_media_database")
.fallbackToDestructiveMigration()
.build()
@Provides
@OfflineDatabase
fun provideOfflineMovieDao(@OfflineDatabase db: OfflineMediaDatabase) = db.movieDao()
@Provides
@OfflineDatabase
fun provideOfflineSeriesDao(@OfflineDatabase db: OfflineMediaDatabase) = db.seriesDao()
@Provides
@OfflineDatabase
fun provideOfflineSeasonDao(@OfflineDatabase db: OfflineMediaDatabase) = db.seasonDao()
@Provides
@OfflineDatabase
fun provideOfflineEpisodeDao(@OfflineDatabase db: OfflineMediaDatabase) = db.episodeDao()
@Provides
@OfflineDatabase
fun provideOfflineCastMemberDao(@OfflineDatabase db: OfflineMediaDatabase) = db.castMemberDao()
@Provides
@OfflineDatabase
fun provideOfflineLibraryDao(@OfflineDatabase db: OfflineMediaDatabase) = db.libraryDao()
// Data Sources
@Provides
@Singleton
@OnlineDatabase
fun provideOnlineDataSource(
@OnlineDatabase database: MediaDatabase,
@OnlineDatabase movieDao: MovieDao,
@OnlineDatabase seriesDao: SeriesDao,
@OnlineDatabase seasonDao: SeasonDao,
@OnlineDatabase episodeDao: EpisodeDao,
@OnlineDatabase castMemberDao: CastMemberDao,
@OnlineDatabase libraryDao: LibraryDao
): RoomMediaLocalDataSource = RoomMediaLocalDataSource(
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao
)
@Provides
@Singleton
@OfflineDatabase
fun provideOfflineDataSource(
@OfflineDatabase database: OfflineMediaDatabase,
@OfflineDatabase movieDao: MovieDao,
@OfflineDatabase seriesDao: SeriesDao,
@OfflineDatabase seasonDao: SeasonDao,
@OfflineDatabase episodeDao: EpisodeDao,
@OfflineDatabase castMemberDao: CastMemberDao,
@OfflineDatabase libraryDao: LibraryDao
): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource(
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao
)
// Default (unqualified) data source for backward compatibility
@Provides
@Singleton
fun provideDefaultDataSource(
@OnlineDatabase dataSource: RoomMediaLocalDataSource
): RoomMediaLocalDataSource = dataSource
}

View File

@@ -1,33 +0,0 @@
package hu.bbara.purefin.core.data.local.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao
@Database(
entities = [
MovieEntity::class,
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
LibraryEntity::class,
CastMemberEntity::class
],
version = 3,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
abstract class OfflineMediaDatabase : RoomDatabase() {
abstract fun movieDao(): MovieDao
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
abstract fun libraryDao(): LibraryDao
abstract fun castMemberDao(): CastMemberDao
}

View File

@@ -1,364 +0,0 @@
package hu.bbara.purefin.core.data.local.room
import androidx.room.withTransaction
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID
import javax.inject.Singleton
@Singleton
class RoomMediaLocalDataSource(
private val database: MediaDatabase,
private val movieDao: MovieDao,
private val seriesDao: SeriesDao,
private val seasonDao: SeasonDao,
private val episodeDao: EpisodeDao,
private val castMemberDao: CastMemberDao,
private val libraryDao: LibraryDao
) {
// Lightweight Flows for list screens (home, library)
val librariesFlow: Flow<List<Library>> = libraryDao.observeAllWithContent()
.map { relation ->
relation.map { libraryEntity ->
libraryEntity.library.toDomain(
movies = libraryEntity.movies.map { it.toDomain(listOf()) },
series = libraryEntity.series.map { it.toDomain(listOf(), listOf()) }
)
}
}
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
.map { entities ->
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
}
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
// Full content Flow for series detail screen (scoped to one series)
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
seriesDao.observeWithContent(seriesId).map { relation ->
relation?.let {
it.series.toDomain(
seasons = it.seasons.map { swe ->
swe.season.toDomain(
episodes = swe.episodes.map { ep -> ep.toDomain(cast = emptyList()) }
)
},
cast = emptyList()
)
}
}
suspend fun saveLibraries(libraries: List<Library>) {
database.withTransaction {
libraryDao.deleteAll()
libraryDao.upsertAll(libraries.map { it.toEntity() })
}
}
suspend fun saveMovies(movies: List<Movie>) {
database.withTransaction {
movieDao.upsertAll(movies.map { it.toEntity() })
}
}
suspend fun saveSeries(seriesList: List<Series>) {
database.withTransaction {
seriesDao.upsertAll(seriesList.map { it.toEntity() })
}
}
suspend fun saveSeriesContent(series: Series) {
database.withTransaction {
// First ensure the series exists before adding seasons/episodes/cast
seriesDao.upsert(series.toEntity())
episodeDao.deleteBySeriesId(series.id)
seasonDao.deleteBySeriesId(series.id)
series.seasons.forEach { season ->
seasonDao.upsert(season.toEntity())
season.episodes.forEach { episode ->
episodeDao.upsert(episode.toEntity())
}
}
}
}
suspend fun saveEpisode(episode: Episode) {
database.withTransaction {
seriesDao.getById(episode.seriesId)
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
episodeDao.upsert(episode.toEntity())
}
}
suspend fun getMovies(): List<Movie> {
val movies = movieDao.getAll()
return movies.map { entity ->
val cast = castMemberDao.getByMovieId(entity.id).map { it.toDomain() }
entity.toDomain(cast)
}
}
suspend fun getMovie(id: UUID): Movie? {
val entity = movieDao.getById(id) ?: return null
val cast = castMemberDao.getByMovieId(id).map { it.toDomain() }
return entity.toDomain(cast)
}
suspend fun getSeries(): List<Series> {
return seriesDao.getAll().mapNotNull { entity -> getSeriesInternal(entity.id, includeContent = false) }
}
suspend fun getSeriesBasic(id: UUID): Series? = getSeriesInternal(id, includeContent = false)
suspend fun getSeriesWithContent(id: UUID): Series? = getSeriesInternal(id, includeContent = true)
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
val entity = seriesDao.getById(id) ?: return null
val cast = castMemberDao.getBySeriesId(id).map { it.toDomain() }
val seasons = if (includeContent) {
seasonDao.getBySeriesId(id).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
}
seasonEntity.toDomain(episodes)
}
} else emptyList()
return entity.toDomain(seasons, cast)
}
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
val seasonEntity = seasonDao.getById(seasonId) ?: return null
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
}
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 ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
}
seasonEntity.toDomain(episodes)
}
}
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
return episodeEntity.toDomain(cast)
}
suspend fun getEpisodeById(episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
return episodeEntity.toDomain(cast)
}
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
movieDao.getById(mediaId)?.let {
movieDao.updateProgress(mediaId, progress, watched)
return
}
episodeDao.getById(mediaId)?.let { episode ->
database.withTransaction {
episodeDao.updateProgress(mediaId, progress, watched)
val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId)
seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched)
val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId)
seriesDao.updateUnwatchedCount(episode.seriesId, seriesUnwatched)
}
}
}
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(cast)
}
}
private fun Library.toEntity() = LibraryEntity(
id = id,
name = name,
type = when (type) {
CollectionType.MOVIES -> "MOVIES"
CollectionType.TVSHOWS -> "TVSHOWS"
else -> throw UnsupportedOperationException("Unsupported library type: $type")
}
)
private fun LibraryEntity.toDomain(series: List<Series>, movies: List<Movie>) = Library(
id = id,
name = name,
type = when (type) {
"MOVIES" -> CollectionType.MOVIES
"TVSHOWS" -> CollectionType.TVSHOWS
else -> throw UnsupportedOperationException("Unsupported library type: $type")
},
movies = if (type == "MOVIES") movies else null,
series = if (type == "TVSHOWS") series else null,
)
private fun Movie.toEntity() = MovieEntity(
id = id,
libraryId = libraryId,
title = title,
progress = progress,
watched = watched,
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = audioTrack,
subtitles = subtitles
)
private fun Series.toEntity() = SeriesEntity(
id = id,
libraryId = libraryId,
name = name,
synopsis = synopsis,
year = year,
heroImageUrl = heroImageUrl,
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount
)
private fun Season.toEntity() = SeasonEntity(
id = id,
seriesId = seriesId,
name = name,
index = index,
unwatchedEpisodeCount = unwatchedEpisodeCount,
episodeCount = episodeCount
)
private fun Episode.toEntity() = EpisodeEntity(
id = id,
seriesId = seriesId,
seasonId = seasonId,
index = index,
title = title,
synopsis = synopsis,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
progress = progress,
watched = watched,
format = format,
heroImageUrl = heroImageUrl
)
private fun MovieEntity.toDomain(cast: List<CastMember>) = Movie(
id = id,
libraryId = libraryId,
title = title,
progress = progress,
watched = watched,
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast
)
private fun SeriesEntity.toDomain(seasons: List<Season>, cast: List<CastMember>) = Series(
id = id,
libraryId = libraryId,
name = name,
synopsis = synopsis,
year = year,
heroImageUrl = heroImageUrl,
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount,
seasons = seasons,
cast = cast
)
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
id = id,
seriesId = seriesId,
name = name,
index = index,
unwatchedEpisodeCount = unwatchedEpisodeCount,
episodeCount = episodeCount,
episodes = episodes
)
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
id = id,
seriesId = seriesId,
seasonId = seasonId,
index = index,
title = title,
synopsis = synopsis,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
progress = progress,
watched = watched,
format = format,
heroImageUrl = heroImageUrl,
cast = cast
)
private fun CastMember.toMovieEntity(movieId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
movieId = movieId
)
private fun CastMember.toSeriesEntity(seriesId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
seriesId = seriesId
)
private fun CastMember.toEpisodeEntity(episodeId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
episodeId = episodeId
)
private fun CastMemberEntity.toDomain() = CastMember(
name = name,
role = role,
imageUrl = imageUrl
)
}

View File

@@ -0,0 +1,53 @@
package hu.bbara.purefin.core.data.room
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.offline.OfflineMediaDatabase
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object MediaDatabaseModule {
// Offline Database and DAOs
@Provides
@Singleton
fun provideOfflineDatabase(@ApplicationContext context: Context): OfflineMediaDatabase =
Room.databaseBuilder(context, OfflineMediaDatabase::class.java, "offline_media_database")
.fallbackToDestructiveMigration()
.build()
@Provides
fun provideOfflineMovieDao(db: OfflineMediaDatabase) = db.movieDao()
@Provides
fun provideOfflineSeriesDao(db: OfflineMediaDatabase) = db.seriesDao()
@Provides
fun provideOfflineSeasonDao(db: OfflineMediaDatabase) = db.seasonDao()
@Provides
fun provideOfflineEpisodeDao(db: OfflineMediaDatabase) = db.episodeDao()
@Provides
@Singleton
fun provideOfflineDataSource(
database: OfflineMediaDatabase,
movieDao: MovieDao,
seriesDao: SeriesDao,
seasonDao: SeasonDao,
episodeDao: EpisodeDao
): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource(
database, movieDao, seriesDao, seasonDao, episodeDao
)
}

View File

@@ -1,7 +1,12 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room
import androidx.room.Embedded
import androidx.room.Relation
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.LibraryEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
data class SeasonWithEpisodes(
@Embedded val season: SeasonEntity,

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room
import androidx.room.TypeConverter
import java.util.UUID

View File

@@ -1,9 +1,9 @@
package hu.bbara.purefin.core.data.local.room.dao
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.CastMemberEntity
import hu.bbara.purefin.core.data.room.entity.CastMemberEntity
import java.util.UUID
@Dao

View File

@@ -1,9 +1,9 @@
package hu.bbara.purefin.core.data.local.room.dao
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.core.data.local.room.dao
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.LibraryEntity
import hu.bbara.purefin.core.data.local.room.LibraryWithContent
import hu.bbara.purefin.core.data.room.LibraryWithContent
import hu.bbara.purefin.core.data.room.entity.LibraryEntity
import kotlinx.coroutines.flow.Flow
@Dao

View File

@@ -1,9 +1,9 @@
package hu.bbara.purefin.core.data.local.room.dao
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.MovieEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID

View File

@@ -1,9 +1,9 @@
package hu.bbara.purefin.core.data.local.room.dao
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
import java.util.UUID
@Dao

View File

@@ -1,11 +1,11 @@
package hu.bbara.purefin.core.data.local.room.dao
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.SeriesEntity
import hu.bbara.purefin.core.data.local.room.SeriesWithSeasonsAndEpisodes
import hu.bbara.purefin.core.data.room.SeriesWithSeasonsAndEpisodes
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.Index

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.ForeignKey

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.ForeignKey
@@ -8,13 +8,6 @@ import java.util.UUID
@Entity(
tableName = "movies",
foreignKeys = [
ForeignKey(
entity = LibraryEntity::class,
parentColumns = ["id"],
childColumns = ["libraryId"],
),
],
indices = [Index("libraryId")]
)
data class MovieEntity(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.ForeignKey

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.ForeignKey
@@ -8,13 +8,6 @@ import java.util.UUID
@Entity(
tableName = "series",
foreignKeys = [
ForeignKey(
entity = LibraryEntity::class,
parentColumns = ["id"],
childColumns = ["libraryId"]
),
],
indices = [Index("libraryId")]
)
data class SeriesEntity(

View File

@@ -0,0 +1,32 @@
package hu.bbara.purefin.core.data.room.offline
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import hu.bbara.purefin.core.data.room.UuidConverters
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
@Database(
entities = [
MovieEntity::class,
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
],
version = 4,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
abstract class OfflineMediaDatabase : RoomDatabase() {
abstract fun movieDao(): MovieDao
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
}

View File

@@ -1,21 +1,20 @@
package hu.bbara.purefin.core.data.local.room
package hu.bbara.purefin.core.data.room.offline
import androidx.room.withTransaction
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID
import javax.inject.Singleton
@@ -26,31 +25,18 @@ class OfflineRoomMediaLocalDataSource(
private val seriesDao: SeriesDao,
private val seasonDao: SeasonDao,
private val episodeDao: EpisodeDao,
private val castMemberDao: CastMemberDao,
private val libraryDao: LibraryDao
) {
// Lightweight Flows for list screens (home, library)
val librariesFlow: Flow<List<Library>> = libraryDao.observeAllWithContent()
.map { relation ->
relation.map {
it.library.toDomain(
movies = it.movies.map { e -> e.toDomain(cast = emptyList()) },
series = it.series.map { e -> e.toDomain(seasons = emptyList(), cast = emptyList()) }
)
}
}
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
.map { entities -> entities.associate { it.id to it.toDomain() } }
val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
.map { entities ->
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
entities.associate { it.id to it.toDomain(seasons = emptyList()) }
}
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
.map { entities -> entities.associate { it.id to it.toDomain() } }
// Full content Flow for series detail screen (scoped to one series)
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
@@ -59,21 +45,12 @@ class OfflineRoomMediaLocalDataSource(
it.series.toDomain(
seasons = it.seasons.map { swe ->
swe.season.toDomain(
episodes = swe.episodes.map { ep -> ep.toDomain(cast = emptyList()) }
episodes = swe.episodes.map { ep -> ep.toDomain() }
)
},
cast = emptyList()
)
} )
}
}
suspend fun saveLibraries(libraries: List<Library>) {
database.withTransaction {
libraryDao.deleteAll()
libraryDao.upsertAll(libraries.map { it.toEntity() })
}
}
suspend fun saveMovies(movies: List<Movie>) {
database.withTransaction {
movieDao.upsertAll(movies.map { it.toEntity() })
@@ -115,15 +92,13 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getMovies(): List<Movie> {
val movies = movieDao.getAll()
return movies.map { entity ->
val cast = castMemberDao.getByMovieId(entity.id).map { it.toDomain() }
entity.toDomain(cast)
entity.toDomain()
}
}
suspend fun getMovie(id: UUID): Movie? {
val entity = movieDao.getById(id) ?: return null
val cast = castMemberDao.getByMovieId(id).map { it.toDomain() }
return entity.toDomain(cast)
return entity.toDomain()
}
suspend fun getSeries(): List<Series> {
@@ -136,24 +111,21 @@ class OfflineRoomMediaLocalDataSource(
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
val entity = seriesDao.getById(id) ?: return null
val cast = castMemberDao.getBySeriesId(id).map { it.toDomain() }
val seasons = if (includeContent) {
seasonDao.getBySeriesId(id).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
episodeEntity.toDomain()
}
seasonEntity.toDomain(episodes)
}
} else emptyList()
return entity.toDomain(seasons, cast)
return entity.toDomain(seasons)
}
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
val seasonEntity = seasonDao.getById(seasonId) ?: return null
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
episodeEntity.toDomain()
}
return seasonEntity.toDomain(episodes)
}
@@ -161,8 +133,7 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getSeasons(seriesId: UUID): List<Season> {
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
episodeEntity.toDomain()
}
seasonEntity.toDomain(episodes)
}
@@ -170,14 +141,12 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
return episodeEntity.toDomain(cast)
return episodeEntity.toDomain()
}
suspend fun getEpisodeById(episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
return episodeEntity.toDomain(cast)
return episodeEntity.toDomain()
}
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
@@ -199,33 +168,10 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(cast)
episodeEntity.toDomain()
}
}
private fun Library.toEntity() = LibraryEntity(
id = id,
name = name,
type = when (type) {
CollectionType.MOVIES -> "MOVIES"
CollectionType.TVSHOWS -> "TVSHOWS"
else -> throw UnsupportedOperationException("Unsupported library type: $type")
}
)
private fun LibraryEntity.toDomain(series: List<Series>, movies: List<Movie>) = Library(
id = id,
name = name,
type = when (type) {
"MOVIES" -> CollectionType.MOVIES
"TVSHOWS" -> CollectionType.TVSHOWS
else -> throw UnsupportedOperationException("Unsupported library type: $type")
},
movies = if (type == "MOVIES") movies else null,
series = if (type == "TVSHOWS") series else null,
)
private fun Movie.toEntity() = MovieEntity(
id = id,
libraryId = libraryId,
@@ -278,7 +224,7 @@ class OfflineRoomMediaLocalDataSource(
heroImageUrl = heroImageUrl
)
private fun MovieEntity.toDomain(cast: List<CastMember>) = Movie(
private fun MovieEntity.toDomain() = Movie(
id = id,
libraryId = libraryId,
title = title,
@@ -292,10 +238,10 @@ class OfflineRoomMediaLocalDataSource(
heroImageUrl = heroImageUrl,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast
cast = emptyList()
)
private fun SeriesEntity.toDomain(seasons: List<Season>, cast: List<CastMember>) = Series(
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series(
id = id,
libraryId = libraryId,
name = name,
@@ -305,7 +251,7 @@ class OfflineRoomMediaLocalDataSource(
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount,
seasons = seasons,
cast = cast
cast = emptyList()
)
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
@@ -318,7 +264,7 @@ class OfflineRoomMediaLocalDataSource(
episodes = episodes
)
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
private fun EpisodeEntity.toDomain() = Episode(
id = id,
seriesId = seriesId,
seasonId = seasonId,
@@ -332,33 +278,6 @@ class OfflineRoomMediaLocalDataSource(
watched = watched,
format = format,
heroImageUrl = heroImageUrl,
cast = cast
)
private fun CastMember.toMovieEntity(movieId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
movieId = movieId
)
private fun CastMember.toSeriesEntity(seriesId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
seriesId = seriesId
)
private fun CastMember.toEpisodeEntity(episodeId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
episodeId = episodeId
)
private fun CastMemberEntity.toDomain() = CastMember(
name = name,
role = role,
imageUrl = imageUrl
cast = emptyList()
)
}

View File

@@ -11,9 +11,8 @@ import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.local.room.OfflineDatabase
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Movie
import kotlinx.coroutines.Dispatchers
@@ -33,8 +32,8 @@ class MediaDownloadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadManager: DownloadManager,
private val jellyfinApiClient: JellyfinApiClient,
@OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
@OfflineDatabase private val movieDao: MovieDao,
private val offlineDataSource: OfflineRoomMediaLocalDataSource,
private val movieDao: MovieDao,
private val userSessionRepository: UserSessionRepository
) {