temp: room db refactor

This commit is contained in:
2026-02-21 13:37:53 +01:00
parent b21454c764
commit c0bd0b531d
27 changed files with 177 additions and 376 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,9 @@ 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.room.OfflineDatabase
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource import hu.bbara.purefin.core.data.room.local.RoomMediaLocalDataSource
import hu.bbara.purefin.core.data.local.room.RoomMediaLocalDataSource import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
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
@@ -185,7 +185,6 @@ class InMemoryMediaRepository @Inject constructor(
it.toLibrary() it.toLibrary()
} }
localDataSource.saveLibraries(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)

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,16 @@
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.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.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
@@ -28,40 +23,23 @@ import javax.inject.Singleton
@Singleton @Singleton
class OfflineMediaRepository @Inject constructor( class OfflineMediaRepository @Inject constructor(
@OfflineDatabase private val localDataSource: OfflineRoomMediaLocalDataSource @OfflineDatabase 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 +47,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,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,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,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room package hu.bbara.purefin.core.data.room
import javax.inject.Qualifier import javax.inject.Qualifier

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.local.room package hu.bbara.purefin.core.data.room
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
@@ -7,12 +7,16 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.local.room.dao.CastMemberDao import hu.bbara.purefin.core.data.room.dao.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao import hu.bbara.purefin.core.data.room.dao.LibraryDao
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.local.room.dao.SeasonDao import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.local.MediaDatabase
import hu.bbara.purefin.core.data.room.local.RoomMediaLocalDataSource
import hu.bbara.purefin.core.data.room.offline.OfflineMediaDatabase
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -77,13 +81,6 @@ object MediaDatabaseModule {
@OfflineDatabase @OfflineDatabase
fun provideOfflineEpisodeDao(@OfflineDatabase db: OfflineMediaDatabase) = db.episodeDao() 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 // Data Sources
@Provides @Provides
@@ -109,11 +106,9 @@ object MediaDatabaseModule {
@OfflineDatabase movieDao: MovieDao, @OfflineDatabase movieDao: MovieDao,
@OfflineDatabase seriesDao: SeriesDao, @OfflineDatabase seriesDao: SeriesDao,
@OfflineDatabase seasonDao: SeasonDao, @OfflineDatabase seasonDao: SeasonDao,
@OfflineDatabase episodeDao: EpisodeDao, @OfflineDatabase episodeDao: EpisodeDao
@OfflineDatabase castMemberDao: CastMemberDao,
@OfflineDatabase libraryDao: LibraryDao
): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource( ): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource(
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao database, movieDao, seriesDao, seasonDao, episodeDao
) )
// Default (unqualified) data source for backward compatibility // Default (unqualified) data source for backward compatibility
@@ -122,4 +117,4 @@ object MediaDatabaseModule {
fun provideDefaultDataSource( fun provideDefaultDataSource(
@OnlineDatabase dataSource: RoomMediaLocalDataSource @OnlineDatabase dataSource: RoomMediaLocalDataSource
): RoomMediaLocalDataSource = dataSource ): RoomMediaLocalDataSource = dataSource
} }

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
@@ -12,4 +12,4 @@ class UuidConverters {
@TypeConverter @TypeConverter
fun uuidToString(uuid: UUID?): String? = uuid?.toString() fun uuidToString(uuid: UUID?): String? = uuid?.toString()
} }

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

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
@@ -20,7 +20,7 @@ import java.util.UUID
) )
data class SeasonEntity( data class SeasonEntity(
@PrimaryKey val id: UUID, @PrimaryKey val id: UUID,
val seriesId: UUID, val seriesId: UUID?,
val name: String, val name: String,
val index: Int, val index: Int,
val unwatchedEpisodeCount: Int, val unwatchedEpisodeCount: Int,

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
@@ -12,14 +12,15 @@ import java.util.UUID
ForeignKey( ForeignKey(
entity = LibraryEntity::class, entity = LibraryEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["libraryId"] childColumns = ["libraryId"],
deferred = true
), ),
], ],
indices = [Index("libraryId")] indices = [Index("libraryId")]
) )
data class SeriesEntity( data class SeriesEntity(
@PrimaryKey val id: UUID, @PrimaryKey val id: UUID,
val libraryId: UUID, val libraryId: UUID?,
val name: String, val name: String,
val synopsis: String, val synopsis: String,
val year: String, val year: String,

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.core.data.room.local
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.CastMemberDao
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.LibraryDao
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.CastMemberEntity
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
@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,12 +1,18 @@
package hu.bbara.purefin.core.data.local.room package hu.bbara.purefin.core.data.room.local
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.CastMemberDao
import hu.bbara.purefin.core.data.local.room.dao.EpisodeDao import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.local.room.dao.LibraryDao import hu.bbara.purefin.core.data.room.dao.LibraryDao
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.local.room.dao.SeasonDao import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.local.room.dao.SeriesDao import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.entity.CastMemberEntity
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
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
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

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 = 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
}

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,9 @@ 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.OfflineDatabase
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.local.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.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