diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e131106a..8c855a9f 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.0.0" +val baseAppVersionName = "1.1.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 9cf10bc9..ca001157 100644 --- a/app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt @@ -53,4 +53,5 @@ internal fun miruPlayDatabaseMigrations(): Array = MiruPlayDatabase.MIGRATION_4_5, MiruPlayDatabase.MIGRATION_5_6, MiruPlayDatabase.MIGRATION_6_7, + MiruPlayDatabase.MIGRATION_7_8, ) 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 70a0cc4e..0667d221 100644 --- a/app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt +++ b/app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt @@ -6,7 +6,7 @@ import org.junit.Test class AppModuleTest { @Test - fun `database builder migrations include schema 5 to 6 upgrade`() { + fun `database builder includes every schema migration`() { assertEquals( listOf( MiruPlayDatabase.MIGRATION_1_2, @@ -15,6 +15,7 @@ class AppModuleTest { MiruPlayDatabase.MIGRATION_4_5, MiruPlayDatabase.MIGRATION_5_6, MiruPlayDatabase.MIGRATION_6_7, + MiruPlayDatabase.MIGRATION_7_8, ), miruPlayDatabaseMigrations().toList(), ) diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt index 4b21b804..fa19a054 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt @@ -59,6 +59,8 @@ fun playbackAudioMenuTitle(): String = "音轨" fun playbackSubtitleCountLabel(count: Int): String = if (count <= 0) "无字幕" else "字幕 $count" +fun playbackSubtitleOffLabel(): String = "关闭字幕" + fun playbackAudioTrackCountLabel(count: Int): String = "音轨 ${count.coerceAtLeast(0)}" diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt index cf27fa0c..e80b8e15 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt @@ -1,15 +1,41 @@ package com.miruplay.tv.model fun buildExternalSubtitleTracks(value: String): List = - value - .split(';', '\n') + buildExternalSubtitleTracks(value.split(';', '\n')) + +fun buildExternalSubtitleTracks(paths: List): List = + paths .map { it.trim() } .filter { it.isNotBlank() } + .distinct() .map(::externalSubtitleTrackFromPath) +fun matchingExternalSubtitlePaths( + videoPath: String, + siblingPaths: Iterable, +): List { + val videoFileName = MediaPathConventions.fileName(videoPath) + val videoStem = MediaPathConventions.stem(videoFileName).lowercase() + if (videoStem.isBlank()) return emptyList() + + return siblingPaths + .asSequence() + .filter { candidate -> candidate != videoPath && candidate.isSupportedExternalSubtitlePath() } + .filter { candidate -> + val subtitleStem = MediaPathConventions.stem(MediaPathConventions.fileName(candidate)).lowercase() + subtitleStem == videoStem || EXTERNAL_SUBTITLE_SUFFIX_SEPARATORS.any { separator -> + subtitleStem.startsWith("$videoStem$separator") + } + } + .distinct() + .sortedBy { it.lowercase() } + .toList() +} + fun externalSubtitleTrackFromPath(path: String): SubtitleTrack { val normalized = path.trim() return SubtitleTrack( + language = subtitleLanguageFromPath(normalized), title = normalized.substringAfterLast('/').substringAfterLast('\\'), isExternal = true, path = normalized, @@ -24,3 +50,37 @@ fun subtitleFormatFromPath(path: String): SubtitleFormat = "vtt" -> SubtitleFormat.VTT else -> SubtitleFormat.SRT } + +private fun subtitleLanguageFromPath(path: String): String { + val stem = MediaPathConventions.stem(MediaPathConventions.fileName(path)) + val candidate = listOf('.', '_') + .map { separator -> stem.substringAfterLast(separator, "") } + .firstOrNull(::isSubtitleLanguageCode) + ?.lowercase() + ?: return "und" + return when (candidate) { + "chs" -> "zh-Hans" + "cht" -> "zh-Hant" + "chi", "zho" -> "zh" + "eng" -> "en" + "jpn" -> "ja" + else -> candidate.split('-', limit = 2).let { parts -> + if (parts.size == 1) parts[0] + else parts[0] + "-" + if (parts[1].length == 2) parts[1].uppercase() + else parts[1].replaceFirstChar(Char::uppercase) + } + } +} + +private fun isSubtitleLanguageCode(value: String): Boolean { + val parts = value.split('-') + return parts.size in 1..2 && parts.withIndex().all { (index, part) -> + part.length in (if (index == 0) 2..3 else 2..4) && part.all(Char::isLetter) + } +} + +private fun String.isSupportedExternalSubtitlePath(): Boolean = + substringAfterLast('.', "").lowercase() in SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS + +private val SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS = setOf("ass", "ssa", "srt", "vtt") +private val EXTERNAL_SUBTITLE_SUFFIX_SEPARATORS = listOf(".", " ", "_", "-", "[") diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt index 2c0ec41a..8c9e371b 100644 --- a/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt @@ -17,9 +17,18 @@ class SubtitleTracksTest { assertEquals("Episode 01.ass", track.title) assertEquals("D:/Anime/Show/Episode 01.ass", track.path) assertEquals(SubtitleFormat.ASS, track.format) + assertEquals("und", track.language) assertTrue(track.isExternal) } + @Test + fun `external subtitle parses common language suffixes`() { + assertEquals("zh-Hans", externalSubtitleTrackFromPath("Episode.zh-Hans.ass").language) + assertEquals("zh-CN", externalSubtitleTrackFromPath("Episode.zh-cn.srt").language) + assertEquals("zh-Hant", externalSubtitleTrackFromPath("Episode_cht.srt").language) + assertEquals("en", externalSubtitleTrackFromPath("Episode.eng.vtt").language) + } + @Test fun `subtitle list accepts semicolon and newline separated paths`() { val tracks = buildExternalSubtitleTracks("D:/Anime/01.ass; D:/Anime/01.vtt\nD:/Anime/01.ssa") @@ -31,4 +40,40 @@ class SubtitleTracksTest { fun `unknown subtitle extension falls back to srt`() { assertEquals(SubtitleFormat.SRT, subtitleFormatFromPath("D:/Anime/Show/Episode 01.txt")) } + + @Test + fun `same stem subtitle and language suffixes attach in stable order`() { + val video = "/Anime/Show/Episode 01.mkv" + + assertEquals( + listOf( + "/Anime/Show/Episode 01.ass", + "/Anime/Show/Episode 01.zh-CN.srt", + "/Anime/Show/Episode 01_cht.vtt", + ), + matchingExternalSubtitlePaths( + video, + listOf( + video, + "/Anime/Show/Episode 01_cht.vtt", + "/Anime/Show/Episode 01.zh-CN.srt", + "/Anime/Show/Episode 01.ass", + ), + ), + ) + } + + @Test + fun `subtitle matching does not confuse adjacent episodes or unsupported sub files`() { + assertTrue( + matchingExternalSubtitlePaths( + "/Anime/Episode 01.mkv", + listOf( + "/Anime/Episode 010.ass", + "/Anime/Episode 02.srt", + "/Anime/Episode 01.sub", + ), + ).isEmpty(), + ) + } } diff --git a/data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/8.json b/data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/8.json new file mode 100644 index 00000000..ab110f71 --- /dev/null +++ b/data/schemas/com.miruplay.tv.data.db.MiruPlayDatabase/8.json @@ -0,0 +1,883 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "1ccb591d6a32f991d75e66d4ebee537e", + "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)", + "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 + } + ], + "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, '1ccb591d6a32f991d75e66d4ebee537e')" + ] + } +} \ 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 cdf88ff4..cb049b91 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, 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" @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 61f015fc..9a2f2f0e 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 = 7, + version = 8, exportSchema = true, entities = [ AnimeEntity::class, @@ -35,6 +35,14 @@ abstract class MiruPlayDatabase : RoomDatabase() { abstract fun cloudDriveAutomationDao(): CloudDriveAutomationDao companion object { + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE index_entry ADD COLUMN external_subtitle_paths TEXT NOT NULL DEFAULT '[]'" + ) + } + } + val MIGRATION_6_7 = object : Migration(6, 7) { 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 d31485d3..0d7cd707 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 @@ -16,6 +16,7 @@ data class IndexEntryEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "source_id") val sourceId: Long, val path: String, + @ColumnInfo(name = "external_subtitle_paths") val externalSubtitlePaths: List = emptyList(), @ColumnInfo(name = "anime_name") val animeName: String? = null, @ColumnInfo(name = "episode_title") val episodeTitle: String? = null, val plot: String? = null, 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 a89b4a73..5522344c 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 @@ -87,6 +87,7 @@ class IndexRepositoryImpl @Inject constructor( private fun IndexRepositoryEntity.toEntity(sourceId: Long) = IndexEntryEntity( sourceId = sourceId, path = path, + externalSubtitlePaths = externalSubtitlePaths, animeName = animeName, episodeTitle = episodeTitle, plot = plot, @@ -106,6 +107,7 @@ private fun IndexRepositoryEntity.toEntity(sourceId: Long) = IndexEntryEntity( private fun IndexEntryEntity.toDomain() = IndexRepositoryEntity( sourceId = sourceId, path = path, + externalSubtitlePaths = externalSubtitlePaths, animeName = animeName, episodeTitle = episodeTitle, plot = plot, 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 f6317600..fdcf0118 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 @@ -115,6 +115,18 @@ class MiruPlayDatabaseMigrationTest { assertTrue(normalizedSql.any { it.contains("INSERT OR REPLACE INTO drama_series_cache") }) assertTrue(normalizedSql.any { it.contains("FROM anime") && it.contains("WHERE id LIKE 'drama-series:%'") }) } + + @Test + fun `migration 7 to 8 adds external subtitle paths with empty list default`() { + val database = RecordingSupportSQLiteDatabase() + + MiruPlayDatabase.MIGRATION_7_8.migrate(database.proxy) + + assertEquals( + listOf("ALTER TABLE index_entry ADD COLUMN external_subtitle_paths TEXT NOT NULL DEFAULT '[]'"), + database.sql, + ) + } } private class RecordingSupportSQLiteDatabase : InvocationHandler { 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 f4212e1c..8a515201 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 @@ -36,6 +36,22 @@ class IndexRepositoryImplTest { database.close() } + @Test + fun `external subtitle paths round trip through Room`() = runBlocking { + val entry = MediaIndexEntry( + sourceId = 7L, + path = "/Series/01.mkv", + externalSubtitlePaths = listOf("/Series/01.zh-CN.ass", "/Series/01.en.srt"), + ) + + repository.rebuildIndex(7L, listOf(entry)) + + assertEquals( + entry.externalSubtitlePaths, + repository.queryIndex(7L, entry.path).getOrNull()?.single()?.externalSubtitlePaths, + ) + } + @Test fun `getAnimeInIndex returns all cached metadata key candidates`() = runBlocking { repository.rebuildIndex( diff --git a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt index 798b15c6..4283267a 100644 --- a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt +++ b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt @@ -49,6 +49,7 @@ import com.miruplay.tv.mediasource.desktop.DesktopMediaSource import com.miruplay.tv.mediasource.desktop.DesktopMediaSourceFactory import com.miruplay.tv.mediasource.desktop.DesktopPlaybackBridge import com.miruplay.tv.mediasource.desktop.desktopSourceFromInfo +import com.miruplay.tv.mediasource.desktop.playableUriFor import com.miruplay.tv.model.Anime import com.miruplay.tv.model.FileEntry import com.miruplay.tv.model.CloudDriveLibraryMode @@ -610,6 +611,7 @@ internal fun MiruPlayDesktopComposeApp( var logUploadTokenInput by remember { mutableStateOf("") } var mediaPath by remember { mutableStateOf(desktopInitialMediaPathFromEnvironment()) } var subtitlePath by remember { mutableStateOf("") } + var subtitleOverrideMediaPath by remember { mutableStateOf(null) } var startSeconds by remember { mutableStateOf("0") } var fullscreen by remember { mutableStateOf(false) } var keepOpen by remember { mutableStateOf(false) } @@ -1032,18 +1034,37 @@ internal fun MiruPlayDesktopComposeApp( incrementPlayCount = incrementPlayCount, ) - fun playbackLaunchRequestFor( + suspend fun playbackLaunchRequestFor( path: String, startPositionMs: Long, sourceOverride: DesktopMediaSource? = null, sourceIdOverride: Long? = null, episodeId: String? = null, - ): DesktopPlaybackLaunchRequest = - DesktopPlaybackLaunchRequest( + ): DesktopPlaybackLaunchRequest { + val playbackMediaSource = sourceOverride ?: activeSource + val playbackSourceId = sourceIdOverride ?: activeSourceId + val hasSubtitleOverride = subtitleOverrideMediaPath == path + val manualSubtitlePath = subtitlePath.takeIf { hasSubtitleOverride }.orEmpty() + val indexedPath = episodeId + ?.takeIf { it.substringBefore(':').toLongOrNull() == playbackSourceId } + ?.substringAfter(':') + ?: path + val indexedSubtitlePaths = if (!hasSubtitleOverride && playbackSourceId != null) { + when (val entries = repositories.index.queryIndex(playbackSourceId, indexedPath)) { + is Result.Success -> entries.data.firstOrNull { it.path == indexedPath } + ?.externalSubtitlePaths + .orEmpty() + .map { playableUriFor(playbackMediaSource, playbackBridge, it) } + is Result.Error -> emptyList() + } + } else { + emptyList() + } + return DesktopPlaybackLaunchRequest( mpvPath = mpvPath, configDir = configDir, mediaPath = path, - subtitlePath = if (path == mediaPath) subtitlePath else "", + subtitlePath = if (hasSubtitleOverride) manualSubtitlePath else indexedSubtitlePaths.joinToString(";"), startSeconds = startPositionMs.coerceAtLeast(0L).let { position -> PlaybackTimingConventions.formatMpvStartSeconds(position) }, @@ -1051,12 +1072,13 @@ internal fun MiruPlayDesktopComposeApp( keepOpen = keepOpen, rifeEnabled = rifeEnabled, rifeBackend = rifeBackend, - activeSource = sourceOverride ?: activeSource, - activeSourceId = sourceIdOverride ?: activeSourceId, + activeSource = playbackMediaSource, + activeSourceId = playbackSourceId, blankMediaMessage = playbackBlankMediaMessage(), fallbackMediaSourceId = DESKTOP_PLAYBACK_MEDIA_SOURCE_ID, episodeId = episodeId, ) + } fun requestDetailPanelFocus(panel: DesktopDetailFocusPanel): Boolean { when (panel) { @@ -1106,6 +1128,7 @@ internal fun MiruPlayDesktopComposeApp( startSeconds = PlaybackTimingConventions.formatMpvStartSeconds(result.data.source.startPosition) if (path != previousMediaPath) { subtitlePath = "" + subtitleOverrideMediaPath = null } saveDesktopPlaybackStartProgress( session = result.data.session, @@ -2595,7 +2618,10 @@ internal fun MiruPlayDesktopComposeApp( mediaPath = mediaPath, onMediaPathChange = { mediaPath = it }, subtitlePath = subtitlePath, - onSubtitlePathChange = { subtitlePath = it }, + onSubtitlePathChange = { + subtitlePath = it + subtitleOverrideMediaPath = mediaPath + }, startSeconds = startSeconds, onStartSecondsChange = { startSeconds = it }, fullscreen = fullscreen, diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt index 471e440a..76b3fd7a 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt @@ -49,6 +49,7 @@ fun buildEmbeddedMpvSessionOptions( "keep-open" to "yes", "osc" to "no", "input-default-bindings" to "yes", + "sub-auto" to "no", ), ) } diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index e8cacb2b..65f1db67 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -12,9 +12,11 @@ import androidx.media3.common.Effect import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener @@ -55,6 +57,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private data class ExoTrackSelection( + val group: Tracks.Group, + val trackIndex: Int, +) + @UnstableApi @Singleton class ExoPlaybackController @Inject constructor( @@ -91,6 +98,9 @@ class ExoPlaybackController @Inject constructor( private val availableSubtitles = mutableListOf() private val availableAudioTracks = mutableListOf() + private val exoSubtitleSelections = mutableListOf() + private val exoAudioSelections = mutableListOf() + private val embeddedSubtitleTrackIds = mutableListOf() private var selectedSubtitleTrackIndex: Int? = null private var selectedAudioTrackIndex: Int? = null private var currentSource: PlaybackSource? = null @@ -196,12 +206,12 @@ class ExoPlaybackController @Inject constructor( stopInactivePlayers(player) preparePlayerForPlayback(player) - val subtitleConfigs = source.subtitleTracks.map { track -> + val subtitleConfigs = source.subtitleTracks.mapIndexed { index, track -> MediaItem.SubtitleConfiguration.Builder(Uri.parse(track.path)) - .setMimeType(mimeTypeForFormat(track.format)) + .setMimeType(subtitleMimeTypeForFormat(track.format)) .setLanguage(track.language) .setLabel(track.title.ifEmpty { track.language }) - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .setSelectionFlags(if (index == 0) C.SELECTION_FLAG_DEFAULT else 0) .build() } @@ -363,6 +373,9 @@ class ExoPlaybackController @Inject constructor( autoResumeSeekCalled = false availableSubtitles.clear() availableAudioTracks.clear() + exoSubtitleSelections.clear() + exoAudioSelections.clear() + embeddedSubtitleTrackIds.clear() selectedSubtitleTrackIndex = null selectedAudioTrackIndex = null containerSignalDescriptor = null @@ -392,12 +405,23 @@ class ExoPlaybackController @Inject constructor( } } - override suspend fun setSubtitleTrack(trackIndex: Int) { - selectTrackGroup(C.TRACK_TYPE_TEXT, trackIndex) + override suspend fun setSubtitleTrack(trackIndex: Int?) { + when (_activeRenderBackend.value) { + PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED -> withContext(Dispatchers.Main) { + val nativeTrackId = when (trackIndex) { + null -> null + else -> embeddedSubtitleTrackIds.getOrNull(trackIndex) ?: return@withContext + } + embeddedMpvView?.setSubtitleTrack(nativeTrackId) + selectedSubtitleTrackIndex = trackIndex + } + PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID -> Unit + else -> selectExoTrack(C.TRACK_TYPE_TEXT, trackIndex) + } } override suspend fun setAudioTrack(trackIndex: Int) { - selectTrackGroup(C.TRACK_TYPE_AUDIO, trackIndex) + selectExoTrack(C.TRACK_TYPE_AUDIO, trackIndex) } override fun getAvailableSubtitles(): List = availableSubtitles.toList() @@ -458,7 +482,11 @@ class ExoPlaybackController @Inject constructor( "start_position_ms" to embeddedMpvPositionMs.toString(), ), ) - mpvView.loadMedia(source.uri, embeddedMpvPositionMs) + mpvView.loadMedia( + path = source.uri, + startPositionMs = embeddedMpvPositionMs, + externalSubtitlePaths = source.subtitleTracks.map { it.path }, + ) } } } @@ -861,15 +889,27 @@ class ExoPlaybackController @Inject constructor( } } - private suspend fun selectTrackGroup(trackType: Int, trackIndex: Int) = withContext(Dispatchers.Main) { + private suspend fun selectExoTrack(trackType: Int, trackIndex: Int?) = withContext(Dispatchers.Main) { val player = activeExoPlayer() - val group = player.currentTracks.groups.filter { it.type == trackType }.getOrNull(trackIndex) - ?: return@withContext + if (trackType == C.TRACK_TYPE_TEXT && trackIndex == null) { + player.trackSelectionParameters = player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(trackType, true) + .clearOverridesOfType(trackType) + .build() + selectedSubtitleTrackIndex = null + return@withContext + } + val target = when (trackType) { + C.TRACK_TYPE_TEXT -> exoSubtitleSelections.getOrNull(trackIndex ?: return@withContext) + C.TRACK_TYPE_AUDIO -> exoAudioSelections.getOrNull(trackIndex ?: return@withContext) + else -> null + } ?: return@withContext player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() .setTrackTypeDisabled(trackType, false) .clearOverridesOfType(trackType) - .addOverride(TrackSelectionOverride(group.mediaTrackGroup, 0)) + .addOverride(TrackSelectionOverride(target.group.mediaTrackGroup, target.trackIndex)) .build() if (trackType == C.TRACK_TYPE_TEXT) { selectedSubtitleTrackIndex = trackIndex @@ -881,41 +921,46 @@ class ExoPlaybackController @Inject constructor( private fun updateAvailableTracks() { availableSubtitles.clear() availableAudioTracks.clear() + exoSubtitleSelections.clear() + exoAudioSelections.clear() selectedSubtitleTrackIndex = null selectedAudioTrackIndex = null try { val tracks = activeExoPlayer().currentTracks - for (i in 0 until tracks.groups.size) { - val group = tracks.groups[i] - when (group.type) { - C.TRACK_TYPE_TEXT -> { - val trackIndex = availableSubtitles.size - val format = group.getTrackFormat(0) - availableSubtitles.add( - SubtitleTrack( - language = format.language ?: "und", - title = format.label ?: "", - isExternal = false, - path = "", - format = SubtitleFormat.SRT, - ), - ) - if (group.isTrackSelected(0)) selectedSubtitleTrackIndex = trackIndex - } + for (group in tracks.groups) { + for (trackInGroup in 0 until group.length) { + if (!group.isTrackSupported(trackInGroup)) continue + val format = group.getTrackFormat(trackInGroup) + when (group.type) { + C.TRACK_TYPE_TEXT -> { + val trackIndex = availableSubtitles.size + availableSubtitles.add( + SubtitleTrack( + language = format.language ?: "und", + title = format.label ?: "", + isExternal = false, + path = "", + format = SubtitleFormat.SRT, + ), + ) + exoSubtitleSelections.add(ExoTrackSelection(group, trackInGroup)) + if (group.isTrackSelected(trackInGroup)) selectedSubtitleTrackIndex = trackIndex + } - C.TRACK_TYPE_AUDIO -> { - val trackIndex = availableAudioTracks.size - val format = group.getTrackFormat(0) - availableAudioTracks.add( - AudioTrack( - index = trackIndex, - language = format.language ?: "und", - title = format.label, - codec = format.codecs, - ), - ) - if (group.isTrackSelected(0)) selectedAudioTrackIndex = trackIndex + C.TRACK_TYPE_AUDIO -> { + val trackIndex = availableAudioTracks.size + availableAudioTracks.add( + AudioTrack( + index = trackIndex, + language = format.language ?: "und", + title = format.label, + codec = format.codecs, + ), + ) + exoAudioSelections.add(ExoTrackSelection(group, trackInGroup)) + if (group.isTrackSelected(trackInGroup)) selectedAudioTrackIndex = trackIndex + } } } } @@ -924,14 +969,6 @@ class ExoPlaybackController @Inject constructor( } } - private fun mimeTypeForFormat(format: SubtitleFormat): String = - when (format) { - SubtitleFormat.SRT -> "application/x-subrip" - SubtitleFormat.ASS, SubtitleFormat.SSA -> "text/x-ass" - SubtitleFormat.VTT -> "text/vtt" - else -> "application/x-subrip" - } - private fun refreshVideoSignalDescriptor(format: Format?) { val runtimeDescriptor = resolveVideoSignalDescriptor(format) val mergedDescriptor = mergeVideoSignalDescriptor( @@ -1167,7 +1204,11 @@ class ExoPlaybackController @Inject constructor( "start_position_ms" to embeddedMpvPositionMs.toString(), ), ) - mpvView.loadMedia(source.uri, embeddedMpvPositionMs) + mpvView.loadMedia( + path = source.uri, + startPositionMs = embeddedMpvPositionMs, + externalSubtitlePaths = source.subtitleTracks.map { it.path }, + ) } private fun ensureEmbeddedMpvView(container: ViewGroup): MiruMpvSurfaceView { @@ -1186,6 +1227,7 @@ class ExoPlaybackController @Inject constructor( return existing } val created = MiruMpvSurfaceView(container.context).apply { + onSubtitleTracksChanged = { view -> refreshEmbeddedMpvSubtitleTracks(view) } onStateChanged = { snapshot -> embeddedMpvPositionMs = snapshot.positionMs embeddedMpvDurationMs = snapshot.durationMs @@ -1256,6 +1298,32 @@ class ExoPlaybackController @Inject constructor( return created } + private fun refreshEmbeddedMpvSubtitleTracks(view: MiruMpvSurfaceView) { + val tracks = view.subtitleTracks() + availableSubtitles.clear() + embeddedSubtitleTrackIds.clear() + selectedSubtitleTrackIndex = null + tracks.forEach { track -> + val index = availableSubtitles.size + availableSubtitles.add( + SubtitleTrack( + language = track.language, + title = track.title, + isExternal = track.external, + path = "", + format = when (track.codec.lowercase()) { + "ass" -> SubtitleFormat.ASS + "ssa" -> SubtitleFormat.SSA + "webvtt" -> SubtitleFormat.VTT + else -> SubtitleFormat.SRT + }, + ), + ) + embeddedSubtitleTrackIds.add(track.id) + if (track.selected) selectedSubtitleTrackIndex = index + } + } + private fun recordPlaybackClockSample(snapshot: MiruMpvSurfaceView.StateSnapshot) { playbackClockSamples.updateAndGet { current -> (current + PlaybackClockSample( @@ -1448,6 +1516,13 @@ internal fun shouldLogPlaybackLoad( (scheme == "http" || scheme == "https") } +internal fun subtitleMimeTypeForFormat(format: SubtitleFormat): String = when (format) { + SubtitleFormat.SRT -> MimeTypes.APPLICATION_SUBRIP + SubtitleFormat.ASS, SubtitleFormat.SSA -> MimeTypes.TEXT_SSA + SubtitleFormat.VTT -> MimeTypes.TEXT_VTT + else -> MimeTypes.APPLICATION_SUBRIP +} + internal fun mediaTrackTypeLabel(trackType: Int): String = when (trackType) { C.TRACK_TYPE_VIDEO -> "video" C.TRACK_TYPE_AUDIO -> "audio" diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt index e6f32fba..5654e1b5 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt @@ -55,7 +55,7 @@ interface PlaybackController { /** * Select subtitle track by index */ - suspend fun setSubtitleTrack(trackIndex: Int) + suspend fun setSubtitleTrack(trackIndex: Int?) /** * Select audio track by index diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptionsTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptionsTest.kt index e67057a2..6583038c 100644 --- a/player-core/src/test/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptionsTest.kt +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptionsTest.kt @@ -60,6 +60,7 @@ class EmbeddedMpvSessionOptionsTest { assertEquals("perceptual", options.gamutMappingMode) assertEquals(listOf("/tmp/test.glsl"), options.shaderPaths) assertEquals("1.25", options.extraOptions.getValue("speed")) + assertEquals("no", options.extraOptions.getValue("sub-auto")) } @Test diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/SubtitleMimeTypeTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/SubtitleMimeTypeTest.kt new file mode 100644 index 00000000..989592d9 --- /dev/null +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/SubtitleMimeTypeTest.kt @@ -0,0 +1,14 @@ +package com.miruplay.tv.player + +import androidx.media3.common.MimeTypes +import com.miruplay.tv.model.SubtitleFormat +import org.junit.Assert.assertEquals +import org.junit.Test + +class SubtitleMimeTypeTest { + @Test + fun `ass and ssa use the Media3 SSA mime type`() { + assertEquals(MimeTypes.TEXT_SSA, subtitleMimeTypeForFormat(SubtitleFormat.ASS)) + assertEquals(MimeTypes.TEXT_SSA, subtitleMimeTypeForFormat(SubtitleFormat.SSA)) + } +} diff --git a/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt b/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt index 3a79d3e9..a0ad6f6d 100644 --- a/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt +++ b/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt @@ -42,7 +42,17 @@ class MiruMpvSurfaceView @JvmOverloads constructor( val eofReached: Boolean = false, ) + data class SubtitleTrackInfo( + val id: Int, + val language: String, + val title: String, + val codec: String, + val external: Boolean, + val selected: Boolean, + ) + var onStateChanged: ((StateSnapshot) -> Unit)? = null + var onSubtitleTracksChanged: ((MiruMpvSurfaceView) -> Unit)? = null var onFileLoaded: (() -> Unit)? = null var onPlaybackRestart: (() -> Unit)? = null @@ -50,6 +60,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( private var sessionOptions = SessionOptions() private var appliedSessionOptions: SessionOptions? = null private var pendingStartPositionMs: Long? = null + private var pendingExternalSubtitlePaths: List = emptyList() private var lastState = StateSnapshot() private val recentNativeLogMessages = ArrayDeque() @@ -74,6 +85,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( releasePlayer() initialized = false appliedSessionOptions = null + pendingExternalSubtitlePaths = emptyList() lastState = StateSnapshot() synchronized(recentNativeLogMessages) { recentNativeLogMessages.clear() @@ -89,12 +101,17 @@ class MiruMpvSurfaceView @JvmOverloads constructor( return true } - fun loadMedia(path: String, startPositionMs: Long = 0L) { + fun loadMedia( + path: String, + startPositionMs: Long = 0L, + externalSubtitlePaths: List = emptyList(), + ) { ensureInitialized() if (appliedSessionOptions != sessionOptions) { applyRuntimeOptions(sessionOptions) } pendingStartPositionMs = startPositionMs.coerceAtLeast(0L).takeIf(::shouldApplyPendingStartSeek) + pendingExternalSubtitlePaths = externalSubtitlePaths.filter(String::isNotBlank).distinct() if (shouldLoadMpvFileImmediately(isPlaybackSurfaceAttached())) { MPVLib.command(arrayOf("loadfile", path)) } else { @@ -102,6 +119,29 @@ class MiruMpvSurfaceView @JvmOverloads constructor( } } + fun subtitleTracks(): List { + if (!initialized) return emptyList() + val count = MPVLib.getPropertyInt("track-list/count") ?: return emptyList() + return (0 until count).mapNotNull { index -> + if (MPVLib.getPropertyString("track-list/$index/type") != "sub") return@mapNotNull null + val id = MPVLib.getPropertyInt("track-list/$index/id") ?: return@mapNotNull null + SubtitleTrackInfo( + id = id, + language = MPVLib.getPropertyString("track-list/$index/lang") ?: "und", + title = MPVLib.getPropertyString("track-list/$index/title") ?: "", + codec = MPVLib.getPropertyString("track-list/$index/codec") ?: "", + external = MPVLib.getPropertyBoolean("track-list/$index/external") ?: false, + selected = MPVLib.getPropertyBoolean("track-list/$index/selected") ?: false, + ) + } + } + + fun setSubtitleTrack(trackId: Int?) { + if (initialized) { + MPVLib.setPropertyString("sid", trackId?.toString() ?: "no") + } + } + fun pausePlayback() { if (initialized) { MPVLib.setPropertyBoolean("pause", true) @@ -171,11 +211,14 @@ class MiruMpvSurfaceView @JvmOverloads constructor( MPVLib.observeProperty("duration/full", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE) MPVLib.observeProperty("pause", MPVLib.MpvFormat.MPV_FORMAT_FLAG) MPVLib.observeProperty("eof-reached", MPVLib.MpvFormat.MPV_FORMAT_FLAG) + MPVLib.observeProperty("track-list/count", MPVLib.MpvFormat.MPV_FORMAT_INT64) } override fun eventProperty(property: String) = Unit - override fun eventProperty(property: String, value: Long) = Unit + override fun eventProperty(property: String, value: Long) { + if (property == "track-list/count") onSubtitleTracksChanged?.invoke(this) + } override fun eventProperty(property: String, value: Boolean) { when (property) { @@ -199,6 +242,9 @@ class MiruMpvSurfaceView @JvmOverloads constructor( pendingStartPositionMs?.takeIf(::shouldApplyPendingStartSeek)?.let { startPositionMs -> MPVLib.command(arrayOf("seek", (startPositionMs / 1000.0).toString(), "absolute+exact")) } + pendingExternalSubtitlePaths.forEachIndexed { index, path -> + MPVLib.command(arrayOf("sub-add", path, if (index == 0) "select" else "auto")) + } onFileLoaded?.invoke() } MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { 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 f8228fdc..7f3cbbcc 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 @@ -27,6 +27,7 @@ interface MediaIndexRepository { data class MediaIndexEntry( val sourceId: Long, val path: String, + val externalSubtitlePaths: List = emptyList(), val animeName: String? = null, val episodeTitle: String? = null, val plot: String? = null, diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt new file mode 100644 index 00000000..e1df64cf --- /dev/null +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt @@ -0,0 +1,36 @@ +package com.miruplay.tv.repository + +import com.miruplay.tv.core.common.Result +import com.miruplay.tv.model.PlaybackSource +import com.miruplay.tv.model.buildExternalSubtitleTracks + +class PlaybackSubtitleResolver( + private val index: MediaIndexRepository, + private val mediaSources: MediaSourceRepository, +) { + suspend fun resolve(source: PlaybackSource): PlaybackSource { + val episodeId = source.episodeId ?: return source + val sourceId = episodeId.substringBefore(':').toLongOrNull() ?: return source + val mediaSource = when (val result = mediaSources.getSourceById(sourceId)) { + is Result.Success -> result.data + is Result.Error -> return source + } + val entries = when (val result = index.queryIndex(sourceId, "")) { + is Result.Success -> result.data + is Result.Error -> return source + } + val episodePath = episodeId.substringAfter(':', "") + val entry = entries.firstOrNull { indexed -> + mediaSource.playableUriForIndexedPath(indexed.path) == source.uri + } ?: entries.firstOrNull { indexed -> indexed.path == episodePath } + ?: return source + + val discoveredTracks = buildExternalSubtitleTracks( + entry.externalSubtitlePaths.map(mediaSource::playableUriForIndexedPath), + ) + if (discoveredTracks.isEmpty()) return source + return source.copy( + subtitleTracks = (source.subtitleTracks + discoveredTracks).distinctBy { it.path }, + ) + } +} diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt new file mode 100644 index 00000000..80b52cc9 --- /dev/null +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt @@ -0,0 +1,89 @@ +package com.miruplay.tv.repository + +import com.miruplay.tv.core.common.AppError +import com.miruplay.tv.core.common.Result +import com.miruplay.tv.model.MediaSourceInfo +import com.miruplay.tv.model.MediaSourceInfoConventions +import com.miruplay.tv.model.PlaybackSource +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackSubtitleResolverTest { + @Test + fun `resolver attaches indexed WebDAV subtitles and keeps explicit tracks`() = runBlocking { + val mediaSource = MediaSourceInfoConventions.webDav( + url = "https://dav.example/anime", + name = "DAV", + ).copy(id = 7L) + val resolver = PlaybackSubtitleResolver( + index = FakeIndexRepository( + listOf( + MediaIndexEntry( + sourceId = 7L, + path = "/Show/Episode 01.mkv", + externalSubtitlePaths = listOf( + "/Show/Episode 01.ass", + "/Show/Episode 01.zh-CN.srt", + ), + ), + ), + ), + mediaSources = FakeSourceRepository(mediaSource), + ) + + val resolved = resolver.resolve( + PlaybackSource( + uri = "https://dav.example/anime/Show/Episode%2001.mkv", + mediaSourceId = "anime", + episodeId = "7:/Show/Episode 01.mkv", + ), + ) + + assertEquals( + listOf( + "https://dav.example/anime/Show/Episode%2001.ass", + "https://dav.example/anime/Show/Episode%2001.zh-CN.srt", + ), + resolved.subtitleTracks.map { it.path }, + ) + } + + @Test + fun `resolver leaves direct playback without an indexed episode unchanged`() = runBlocking { + val source = PlaybackSource(uri = "https://example.test/video.mkv", mediaSourceId = "direct") + val resolver = PlaybackSubtitleResolver( + index = FakeIndexRepository(emptyList()), + mediaSources = FakeSourceRepository(), + ) + + assertEquals(source, resolver.resolve(source)) + } +} + +private class FakeIndexRepository( + private val entries: List, +) : MediaIndexRepository { + override suspend fun rebuildIndex(sourceId: Long, entries: List): Result = Result.success(Unit) + override suspend fun upsertEntry(sourceId: Long, entry: MediaIndexEntry): Result = Result.success(Unit) + override suspend fun queryIndex(sourceId: Long, query: String): Result> = + Result.success(entries.filter { it.sourceId == sourceId }) + override suspend fun getAnimeInIndex(sourceId: Long): Result> = Result.success(emptyList()) + override suspend fun clearIndex(sourceId: Long): Result = Result.success(Unit) + override suspend fun saveLastBatchUndo(sourceId: Long, entries: List): Result = Result.success(Unit) + override suspend fun getLastBatchUndo(sourceId: Long): Result> = Result.success(emptyList()) + override suspend fun clearLastBatchUndo(sourceId: Long): Result = Result.success(Unit) +} + +private class FakeSourceRepository( + private vararg val sources: MediaSourceInfo, +) : MediaSourceRepository { + override suspend fun addSource(source: MediaSourceInfo): Result = Result.success(source.id) + override suspend fun removeSource(sourceId: Long): Result = Result.success(Unit) + override suspend fun getSources(): Result> = Result.success(sources.toList()) + override suspend fun updateSource(source: MediaSourceInfo): Result = Result.success(Unit) + override suspend fun getSourceById(sourceId: Long): Result = + sources.firstOrNull { it.id == sourceId } + ?.let { Result.success(it) } + ?: Result.failure(AppError.MediaSourceError.NotFound("Source id: $sourceId")) +} diff --git a/scanner-desktop/src/main/kotlin/com/miruplay/tv/scanner/desktop/DesktopMediaLibraryScanner.kt b/scanner-desktop/src/main/kotlin/com/miruplay/tv/scanner/desktop/DesktopMediaLibraryScanner.kt index a1ddecd7..9b0cd63d 100644 --- a/scanner-desktop/src/main/kotlin/com/miruplay/tv/scanner/desktop/DesktopMediaLibraryScanner.kt +++ b/scanner-desktop/src/main/kotlin/com/miruplay/tv/scanner/desktop/DesktopMediaLibraryScanner.kt @@ -11,6 +11,7 @@ import com.miruplay.tv.model.NfoMetadata import com.miruplay.tv.model.TvShowNfoMetadata import com.miruplay.tv.model.VideoFilenameInference import com.miruplay.tv.model.VideoFilenameMetadata +import com.miruplay.tv.model.matchingExternalSubtitlePaths import com.miruplay.tv.model.sanitizeRecognizedText import com.miruplay.tv.repository.MediaIndexEntry import kotlinx.coroutines.Dispatchers @@ -72,6 +73,7 @@ class DesktopMediaLibraryScanner( if (listed is Result.Error) return Result.failure(listed.error) val files = (listed as Result.Success).data + val siblingFilePaths = files.filterNot { it.isDirectory }.map { it.path } for (entry in files) { if (entry.isDirectory) { if (config.includeDirectories) { @@ -91,7 +93,7 @@ class DesktopMediaLibraryScanner( if (subResult is Result.Error) return subResult } } else if (entry.isVideoFile()) { - entries += entry.toVideoIndexEntry(sourceId, source, tvShow) + entries += entry.toVideoIndexEntry(sourceId, source, tvShow, siblingFilePaths) counters.filesIndexed += 1 } } @@ -113,12 +115,14 @@ class DesktopMediaLibraryScanner( sourceId: Long, source: MediaSource, tvShow: TvShowNfoMetadata?, + siblingFilePaths: List, ): MediaIndexEntry { val nfo = nfoReader.readEpisodeForVideo(source, path) val inferred = inferVideoMetadata(name, path) return MediaIndexEntry( sourceId = sourceId, path = path, + externalSubtitlePaths = matchingExternalSubtitlePaths(path, siblingFilePaths), animeName = nfo?.showTitle ?: tvShow?.title ?: tvShow?.originalTitle diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/DefaultMediaScanner.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/DefaultMediaScanner.kt index db2fda7b..0daf005b 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/DefaultMediaScanner.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/DefaultMediaScanner.kt @@ -4,6 +4,7 @@ import com.miruplay.tv.core.common.AppError import com.miruplay.tv.core.common.Result import com.miruplay.tv.mediasource.MediaSource import com.miruplay.tv.model.ScanResult +import com.miruplay.tv.model.matchingExternalSubtitlePaths import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -95,6 +96,9 @@ class DefaultMediaScanner( val entries = mutableListOf() source.listFiles(path).onSuccess { files -> + val siblingFilePaths = files + .filter { !it.isDirectory && shouldInclude(it.name, config) } + .map { it.path } files.forEach { file -> if (!shouldInclude(file.name, config)) return@forEach @@ -113,6 +117,7 @@ class DefaultMediaScanner( entries.add(MediaIndexEntry( name = file.name, path = file.path, + externalSubtitlePaths = matchingExternalSubtitlePaths(file.path, siblingFilePaths), isDirectory = false, size = file.size, lastModified = file.lastModified, @@ -143,6 +148,7 @@ class DefaultMediaScanner( data class MediaIndexEntry( val name: String, val path: String, + val externalSubtitlePaths: List = emptyList(), val isDirectory: Boolean, val size: Long, val lastModified: Long, 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 3a76ec87..d25f4bf3 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val MLIP_DATABASE_PATH = "library.db" -private const val MLIP_SCHEMA_VERSION = 1 +private val SUPPORTED_MLIP_SCHEMA_VERSIONS = 1..2 private const val MLIP_METADATA_SOURCE = "MLIP" @Singleton @@ -177,11 +177,12 @@ class MlipLibraryIndexImporter @Inject constructor( private fun validateMlipDatabase(database: SQLiteDatabase): Result { val userVersion = database.singleInt("PRAGMA user_version") ?: 0 - if (userVersion != MLIP_SCHEMA_VERSION) { + if (userVersion !in SUPPORTED_MLIP_SCHEMA_VERSIONS) { return Result.failure(AppError.LibraryIndexError.UnsupportedVersion(userVersion)) } val tables = database.tableNames() - val missing = requiredTables.filterNot { it in tables } + val expectedTables = if (userVersion >= 2) requiredTables + v2RequiredTables else requiredTables + val missing = expectedTables.filterNot { it in tables } if (missing.isNotEmpty()) { return Result.failure(AppError.LibraryIndexError.InvalidSchema("Missing tables: ${missing.joinToString()}")) } @@ -190,8 +191,8 @@ class MlipLibraryIndexImporter @Inject constructor( if (!protocol.equals("MLIP", ignoreCase = true)) { return Result.failure(AppError.LibraryIndexError.InvalidSchema("meta.protocol is not MLIP")) } - if (schema != MLIP_SCHEMA_VERSION.toString()) { - return Result.failure(AppError.LibraryIndexError.InvalidSchema("meta.schema is not $MLIP_SCHEMA_VERSION")) + if (schema != userVersion.toString()) { + return Result.failure(AppError.LibraryIndexError.InvalidSchema("meta.schema does not match user_version $userVersion")) } return Result.success(Unit) } @@ -201,6 +202,7 @@ class MlipLibraryIndexImporter @Inject constructor( val externalIdsBySeriesId = database.readExternalIdsBySeriesId() val releaseDatesBySeriesId = database.readReleaseDatesBySeriesId() val posterBySeriesId = database.readPosterBySeriesId() + val externalSubtitlePathsByMediaFileId = database.readExternalSubtitlePathsByMediaFileId() val seriesById = database.readSeries( sourceId, genresBySeriesId, @@ -233,6 +235,7 @@ class MlipLibraryIndexImporter @Inject constructor( emptyArray(), ).use { cursor -> while (cursor.moveToNext()) { + val mediaFileId = cursor.long("media_file_id") val rawPath = cursor.string("media_path") val indexPath = normalizeMlipMediaPath(rawPath) ?: throw InvalidMlipSchemaException("Unsafe media path: $rawPath") @@ -267,6 +270,7 @@ class MlipLibraryIndexImporter @Inject constructor( val indexEntry = MediaIndexEntry( sourceId = sourceId, path = indexPath, + externalSubtitlePaths = externalSubtitlePathsByMediaFileId[mediaFileId].orEmpty(), animeName = series.anime.displayTitleForIndex(), episodeTitle = episodeTitle, seasonNumber = season, @@ -297,6 +301,23 @@ class MlipLibraryIndexImporter @Inject constructor( ) } + private fun SQLiteDatabase.readExternalSubtitlePathsByMediaFileId(): Map> { + if ("media_subtitle" !in tableNames()) return emptyMap() + val result = linkedMapOf>() + rawQuery( + "SELECT media_file_id, path FROM media_subtitle ORDER BY media_file_id, sort_order, path", + emptyArray(), + ).use { cursor -> + while (cursor.moveToNext()) { + val rawPath = cursor.string("path") + val normalized = normalizeMlipMediaPath(rawPath) + ?: throw InvalidMlipSchemaException("Unsafe subtitle path: $rawPath") + result.getOrPut(cursor.long("media_file_id")) { mutableListOf() }.add(normalized) + } + } + return result.mapValues { (_, paths) -> paths.distinct() } + } + private fun SQLiteDatabase.readSeries( sourceId: Long, genresBySeriesId: Map>, @@ -502,6 +523,8 @@ private fun normalizeMlipRelativePath(path: String): String? { return segments.joinToString("/") } +private val v2RequiredTables = setOf("series_release_date", "media_subtitle") + private val requiredTables = setOf( "meta", "series", diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt index 36b25b12..8bbbbbfa 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt @@ -25,6 +25,7 @@ import com.miruplay.tv.model.ScanResult import com.miruplay.tv.model.TvShowNfoMetadata import com.miruplay.tv.model.UniqueId import com.miruplay.tv.model.displayTitle +import com.miruplay.tv.model.matchingExternalSubtitlePaths import com.miruplay.tv.model.recognitionMode import com.miruplay.tv.repository.CloudDriveAutomationRepository import com.miruplay.tv.repository.MediaIndexEntry @@ -1326,6 +1327,10 @@ class ScanCoordinator @Inject constructor( ) } + val siblingFilePaths = files + .filterNot { it.isDirectory } + .filter { rootPath == null || isWithinRoot(it.path, rootPath) } + .map { it.path } for (file in files) { currentCoroutineContext().ensureActive() diagnostics.entriesSeen += 1 @@ -1498,6 +1503,7 @@ class ScanCoordinator @Inject constructor( indexEntities.add(MediaIndexEntry( sourceId = sourceId, path = file.path, + externalSubtitlePaths = matchingExternalSubtitlePaths(file.path, siblingFilePaths), animeName = match.animeName, seasonNumber = match.seasonNumber, episodeNumber = match.episodeNumber, 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 09b26db3..70bee260 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt @@ -60,7 +60,7 @@ class MlipLibraryIndexImporterTest { fun `unsupported mlip version fails clearly`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") try { - createMlipDatabase(databaseFile, userVersion = 2) + createMlipDatabase(databaseFile, userVersion = 3) val result = importDatabase(databaseFile) @@ -71,6 +71,21 @@ class MlipLibraryIndexImporterTest { } } + @Test + fun `mlip v2 requires subtitle table`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + try { + createMlipDatabase(databaseFile, includeExternalSubtitles = 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 `missing mlip tables fail clearly`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") @@ -311,6 +326,7 @@ class MlipLibraryIndexImporterTest { assertEquals(7L, entry.sourceId) assertEquals("/Series/01.mkv", entry.path) assertEquals("中文标题", entry.animeName) + assertEquals(listOf("/Series/01.zh-CN.ass", "/Series/01.en.srt"), entry.externalSubtitlePaths) assertEquals("第 1 集", entry.episodeTitle) assertEquals(1, entry.seasonNumber) assertEquals(1, entry.episodeNumber) @@ -354,13 +370,20 @@ class MlipLibraryIndexImporterTest { streams = mapOf("library.db" to { databaseFile.inputStream() }), ) try { - createMlipDatabase(databaseFile, includeReleaseDate = false) + createMlipDatabase( + databaseFile, + userVersion = 1, + includeReleaseDate = false, + includeExternalSubtitles = false, + ) + val indexRepository = RecordingIndexRepository() - val result = MlipLibraryIndexImporter(RecordingIndexRepository(), metadataRepository) + val result = MlipLibraryIndexImporter(indexRepository, metadataRepository) .importLibrary(source, mediaSource) assertTrue(result.isSuccess()) assertEquals("2024", metadataRepository.anime.single().airDate) + assertTrue(indexRepository.entries.single().externalSubtitlePaths.isEmpty()) } finally { databaseFile.delete() } @@ -392,9 +415,10 @@ class MlipLibraryIndexImporterTest { private fun createMlipDatabase( file: File, - userVersion: Int = 1, + userVersion: Int = 2, includeRequiredTables: Boolean = true, includeReleaseDate: Boolean = true, + includeExternalSubtitles: Boolean = true, mediaPath: String = "Series/01.mkv", ) { file.delete() @@ -402,7 +426,7 @@ class MlipLibraryIndexImporterTest { try { database.execSQL("PRAGMA user_version = $userVersion") database.execSQL("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") - database.execSQL("INSERT INTO meta (key, value) VALUES ('protocol', 'MLIP'), ('schema', '1')") + database.execSQL("INSERT INTO meta (key, value) VALUES ('protocol', 'MLIP'), ('schema', '$userVersion')") if (!includeRequiredTables) return database.execSQL("CREATE TABLE series (id INTEGER PRIMARY KEY, uuid TEXT NOT NULL, title TEXT NOT NULL, original_title TEXT, summary TEXT, year INTEGER, series_type INTEGER)") database.execSQL("CREATE TABLE episode (id INTEGER PRIMARY KEY, uuid TEXT NOT NULL, series_id INTEGER NOT NULL, season INTEGER, episode REAL, sort_order REAL, title TEXT, summary TEXT, runtime INTEGER)") @@ -418,6 +442,10 @@ class MlipLibraryIndexImporterTest { 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')") } + if (includeExternalSubtitles) { + database.execSQL("CREATE TABLE media_subtitle (media_file_id INTEGER NOT NULL, path TEXT NOT NULL, language TEXT, title TEXT, sort_order INTEGER NOT NULL DEFAULT 0)") + 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)") + } 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/scanner/src/test/kotlin/com/miruplay/tv/scanner/ScanCoordinatorTest.kt b/scanner/src/test/kotlin/com/miruplay/tv/scanner/ScanCoordinatorTest.kt index 0e9ad7ce..8ea3e734 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/ScanCoordinatorTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/ScanCoordinatorTest.kt @@ -591,6 +591,45 @@ class ScanCoordinatorTest { assertTrue("Bangumi fallback candidates should be skipped", scraper.aliasCandidates.isEmpty()) } + @Test + fun `scanSource associates same stem external subtitles`() = runBlocking { + val sourceInfo = MediaSourceInfo( + id = 49L, + name = "Subtitle WebDAV", + type = MediaSourceType.WEBDAV, + connectionInfo = mapOf("url" to "http://example.test/dav", "disableOnlineMetadata" to "true"), + ) + val videoPath = "/Show/Show - S01E01.mkv" + val mediaSource = FakeMediaSource( + listings = mapOf( + "" to listOf(FileEntry(name = "Show", path = "/Show", isDirectory = true)), + "/Show" to listOf( + FileEntry(name = "Show - S01E01.mkv", path = videoPath, isDirectory = false, size = 1234), + FileEntry(name = "Show - S01E01.ass", path = "/Show/Show - S01E01.ass", isDirectory = false, size = 100), + FileEntry(name = "Show - S01E01.zh-CN.srt", path = "/Show/Show - S01E01.zh-CN.srt", isDirectory = false, size = 100), + FileEntry(name = "Show - S01E010.ass", path = "/Show/Show - S01E010.ass", isDirectory = false, size = 100), + ), + ), + ) + val indexRepository = RecordingIndexRepository() + val coordinator = ScanCoordinator( + mediaRepository = SingleSourceRepository(sourceInfo), + mediaSourceFactory = SingleMediaSourceFactory(mediaSource), + indexRepository = indexRepository, + metadataRepository = RecordingMetadataRepository(), + filenameMetadataParser = EmptyFilenameMetadataParser, + metadataScrapers = setOf(RecordingBangumiScraper()), + ) + + val result = coordinator.scanSource(sourceInfo.id) + + assertTrue("Scan should succeed", result.isSuccess()) + assertEquals( + listOf("/Show/Show - S01E01.ass", "/Show/Show - S01E01.zh-CN.srt"), + indexRepository.entries.single().externalSubtitlePaths, + ) + } + @Test fun `scanSource skips Bangumi cache reuse and lookup for drama sources`() = runBlocking { val sourceInfo = MediaSourceInfo( diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt index 8e53f1b0..c6210528 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt @@ -124,6 +124,7 @@ import com.miruplay.tv.model.playbackSpeedMenuTitle import com.miruplay.tv.model.playbackSpeedOptions import com.miruplay.tv.model.playbackSpeedValueLabel import com.miruplay.tv.model.playbackSubtitleCountLabel +import com.miruplay.tv.model.playbackSubtitleOffLabel import com.miruplay.tv.model.playbackSubtitleOptionLabel import com.miruplay.tv.model.playbackSubtitlesMenuTitle import com.miruplay.tv.model.toneMappingPresetLabel @@ -721,7 +722,7 @@ private fun PlayerChrome( onTogglePlayback: () -> Unit, onSkipBackward: () -> Unit, onSkipForward: () -> Unit, - onSelectSubtitle: (Int) -> Unit, + onSelectSubtitle: (Int?) -> Unit, onSelectAudioTrack: (Int) -> Unit, onSelectSpeed: (Float) -> Unit, onSelectPicturePreset: (ToneMappingProfilePreset) -> Unit, @@ -1161,7 +1162,7 @@ internal fun PlayerOptionsPanel( selectedAudioTrackIndex: Int?, playbackSpeed: Float, currentPicturePreset: ToneMappingProfilePreset, - onSelectSubtitle: (Int) -> Unit, + onSelectSubtitle: (Int?) -> Unit, onSelectAudioTrack: (Int) -> Unit, onSelectSpeed: (Float) -> Unit, onSelectPicturePreset: (ToneMappingProfilePreset) -> Unit, @@ -1219,16 +1220,17 @@ internal fun PlayerOptionsPanel( verticalArrangement = Arrangement.spacedBy(10.dp), itemVerticalAlignment = Alignment.CenterVertically, ) { + PlayerOptionButton( + text = playbackSubtitleOffLabel(), + selected = selectedSubtitleTrackIndex == null, + onClick = { onSelectSubtitle(null) }, + modifier = Modifier.then(initialFocusHandle.modifier()), + ) subtitles.forEachIndexed { index, track -> PlayerOptionButton( text = playbackSubtitleOptionLabel(track, index), selected = index == selectedSubtitleTrackIndex, onClick = { onSelectSubtitle(index) }, - modifier = if (index == 0) { - Modifier.then(initialFocusHandle.modifier()) - } else { - Modifier - } ) } } diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt index 7882b3bb..177f48ef 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt @@ -28,9 +28,11 @@ import com.miruplay.tv.player.LibVlcVoutMode import com.miruplay.tv.player.PlaybackController import com.miruplay.tv.model.SubtitleTrack import com.miruplay.tv.model.toPlaybackSource +import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaSourceRepository import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.NextPlaybackSourceResolver +import com.miruplay.tv.repository.PlaybackSubtitleResolver import com.miruplay.tv.repository.PlaybackPreferencesRepository import com.miruplay.tv.repository.PlaybackProgressRepository import com.miruplay.tv.repository.savePlaybackProgressOnCompletion @@ -54,6 +56,7 @@ class PlayerViewModel @Inject constructor( private val progressRepository: PlaybackProgressRepository, private val metadataRepository: Lazy, private val mediaRepository: Lazy, + private val mediaIndexRepository: Lazy, private val bangumiSyncEngine: Lazy, private val playbackPreferences: PlaybackPreferencesRepository ) : ViewModel() { @@ -161,23 +164,30 @@ class PlayerViewModel @Inject constructor( mediaSources = mediaRepository.get(), ) } + private val playbackSubtitleResolver by lazy { + PlaybackSubtitleResolver( + index = mediaIndexRepository.get(), + mediaSources = mediaRepository.get(), + ) + } fun play(source: PlaybackSource, ownerToken: Any? = activeScreenOwnerToken) { viewModelScope.launch { + val resolvedSource = playbackSubtitleResolver.resolve(source) _errorMessage.value = null pendingSeekPositionMs = null - _currentPosition.value = source.startPosition.coerceAtLeast(0L) - activeSource = source + _currentPosition.value = resolvedSource.startPosition.coerceAtLeast(0L) + activeSource = resolvedSource activeScreenOwnerToken = ownerToken - _activePlaybackSource.value = source - startPresentationResolution(source) + _activePlaybackSource.value = resolvedSource + startPresentationResolution(resolvedSource) refreshFormatAwarePreferences() - playbackController.play(source).also { + playbackController.play(resolvedSource).also { _duration.value = playbackController.getDuration() refreshTracks() startPositionPolling() - startProgressSaving(source) - startFinishObserver(source) + startProgressSaving(resolvedSource) + startFinishObserver(resolvedSource) } } } @@ -241,7 +251,7 @@ class PlayerViewModel @Inject constructor( _controlsVisible.value = false } - fun selectSubtitle(index: Int) { + fun selectSubtitle(index: Int?) { viewModelScope.launch { playbackController.setSubtitleTrack(index) refreshTracks()