From fe97ff5e73f57aaa4755fe500a99290f5c513701 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 16 Feb 2026 09:03:20 +0100 Subject: [PATCH] feat: add offline media repository and update dependency injection for media sources --- .../purefin/data/MediaRepositoryModule.kt | 13 +- .../purefin/data/OfflineMediaRepository.kt | 64 ++++ .../data/local/room/DatabaseQualifiers.kt | 35 ++ .../data/local/room/MediaDatabaseModule.kt | 91 ++++- .../data/local/room/OfflineMediaDatabase.kt | 30 ++ .../room/OfflineRoomMediaLocalDataSource.kt | 322 ++++++++++++++++++ .../local/room/RoomMediaLocalDataSource.kt | 2 +- 7 files changed, 548 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/DatabaseQualifiers.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/OfflineRoomMediaLocalDataSource.kt diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt index d920fbd..d526b99 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt @@ -4,11 +4,22 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.data.local.room.OnlineRepository +import hu.bbara.purefin.data.local.room.OfflineRepository @Module @InstallIn(SingletonComponent::class) abstract class MediaRepositoryModule { @Binds - abstract fun bindMediaRepository(impl: InMemoryMediaRepository): MediaRepository + @OnlineRepository + abstract fun bindOnlineMediaRepository(impl: InMemoryMediaRepository): MediaRepository + + @Binds + @OfflineRepository + abstract fun bindOfflineMediaRepository(impl: OfflineMediaRepository): MediaRepository + + // Default binding for backward compatibility (uses online repository) + @Binds + abstract fun bindDefaultMediaRepository(impl: InMemoryMediaRepository): MediaRepository } diff --git a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt new file mode 100644 index 0000000..075a2b7 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt @@ -0,0 +1,64 @@ +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.Movie +import hu.bbara.purefin.data.model.Series +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Offline media repository for managing downloaded content. + * This repository only accesses the local offline database and does not make network calls. + */ +@Singleton +class OfflineMediaRepository @Inject constructor( + @OfflineDatabase private val localDataSource: OfflineRoomMediaLocalDataSource +) : MediaRepository { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Offline repository is always ready (no network loading required) + private val _state: MutableStateFlow = MutableStateFlow(MediaRepositoryState.Ready) + override val state: StateFlow = _state.asStateFlow() + + override val movies: StateFlow> = localDataSource.moviesFlow + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val series: StateFlow> = localDataSource.seriesFlow + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val episodes: StateFlow> = localDataSource.episodesFlow + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override fun observeSeriesWithContent(seriesId: UUID): Flow { + return localDataSource.observeSeriesWithContent(seriesId) + } + + override suspend fun ensureReady() { + // Offline repository is always ready - no initialization needed + } + + override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { + if (durationMs <= 0) return + val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 + val watched = progressPercent >= 90.0 + // Write to offline database - the reactive Flows propagate changes to UI automatically + localDataSource.updateWatchProgress(mediaId, progressPercent, watched) + } + + override suspend fun refreshHomeData() { + // No-op for offline repository - no network refresh available + } +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/DatabaseQualifiers.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/DatabaseQualifiers.kt new file mode 100644 index 0000000..00ce1c4 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/DatabaseQualifiers.kt @@ -0,0 +1,35 @@ +package hu.bbara.purefin.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 diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt index 0d6824d..d11fa53 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt @@ -7,31 +7,108 @@ import dagger.Provides import dagger.hilt.InstallIn 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.MovieDao +import hu.bbara.purefin.data.local.room.dao.SeasonDao +import hu.bbara.purefin.data.local.room.dao.SeriesDao import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object MediaDatabaseModule { + // Online Database and DAOs @Provides @Singleton - fun provideDatabase(@ApplicationContext context: Context): MediaDatabase = - Room.databaseBuilder(context, MediaDatabase::class.java, "media_database") + @OnlineDatabase + fun provideOnlineDatabase(@ApplicationContext context: Context): MediaDatabase = + Room.inMemoryDatabaseBuilder(context, MediaDatabase::class.java) .fallbackToDestructiveMigration() .build() @Provides - fun provideMovieDao(db: MediaDatabase) = db.movieDao() + @OnlineDatabase + fun provideOnlineMovieDao(@OnlineDatabase db: MediaDatabase) = db.movieDao() @Provides - fun provideSeriesDao(db: MediaDatabase) = db.seriesDao() + @OnlineDatabase + fun provideOnlineSeriesDao(@OnlineDatabase db: MediaDatabase) = db.seriesDao() @Provides - fun provideSeasonDao(db: MediaDatabase) = db.seasonDao() + @OnlineDatabase + fun provideOnlineSeasonDao(@OnlineDatabase db: MediaDatabase) = db.seasonDao() @Provides - fun provideEpisodeDao(db: MediaDatabase) = db.episodeDao() + @OnlineDatabase + fun provideOnlineEpisodeDao(@OnlineDatabase db: MediaDatabase) = db.episodeDao() @Provides - fun provideCastMemberDao(db: MediaDatabase) = db.castMemberDao() + @OnlineDatabase + fun provideOnlineCastMemberDao(@OnlineDatabase db: MediaDatabase) = db.castMemberDao() + + // 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() + + // 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 + ): RoomMediaLocalDataSource = RoomMediaLocalDataSource( + database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao + ) + + @Provides + @Singleton + @OfflineDatabase + fun provideOfflineDataSource( + @OfflineDatabase database: OfflineMediaDatabase, + @OfflineDatabase movieDao: MovieDao, + @OfflineDatabase seriesDao: SeriesDao, + @OfflineDatabase seasonDao: SeasonDao, + @OfflineDatabase episodeDao: EpisodeDao, + @OfflineDatabase castMemberDao: CastMemberDao + ): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource( + database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao + ) + + // Default (unqualified) data source for backward compatibility + @Provides + @Singleton + fun provideDefaultDataSource( + @OnlineDatabase dataSource: RoomMediaLocalDataSource + ): RoomMediaLocalDataSource = dataSource } diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt new file mode 100644 index 0000000..cb6198b --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt @@ -0,0 +1,30 @@ +package hu.bbara.purefin.data.local.room + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +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.MovieDao +import hu.bbara.purefin.data.local.room.dao.SeasonDao +import hu.bbara.purefin.data.local.room.dao.SeriesDao + +@Database( + entities = [ + MovieEntity::class, + SeriesEntity::class, + SeasonEntity::class, + EpisodeEntity::class, + CastMemberEntity::class + ], + version = 1, + 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 castMemberDao(): CastMemberDao +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineRoomMediaLocalDataSource.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineRoomMediaLocalDataSource.kt new file mode 100644 index 0000000..3d3ebb7 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineRoomMediaLocalDataSource.kt @@ -0,0 +1,322 @@ +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.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.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 java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OfflineRoomMediaLocalDataSource( + private val database: OfflineMediaDatabase, + private val movieDao: MovieDao, + private val seriesDao: SeriesDao, + private val seasonDao: SeasonDao, + private val episodeDao: EpisodeDao, + private val castMemberDao: CastMemberDao +) { + + // Lightweight Flows for list screens (home, library) + val moviesFlow: Flow> = movieDao.observeAll() + .map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } } + + val seriesFlow: Flow> = seriesDao.observeAll() + .map { entities -> + entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) } + } + + val episodesFlow: Flow> = 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 = + 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 saveMovies(movies: List) { + database.withTransaction { + movieDao.upsertAll(movies.map { it.toEntity() }) + } + } + + suspend fun saveSeries(seriesList: List) { + 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 { + 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 { + 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 { + 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 { + return episodeDao.getBySeriesId(seriesId).map { episodeEntity -> + val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() } + episodeEntity.toDomain(cast) + } + } + + 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) = 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, cast: List) = 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) = Season( + id = id, + seriesId = seriesId, + name = name, + index = index, + unwatchedEpisodeCount = unwatchedEpisodeCount, + episodeCount = episodeCount, + episodes = episodes + ) + + private fun EpisodeEntity.toDomain(cast: List) = 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 + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt index b3473e1..54b1289 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt @@ -18,7 +18,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class RoomMediaLocalDataSource @Inject constructor( +class RoomMediaLocalDataSource( private val database: MediaDatabase, private val movieDao: MovieDao, private val seriesDao: SeriesDao,