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

View File

@@ -4,22 +4,12 @@ import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.local.room.OfflineRepository
import hu.bbara.purefin.core.data.local.room.OnlineRepository
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class MediaRepositoryModule { abstract class MediaRepositoryModule {
@Binds @Binds
@OnlineRepository abstract fun bindInMemoryMediaRepository(impl: InMemoryMediaRepository): MediaRepository
abstract fun bindOnlineMediaRepository(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 package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.local.room.OfflineDatabase import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.model.Episode 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.Movie
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -27,41 +21,24 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class OfflineMediaRepository @Inject constructor( class OfflineMediaRepository @Inject constructor(
@OfflineDatabase private val localDataSource: OfflineRoomMediaLocalDataSource private val localDataSource: OfflineRoomMediaLocalDataSource
) : MediaRepository { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Offline repository is always ready (no network loading required) val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
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
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .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()) .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()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
// Offline mode doesn't support these server-side features fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
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?> {
return localDataSource.observeSeriesWithContent(seriesId) return localDataSource.observeSeriesWithContent(seriesId)
} }
override suspend fun ensureReady() { suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
// Offline repository is always ready - no initialization needed
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0 val watched = progressPercent >= 90.0
@@ -69,7 +46,4 @@ class OfflineMediaRepository @Inject constructor(
localDataSource.updateWatchProgress(mediaId, progressPercent, watched) 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.Embedded
import androidx.room.Relation 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( data class SeasonWithEpisodes(
@Embedded val season: SeasonEntity, @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 androidx.room.TypeConverter
import java.util.UUID 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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert 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 import java.util.UUID
@Dao @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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert 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 kotlinx.coroutines.flow.Flow
import java.util.UUID 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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.LibraryEntity import hu.bbara.purefin.core.data.room.LibraryWithContent
import hu.bbara.purefin.core.data.local.room.LibraryWithContent import hu.bbara.purefin.core.data.room.entity.LibraryEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert 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 kotlinx.coroutines.flow.Flow
import java.util.UUID 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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert 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 import java.util.UUID
@Dao @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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import hu.bbara.purefin.core.data.local.room.SeriesEntity import hu.bbara.purefin.core.data.room.SeriesWithSeasonsAndEpisodes
import hu.bbara.purefin.core.data.local.room.SeriesWithSeasonsAndEpisodes import hu.bbara.purefin.core.data.room.entity.SeriesEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.util.UUID 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.Entity
import androidx.room.Index 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.Entity
import androidx.room.ForeignKey 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.Entity
import androidx.room.PrimaryKey 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.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
@@ -8,13 +8,6 @@ import java.util.UUID
@Entity( @Entity(
tableName = "movies", tableName = "movies",
foreignKeys = [
ForeignKey(
entity = LibraryEntity::class,
parentColumns = ["id"],
childColumns = ["libraryId"],
),
],
indices = [Index("libraryId")] indices = [Index("libraryId")]
) )
data class MovieEntity( 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.Entity
import androidx.room.ForeignKey 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.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
@@ -8,13 +8,6 @@ import java.util.UUID
@Entity( @Entity(
tableName = "series", tableName = "series",
foreignKeys = [
ForeignKey(
entity = LibraryEntity::class,
parentColumns = ["id"],
childColumns = ["libraryId"]
),
],
indices = [Index("libraryId")] indices = [Index("libraryId")]
) )
data class SeriesEntity( 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 androidx.room.withTransaction
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.MovieDao import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.local.room.dao.SeasonDao import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao import hu.bbara.purefin.core.data.room.entity.MovieEntity
import hu.bbara.purefin.core.model.CastMember 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.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID import java.util.UUID
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,31 +25,18 @@ class OfflineRoomMediaLocalDataSource(
private val seriesDao: SeriesDao, private val seriesDao: SeriesDao,
private val seasonDao: SeasonDao, private val seasonDao: SeasonDao,
private val episodeDao: EpisodeDao, 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() 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() val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
.map { entities -> .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() 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) // Full content Flow for series detail screen (scoped to one series)
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> = fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
@@ -59,21 +45,12 @@ class OfflineRoomMediaLocalDataSource(
it.series.toDomain( it.series.toDomain(
seasons = it.seasons.map { swe -> seasons = it.seasons.map { swe ->
swe.season.toDomain( 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>) { suspend fun saveMovies(movies: List<Movie>) {
database.withTransaction { database.withTransaction {
movieDao.upsertAll(movies.map { it.toEntity() }) movieDao.upsertAll(movies.map { it.toEntity() })
@@ -115,15 +92,13 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getMovies(): List<Movie> { suspend fun getMovies(): List<Movie> {
val movies = movieDao.getAll() val movies = movieDao.getAll()
return movies.map { entity -> return movies.map { entity ->
val cast = castMemberDao.getByMovieId(entity.id).map { it.toDomain() } entity.toDomain()
entity.toDomain(cast)
} }
} }
suspend fun getMovie(id: UUID): Movie? { suspend fun getMovie(id: UUID): Movie? {
val entity = movieDao.getById(id) ?: return null val entity = movieDao.getById(id) ?: return null
val cast = castMemberDao.getByMovieId(id).map { it.toDomain() } return entity.toDomain()
return entity.toDomain(cast)
} }
suspend fun getSeries(): List<Series> { suspend fun getSeries(): List<Series> {
@@ -136,24 +111,21 @@ class OfflineRoomMediaLocalDataSource(
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? { private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
val entity = seriesDao.getById(id) ?: return null val entity = seriesDao.getById(id) ?: return null
val cast = castMemberDao.getBySeriesId(id).map { it.toDomain() }
val seasons = if (includeContent) { val seasons = if (includeContent) {
seasonDao.getBySeriesId(id).map { seasonEntity -> seasonDao.getBySeriesId(id).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity -> val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() } episodeEntity.toDomain()
episodeEntity.toDomain(episodeCast)
} }
seasonEntity.toDomain(episodes) seasonEntity.toDomain(episodes)
} }
} else emptyList() } else emptyList()
return entity.toDomain(seasons, cast) return entity.toDomain(seasons)
} }
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? { suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
val seasonEntity = seasonDao.getById(seasonId) ?: return null val seasonEntity = seasonDao.getById(seasonId) ?: return null
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity -> val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() } episodeEntity.toDomain()
episodeEntity.toDomain(episodeCast)
} }
return seasonEntity.toDomain(episodes) return seasonEntity.toDomain(episodes)
} }
@@ -161,8 +133,7 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getSeasons(seriesId: UUID): List<Season> { suspend fun getSeasons(seriesId: UUID): List<Season> {
return seasonDao.getBySeriesId(seriesId).map { seasonEntity -> return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity -> val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() } episodeEntity.toDomain()
episodeEntity.toDomain(episodeCast)
} }
seasonEntity.toDomain(episodes) seasonEntity.toDomain(episodes)
} }
@@ -170,14 +141,12 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? { suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() } return episodeEntity.toDomain()
return episodeEntity.toDomain(cast)
} }
suspend fun getEpisodeById(episodeId: UUID): Episode? { suspend fun getEpisodeById(episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() } return episodeEntity.toDomain()
return episodeEntity.toDomain(cast)
} }
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) { suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
@@ -199,33 +168,10 @@ class OfflineRoomMediaLocalDataSource(
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> { suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
return episodeDao.getBySeriesId(seriesId).map { episodeEntity -> return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() } episodeEntity.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( private fun Movie.toEntity() = MovieEntity(
id = id, id = id,
libraryId = libraryId, libraryId = libraryId,
@@ -278,7 +224,7 @@ class OfflineRoomMediaLocalDataSource(
heroImageUrl = heroImageUrl heroImageUrl = heroImageUrl
) )
private fun MovieEntity.toDomain(cast: List<CastMember>) = Movie( private fun MovieEntity.toDomain() = Movie(
id = id, id = id,
libraryId = libraryId, libraryId = libraryId,
title = title, title = title,
@@ -292,10 +238,10 @@ class OfflineRoomMediaLocalDataSource(
heroImageUrl = heroImageUrl, heroImageUrl = heroImageUrl,
audioTrack = audioTrack, audioTrack = audioTrack,
subtitles = subtitles, 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, id = id,
libraryId = libraryId, libraryId = libraryId,
name = name, name = name,
@@ -305,7 +251,7 @@ class OfflineRoomMediaLocalDataSource(
unwatchedEpisodeCount = unwatchedEpisodeCount, unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount, seasonCount = seasonCount,
seasons = seasons, seasons = seasons,
cast = cast cast = emptyList()
) )
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season( private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
@@ -318,7 +264,7 @@ class OfflineRoomMediaLocalDataSource(
episodes = episodes episodes = episodes
) )
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode( private fun EpisodeEntity.toDomain() = Episode(
id = id, id = id,
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId, seasonId = seasonId,
@@ -332,33 +278,6 @@ class OfflineRoomMediaLocalDataSource(
watched = watched, watched = watched,
format = format, format = format,
heroImageUrl = heroImageUrl, heroImageUrl = heroImageUrl,
cast = cast cast = emptyList()
)
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

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