From eaee593ca28dc4e072a1f0e048af0242cfc91355 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Tue, 14 Jul 2026 09:06:10 +0800 Subject: [PATCH 1/2] Import and display MLIP v3 extras --- app/build.gradle.kts | 2 +- .../kotlin/com/miruplay/tv/di/AppModule.kt | 1 + .../com/miruplay/tv/di/AppModuleTest.kt | 1 + .../9.json | 908 ++++++++++++++++++ .../com/miruplay/tv/data/dao/IndexDao.kt | 2 +- .../miruplay/tv/data/db/MiruPlayDatabase.kt | 11 +- .../tv/data/entity/IndexEntryEntity.kt | 6 +- .../tv/data/repository/IndexRepositoryImpl.kt | 13 +- .../data/db/MiruPlayDatabaseMigrationTest.kt | 17 + .../repository/IndexRepositoryImplTest.kt | 17 + .../tv/repository/LibraryAnimeResolver.kt | 7 + .../tv/repository/LibraryEpisodeResolver.kt | 1 + .../tv/repository/MediaIndexMetadataCache.kt | 4 +- .../tv/repository/MediaIndexPosterGrouping.kt | 10 +- .../tv/repository/MediaIndexRepository.kt | 19 + .../tv/repository/PlayableUriResolver.kt | 28 +- .../repository/LibraryEpisodeResolverTest.kt | 24 + .../repository/MediaIndexMetadataCacheTest.kt | 7 + .../MediaIndexPosterGroupingTest.kt | 25 + .../tv/repository/PlayableUriResolverTest.kt | 31 + .../tv/scanner/MlipLibraryIndexImporter.kt | 75 +- .../scanner/MlipLibraryIndexImporterTest.kt | 114 ++- .../tv/ui/detail/AnimeDetailScreen.kt | 27 +- .../tv/ui/detail/AnimeDetailViewModel.kt | 10 +- 24 files changed, 1340 insertions(+), 20 deletions(-) create mode 100644 data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/9.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c855a9f..61a33b3f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { // 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。 // 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。 -val baseAppVersionName = "1.1.0" +val baseAppVersionName = "1.2.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt b/app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt index ca001157..80364ef7 100644 --- a/app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt @@ -54,4 +54,5 @@ internal fun miruPlayDatabaseMigrations(): Array = MiruPlayDatabase.MIGRATION_5_6, MiruPlayDatabase.MIGRATION_6_7, MiruPlayDatabase.MIGRATION_7_8, + MiruPlayDatabase.MIGRATION_8_9, ) diff --git a/app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt b/app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt index 0667d221..012498b7 100644 --- a/app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt +++ b/app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt @@ -16,6 +16,7 @@ class AppModuleTest { MiruPlayDatabase.MIGRATION_5_6, MiruPlayDatabase.MIGRATION_6_7, MiruPlayDatabase.MIGRATION_7_8, + MiruPlayDatabase.MIGRATION_8_9, ), miruPlayDatabaseMigrations().toList(), ) diff --git a/data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/9.json b/data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/9.json new file mode 100644 index 00000000..3ce25b29 --- /dev/null +++ b/data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/9.json @@ -0,0 +1,908 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "84bd85940736bf14a7c49d919d02d092", + "entities": [ + { + "tableName": "anime", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `title_cn` TEXT, `summary` TEXT, `genres` TEXT, `studio` TEXT, `director` TEXT, `episode_count` INTEGER NOT NULL, `air_date` TEXT, `rating` REAL NOT NULL, `bangumi_id` TEXT, `anilist_id` TEXT, `tmdb_id` TEXT, `poster_url` TEXT, `poster_local_path` TEXT, `fanart_url` TEXT, `bangumi_collection_type` INTEGER, `bangumi_ep_status` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "titleCn", + "columnName": "title_cn", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "studio", + "columnName": "studio", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "director", + "columnName": "director", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "episodeCount", + "columnName": "episode_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "airDate", + "columnName": "air_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bangumiId", + "columnName": "bangumi_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "anilistId", + "columnName": "anilist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tmdbId", + "columnName": "tmdb_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "posterUrl", + "columnName": "poster_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "posterLocalPath", + "columnName": "poster_local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fanartUrl", + "columnName": "fanart_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bangumiCollectionType", + "columnName": "bangumi_collection_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bangumiEpStatus", + "columnName": "bangumi_ep_status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "episode", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `anime_id` TEXT NOT NULL, `season_number` INTEGER NOT NULL, `episode_number` INTEGER NOT NULL, `title` TEXT, `file_path` TEXT NOT NULL, `file_name` TEXT, `duration` INTEGER NOT NULL, `thumbnail_path` TEXT, `bangumi_episode_id` INTEGER, `bangumi_collection_type` INTEGER, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "animeId", + "columnName": "anime_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seasonNumber", + "columnName": "season_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "episodeNumber", + "columnName": "episode_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "file_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailPath", + "columnName": "thumbnail_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bangumiEpisodeId", + "columnName": "bangumi_episode_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bangumiCollectionType", + "columnName": "bangumi_collection_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episode_anime_id_season_number", + "unique": false, + "columnNames": [ + "anime_id", + "season_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_anime_id_season_number` ON `${TABLE_NAME}` (`anime_id`, `season_number`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "media_source", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `content_mode` TEXT NOT NULL DEFAULT 'ANIME', `url` TEXT, `username` TEXT, `password` TEXT, `extra_config` TEXT, `is_connected` INTEGER NOT NULL, `last_scanned` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentMode", + "columnName": "content_mode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ANIME'" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extraConfig", + "columnName": "extra_config", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isConnected", + "columnName": "is_connected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScanned", + "columnName": "last_scanned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` TEXT NOT NULL, `position_ms` INTEGER NOT NULL, `last_watched` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, PRIMARY KEY(`episode_id`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episode_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWatched", + "columnName": "last_watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episode_id" + ] + }, + "indices": [ + { + "name": "index_progress_episode_id", + "unique": true, + "columnNames": [ + "episode_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_progress_episode_id` ON `${TABLE_NAME}` (`episode_id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "index_entry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `source_id` INTEGER NOT NULL, `path` TEXT NOT NULL, `external_subtitle_paths` TEXT NOT NULL, `anime_name` TEXT, `episode_title` TEXT, `plot` TEXT, `season_number` INTEGER, `episode_number` INTEGER, `metadata_source` TEXT, `metadata_id` TEXT, `metadata_title` TEXT, `scrape_status` TEXT, `scrape_message` TEXT, `scraped_at` INTEGER NOT NULL, `is_directory` INTEGER NOT NULL, `file_size` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `extra_kind` INTEGER, `extra_ordinal` INTEGER, `extra_sort_order` INTEGER, `duration` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalSubtitlePaths", + "columnName": "external_subtitle_paths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "animeName", + "columnName": "anime_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "episodeTitle", + "columnName": "episode_title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plot", + "columnName": "plot", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seasonNumber", + "columnName": "season_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "episodeNumber", + "columnName": "episode_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "metadataSource", + "columnName": "metadata_source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataId", + "columnName": "metadata_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataTitle", + "columnName": "metadata_title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "scrapeStatus", + "columnName": "scrape_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "scrapeMessage", + "columnName": "scrape_message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "scrapedAt", + "columnName": "scraped_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extraKind", + "columnName": "extra_kind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "extraOrdinal", + "columnName": "extra_ordinal", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "extraSortOrder", + "columnName": "extra_sort_order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_index_entry_source_id_anime_name", + "unique": false, + "columnNames": [ + "source_id", + "anime_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_index_entry_source_id_anime_name` ON `${TABLE_NAME}` (`source_id`, `anime_name`)" + }, + { + "name": "index_index_entry_source_id_path", + "unique": true, + "columnNames": [ + "source_id", + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_index_entry_source_id_path` ON `${TABLE_NAME}` (`source_id`, `path`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "drama_series_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`series_id` TEXT NOT NULL, `title` TEXT NOT NULL, `original_title` TEXT NOT NULL, `summary` TEXT NOT NULL, `season_count` INTEGER NOT NULL, `episode_count` INTEGER NOT NULL, `poster_url` TEXT, `fanart_url` TEXT, `first_air_date` TEXT, `metadata_source` TEXT, `metadata_id` TEXT, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`series_id`))", + "fields": [ + { + "fieldPath": "seriesId", + "columnName": "series_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seasonCount", + "columnName": "season_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "episodeCount", + "columnName": "episode_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "posterUrl", + "columnName": "poster_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fanartUrl", + "columnName": "fanart_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firstAirDate", + "columnName": "first_air_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSource", + "columnName": "metadata_source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataId", + "columnName": "metadata_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "series_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "cloud_drive_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `endpoint_url` TEXT NOT NULL, `username` TEXT NOT NULL, `webdav_source_id` INTEGER, `inbox_path` TEXT NOT NULL, `library_path` TEXT NOT NULL, `library_mode` TEXT NOT NULL, `interval_minutes` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `last_run_at` INTEGER NOT NULL, `rss_proxy_enabled` INTEGER NOT NULL, `rss_proxy_host` TEXT NOT NULL, `rss_proxy_port` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endpointUrl", + "columnName": "endpoint_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webDavSourceId", + "columnName": "webdav_source_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inboxPath", + "columnName": "inbox_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryPath", + "columnName": "library_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryMode", + "columnName": "library_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "intervalMinutes", + "columnName": "interval_minutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRunAt", + "columnName": "last_run_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rssProxyEnabled", + "columnName": "rss_proxy_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rssProxyHost", + "columnName": "rss_proxy_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rssProxyPort", + "columnName": "rss_proxy_port", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rss_subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `filter_regex` TEXT, `enabled` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filterRegex", + "columnName": "filter_regex", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "last_checked_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rss_subscription_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_rss_subscription_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rss_processed_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `subscription_id` INTEGER NOT NULL, `item_key` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `processed_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemKey", + "columnName": "item_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "processedAt", + "columnName": "processed_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rss_processed_item_subscription_id_item_key", + "unique": true, + "columnNames": [ + "subscription_id", + "item_key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_rss_processed_item_subscription_id_item_key` ON `${TABLE_NAME}` (`subscription_id`, `item_key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rss_download_task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `subscription_id` INTEGER NOT NULL, `item_key` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemKey", + "columnName": "item_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rss_download_task_subscription_id_item_key", + "unique": false, + "columnNames": [ + "subscription_id", + "item_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rss_download_task_subscription_id_item_key` ON `${TABLE_NAME}` (`subscription_id`, `item_key`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "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, '84bd85940736bf14a7c49d919d02d092')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/miruplay/tv/data/dao/IndexDao.kt b/data/src/main/kotlin/com/miruplay/tv/data/dao/IndexDao.kt index cb049b91..eb260ac1 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/dao/IndexDao.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/dao/IndexDao.kt @@ -7,7 +7,7 @@ import androidx.room.Query import com.miruplay.tv.data.entity.IndexEntryEntity private const val INDEX_COLUMNS = - "id, source_id, path, external_subtitle_paths, anime_name, episode_title, plot, season_number, episode_number, metadata_source, metadata_id, metadata_title, scrape_status, scrape_message, scraped_at, is_directory, file_size, last_modified" + "id, source_id, path, external_subtitle_paths, anime_name, episode_title, plot, season_number, episode_number, metadata_source, metadata_id, metadata_title, scrape_status, scrape_message, scraped_at, is_directory, file_size, last_modified, extra_kind, extra_ordinal, extra_sort_order, duration" @Dao interface IndexDao { diff --git a/data/src/main/kotlin/com/miruplay/tv/data/db/MiruPlayDatabase.kt b/data/src/main/kotlin/com/miruplay/tv/data/db/MiruPlayDatabase.kt index 9a2f2f0e..d287ba1e 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/db/MiruPlayDatabase.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/db/MiruPlayDatabase.kt @@ -9,7 +9,7 @@ import com.miruplay.tv.data.entity.* import androidx.sqlite.db.SupportSQLiteDatabase @Database( - version = 8, + version = 9, exportSchema = true, entities = [ AnimeEntity::class, @@ -35,6 +35,15 @@ abstract class MiruPlayDatabase : RoomDatabase() { abstract fun cloudDriveAutomationDao(): CloudDriveAutomationDao companion object { + val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE index_entry ADD COLUMN extra_kind INTEGER") + database.execSQL("ALTER TABLE index_entry ADD COLUMN extra_ordinal INTEGER") + database.execSQL("ALTER TABLE index_entry ADD COLUMN extra_sort_order INTEGER") + database.execSQL("ALTER TABLE index_entry ADD COLUMN duration INTEGER NOT NULL DEFAULT 0") + } + } + val MIGRATION_7_8 = object : Migration(7, 8) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( diff --git a/data/src/main/kotlin/com/miruplay/tv/data/entity/IndexEntryEntity.kt b/data/src/main/kotlin/com/miruplay/tv/data/entity/IndexEntryEntity.kt index 0d7cd707..56ed2840 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/entity/IndexEntryEntity.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/entity/IndexEntryEntity.kt @@ -30,5 +30,9 @@ data class IndexEntryEntity( @ColumnInfo(name = "scraped_at") val scrapedAt: Long = 0L, @ColumnInfo(name = "is_directory") val isDirectory: Boolean = false, @ColumnInfo(name = "file_size") val fileSize: Long = 0L, - @ColumnInfo(name = "last_modified") val lastModified: Long = 0L + @ColumnInfo(name = "last_modified") val lastModified: Long = 0L, + @ColumnInfo(name = "extra_kind") val extraKind: Int? = null, + @ColumnInfo(name = "extra_ordinal") val extraOrdinal: Int? = null, + @ColumnInfo(name = "extra_sort_order") val extraSortOrder: Int? = null, + @ColumnInfo(defaultValue = "0") val duration: Long = 0L, ) diff --git a/data/src/main/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImpl.kt b/data/src/main/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImpl.kt index 5522344c..0a793bca 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImpl.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.miruplay.tv.core.common.Result import com.miruplay.tv.data.dao.IndexDao import com.miruplay.tv.data.db.MiruPlayDatabase import com.miruplay.tv.data.entity.IndexEntryEntity +import com.miruplay.tv.repository.MediaExtraKind import com.miruplay.tv.repository.MediaScrapeStatus import androidx.room.withTransaction import kotlinx.coroutines.Dispatchers @@ -101,7 +102,11 @@ private fun IndexRepositoryEntity.toEntity(sourceId: Long) = IndexEntryEntity( scrapedAt = scrapedAt, isDirectory = isDirectory, fileSize = fileSize, - lastModified = lastModified + lastModified = lastModified, + extraKind = extraKind?.value, + extraOrdinal = extraOrdinal, + extraSortOrder = extraSortOrder, + duration = duration, ) private fun IndexEntryEntity.toDomain() = IndexRepositoryEntity( @@ -121,7 +126,11 @@ private fun IndexEntryEntity.toDomain() = IndexRepositoryEntity( scrapedAt = scrapedAt, isDirectory = isDirectory, fileSize = fileSize, - lastModified = lastModified + lastModified = lastModified, + extraKind = extraKind?.let(MediaExtraKind::fromValue), + extraOrdinal = extraOrdinal, + extraSortOrder = extraSortOrder, + duration = duration, ) private fun MediaScrapeStatus?.toStorageValue(): String? = this?.name diff --git a/data/src/test/kotlin/com/miruplay/tv/data/db/MiruPlayDatabaseMigrationTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/db/MiruPlayDatabaseMigrationTest.kt index fdcf0118..9987510f 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/db/MiruPlayDatabaseMigrationTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/db/MiruPlayDatabaseMigrationTest.kt @@ -116,6 +116,23 @@ class MiruPlayDatabaseMigrationTest { assertTrue(normalizedSql.any { it.contains("FROM anime") && it.contains("WHERE id LIKE 'drama-series:%'") }) } + @Test + fun `migration 8 to 9 adds series extra index fields`() { + val database = RecordingSupportSQLiteDatabase() + + MiruPlayDatabase.MIGRATION_8_9.migrate(database.proxy) + + assertEquals( + listOf( + "ALTER TABLE index_entry ADD COLUMN extra_kind INTEGER", + "ALTER TABLE index_entry ADD COLUMN extra_ordinal INTEGER", + "ALTER TABLE index_entry ADD COLUMN extra_sort_order INTEGER", + "ALTER TABLE index_entry ADD COLUMN duration INTEGER NOT NULL DEFAULT 0", + ), + database.sql, + ) + } + @Test fun `migration 7 to 8 adds external subtitle paths with empty list default`() { val database = RecordingSupportSQLiteDatabase() diff --git a/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt index 8a515201..6cfeb854 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt @@ -3,6 +3,7 @@ package com.miruplay.tv.data.repository import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.miruplay.tv.data.db.MiruPlayDatabase +import com.miruplay.tv.repository.MediaExtraKind import com.miruplay.tv.repository.MediaIndexEntry import kotlinx.coroutines.runBlocking import org.junit.After @@ -52,6 +53,22 @@ class IndexRepositoryImplTest { ) } + @Test + fun `series extra fields round trip through Room`() = runBlocking { + val entry = MediaIndexEntry( + sourceId = 7L, + path = "/Series/OVA.mkv", + extraKind = MediaExtraKind.OVA, + extraOrdinal = 1, + extraSortOrder = 2, + duration = 600_000L, + ) + + repository.rebuildIndex(7L, listOf(entry)) + + assertEquals(entry, repository.queryIndex(7L, entry.path).getOrNull()?.single()) + } + @Test fun `getAnimeInIndex returns all cached metadata key candidates`() = runBlocking { repository.rebuildIndex( diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt index 11afa74b..00944342 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt @@ -20,6 +20,7 @@ data class LibraryIndexedAnimeGroup( data class LibraryAnimeDetail( val anime: Anime, val episodes: List, + val extras: List = emptyList(), ) class LibraryAnimeResolver( @@ -72,6 +73,11 @@ class LibraryAnimeResolver( ) .distinctBy { it.id } .sortedForPlaybackQueue() + val extras = relatedPairs + .flatMap { (relatedGroup, relatedAnimeItem) -> + relatedGroup.entries.toIndexedExtras(relatedGroup.source, relatedAnimeItem.id) + } + .distinctBy(Episode::id) val displayAnime = if (relatedAnime.size > 1) { relatedAnime.mergeAnimeGroupForDisplay().copy(id = animeId) } else { @@ -82,6 +88,7 @@ class LibraryAnimeResolver( return LibraryAnimeDetail( anime = displayAnime, episodes = episodes, + extras = extras, ) } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt index 392abfbd..f2d8b19a 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt @@ -99,6 +99,7 @@ class LibraryEpisodeResolver( val entries = index.queryIndex(sourceId, query) .getOrNull() .orEmpty() + .filterNot(MediaIndexEntry::isSeriesExtra) val entry = entries.firstOrNull { it.path == path || episodeId.endsWith(it.path) || path.endsWith(it.path) } ?: entries.firstOrNull { MediaPathConventions.fileName(it.path) == fileName } ?: continue diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCache.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCache.kt index 21a38e92..93a22f2a 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCache.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCache.kt @@ -23,6 +23,7 @@ class MediaIndexMetadataCache( var episodesCached = 0 entries .mediaFilesOnly() + .filterNot(MediaIndexEntry::isSeriesExtra) .groupBy { it.animeName?.takeIf(String::isNotBlank) ?: "Unknown" } .forEach { (animeId, animeEntries) -> val episodes = episodeTransform( @@ -69,7 +70,8 @@ fun List.toCachedIndexedEpisodes( source: MediaSourceInfo?, animeId: String, ): List = - sortedByMediaIndexEpisodeOrder() + filterNot(MediaIndexEntry::isSeriesExtra) + .sortedByMediaIndexEpisodeOrder() .mapIndexed { index, entry -> entry.toCachedIndexedEpisode( source = source, diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexPosterGrouping.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexPosterGrouping.kt index 3f3a3666..e349f072 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexPosterGrouping.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexPosterGrouping.kt @@ -12,6 +12,8 @@ data class MediaIndexPosterGroup( ) { val primaryEntry: MediaIndexEntry = entries + .filterNot(MediaIndexEntry::isSeriesExtra) + .ifEmpty { entries } .sortedWith(compareBy { it.episodeNumber ?: Int.MAX_VALUE }.thenBy { it.path }) .first() val entryPaths: Set = entries.map { it.path }.toSet() @@ -22,9 +24,10 @@ data class MediaIndexPosterGroup( ?: entry.animeName?.takeIf { it.isNotBlank() }?.let { "title:${it.lowercase()}" } } val subtitle: String = buildString { - append(entries.size) + val episodeCount = entries.count { !it.isSeriesExtra() } + append(episodeCount) append(" episode") - if (entries.size != 1) append('s') + if (episodeCount != 1) append('s') primaryEntry.seasonNumber?.let { append(" · S").append(it) } primaryEntry.metadataSource?.takeIf { it.isNotBlank() }?.let { append(" · ").append(it) } } @@ -62,7 +65,7 @@ fun MediaIndexPosterGroup.toIndexedAnime(): Anime = Anime( id = animeId, title = title, - episodeCount = entries.size, + episodeCount = entries.count { !it.isSeriesExtra() }, summary = primaryEntry.plot.orEmpty(), ) @@ -104,6 +107,7 @@ fun List.mediaIndexEpisodesForPosterSelection( val selected = selectedEntry?.takeUnless { it.isDirectory } ?: return emptyList() return mediaFilesOnly() .asSequence() + .filterNot(MediaIndexEntry::isSeriesExtra) .filter { it.belongsToPosterGroup(selected, mergeSameAnimeEnabled) } .sortedWith(mediaIndexEpisodeComparator) .toList() diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt index 7f3cbbcc..e3a6f37e 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt @@ -12,6 +12,19 @@ enum class MediaScrapeStatus { FAILED, } +@Serializable +enum class MediaExtraKind(val value: Int) { + OVA(1), + SPECIAL(2), + NCOP(3), + NCED(4), + GALLERY(5); + + companion object { + fun fromValue(value: Int): MediaExtraKind? = entries.firstOrNull { it.value == value } + } +} + interface MediaIndexRepository { suspend fun rebuildIndex(sourceId: Long, entries: List): Result suspend fun upsertEntry(sourceId: Long, entry: MediaIndexEntry): Result @@ -42,4 +55,10 @@ data class MediaIndexEntry( val isDirectory: Boolean = false, val fileSize: Long = 0L, val lastModified: Long = 0L, + val extraKind: MediaExtraKind? = null, + val extraOrdinal: Int? = null, + val extraSortOrder: Int? = null, + val duration: Long = 0L, ) + +fun MediaIndexEntry.isSeriesExtra(): Boolean = extraKind != null diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt index 0396d06b..9132b4e4 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt @@ -71,9 +71,35 @@ fun List.toIndexedEpisodes( source: MediaSourceInfo?, animeId: String, ): List = - map { entry -> entry.toIndexedEpisode(source, animeId) } + filterNot(MediaIndexEntry::isSeriesExtra) + .map { entry -> entry.toIndexedEpisode(source, animeId) } .sortedForPlaybackQueue() +fun List.toIndexedExtras( + source: MediaSourceInfo?, + animeId: String, +): List = + filter(MediaIndexEntry::isSeriesExtra) + .sortedWith( + compareBy( + { it.extraKind?.value ?: Int.MAX_VALUE }, + { it.extraSortOrder ?: Int.MAX_VALUE }, + { it.path }, + ), + ) + .map { entry -> + Episode( + id = "${entry.sourceId}:${entry.path}", + animeId = animeId, + seasonNumber = 0, + episodeNumber = entry.extraOrdinal ?: 1, + title = entry.episodeTitle.orEmpty(), + filePath = source.playableUriForIndexedPath(entry.path), + fileName = MediaPathConventions.fileName(entry.path), + duration = entry.duration, + ) + } + private fun MediaSourceInfo.matchesPath(path: String): Boolean = when (type) { MediaSourceType.LOCAL -> { diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt index e17696e9..573d613f 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt @@ -117,6 +117,30 @@ class LibraryEpisodeResolverTest { assertEquals(45_000L, items.single().episode.watchedPosition) } + @Test + fun `continue watching ignores indexed series extras`() = runBlocking { + val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) + val path = "D:/Anime/Frieren/NCOP01.mkv" + val resolver = resolver( + sources = listOf(source), + entries = listOf( + MediaIndexEntry( + sourceId = 1L, + path = path, + animeName = "Frieren", + extraKind = MediaExtraKind.NCOP, + extraOrdinal = 1, + ), + ), + progressRecords = listOf( + ProgressRecord("1:$path", positionMs = 45_000L, lastWatched = 99L), + ), + ) + + assertNull(resolver.findEpisodeById("1:$path")) + assertTrue(resolver.loadContinueWatchingEpisodes().isEmpty()) + } + @Test fun `continue watching result preserves progress errors`() = runBlocking { val error = AppError.MediaSourceError.NotFound("progress-store") diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCacheTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCacheTest.kt index 2c573b6a..b10b1067 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCacheTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexMetadataCacheTest.kt @@ -16,6 +16,13 @@ class MediaIndexMetadataCacheTest { val entries = listOf( MediaIndexEntry(sourceId = 2L, path = "/Frieren/Episode 02.mkv", animeName = "Frieren", episodeNumber = 2), MediaIndexEntry(sourceId = 2L, path = "/Frieren/Episode 01.mkv", animeName = "Frieren", episodeNumber = 1), + MediaIndexEntry( + sourceId = 2L, + path = "/Frieren/NCOP01.mkv", + animeName = "Frieren", + extraKind = MediaExtraKind.NCOP, + extraOrdinal = 1, + ), ) val result = MediaIndexMetadataCache(metadata).cache(source, entries) diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexPosterGroupingTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexPosterGroupingTest.kt index 23693d74..598cb0db 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexPosterGroupingTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/MediaIndexPosterGroupingTest.kt @@ -24,6 +24,31 @@ class MediaIndexPosterGroupingTest { assertEquals("2 episodes", frieren.subtitle) } + @Test + fun `poster subtitle and episode count exclude series extras`() { + val normal = MediaIndexEntry( + sourceId = 1, + path = "show/Frieren - 01.mkv", + animeName = "Frieren", + episodeNumber = 1, + ) + val extra = MediaIndexEntry( + sourceId = 1, + path = "show/Frieren - NCOP01.mkv", + animeName = "Frieren", + extraKind = MediaExtraKind.NCOP, + extraOrdinal = 1, + ) + + val mixed = listOf(normal, extra).toMediaIndexPosterGroups().single() + assertEquals("1 episode", mixed.subtitle) + assertEquals(1, mixed.toIndexedAnime().episodeCount) + + val extrasOnly = listOf(extra).toMediaIndexPosterGroups().single() + assertEquals("0 episodes", extrasOnly.subtitle) + assertEquals(0, extrasOnly.toIndexedAnime().episodeCount) + } + @Test fun `poster groups can merge entries that share external metadata`() { val entries = listOf( diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlayableUriResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlayableUriResolverTest.kt index 5676e38e..6ce66152 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlayableUriResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlayableUriResolverTest.kt @@ -101,6 +101,37 @@ class PlayableUriResolverTest { assertEquals("Episode 01.mkv", episodes.first().fileName) } + @Test + fun `extras are excluded from episodes and projected separately`() { + val source = MediaSourceInfoConventions.webDav(url = "https://dav.example/anime", name = "DAV").copy(id = 7L) + val entries = listOf( + MediaIndexEntry(sourceId = 7L, path = "/Show/01.mkv", episodeNumber = 1), + MediaIndexEntry( + sourceId = 7L, + path = "/Show/NCOP02.mkv", + episodeTitle = "NCOP 02", + extraKind = MediaExtraKind.NCOP, + extraOrdinal = 2, + extraSortOrder = 2, + duration = 90_000L, + ), + MediaIndexEntry( + sourceId = 7L, + path = "/Show/OVA.mkv", + episodeTitle = "OVA", + extraKind = MediaExtraKind.OVA, + extraOrdinal = 1, + extraSortOrder = 1, + ), + ) + + assertEquals(listOf("7:/Show/01.mkv"), entries.toIndexedEpisodes(source, "show").map { it.id }) + val extras = entries.toIndexedExtras(source, "show") + assertEquals(listOf("OVA", "NCOP 02"), extras.map { it.title }) + assertEquals(listOf(0, 0), extras.map { it.seasonNumber }) + assertEquals(90_000L, extras.last().duration) + } + @Test fun `resolvePlayableUri returns local path when no matching WebDAV source exists`() = runBlocking { val repository = FakeMediaSourceRepository( diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt index d25f4bf3..049dc1c4 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -12,6 +12,7 @@ import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.Episode +import com.miruplay.tv.repository.MediaExtraKind import com.miruplay.tv.repository.MediaIndexEntry import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaScrapeStatus @@ -25,7 +26,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val MLIP_DATABASE_PATH = "library.db" -private val SUPPORTED_MLIP_SCHEMA_VERSIONS = 1..2 +private val SUPPORTED_MLIP_SCHEMA_VERSIONS = 1..3 private const val MLIP_METADATA_SOURCE = "MLIP" @Singleton @@ -72,7 +73,7 @@ class MlipLibraryIndexImporter @Inject constructor( is Result.Error -> return@withContext Result.failure(queried.error) } val mediaFiles = snapshot.mediaFiles - val entries = mediaFiles.map { it.indexEntry } + val entries = mediaFiles.map { it.indexEntry } + snapshot.extras when (val rebuilt = indexRepository.rebuildIndex(source.id, entries)) { is Result.Success -> Unit is Result.Error -> return@withContext Result.failure(rebuilt.error) @@ -138,6 +139,7 @@ class MlipLibraryIndexImporter @Inject constructor( skippedFileCount = snapshot.skippedFiles, artworkCachedCount = artworkCachedCount, nonIntegerEpisodeCount = snapshot.nonIntegerEpisodes, + extraCount = snapshot.extras.size, ) MiruLog.i( TAG, @@ -149,6 +151,7 @@ class MlipLibraryIndexImporter @Inject constructor( "media_file_count" to result.mediaFileCount.toString(), "skipped_file_count" to result.skippedFileCount.toString(), "non_integer_episode_count" to result.nonIntegerEpisodeCount.toString(), + "extra_count" to result.extraCount.toString(), ), ) Result.success(result) @@ -181,7 +184,9 @@ class MlipLibraryIndexImporter @Inject constructor( return Result.failure(AppError.LibraryIndexError.UnsupportedVersion(userVersion)) } val tables = database.tableNames() - val expectedTables = if (userVersion >= 2) requiredTables + v2RequiredTables else requiredTables + val expectedTables = requiredTables + + (if (userVersion >= 2) v2RequiredTables else emptySet()) + + (if (userVersion >= 3) v3RequiredTables else emptySet()) val missing = expectedTables.filterNot { it in tables } if (missing.isNotEmpty()) { return Result.failure(AppError.LibraryIndexError.InvalidSchema("Missing tables: ${missing.joinToString()}")) @@ -194,6 +199,13 @@ class MlipLibraryIndexImporter @Inject constructor( if (schema != userVersion.toString()) { return Result.failure(AppError.LibraryIndexError.InvalidSchema("meta.schema does not match user_version $userVersion")) } + if (userVersion >= 3 && database.singleInt( + "SELECT enabled FROM capability WHERE name = ?", + "extra", + ) != 1 + ) { + return Result.failure(AppError.LibraryIndexError.InvalidSchema("MLIP v3 requires capability.extra = 1")) + } return Result.success(Unit) } @@ -296,11 +308,65 @@ class MlipLibraryIndexImporter @Inject constructor( return MlipSnapshot( series = seriesById.values.toList(), mediaFiles = mediaFiles, + extras = if ((database.singleInt("PRAGMA user_version") ?: 0) >= 3) { + database.readExtras(sourceId, seriesById) + } else { + emptyList() + }, skippedFiles = skippedFiles, nonIntegerEpisodes = nonIntegerEpisodes, ) } + private fun SQLiteDatabase.readExtras( + sourceId: Long, + seriesById: Map, + ): List { + if ("media_extra" !in tableNames()) return emptyList() + val result = mutableListOf() + rawQuery( + """ + SELECT series_id, extra_kind, ordinal, sort_order, title, path, size, modified_time, runtime + FROM media_extra + ORDER BY series_id, extra_kind, sort_order, path + """.trimIndent(), + emptyArray(), + ).use { cursor -> + while (cursor.moveToNext()) { + val series = seriesById[cursor.long("series_id")] + ?: throw InvalidMlipSchemaException("Extra references an unknown series") + val rawKind = cursor.int("extra_kind") + val kind = MediaExtraKind.fromValue(rawKind) + ?: throw InvalidMlipSchemaException("Unknown extra_kind: $rawKind") + val rawPath = cursor.string("path") + val indexPath = normalizeMlipMediaPath(rawPath) + ?: throw InvalidMlipSchemaException("Unsafe extra path: $rawPath") + if (!MediaFileConventions.isVideoName(indexPath)) { + throw InvalidMlipSchemaException("Extra path is not a video: $rawPath") + } + result += MediaIndexEntry( + sourceId = sourceId, + path = indexPath, + animeName = series.anime.displayTitleForIndex(), + episodeTitle = cursor.string("title"), + metadataSource = MLIP_METADATA_SOURCE, + metadataId = series.anime.id, + metadataTitle = series.anime.displayTitleForIndex(), + scrapeStatus = MediaScrapeStatus.SCRAPED, + scrapeMessage = "Imported from MLIP library.db", + scrapedAt = System.currentTimeMillis(), + fileSize = cursor.longOrNull("size") ?: 0L, + lastModified = cursor.longOrNull("modified_time").toMlipEpochMillis(), + extraKind = kind, + extraOrdinal = cursor.int("ordinal"), + extraSortOrder = cursor.int("sort_order"), + duration = (cursor.longOrNull("runtime") ?: 0L).coerceAtLeast(0L) * 1000L, + ) + } + } + return result + } + private fun SQLiteDatabase.readExternalSubtitlePathsByMediaFileId(): Map> { if ("media_subtitle" !in tableNames()) return emptyMap() val result = linkedMapOf>() @@ -489,6 +555,7 @@ data class MlipImportResult( val skippedFileCount: Int, val artworkCachedCount: Int, val nonIntegerEpisodeCount: Int, + val extraCount: Int, ) internal fun normalizeMlipMediaPath(path: String): String? { @@ -524,6 +591,7 @@ private fun normalizeMlipRelativePath(path: String): String? { } private val v2RequiredTables = setOf("series_release_date", "media_subtitle") +private val v3RequiredTables = setOf("media_extra") private val requiredTables = setOf( "meta", @@ -542,6 +610,7 @@ private val requiredTables = setOf( private data class MlipSnapshot( val series: List, val mediaFiles: List, + val extras: List, val skippedFiles: Int, val nonIntegerEpisodes: Int, ) diff --git a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt index e0f80a58..6fb7ccde 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt @@ -15,6 +15,7 @@ import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceInfoConventions import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.MlipMetadataMode +import com.miruplay.tv.repository.MediaExtraKind import com.miruplay.tv.repository.MediaIndexEntry import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaScrapeStatus @@ -60,7 +61,7 @@ class MlipLibraryIndexImporterTest { fun `unsupported mlip version fails clearly`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") try { - createMlipDatabase(databaseFile, userVersion = 3) + createMlipDatabase(databaseFile, userVersion = 4) val result = importDatabase(databaseFile) @@ -71,6 +72,107 @@ class MlipLibraryIndexImporterTest { } } + @Test + fun `mlip v3 requires extra table`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + try { + createMlipDatabase(databaseFile, userVersion = 3, includeExtras = false) + + val result = importDatabase(databaseFile) + + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.LibraryIndexError.InvalidSchema) + } finally { + databaseFile.delete() + } + } + + @Test + fun `mlip v3 requires enabled extra capability`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + try { + createMlipDatabase(databaseFile, userVersion = 3, includeExtraCapability = false) + + val result = importDatabase(databaseFile) + + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.LibraryIndexError.InvalidSchema) + } finally { + databaseFile.delete() + } + } + + @Test + fun `mlip v2 ignores a stray extra table`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val indexRepository = RecordingIndexRepository() + val source = mlipSource() + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf("library.db" to { databaseFile.inputStream() }), + ) + try { + createMlipDatabase(databaseFile, userVersion = 2, includeExtras = true) + + val result = MlipLibraryIndexImporter(indexRepository, RecordingMetadataRepository()) + .importLibrary(source, mediaSource) + + assertTrue(result.isSuccess()) + assertEquals(1, indexRepository.entries.size) + assertTrue(indexRepository.entries.none { it.extraKind != null }) + } finally { + databaseFile.delete() + } + } + + @Test + fun `mlip v3 imports extras without caching them as episodes`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val indexRepository = RecordingIndexRepository() + val metadataRepository = RecordingMetadataRepository() + val source = mlipSource() + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf("library.db" to { databaseFile.inputStream() }), + ) + try { + createMlipDatabase(databaseFile, userVersion = 3) + + val result = MlipLibraryIndexImporter(indexRepository, metadataRepository) + .importLibrary(source, mediaSource) + + assertTrue(result.isSuccess()) + val importResult = (result as Result.Success).data + assertEquals(1, importResult.episodeCount) + assertEquals(1, importResult.extraCount) + assertEquals(2, indexRepository.entries.size) + assertEquals(1, metadataRepository.episodes.size) + val extra = indexRepository.entries.single { it.extraKind != null } + assertEquals(MediaExtraKind.OVA, extra.extraKind) + assertEquals(1, extra.extraOrdinal) + assertEquals("OVA", extra.episodeTitle) + assertEquals("/Series/OVA.mkv", extra.path) + assertEquals(600_000L, extra.duration) + } finally { + databaseFile.delete() + } + } + + @Test + fun `mlip v3 rejects unknown extra kind`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + try { + createMlipDatabase(databaseFile, userVersion = 3, extraKind = 99) + + val result = importDatabase(databaseFile) + + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.LibraryIndexError.InvalidSchema) + } finally { + databaseFile.delete() + } + } + @Test fun `mlip v2 requires subtitle table`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") @@ -419,6 +521,9 @@ class MlipLibraryIndexImporterTest { includeRequiredTables: Boolean = true, includeReleaseDate: Boolean = true, includeExternalSubtitles: Boolean = true, + includeExtras: Boolean = userVersion >= 3, + includeExtraCapability: Boolean = userVersion >= 3, + extraKind: Int = 1, mediaPath: String = "Series/01.mkv", ) { file.delete() @@ -439,6 +544,9 @@ class MlipLibraryIndexImporterTest { database.execSQL("CREATE TABLE episode_external_id (episode_id INTEGER NOT NULL, provider INTEGER NOT NULL, value TEXT NOT NULL)") database.execSQL("CREATE TABLE capability (name TEXT PRIMARY KEY, enabled INTEGER NOT NULL)") database.execSQL("INSERT INTO capability (name, enabled) VALUES ('subtitle', ${if (includeExternalSubtitles) 1 else 0})") + if (includeExtraCapability) { + database.execSQL("INSERT INTO capability (name, enabled) VALUES ('extra', 1)") + } if (includeReleaseDate) { database.execSQL("CREATE TABLE series_release_date (series_id INTEGER PRIMARY KEY, air_date TEXT NOT NULL)") database.execSQL("INSERT INTO series_release_date (series_id, air_date) VALUES (1, '2024-04-03')") @@ -447,6 +555,10 @@ class MlipLibraryIndexImporterTest { database.execSQL("CREATE TABLE media_subtitle (id INTEGER PRIMARY KEY, media_file_id INTEGER NOT NULL, path TEXT NOT NULL, language TEXT, title TEXT, sort_order INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(media_file_id) REFERENCES media_file(id) ON DELETE CASCADE, UNIQUE(media_file_id, path))") database.execSQL("INSERT INTO media_subtitle (media_file_id, path, language, title, sort_order) VALUES (100, 'Series/01.en.srt', 'en', 'English', 2), (100, 'Series/01.zh-CN.ass', 'zh-CN', '简体中文', 1)") } + if (includeExtras) { + database.execSQL("CREATE TABLE media_extra (id INTEGER PRIMARY KEY, uuid TEXT NOT NULL, series_id INTEGER NOT NULL, extra_kind INTEGER NOT NULL, ordinal INTEGER NOT NULL, sort_order INTEGER NOT NULL, title TEXT NOT NULL, path TEXT NOT NULL, size INTEGER, modified_time INTEGER, runtime INTEGER)") + database.execSQL("INSERT INTO media_extra (id, uuid, series_id, extra_kind, ordinal, sort_order, title, path, size, modified_time, runtime) VALUES (200, 'extra-uuid', 1, $extraKind, 1, 1, 'OVA', 'Series/OVA.mkv', 5678, 1700000002, 600)") + } database.execSQL("INSERT INTO series (id, uuid, title, original_title, summary, year, series_type) VALUES (1, 'series-uuid', '中文标题', 'Original Title', '简介', 2024, 1)") database.execSQL("INSERT INTO episode (id, uuid, series_id, season, episode, sort_order, title, summary, runtime) VALUES (10, 'episode-uuid-1', 1, 1, 1.0, 1.0, '第 1 集', '', 1440)") database.execSQL("INSERT INTO episode (id, uuid, series_id, season, episode, sort_order, title, summary, runtime) VALUES (11, 'episode-uuid-1-5', 1, 1, 1.5, 1.5, '第 1.5 集', '', 1440)") diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt index b81caa77..c7b1c23b 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt @@ -113,6 +113,7 @@ fun AnimeDetailScreen( val seasons by viewModel.seasons.collectAsStateWithLifecycle() val selectedSeason by viewModel.selectedSeason.collectAsStateWithLifecycle() val episodes by viewModel.episodesWithProgress.collectAsStateWithLifecycle() + val extras by viewModel.extrasWithProgress.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val actionMessage by viewModel.actionMessage.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() @@ -139,6 +140,7 @@ fun AnimeDetailScreen( seasons = seasons.map { it.seasonNumber }, selectedSeason = selectedSeason, episodes = episodes, + extras = extras, actionMessage = actionMessage, isSyncing = isSyncing, onPlayEpisode = onPlayEpisode, @@ -170,6 +172,7 @@ private fun DetailContent( seasons: List, selectedSeason: Int, episodes: List>, + extras: List>, actionMessage: String?, isSyncing: Boolean, onPlayEpisode: (Episode) -> Unit, @@ -344,6 +347,21 @@ private fun DetailContent( onPlay = { onPlayEpisode(episode) } ) } + if (extras.isNotEmpty()) { + Spacer(Modifier.height(28.dp)) + Text(text = "特典", style = TvTypography.subtitle, color = TextPrimary) + Spacer(Modifier.height(12.dp)) + extras.forEach { (extra, progress) -> + EpisodeListItem( + episode = extra, + progress = extra.progressFraction(progress), + progressText = extra.progressLabel(progress), + isWatched = extra.isCompleted(progress), + isExtra = true, + onPlay = { onPlayEpisode(extra) }, + ) + } + } Spacer(Modifier.height(28.dp)) } } @@ -697,7 +715,8 @@ private fun EpisodeListItem( progress: Float, progressText: String, isWatched: Boolean, - onPlay: () -> Unit + onPlay: () -> Unit, + isExtra: Boolean = false, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -739,7 +758,7 @@ private fun EpisodeListItem( ) } else { Text( - text = episode.episodeNumber.toString().padStart(2, '0'), + text = if (isExtra) "EX" else episode.episodeNumber.toString().padStart(2, '0'), color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Bold @@ -751,7 +770,9 @@ private fun EpisodeListItem( Column(modifier = Modifier.weight(1f)) { Text( - text = detailEpisodeTitleLabel(episode.episodeNumber, episode.title), + text = if (isExtra) episode.title.ifBlank { episode.fileName } else { + detailEpisodeTitleLabel(episode.episodeNumber, episode.title) + }, color = TextPrimary, fontSize = 17.sp, maxLines = 1, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModel.kt index aa9ced19..6a358635 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModel.kt @@ -83,6 +83,9 @@ class AnimeDetailViewModel @Inject constructor( private val _episodesWithProgress = MutableStateFlow>>(emptyList()) val episodesWithProgress: StateFlow>> = _episodesWithProgress.asStateFlow() + private val _extrasWithProgress = MutableStateFlow>>(emptyList()) + val extrasWithProgress: StateFlow>> = _extrasWithProgress.asStateFlow() + private var allEpisodesWithProgress: List> = emptyList() private val _isLoading = MutableStateFlow(true) @@ -115,7 +118,7 @@ class AnimeDetailViewModel @Inject constructor( libraryAnimeResolver.loadAnimeDetail(animeId) } _anime.value = detail?.anime - updateEpisodes(detail?.episodes.orEmpty()) + updateEpisodes(detail?.episodes.orEmpty(), detail?.extras.orEmpty()) _isLoading.value = false } } @@ -351,7 +354,7 @@ class AnimeDetailViewModel @Inject constructor( } } - private suspend fun updateEpisodes(epList: List) { + private suspend fun updateEpisodes(epList: List, extras: List) { _seasons.value = epList.toSeasons() val selectedSeason = epList.activeSeasonOrDefault(_selectedSeason.value) _selectedSeason.value = selectedSeason @@ -362,6 +365,9 @@ class AnimeDetailViewModel @Inject constructor( } allEpisodesWithProgress = withProgress _episodesWithProgress.value = withProgress.episodesForSeason(selectedSeason) + _extrasWithProgress.value = extras.map { extra -> + extra to progressRepository.getProgress(extra.id).getOrNull() + } } fun syncBangumi() { From 438053b6bf2d39acda3bb26c12d50aa9b037168c Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Tue, 14 Jul 2026 09:22:05 +0800 Subject: [PATCH 2/2] Address MLIP v3 review findings --- .../repository/IndexRepositoryImplTest.kt | 20 +++++++ .../tv/repository/LibraryAnimeResolver.kt | 14 ++++- .../tv/repository/MediaIndexRepository.kt | 3 +- .../tv/repository/PlayableUriResolver.kt | 42 +++++++------- .../tv/repository/LibraryAnimeResolverTest.kt | 56 +++++++++++++++++++ .../tv/scanner/MlipLibraryIndexImporter.kt | 4 +- 6 files changed, 116 insertions(+), 23 deletions(-) diff --git a/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt index 6cfeb854..7064b8da 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/repository/IndexRepositoryImplTest.kt @@ -3,11 +3,14 @@ package com.miruplay.tv.data.repository import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.miruplay.tv.data.db.MiruPlayDatabase +import com.miruplay.tv.data.entity.IndexEntryEntity import com.miruplay.tv.repository.MediaExtraKind import com.miruplay.tv.repository.MediaIndexEntry +import com.miruplay.tv.repository.isSeriesExtra import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -69,6 +72,23 @@ class IndexRepositoryImplTest { assertEquals(entry, repository.queryIndex(7L, entry.path).getOrNull()?.single()) } + @Test + fun `unknown stored extra kind stays isolated from episode flows`() = runBlocking { + database.indexDao().insertAll( + listOf( + IndexEntryEntity( + sourceId = 7L, + path = "/Series/Unknown.mkv", + extraKind = 99, + ), + ), + ) + + val entry = repository.queryIndex(7L, "").getOrNull()?.single() + assertEquals(MediaExtraKind.UNKNOWN, entry?.extraKind) + assertTrue(entry?.isSeriesExtra() == true) + } + @Test fun `getAnimeInIndex returns all cached metadata key candidates`() = runBlocking { repository.rebuildIndex( diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt index 00944342..aedaf5a0 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryAnimeResolver.kt @@ -75,9 +75,19 @@ class LibraryAnimeResolver( .sortedForPlaybackQueue() val extras = relatedPairs .flatMap { (relatedGroup, relatedAnimeItem) -> - relatedGroup.entries.toIndexedExtras(relatedGroup.source, relatedAnimeItem.id) + relatedGroup.entries + .filter(MediaIndexEntry::isSeriesExtra) + .map { entry -> Triple(entry, relatedGroup.source, relatedAnimeItem.id) } } - .distinctBy(Episode::id) + .distinctBy { (entry) -> entry.sourceId to entry.path } + .sortedWith( + compareBy>( + { (entry) -> entry.extraKind?.value ?: Int.MAX_VALUE }, + { (entry) -> entry.extraSortOrder ?: Int.MAX_VALUE }, + { (entry) -> entry.path }, + ), + ) + .map { (entry, source, animeId) -> entry.toIndexedExtra(source, animeId) } val displayAnime = if (relatedAnime.size > 1) { relatedAnime.mergeAnimeGroupForDisplay().copy(id = animeId) } else { diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt index e3a6f37e..090ce727 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexRepository.kt @@ -14,6 +14,7 @@ enum class MediaScrapeStatus { @Serializable enum class MediaExtraKind(val value: Int) { + UNKNOWN(0), OVA(1), SPECIAL(2), NCOP(3), @@ -21,7 +22,7 @@ enum class MediaExtraKind(val value: Int) { GALLERY(5); companion object { - fun fromValue(value: Int): MediaExtraKind? = entries.firstOrNull { it.value == value } + fun fromValue(value: Int): MediaExtraKind = entries.firstOrNull { it.value == value } ?: UNKNOWN } } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt index 9132b4e4..2cb0e934 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlayableUriResolver.kt @@ -75,30 +75,34 @@ fun List.toIndexedEpisodes( .map { entry -> entry.toIndexedEpisode(source, animeId) } .sortedForPlaybackQueue() +fun MediaIndexEntry.toIndexedExtra( + source: MediaSourceInfo?, + animeId: String, +): Episode = + Episode( + id = "$sourceId:$path", + animeId = animeId, + seasonNumber = 0, + episodeNumber = extraOrdinal ?: 1, + title = episodeTitle.orEmpty(), + filePath = source.playableUriForIndexedPath(path), + fileName = MediaPathConventions.fileName(path), + duration = duration, + ) + fun List.toIndexedExtras( source: MediaSourceInfo?, animeId: String, ): List = filter(MediaIndexEntry::isSeriesExtra) - .sortedWith( - compareBy( - { it.extraKind?.value ?: Int.MAX_VALUE }, - { it.extraSortOrder ?: Int.MAX_VALUE }, - { it.path }, - ), - ) - .map { entry -> - Episode( - id = "${entry.sourceId}:${entry.path}", - animeId = animeId, - seasonNumber = 0, - episodeNumber = entry.extraOrdinal ?: 1, - title = entry.episodeTitle.orEmpty(), - filePath = source.playableUriForIndexedPath(entry.path), - fileName = MediaPathConventions.fileName(entry.path), - duration = entry.duration, - ) - } + .sortedWith(mediaIndexExtraComparator) + .map { entry -> entry.toIndexedExtra(source, animeId) } + +internal val mediaIndexExtraComparator = compareBy( + { it.extraKind?.value ?: Int.MAX_VALUE }, + { it.extraSortOrder ?: Int.MAX_VALUE }, + { it.path }, +) private fun MediaSourceInfo.matchesPath(path: String): Boolean = when (type) { diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryAnimeResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryAnimeResolverTest.kt index 3442ffda..c9b544bc 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryAnimeResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryAnimeResolverTest.kt @@ -144,6 +144,62 @@ class LibraryAnimeResolverTest { assertEquals("https://dav.example/anime/Frieren/Episode%2001.mkv", detail?.episodes?.first()?.filePath) } + @Test + fun `loadAnimeDetail globally orders extras merged from multiple sources`() = runBlocking { + val second = MediaSourceInfoConventions.webDav(url = "https://dav2.example/anime", name = "DAV 2").copy(id = 2L) + val first = MediaSourceInfoConventions.webDav(url = "https://dav1.example/anime", name = "DAV 1").copy(id = 1L) + val shared = listOf( + MediaIndexEntry( + sourceId = 2L, + path = "/Show/01.mkv", + animeName = "Show Source 2", + episodeNumber = 1, + metadataId = "bgm-1", + metadataTitle = "Shared Show", + ), + MediaIndexEntry( + sourceId = 2L, + path = "/Show/NCOP01.mkv", + animeName = "Show Source 2", + metadataId = "bgm-1", + metadataTitle = "Shared Show", + episodeTitle = "NCOP 01", + extraKind = MediaExtraKind.NCOP, + extraOrdinal = 1, + extraSortOrder = 1, + ), + MediaIndexEntry( + sourceId = 1L, + path = "/Show/01.mkv", + animeName = "Show Source 1", + episodeNumber = 1, + metadataId = "bgm-1", + metadataTitle = "Shared Show", + ), + MediaIndexEntry( + sourceId = 1L, + path = "/Show/OVA.mkv", + animeName = "Show Source 1", + metadataId = "bgm-1", + metadataTitle = "Shared Show", + episodeTitle = "OVA", + extraKind = MediaExtraKind.OVA, + extraOrdinal = 1, + extraSortOrder = 1, + ), + ) + val resolver = resolver( + sources = listOf(second, first), + entries = shared, + cachedAnime = mapOf("bgm-1" to Anime(id = "bgm-1", title = "Shared Show")), + mergeSameAnimeEnabled = true, + ) + + val detail = resolver.loadAnimeDetail("bgm-1") + + assertEquals(listOf("OVA", "NCOP 01"), detail?.extras?.map(Episode::title)) + } + @Test fun `loadAnimeDetail prefers cached metadata and indexed episodes`() = runBlocking { val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt index 049dc1c4..2f1ffca4 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -337,7 +337,9 @@ class MlipLibraryIndexImporter @Inject constructor( ?: throw InvalidMlipSchemaException("Extra references an unknown series") val rawKind = cursor.int("extra_kind") val kind = MediaExtraKind.fromValue(rawKind) - ?: throw InvalidMlipSchemaException("Unknown extra_kind: $rawKind") + if (kind == MediaExtraKind.UNKNOWN) { + throw InvalidMlipSchemaException("Unknown extra_kind: $rawKind") + } val rawPath = cursor.string("path") val indexPath = normalizeMlipMediaPath(rawPath) ?: throw InvalidMlipSchemaException("Unsafe extra path: $rawPath")