feat: Added library to the database scheme and to the MediaRepository. LibraryViewModel now uses it.

This commit is contained in:
2026-02-18 17:56:29 +01:00
parent e7d2fa3d62
commit a0dbef3dc1
15 changed files with 227 additions and 51 deletions

View File

@@ -17,44 +17,38 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject
import kotlin.collections.emptyList
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val mediaRepository: MediaRepository,
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager
) : ViewModel() {
private val _url = userSessionRepository.serverUrl.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ""
)
private val selectedLibrary = MutableStateFlow<UUID?>(null)
private val _libraryItems = MutableStateFlow<List<Media>>(emptyList())
val contents: StateFlow<List<PosterItem>> = combine(
_libraryItems,
mediaRepository.movies,
mediaRepository.series
) { items, moviesMap, seriesMap ->
items.mapNotNull { media ->
when (media) {
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
PosterItem(type = BaseItemKind.MOVIE, movie = it)
}
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
PosterItem(type = BaseItemKind.SERIES, series = it)
}
else -> null
val contents: StateFlow<List<PosterItem>> = combine(selectedLibrary, mediaRepository.libraries) {
libraryId, libraries ->
if (libraryId == null) {
return@combine emptyList()
}
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
when (library.type) {
CollectionType.TVSHOWS -> library.series!!.map { series ->
PosterItem(type = BaseItemKind.SERIES, series = series)
}
CollectionType.MOVIES -> library.movies!!.map { movie ->
PosterItem(type = BaseItemKind.MOVIE, movie = movie)
}
else -> emptyList()
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -86,22 +80,7 @@ class LibraryViewModel @Inject constructor(
fun selectLibrary(libraryId: UUID) {
viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibraryContent(libraryId)
_libraryItems.value = libraryItems.map {
when (it.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = it.id)
BaseItemKind.SERIES -> Media.SeriesMedia(seriesId = it.id)
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}
selectedLibrary.value = libraryId
}
}
fun getImageUrl(itemId: UUID, type: ImageType): String {
return JellyfinImageHelper.toImageUrl(
url = _url.value,
itemId = itemId,
type = type
)
}
}

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
import hu.bbara.purefin.data.local.room.OfflineRepository
import hu.bbara.purefin.data.local.room.OnlineRepository
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Library
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Series
@@ -44,6 +45,10 @@ class ActiveMediaRepository @Inject constructor(
.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())

View File

@@ -47,6 +47,8 @@ class InMemoryMediaRepository @Inject constructor(
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
override val libraries: StateFlow<List<Library>> = localDataSource.librariesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
@@ -108,6 +110,8 @@ class InMemoryMediaRepository @Inject constructor(
val emptyLibraries = filteredLibraries.map {
it.toLibrary()
}
localDataSource.saveLibraries(emptyLibraries)
val filledLibraries = emptyLibraries.map { library ->
return@map loadLibrary(library)
}

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.data
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Library
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Series
@@ -10,6 +11,7 @@ import java.util.UUID
interface MediaRepository {
val libraries: StateFlow<List<Library>>
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
import hu.bbara.purefin.data.local.room.OfflineDatabase
import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Library
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Series
@@ -34,6 +35,9 @@ class OfflineMediaRepository @Inject constructor(
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())

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "library")
data class LibraryEntity (
@PrimaryKey
val id: UUID,
val name: String,
val type: String,
)

View File

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

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.data.local.room.dao.LibraryDao
import hu.bbara.purefin.data.local.room.dao.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao
import hu.bbara.purefin.data.local.room.dao.SeriesDao
@@ -47,6 +48,10 @@ object MediaDatabaseModule {
@OnlineDatabase
fun provideOnlineCastMemberDao(@OnlineDatabase db: MediaDatabase) = db.castMemberDao()
@Provides
@OnlineDatabase
fun provideOnlineLibraryDao(@OnlineDatabase db: MediaDatabase) = db.libraryDao()
// Offline Database and DAOs
@Provides
@Singleton
@@ -76,6 +81,10 @@ object MediaDatabaseModule {
@OfflineDatabase
fun provideOfflineCastMemberDao(@OfflineDatabase db: OfflineMediaDatabase) = db.castMemberDao()
@Provides
@OfflineDatabase
fun provideOfflineLibraryDao(@OfflineDatabase db: OfflineMediaDatabase) = db.libraryDao()
// Data Sources
@Provides
@Singleton
@@ -86,9 +95,10 @@ object MediaDatabaseModule {
@OnlineDatabase seriesDao: SeriesDao,
@OnlineDatabase seasonDao: SeasonDao,
@OnlineDatabase episodeDao: EpisodeDao,
@OnlineDatabase castMemberDao: CastMemberDao
@OnlineDatabase castMemberDao: CastMemberDao,
@OnlineDatabase libraryDao: LibraryDao
): RoomMediaLocalDataSource = RoomMediaLocalDataSource(
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao
)
@Provides
@@ -100,9 +110,10 @@ object MediaDatabaseModule {
@OfflineDatabase seriesDao: SeriesDao,
@OfflineDatabase seasonDao: SeasonDao,
@OfflineDatabase episodeDao: EpisodeDao,
@OfflineDatabase castMemberDao: CastMemberDao
@OfflineDatabase castMemberDao: CastMemberDao,
@OfflineDatabase libraryDao: LibraryDao
): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource(
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao
)
// Default (unqualified) data source for backward compatibility

View File

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

View File

@@ -8,6 +8,7 @@ import hu.bbara.purefin.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.data.local.room.dao.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao
import hu.bbara.purefin.data.local.room.dao.SeriesDao
import hu.bbara.purefin.data.local.room.dao.LibraryDao
@Database(
entities = [
@@ -15,9 +16,10 @@ import hu.bbara.purefin.data.local.room.dao.SeriesDao
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
LibraryEntity::class,
CastMemberEntity::class
],
version = 1,
version = 3,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
@@ -26,5 +28,6 @@ abstract class OfflineMediaDatabase : RoomDatabase() {
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
abstract fun libraryDao(): LibraryDao
abstract fun castMemberDao(): CastMemberDao
}

View File

@@ -3,16 +3,19 @@ package hu.bbara.purefin.data.local.room
import androidx.room.withTransaction
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.data.local.room.dao.LibraryDao
import hu.bbara.purefin.data.local.room.dao.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao
import hu.bbara.purefin.data.local.room.dao.SeriesDao
import hu.bbara.purefin.data.model.CastMember
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Library
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.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.Inject
import javax.inject.Singleton
@@ -24,10 +27,21 @@ class OfflineRoomMediaLocalDataSource(
private val seriesDao: SeriesDao,
private val seasonDao: SeasonDao,
private val episodeDao: EpisodeDao,
private val castMemberDao: CastMemberDao
private val castMemberDao: CastMemberDao,
private val libraryDao: LibraryDao
) {
// Lightweight Flows for list screens (home, library)
val librariesFlow: Flow<List<Library>> = libraryDao.observeAllWithContent()
.map { relation ->
relation.map {
it.library.toDomain(
movies = it.movies.map { e -> e.toDomain(cast = emptyList()) },
series = it.series.map { e -> e.toDomain(seasons = emptyList(), cast = emptyList()) }
)
}
}
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
@@ -54,6 +68,13 @@ class OfflineRoomMediaLocalDataSource(
}
}
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() })
@@ -184,6 +205,28 @@ class OfflineRoomMediaLocalDataSource(
}
}
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,

View File

@@ -3,19 +3,22 @@ package hu.bbara.purefin.data.local.room
import androidx.room.withTransaction
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.data.local.room.dao.LibraryDao
import hu.bbara.purefin.data.local.room.dao.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao
import hu.bbara.purefin.data.local.room.dao.SeriesDao
import hu.bbara.purefin.data.model.CastMember
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Library
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.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.Inject
import javax.inject.Singleton
import kotlin.collections.map
@Singleton
class RoomMediaLocalDataSource(
@@ -24,10 +27,21 @@ class RoomMediaLocalDataSource(
private val seriesDao: SeriesDao,
private val seasonDao: SeasonDao,
private val episodeDao: EpisodeDao,
private val castMemberDao: CastMemberDao
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()) } }
@@ -54,6 +68,13 @@ class RoomMediaLocalDataSource(
}
}
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() })
@@ -184,6 +205,28 @@ class RoomMediaLocalDataSource(
}
}
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,

View File

@@ -21,3 +21,17 @@ data class SeriesWithSeasonsAndEpisodes(
)
val seasons: List<SeasonWithEpisodes>
)
data class LibraryWithContent(
@Embedded val library: LibraryEntity,
@Relation(
parentColumn = "id",
entityColumn = "libraryId"
)
val series: List<SeriesEntity>,
@Relation(
parentColumn = "id",
entityColumn = "libraryId"
)
val movies: List<MovieEntity>
)

View File

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

View File

@@ -0,0 +1,29 @@
package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.LibraryEntity
import hu.bbara.purefin.data.local.room.LibraryWithContent
import kotlinx.coroutines.flow.Flow
@Dao
interface LibraryDao {
@Upsert
suspend fun upsert(library: LibraryEntity)
@Upsert
suspend fun upsertAll(libraries: List<LibraryEntity>)
@Query("SELECT * FROM library")
fun observeAll(): Flow<List<LibraryEntity>>
@Query("SELECT * FROM library")
fun observeAllWithContent(): Flow<List<LibraryWithContent>>
@Query("SELECT * FROM library")
suspend fun getAll(): List<LibraryEntity>
@Query("DELETE FROM library")
suspend fun deleteAll()
}