From c5d56369cf0f9cc31e5c041707b33ab1b7c4504a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:40:23 +0200 Subject: [PATCH] perf(db): resoudre probleme N+1 playlists, batch writes et indexer playback_state --- .../3.json | 224 ++++++++++++++++++ .../localstream/app/data/db/AppDatabase.kt | 11 +- .../app/data/db/dao/PlaylistDao.kt | 10 + .../app/data/db/dao/TmdbMetadataDao.kt | 3 + .../app/data/db/dao/WatchedItemDao.kt | 3 + .../app/data/db/entity/PlaybackStateEntity.kt | 20 +- .../app/data/db/entity/PlaylistEntity.kt | 12 + .../app/data/repository/PlaylistRepository.kt | 39 ++- .../app/data/repository/TmdbRepository.kt | 15 +- .../data/repository/WatchStateRepository.kt | 5 +- .../com/localstream/app/di/NoOpTestDoubles.kt | 4 + .../app/data/AppDatabaseMigrationTest.kt | 28 +++ .../app/data/LegacyDataImporterTest.kt | 6 + .../app/data/WatchedItemDaoTest.kt | 19 +- .../app/data/repository/TmdbRepositoryTest.kt | 4 + .../app/ui/details/DetailsViewModelTest.kt | 11 + .../app/ui/history/HistoryViewModelTest.kt | 3 + .../app/ui/library/LibraryViewModelTest.kt | 6 + .../app/ui/player/PlayerViewModelTest.kt | 5 + .../ui/playlists/PlaylistsViewModelTest.kt | 7 + 20 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json diff --git a/native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json b/native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json new file mode 100644 index 0000000..2633236 --- /dev/null +++ b/native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json @@ -0,0 +1,224 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "d26195f2f513711c07e8e858973d8cb1", + "entities": [ + { + "tableName": "watched_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `watched` INTEGER NOT NULL, `watched_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playback_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `progress_pct` REAL NOT NULL, `position_ms` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPct", + "columnName": "progress_pct", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [ + { + "name": "index_playback_state_last_played_at", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_state_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)" + }, + { + "name": "index_playback_state_progress_pct", + "unique": false, + "columnNames": [ + "progress_pct" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_state_progress_pct` ON `${TABLE_NAME}` (`progress_pct`)" + } + ] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `video_name` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `video_name`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoName", + "columnName": "video_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "video_name" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tmdb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query_key` TEXT NOT NULL, `json` TEXT NOT NULL, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`query_key`))", + "fields": [ + { + "fieldPath": "queryKey", + "columnName": "query_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query_key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd26195f2f513711c07e8e858973d8cb1')" + ] + } +} \ No newline at end of file diff --git a/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt b/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt index ea34564..746be40 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt @@ -30,7 +30,7 @@ import com.localstream.app.data.db.entity.WatchedItemEntity PlaylistItemEntity::class, TmdbMetadataEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { @@ -58,6 +58,13 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE INDEX IF NOT EXISTS `index_playback_state_last_played_at` ON `playback_state` (`last_played_at`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_playback_state_progress_pct` ON `playback_state` (`progress_pct`)") + } + } + @Volatile private var INSTANCE: AppDatabase? = null @@ -68,7 +75,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, DB_NAME, ) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() .also { INSTANCE = it } } diff --git a/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt b/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt index 6e058ca..1ff9fec 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt @@ -9,6 +9,8 @@ import com.localstream.app.data.db.entity.PlaylistEntity import com.localstream.app.data.db.entity.PlaylistItemEntity import kotlinx.coroutines.flow.Flow +import com.localstream.app.data.db.entity.PlaylistWithItems + @Dao @Suppress("TooManyFunctions") interface PlaylistDao { @@ -18,9 +20,17 @@ interface PlaylistDao { @Query("SELECT * FROM playlist ORDER BY created_at ASC") fun observePlaylists(): Flow> + @Transaction + @Query("SELECT * FROM playlist ORDER BY created_at ASC") + fun observePlaylistsWithItems(): Flow> + @Query("SELECT * FROM playlist ORDER BY created_at ASC") suspend fun getAllPlaylists(): List + @Transaction + @Query("SELECT * FROM playlist ORDER BY created_at ASC") + suspend fun getPlaylistsWithItems(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPlaylist(playlist: PlaylistEntity) diff --git a/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt b/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt index b90a279..3303bce 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt @@ -15,6 +15,9 @@ interface TmdbMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMetadata(entity: TmdbMetadataEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMetadataList(entities: List) + @Query("DELETE FROM tmdb_metadata WHERE query_key = :queryKey") suspend fun deleteMetadata(queryKey: String) diff --git a/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt b/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt index 8ccba74..830e8fd 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt @@ -27,6 +27,9 @@ interface WatchedItemDao { @Query("DELETE FROM watched_items WHERE name = :name") suspend fun deleteByName(name: String) + @Query("DELETE FROM watched_items WHERE name IN (:names)") + suspend fun deleteByNames(names: List) + @Query("DELETE FROM watched_items") suspend fun deleteAll() diff --git a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt index a65250b..b30ef81 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt @@ -4,16 +4,24 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import androidx.room.Index + /** * Table Room : progression de lecture. - * Fusionne les trois cl\u00e9s localStorage : watchProgress, watchPositions et recentlyWatched. + * Fusionne les trois clés localStorage : watchProgress, watchPositions et recentlyWatched. * - * - [progressPct] : pourcentage (0.0 – 100.0), \u00e9quivalent "watchProgress" - * - [positionMs] : position en millisecondes, \u00e9quivalent "watchPositions" - * - [lastPlayedAt] : timestamp ms, permet de r\u00e9construire "recentlyWatched" par tri DESC - * - [mediaStoreId] : nullable, fiabilise la cl\u00e9 \u00e0 terme (renommage de fichier) + * - [progressPct] : pourcentage (0.0 – 100.0), équivalent "watchProgress" + * - [positionMs] : position en millisecondes, équivalent "watchPositions" + * - [lastPlayedAt] : timestamp ms, permet de reconstruire "recentlyWatched" par tri DESC + * - [mediaStoreId] : nullable, fiabilise la clé à terme (renommage de fichier) */ -@Entity(tableName = "playback_state") +@Entity( + tableName = "playback_state", + indices = [ + Index(value = ["last_played_at"]), + Index(value = ["progress_pct"]), + ], +) data class PlaybackStateEntity( @PrimaryKey @ColumnInfo(name = "name") val name: String, diff --git a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt index fff9dd0..3bd4b0b 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt @@ -39,4 +39,16 @@ data class PlaylistItemEntity( @ColumnInfo(name = "video_name") val videoName: String, /** Position 0-based dans la playlist. */ @ColumnInfo(name = "position") val position: Int = 0, +) + +/** + * Relation 1:N entre une Playlist et ses items pour chargement optimisé sans N+1. + */ +data class PlaylistWithItems( + @androidx.room.Embedded val playlist: PlaylistEntity, + @androidx.room.Relation( + parentColumn = "id", + entityColumn = "playlist_id", + ) + val items: List = emptyList(), ) diff --git a/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt index 701dc83..5aaf88b 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt @@ -3,43 +3,29 @@ package com.localstream.app.data.repository import com.localstream.app.data.db.dao.PlaylistDao import com.localstream.app.data.db.entity.PlaylistEntity import com.localstream.app.data.db.entity.PlaylistItemEntity +import com.localstream.app.data.db.entity.PlaylistWithItems import com.localstream.app.domain.model.PlaylistInfo import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import java.util.UUID /** * Repository de gestion des playlists utilisateur. * * Toutes les mutations sont suspendues. - * [observePlaylists] expose un Flow de [PlaylistInfo] reconstitu\u00e9 depuis les deux tables Room. + * [observePlaylists] expose un Flow de [PlaylistInfo] reconstitué depuis les deux tables Room. */ class PlaylistRepository(private val playlistDao: PlaylistDao) { - /** Flow reactif : liste compl\u00e8te des playlists avec leurs vid\u00e9os ordonn\u00e9es. */ + /** Flow réactif : liste complète des playlists avec leurs vidéos ordonnées en une seule requête. */ val observePlaylists: Flow> = - combine( - playlistDao.observePlaylists(), - // On observe les items globaux via getAllItems dans un flow d\u00e9di\u00e9 (simple polling n'est - // pas id\u00e9al ; remplacer par une requ\u00eate GROUP BY en Phase 8 si besoin de perf). - playlistDao.observePlaylists(), // second slot — force re-collect quand les playlists changent - ) { playlists, _ -> - buildPlaylistInfoList(playlists) - } - - private suspend fun buildPlaylistInfoList(playlists: List): List = - playlists.map { entity -> - val items = playlistDao.getItems(entity.id) - PlaylistInfo( - id = entity.id, - name = entity.name, - videoNames = items.sortedBy { it.position }.map { it.videoName }, - ) + playlistDao.observePlaylistsWithItems().map { list -> + list.map { it.toPlaylistInfo() } } suspend fun getAllPlaylists(): List { - val entities = playlistDao.getAllPlaylists() - return buildPlaylistInfoList(entities) + val list = playlistDao.getPlaylistsWithItems() + return list.map { it.toPlaylistInfo() } } suspend fun createPlaylist(name: String): PlaylistInfo { @@ -82,4 +68,11 @@ class PlaylistRepository(private val playlistDao: PlaylistDao) { val existing = playlistDao.getAllPlaylists().firstOrNull { it.id == playlistId } ?: return playlistDao.upsertPlaylist(existing.copy(name = newName)) } -} +} + +private fun PlaylistWithItems.toPlaylistInfo(): PlaylistInfo = + PlaylistInfo( + id = playlist.id, + name = playlist.name, + videoNames = items.sortedBy { it.position }.map { it.videoName }, + ) diff --git a/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt index 97ba059..f6769b8 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt @@ -277,7 +277,7 @@ open class TmdbRepository( tmdbApi.getSeason(tvId, seasonNum, apiKey) } val now = System.currentTimeMillis() - for (epDto in seasonDetails.episodes) { + val entities = seasonDetails.episodes.map { epDto -> val epKey = "${lookupName}_s${epDto.seasonNumber}_e${epDto.episodeNumber}" val episode = TmdbEpisode( epKey = epKey, @@ -287,14 +287,15 @@ open class TmdbRepository( seasonNumber = epDto.seasonNumber, episodeNumber = epDto.episodeNumber, ) - tmdbMetadataDao.insertMetadata( - TmdbMetadataEntity( - queryKey = epKey, - json = json.encodeToString(episode), - fetchedAt = now, - ) + TmdbMetadataEntity( + queryKey = epKey, + json = json.encodeToString(episode), + fetchedAt = now, ) } + if (entities.isNotEmpty()) { + tmdbMetadataDao.insertMetadataList(entities) + } } catch (_: Exception) { } } diff --git a/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt index f927ba2..db46856 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt @@ -68,7 +68,10 @@ class WatchStateRepository( } ) } else { - episodes.forEach { ep -> watchedItemDao.deleteByName(ep.name) } + val names = episodes.map { it.name } + if (names.isNotEmpty()) { + watchedItemDao.deleteByNames(names) + } } } diff --git a/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt b/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt index d737494..0fbcdd1 100644 --- a/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt +++ b/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt @@ -47,6 +47,7 @@ class NoOpWatchedItemDao : WatchedItemDao { override suspend fun upsert(item: WatchedItemEntity) {} override suspend fun upsertAll(items: List) {} override suspend fun deleteByName(name: String) {} + override suspend fun deleteByNames(names: List) {} override suspend fun deleteAll() {} override suspend fun findByName(name: String): WatchedItemEntity? = null } @@ -67,6 +68,7 @@ class NoOpPlaybackStateDao : PlaybackStateDao { class NoOpTmdbMetadataDao : TmdbMetadataDao { override suspend fun getMetadata(queryKey: String): TmdbMetadataEntity? = null override suspend fun insertMetadata(entity: TmdbMetadataEntity) {} + override suspend fun insertMetadataList(entities: List) {} override suspend fun deleteMetadata(queryKey: String) {} override suspend fun clearAll() {} override suspend fun getAll(): List = emptyList() @@ -75,7 +77,9 @@ class NoOpTmdbMetadataDao : TmdbMetadataDao { @Suppress("EmptyFunctionBlock") class NoOpPlaylistDao : PlaylistDao { override fun observePlaylists(): Flow> = MutableStateFlow(emptyList()) + override fun observePlaylistsWithItems(): Flow> = MutableStateFlow(emptyList()) override suspend fun getAllPlaylists(): List = emptyList() + override suspend fun getPlaylistsWithItems(): List = emptyList() override suspend fun upsertPlaylist(playlist: PlaylistEntity) {} override suspend fun upsertPlaylists(playlists: List) {} override suspend fun deletePlaylist(id: String) {} diff --git a/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt index 3248fe0..bb1a397 100644 --- a/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt @@ -39,4 +39,32 @@ class AppDatabaseMigrationTest { assertTrue(sql.contains("`fetched_at` INTEGER NOT NULL")) assertTrue(sql.contains("PRIMARY KEY(`query_key`)")) } + + @Test + fun migration2To3_hasCorrectVersions() { + assertEquals(2, AppDatabase.MIGRATION_2_3.startVersion) + assertEquals(3, AppDatabase.MIGRATION_2_3.endVersion) + } + + @Test + fun migration2To3_executesCreateIndicesOnPlaybackState() { + val executedQueries = mutableListOf() + + val dbProxy = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL" && args != null && args.isNotEmpty()) { + executedQueries.add(args[0].toString()) + } + null + } as SupportSQLiteDatabase + + AppDatabase.MIGRATION_2_3.migrate(dbProxy) + + assertEquals(2, executedQueries.size) + assertTrue(executedQueries.any { it.contains("CREATE INDEX IF NOT EXISTS `index_playback_state_last_played_at`") }) + assertTrue(executedQueries.any { it.contains("CREATE INDEX IF NOT EXISTS `index_playback_state_progress_pct`") }) + } } + diff --git a/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt b/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt index 92d24e6..eeade9c 100644 --- a/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt @@ -167,6 +167,7 @@ private class FakeWatchedItemDao : WatchedItemDao { override suspend fun upsert(item: WatchedItemEntity) { store[item.name] = item } override suspend fun upsertAll(items: List) { items.forEach { store[it.name] = it } } override suspend fun deleteByName(name: String) { store.remove(name) } + override suspend fun deleteByNames(names: List) { names.forEach { store.remove(it) } } override suspend fun deleteAll() { store.clear() } override suspend fun findByName(name: String) = store[name] } @@ -187,7 +188,12 @@ private class FakePlaylistDao : PlaylistDao { val playlists = mutableMapOf() val items = mutableListOf() override fun observePlaylists(): Flow> = flow { emit(playlists.values.toList()) } + override fun observePlaylistsWithItems(): Flow> = flow { + emit(playlists.values.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) }) + } override suspend fun getAllPlaylists() = playlists.values.toList() + override suspend fun getPlaylistsWithItems(): List = + playlists.values.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } override suspend fun upsertPlaylist(playlist: PlaylistEntity) { playlists[playlist.id] = playlist } override suspend fun upsertPlaylists(playlists: List) { playlists.forEach { this.playlists[it.id] = it } } override suspend fun deletePlaylist(id: String) { playlists.remove(id); items.removeAll { it.playlistId == id } } diff --git a/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt b/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt index 702a426..095d9c5 100644 --- a/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt @@ -73,9 +73,22 @@ class WatchedItemDaoTest { private fun runBlocking(block: suspend () -> Unit) { kotlinx.coroutines.runBlocking { block() } } + + @Test + fun deleteByNames_removesMultipleItems() = runBlocking { + dao.upsert(WatchedItemEntity("Film1.mp4", watched = true)) + dao.upsert(WatchedItemEntity("Film2.mp4", watched = true)) + dao.upsert(WatchedItemEntity("Film3.mp4", watched = true)) + + dao.deleteByNames(listOf("Film1.mp4", "Film2.mp4")) + + val remaining = dao.getAllWatchedItems() + assertEquals(1, remaining.size) + assertEquals("Film3.mp4", remaining.first().name) + } } -// -------- Impl\u00e9mentation en m\u00e9moire -------- +// -------- Implémentation en mémoire -------- private class InMemoryWatchedItemDao : WatchedItemDao { private val store = mutableMapOf() @@ -94,6 +107,10 @@ private class InMemoryWatchedItemDao : WatchedItemDao { override suspend fun deleteByName(name: String) { store.remove(name) } + override suspend fun deleteByNames(names: List) { + names.forEach { store.remove(it) } + } + override suspend fun deleteAll() { store.clear() } override suspend fun findByName(name: String) = store[name] diff --git a/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt b/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt index 1c1c300..bf87133 100644 --- a/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt @@ -474,6 +474,10 @@ class FakeTmdbMetadataDao : TmdbMetadataDao { map[entity.queryKey] = entity } + override suspend fun insertMetadataList(entities: List) { + entities.forEach { map[it.queryKey] = it } + } + override suspend fun deleteMetadata(queryKey: String) { map.remove(queryKey) } diff --git a/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt index 4832991..a015019 100644 --- a/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt @@ -23,6 +23,7 @@ import com.localstream.app.data.remote.tmdb.dto.TmdbSearchResponse import com.localstream.app.data.remote.tmdb.dto.TmdbSeasonDetailsDto import com.localstream.app.data.repository.OpenSubtitlesRepository import com.localstream.app.data.repository.PlaylistRepository +import kotlinx.coroutines.flow.map import com.localstream.app.data.repository.SettingsRepository import com.localstream.app.data.repository.TmdbRepository import com.localstream.app.data.repository.VideoRepository @@ -193,6 +194,9 @@ class DetailsViewModelTest { override suspend fun deleteByName(name: String) { items.value = items.value.filterNot { it.name == name } } + override suspend fun deleteByNames(names: List) { + items.value = items.value.filterNot { it.name in names } + } override suspend fun deleteAll() { items.value = emptyList() } override suspend fun findByName(name: String): WatchedItemEntity? = items.value.find { it.name == name } } @@ -218,7 +222,13 @@ class DetailsViewModelTest { private val items = mutableListOf() override fun observePlaylists(): Flow> = playlists + override fun observePlaylistsWithItems(): Flow> = + playlists.map { list -> + list.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } + } override suspend fun getAllPlaylists(): List = playlists.value + override suspend fun getPlaylistsWithItems(): List = + playlists.value.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } override suspend fun upsertPlaylist(playlist: PlaylistEntity) { playlists.value = playlists.value.filterNot { it.id == playlist.id } + playlist } @@ -258,6 +268,7 @@ class DetailsViewModelTest { private val items = mutableMapOf() override suspend fun getMetadata(queryKey: String): TmdbMetadataEntity? = items[queryKey] override suspend fun insertMetadata(entity: TmdbMetadataEntity) { items[entity.queryKey] = entity } + override suspend fun insertMetadataList(entities: List) { entities.forEach { items[it.queryKey] = it } } override suspend fun deleteMetadata(queryKey: String) { items.remove(queryKey) } override suspend fun clearAll() = items.clear() override suspend fun getAll(): List = items.values.toList() diff --git a/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt index 404f4bb..1b6ae77 100644 --- a/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt @@ -106,6 +106,9 @@ class HistoryViewModelTest { override suspend fun deleteByName(name: String) { items.value = items.value.filterNot { it.name == name } } + override suspend fun deleteByNames(names: List) { + items.value = items.value.filterNot { it.name in names } + } override suspend fun deleteAll() { items.value = emptyList() } override suspend fun findByName(name: String): WatchedItemEntity? = items.value.find { it.name == name } } diff --git a/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt index 9df3c91..f313ccb 100644 --- a/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt @@ -206,6 +206,9 @@ class LibraryViewModelTest { override suspend fun deleteByName(name: String) { items.value = items.value.filterNot { it.name == name } } + override suspend fun deleteByNames(names: List) { + items.value = items.value.filterNot { it.name in names } + } override suspend fun deleteAll() { items.value = emptyList() } @@ -238,6 +241,9 @@ class LibraryViewModelTest { override suspend fun insertMetadata(entity: TmdbMetadataEntity) { items[entity.queryKey] = entity } + override suspend fun insertMetadataList(entities: List) { + entities.forEach { items[it.queryKey] = it } + } override suspend fun deleteMetadata(queryKey: String) { items.remove(queryKey) } diff --git a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt index 73f5a10..94a7402 100644 --- a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt @@ -460,6 +460,11 @@ class PlayerViewModelTest { flow.value = map.values.toList() } + override suspend fun deleteByNames(names: List) { + names.forEach { map.remove(it) } + flow.value = map.values.toList() + } + override suspend fun deleteAll() { map.clear() flow.value = emptyList() diff --git a/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt index e5e6225..329f21d 100644 --- a/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -106,7 +107,13 @@ class PlaylistsViewModelTest { private val items = mutableListOf() override fun observePlaylists(): Flow> = playlists + override fun observePlaylistsWithItems(): Flow> = + playlists.map { list -> + list.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } + } override suspend fun getAllPlaylists(): List = playlists.value + override suspend fun getPlaylistsWithItems(): List = + playlists.value.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } override suspend fun upsertPlaylist(playlist: PlaylistEntity) { playlists.value = playlists.value.filterNot { it.id == playlist.id } + playlist }