From e34a63806356c3393eb0ec1ffcf85b8733ccb220 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Wed, 22 Jul 2026 19:41:38 +0800 Subject: [PATCH] Add multi-version playback and harden MLIP scans --- app/build.gradle.kts | 2 +- .../kotlin/com/miruplay/tv/MainActivity.kt | 14 +- .../com/miruplay/tv/navigation/NavRoutes.kt | 5 +- .../kotlin/com/miruplay/tv/model/Episode.kt | 69 +++++++ .../tv/model/EpisodeVersionSelectionPolicy.kt | 11 ++ .../miruplay/tv/model/MediaPathConventions.kt | 22 ++- .../com/miruplay/tv/model/PlaybackSource.kt | 2 + .../com/miruplay/tv/model/ScanResult.kt | 1 + .../miruplay/tv/model/EpisodeVersionsTest.kt | 44 +++++ .../preferences/PlaybackPreferencesManager.kt | 17 ++ .../PlaybackPreferencesManagerTest.kt | 10 + .../tv/mediasource/WebDavMediaSource.kt | 9 +- .../tv/mediasource/WebDavMediaSourceTest.kt | 10 + .../EpisodePlaybackSourceResolver.kt | 9 +- .../tv/repository/LibraryAnimeResolver.kt | 4 +- .../tv/repository/LibraryEpisodeResolver.kt | 46 ++++- .../tv/repository/MediaIndexPosterGrouping.kt | 6 + .../repository/NextPlaybackSourceResolver.kt | 115 ++++++++++-- .../tv/repository/PlayableUriResolver.kt | 5 +- .../PlaybackPreferencesRepository.kt | 4 + .../tv/repository/LibraryAnimeResolverTest.kt | 57 +++++- .../repository/LibraryEpisodeResolverTest.kt | 56 ++++++ .../NextPlaybackSourceResolverTest.kt | 173 ++++++++++++++++++ .../tv/repository/PlayableUriResolverTest.kt | 31 ++++ .../tv/scanner/MlipLibraryIndexImporter.kt | 59 +++++- .../miruplay/tv/scanner/ScanCoordinator.kt | 11 +- .../scanner/MlipLibraryIndexImporterTest.kt | 78 +++++++- .../tv/scanner/ScanCoordinatorTest.kt | 2 +- .../tv/ui/components/EpisodeVersionDialog.kt | 107 +++++++++++ .../tv/ui/detail/AnimeDetailScreen.kt | 35 +++- .../tv/ui/detail/AnimeDetailViewModel.kt | 17 +- .../miruplay/tv/ui/library/LibraryScreen.kt | 21 +++ .../tv/ui/library/LibraryViewModel.kt | 118 ++++++------ .../com/miruplay/tv/ui/player/PlayerScreen.kt | 10 + .../miruplay/tv/ui/player/PlayerViewModel.kt | 67 +++++-- .../tv/ui/settings/AddSourceScreen.kt | 40 ++++ .../tv/ui/settings/SettingsViewModel.kt | 9 + .../tv/ui/library/LibraryScanNoticeTest.kt | 55 ++++++ .../ui/settings/SettingsViewModelTokenTest.kt | 3 + .../tv/webcontrol/WebControlLibraryLoader.kt | 16 +- .../tv/webcontrol/WebControlModels.kt | 5 + .../tv/webcontrol/WebControlPlayback.kt | 1 + .../tv/webcontrol/WebControlSourceRequests.kt | 3 + .../webcontrol/WebControlLibraryLoaderTest.kt | 30 ++- .../webcontrol/WebControlSettingsRouteTest.kt | 5 +- .../WebControlSourceRequestsTest.kt | 2 + web-control/frontend/src/App.vue | 37 +++- web-control/frontend/src/styles.css | 17 ++ .../tv/webcontrol/WebControlService.kt | 11 +- 49 files changed, 1338 insertions(+), 143 deletions(-) create mode 100644 core/model/src/main/kotlin/com/miruplay/tv/model/EpisodeVersionSelectionPolicy.kt create mode 100644 core/model/src/test/kotlin/com/miruplay/tv/model/EpisodeVersionsTest.kt create mode 100644 ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/EpisodeVersionDialog.kt create mode 100644 ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibraryScanNoticeTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ff6a21c..1c5181f6 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 = "2.0.0" +val baseAppVersionName = "2.1.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt b/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt index d68a3394..0cfefc3e 100644 --- a/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt @@ -1013,6 +1013,7 @@ private fun MiruPlayNavigation( val encodedPath = Uri.encode(source.uri) val encodedSource = Uri.encode(source.mediaSourceId) val encodedEpisode = Uri.encode(source.episodeId ?: "") + val encodedProgress = Uri.encode(source.progressId ?: "") navigateToPlayerRoute( navController = navController, route = NavRoutes.player( @@ -1020,6 +1021,7 @@ private fun MiruPlayNavigation( mediaSourceId = encodedSource, startPosition = source.startPositionMs, episodeId = encodedEpisode, + progressId = encodedProgress, ), ) } @@ -1208,12 +1210,14 @@ private fun MiruPlayNavigation( val encodedPath = Uri.encode(source.uri) val encodedSource = Uri.encode(source.mediaSourceId) val encodedEpisode = Uri.encode(source.episodeId ?: "") + val encodedProgress = Uri.encode(source.progressId ?: "") navController.navigate( NavRoutes.player( uri = encodedPath, mediaSourceId = encodedSource, startPosition = source.startPosition, episodeId = encodedEpisode, + progressId = encodedProgress, ) ) }.onFailure { error -> @@ -1248,6 +1252,10 @@ private fun MiruPlayNavigation( navArgument("episodeId") { type = NavType.StringType defaultValue = "" + }, + navArgument("progressId") { + type = NavType.StringType + defaultValue = "" } ) ) { backStackEntry -> @@ -1258,12 +1266,16 @@ private fun MiruPlayNavigation( val episodeId = backStackEntry.arguments?.getString("episodeId") ?.let(Uri::decode) ?.takeIf { it.isNotBlank() } + val progressId = backStackEntry.arguments?.getString("progressId") + ?.let(Uri::decode) + ?.takeIf { it.isNotBlank() } val source = PlaybackSource( uri = decodedUri, mediaSourceId = mediaSourceId, startPosition = startPosition, subtitleTracks = emptyList(), - episodeId = episodeId + episodeId = episodeId, + progressId = progressId ?: episodeId, ) PlayerScreen( playbackSource = source, diff --git a/app/src/main/kotlin/com/miruplay/tv/navigation/NavRoutes.kt b/app/src/main/kotlin/com/miruplay/tv/navigation/NavRoutes.kt index ff780e0a..0a27b1ed 100644 --- a/app/src/main/kotlin/com/miruplay/tv/navigation/NavRoutes.kt +++ b/app/src/main/kotlin/com/miruplay/tv/navigation/NavRoutes.kt @@ -16,7 +16,7 @@ object NavRoutes { const val SETTINGS = MiruPlayRouteSurface.SETTINGS_ROUTE const val ANIME_DETAIL = "${MiruPlayRouteSurface.ANIME_ROUTE_PREFIX}/{animeId}" const val PLAYER = "${MiruPlayRouteSurface.PLAYER_ROUTE_PREFIX}/{uri}" - const val PLAYER_WITH_OPTIONS = "$PLAYER?mediaSourceId={mediaSourceId}&startPosition={startPosition}&episodeId={episodeId}" + const val PLAYER_WITH_OPTIONS = "$PLAYER?mediaSourceId={mediaSourceId}&startPosition={startPosition}&episodeId={episodeId}&progressId={progressId}" fun animeDetail(animeId: String) = "${MiruPlayRouteSurface.ANIME_ROUTE_PREFIX}/${MediaPathConventions.encodePathSegment(animeId)}" @@ -29,8 +29,9 @@ object NavRoutes { mediaSourceId: String = "media", startPosition: Long = 0L, episodeId: String = "", + progressId: String = "", ) = "${MiruPlayRouteSurface.PLAYER_ROUTE_PREFIX}/$uri" + - "?mediaSourceId=$mediaSourceId&startPosition=$startPosition&episodeId=$episodeId" + "?mediaSourceId=$mediaSourceId&startPosition=$startPosition&episodeId=$episodeId&progressId=$progressId" fun homeFor(mode: AppMode): String = when (mode) { diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/Episode.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/Episode.kt index 2a89eeb8..138b266a 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/Episode.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/Episode.kt @@ -2,6 +2,14 @@ package com.miruplay.tv.model import kotlinx.serialization.Serializable +@Serializable +data class EpisodeVersion( + val episodeId: String, + val filePath: String, + val fileName: String, + val duration: Long = 0L, +) + @Serializable data class Episode( val id: String, @@ -18,4 +26,65 @@ data class Episode( val thumbnailPath: String? = null, val bangumiEpisodeId: Int? = null, val bangumiCollectionType: Int? = null, + val progressId: String = id, + val versions: List = emptyList(), ) + +fun Episode.availableVersions(): List = + versions.ifEmpty { + listOf( + EpisodeVersion( + episodeId = id, + filePath = filePath, + fileName = fileName, + duration = duration, + ), + ) + } + +fun Episode.withVersion(version: EpisodeVersion): Episode = + copy( + id = version.episodeId, + filePath = version.filePath, + fileName = version.fileName, + duration = version.duration, + ) + +fun List.groupEpisodeVersions(logicalAnimeId: String? = null): List = + groupBy { it.seasonNumber to it.episodeNumber } + .map { (key, candidates) -> + val (seasonNumber, episodeNumber) = key + val animeId = logicalAnimeId ?: candidates.first().animeId + val versions = candidates + .flatMap(Episode::availableVersions) + .distinctBy { it.episodeId to it.filePath } + .sortedBy(EpisodeVersion::filePath) + val representative = candidates.maxWithOrNull( + compareBy { it.title.isNotBlank() } + .thenBy { it.bangumiEpisodeId != null } + .thenBy { it.duration }, + ) ?: candidates.first() + representative.withVersion(versions.first()).copy( + animeId = animeId, + progressId = logicalEpisodeProgressId(animeId, seasonNumber, episodeNumber), + versions = versions, + ) + } + .sortedForPlaybackQueue() + +fun logicalEpisodeProgressId(animeId: String, seasonNumber: Int, episodeNumber: Int): String = + "$animeId#S${seasonNumber}E$episodeNumber" + +fun List.nearestTo(currentPath: String): EpisodeVersion? = + maxWithOrNull( + compareBy { commonPathSegmentCount(currentPath, it.filePath) } + .thenBy { currentPath.commonPrefixWith(it.filePath, ignoreCase = true).length } + .thenByDescending { kotlin.math.abs(pathSegments(currentPath).size - pathSegments(it.filePath).size) } + .thenByDescending(EpisodeVersion::filePath), + ) + +private fun commonPathSegmentCount(left: String, right: String): Int = + pathSegments(left).zip(pathSegments(right)).takeWhile { (a, b) -> a.equals(b, ignoreCase = true) }.size + +private fun pathSegments(path: String): List = + path.replace('\\', '/').substringBefore('?').split('/').filter(String::isNotBlank) diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/EpisodeVersionSelectionPolicy.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/EpisodeVersionSelectionPolicy.kt new file mode 100644 index 00000000..63838bf5 --- /dev/null +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/EpisodeVersionSelectionPolicy.kt @@ -0,0 +1,11 @@ +package com.miruplay.tv.model + +enum class EpisodeVersionSelectionPolicy(val storageValue: String) { + AUTO_NEAREST("auto_nearest"), + MANUAL("manual"); + + companion object { + fun fromStorageValue(value: String?): EpisodeVersionSelectionPolicy = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: AUTO_NEAREST + } +} diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/MediaPathConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/MediaPathConventions.kt index 4e547a55..e114cd28 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/MediaPathConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/MediaPathConventions.kt @@ -104,15 +104,19 @@ object MediaPathConventions { }.getOrElse { REMOTE_URL_REGEX.matchEntire(trimmed) ?.let { match -> - runCatching { - URI( - match.groupValues[1], - match.groupValues[2], - match.groupValues[3].ifBlank { null }, - match.groupValues[4].removePrefix("?").ifBlank { null }, - match.groupValues[5].removePrefix("#").ifBlank { null }, - ).toASCIIString() - }.getOrDefault(trimmed) + val encodedPath = match.groupValues[3] + .split('/') + .joinToString("/") { segment -> + val decoded = runCatching { + URLDecoder.decode( + segment.replace("+", "%2B"), + Charsets.UTF_8.name(), + ) + }.getOrDefault(segment) + encodePathSegment(decoded) + } + "${match.groupValues[1]}://${match.groupValues[2]}$encodedPath" + + match.groupValues[4] + match.groupValues[5] } ?: trimmed } diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt index ace80171..7c43a844 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt @@ -9,6 +9,7 @@ data class PlaybackSource( val startPosition: Long = 0L, // ms val subtitleTracks: List = emptyList(), val episodeId: String? = null, + val progressId: String? = episodeId, ) fun playbackSourceFromInputs( @@ -79,4 +80,5 @@ fun Episode.toPlaybackSource( startPosition = resumePosition(progress), subtitleTracks = emptyList(), episodeId = id, + progressId = progressId, ) diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/ScanResult.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/ScanResult.kt index f17c8983..5824f2d6 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/ScanResult.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/ScanResult.kt @@ -10,4 +10,5 @@ data class ScanResult( val updatedEpisodes: Int = 0, val scraped: Int = 0, val noMatch: Int = 0, + val summary: String? = null, ) diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/EpisodeVersionsTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/EpisodeVersionsTest.kt new file mode 100644 index 00000000..ab8656a0 --- /dev/null +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/EpisodeVersionsTest.kt @@ -0,0 +1,44 @@ +package com.miruplay.tv.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EpisodeVersionsTest { + @Test + fun `duplicate season episode entries become one logical episode`() { + val grouped = listOf( + episode(id = "1:/Anime/WEB/01.mkv", path = "/Anime/WEB/01.mkv"), + episode(id = "1:/Anime/BD/01.mkv", path = "/Anime/BD/01.mkv"), + episode(id = "1:/Anime/WEB/02.mkv", path = "/Anime/WEB/02.mkv", number = 2), + ).groupEpisodeVersions() + + assertEquals(2, grouped.size) + assertEquals(2, grouped.first().versions.size) + assertEquals("anime#S1E1", grouped.first().progressId) + } + + @Test + fun `nearest version keeps the current directory`() { + val versions = listOf( + EpisodeVersion("1:/Anime/BD/02.mkv", "/Anime/BD/02.mkv", "02.mkv"), + EpisodeVersion("1:/Anime/WEB/02.mkv", "/Anime/WEB/02.mkv", "02.mkv"), + ) + + assertEquals( + "/Anime/WEB/02.mkv", + versions.nearestTo("/Anime/WEB/01.mkv")?.filePath, + ) + } + + private fun episode( + id: String, + path: String, + number: Int = 1, + ) = Episode( + id = id, + animeId = "anime", + episodeNumber = number, + filePath = path, + fileName = path.substringAfterLast('/'), + ) +} diff --git a/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt b/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt index f031c4f7..93c3bfc9 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt @@ -2,6 +2,7 @@ package com.miruplay.tv.data.preferences import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend @@ -28,6 +29,14 @@ class PlaybackPreferencesManager @Inject constructor( prefs.edit().putString(KEY_END_ACTION, value.storageValue).apply() } + var episodeVersionSelectionPolicy: EpisodeVersionSelectionPolicy + get() = EpisodeVersionSelectionPolicy.fromStorageValue( + prefs.getString(KEY_EPISODE_VERSION_SELECTION_POLICY, null), + ) + set(value) { + prefs.edit().putString(KEY_EPISODE_VERSION_SELECTION_POLICY, value.storageValue).apply() + } + var preferredSubtitleLanguage: SubtitleLanguagePreference get() = SubtitleLanguagePreference.fromStorageValue( prefs.getString(KEY_PREFERRED_SUBTITLE_LANGUAGE, null), @@ -62,6 +71,13 @@ class PlaybackPreferencesManager @Inject constructor( endAction = action } + override suspend fun getEpisodeVersionSelectionPolicy(): EpisodeVersionSelectionPolicy = + episodeVersionSelectionPolicy + + override suspend fun setEpisodeVersionSelectionPolicy(policy: EpisodeVersionSelectionPolicy) { + episodeVersionSelectionPolicy = policy + } + override suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference = preferredSubtitleLanguage @@ -78,6 +94,7 @@ class PlaybackPreferencesManager @Inject constructor( companion object { private const val KEY_END_ACTION = "end_action" + private const val KEY_EPISODE_VERSION_SELECTION_POLICY = "episode_version_selection_policy" private const val KEY_PREFERRED_SUBTITLE_LANGUAGE = "preferred_subtitle_language" private const val KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES = "format_aware_tone_mapping_preferences" } diff --git a/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt index 8f493e08..7d167e7e 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt @@ -2,6 +2,7 @@ package com.miruplay.tv.data.preferences import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FallbackBackendPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PeakDetectionStrategy @@ -45,6 +46,15 @@ class PlaybackPreferencesManagerTest { assertTrue(preferences.rules.containsKey(VideoRenderRuleKey.DOLBY_VISION)) } + @Test + fun `manager persists episode version selection policy`() = runBlocking { + assertEquals(EpisodeVersionSelectionPolicy.AUTO_NEAREST, manager.getEpisodeVersionSelectionPolicy()) + + manager.setEpisodeVersionSelectionPolicy(EpisodeVersionSelectionPolicy.MANUAL) + + assertEquals(EpisodeVersionSelectionPolicy.MANUAL, manager.getEpisodeVersionSelectionPolicy()) + } + @Test fun `manager persists preferred subtitle language`() = runBlocking { assertEquals(SubtitleLanguagePreference.AUTO, manager.getPreferredSubtitleLanguage()) diff --git a/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt b/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt index a7b1d807..de388266 100644 --- a/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt +++ b/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt @@ -24,6 +24,11 @@ import java.util.Base64 import java.util.concurrent.TimeUnit import javax.inject.Inject +internal fun webDavTransportError(path: String, error: Exception): AppError = + AppError.NetworkError.ServerUnreachable( + error.message?.takeIf(String::isNotBlank)?.let { "$path ($it)" } ?: path, + ) + class WebDavMediaSource @Inject constructor() : MediaSource { override val id: String = "" override lateinit var info: MediaSourceInfo @@ -132,7 +137,7 @@ class WebDavMediaSource @Inject constructor() : MediaSource { } catch (e: Exception) { val url = normalizeUrl(path) Log.w(TAG, "WebDAV GET failed for $url", e) - Result.failure(AppError.MediaSourceError.NotFound(urlWithCause(path.ifBlank { url }, e))) + Result.failure(webDavTransportError(path.ifBlank { url }, e)) } } @@ -168,7 +173,7 @@ class WebDavMediaSource @Inject constructor() : MediaSource { } catch (e: Exception) { val url = normalizeUrl(path) Log.w(TAG, "WebDAV metadata PROPFIND failed for $url", e) - Result.failure(AppError.MediaSourceError.NotFound(urlWithCause(path.ifBlank { url }, e))) + Result.failure(webDavTransportError(path.ifBlank { url }, e)) } } diff --git a/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavMediaSourceTest.kt b/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavMediaSourceTest.kt index 218e8800..4e23d3fa 100644 --- a/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavMediaSourceTest.kt +++ b/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavMediaSourceTest.kt @@ -1,5 +1,6 @@ package com.miruplay.tv.mediasource +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.MediaSourceType @@ -11,6 +12,7 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.IOException import kotlinx.coroutines.runBlocking class WebDavMediaSourceTest { @@ -115,6 +117,14 @@ class WebDavMediaSourceTest { } } + @Test + fun `transport failures map to network errors`() { + val error = webDavTransportError("library.db", IOException("offline")) + + assertTrue(error is AppError.NetworkError.ServerUnreachable) + assertEquals("library.db (offline)", (error as AppError.NetworkError.ServerUnreachable).url) + } + @Test fun `parsePropfindResponse skips current directory and preserves child names`() { val source = WebDavMediaSource( diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/EpisodePlaybackSourceResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/EpisodePlaybackSourceResolver.kt index e33d9c21..2acc60a1 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/EpisodePlaybackSourceResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/EpisodePlaybackSourceResolver.kt @@ -3,6 +3,7 @@ package com.miruplay.tv.repository import com.miruplay.tv.model.Episode import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.model.ProgressRecord +import com.miruplay.tv.model.availableVersions import com.miruplay.tv.model.coercePlaybackPosition import com.miruplay.tv.model.toPlaybackSource @@ -23,12 +24,18 @@ class EpisodePlaybackSourceResolver( ): PlaybackSource = buildEpisodePlaybackSource( episode = episode, - progress = progress.getProgress(episode.id).getOrNull(), + progress = progress.progressFor(episode), playableUri = playbackUriForEpisode(episode), startPositionOverrideMs = startPositionOverrideMs, ) } +suspend fun PlaybackProgressRepository.progressFor(episode: Episode): ProgressRecord? = + (listOf(episode.progressId) + episode.availableVersions().map { it.episodeId }) + .distinct() + .mapNotNull { getProgress(it).getOrNull() } + .maxByOrNull(ProgressRecord::lastWatched) + fun buildEpisodePlaybackSource( episode: Episode, progress: ProgressRecord?, 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 aedaf5a0..93a8c732 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 @@ -4,6 +4,7 @@ import com.miruplay.tv.model.Anime import com.miruplay.tv.model.Episode import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.distinctSeasonEpisodeCount +import com.miruplay.tv.model.groupEpisodeVersions import com.miruplay.tv.model.mergeAnimeGroupForDisplay import com.miruplay.tv.model.mergeSameAnimeForDisplay import com.miruplay.tv.model.sameAnimeGroupFor @@ -72,7 +73,7 @@ class LibraryAnimeResolver( .flatMap { loadEpisodesForAnime(it, null) } ) .distinctBy { it.id } - .sortedForPlaybackQueue() + .groupEpisodeVersions(logicalAnimeId = animeId) val extras = relatedPairs .flatMap { (relatedGroup, relatedAnimeItem) -> relatedGroup.entries @@ -158,6 +159,7 @@ fun LibraryIndexedAnimeGroup.toAnime(): Anime = private fun LibraryIndexedAnimeGroup.cacheKeys(primaryAnimeId: String = animeId): List = buildList { + add(group.preferredMetadataCacheKey()) add(primaryAnimeId) add(animeId) entries.forEach { entry -> 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 f2d8b19a..334e3a68 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 @@ -5,7 +5,9 @@ import com.miruplay.tv.model.Anime import com.miruplay.tv.model.Episode import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.ProgressRecord +import com.miruplay.tv.model.availableVersions import com.miruplay.tv.model.isCompleted +import com.miruplay.tv.model.withVersion data class LibraryContinueWatchingEpisode( val progress: ProgressRecord, @@ -20,8 +22,16 @@ class LibraryEpisodeResolver( private val progress: PlaybackProgressRepository, private val mergeSameAnimeEnabled: suspend () -> Boolean = { false }, ) { + private val animeResolver = LibraryAnimeResolver( + mediaSources = mediaSources, + metadata = metadata, + index = index, + mergeSameAnimeEnabled = mergeSameAnimeEnabled, + ) + suspend fun findEpisodeById(episodeId: String): Episode? { metadata.getCachedEpisode(episodeId).getOrNull()?.let { return it } + findLogicalEpisodeById(episodeId)?.let { return it } findIndexedEpisodeById(episodeId)?.let { return it } findIndexedEpisodeByPath(episodeId)?.let { return it } @@ -51,14 +61,16 @@ class LibraryEpisodeResolver( loadContinueWatchingEpisodesResult(limit).getOrNull().orEmpty() suspend fun loadContinueWatchingEpisodesResult(limit: Int = 20): Result> = - progress.getContinueWatching(limit).map { records -> - records.mapNotNull { record -> - record.toContinueWatchingEpisode() - } + progress.getContinueWatching(Int.MAX_VALUE).map { records -> + records.mapNotNull { record -> record.toContinueWatchingEpisode() } + .groupBy { it.episode.progressId } + .mapNotNull { (_, matches) -> matches.maxByOrNull { it.progress.lastWatched } } + .sortedByDescending { it.progress.lastWatched } + .take(limit) } private suspend fun ProgressRecord.toContinueWatchingEpisode(): LibraryContinueWatchingEpisode? { - val episode = findEpisodeById(episodeId) ?: return null + val episode = findEpisodeById(episodeId)?.toLogicalVersion() ?: return null if (episode.isCompleted(this)) return null return LibraryContinueWatchingEpisode( progress = this, @@ -71,6 +83,28 @@ class LibraryEpisodeResolver( ) } + private suspend fun Episode.toLogicalVersion(): Episode { + if (versions.isNotEmpty()) return this + val logical = animeResolver.loadAnimeDetail(animeId) + ?.episodes + ?.firstOrNull { episode -> episode.availableVersions().any { it.episodeId == id } } + ?: return this + return logical.availableVersions() + .firstOrNull { it.episodeId == id } + ?.let(logical::withVersion) + ?: logical + } + + private suspend fun findLogicalEpisodeById(episodeId: String): Episode? { + val match = LOGICAL_EPISODE_PROGRESS_ID.matchEntire(episodeId) ?: return null + val animeId = match.groupValues[1] + val seasonNumber = match.groupValues[2].toIntOrNull() ?: return null + val episodeNumber = match.groupValues[3].toIntOrNull() ?: return null + return animeResolver.loadAnimeDetail(animeId) + ?.episodes + ?.firstOrNull { it.seasonNumber == seasonNumber && it.episodeNumber == episodeNumber } + } + private suspend fun findIndexedEpisodeById(episodeId: String): Episode? { val sourceParts = episodeId.split(":", limit = 2) val sourceId = sourceParts.getOrNull(0)?.toLongOrNull() ?: return null @@ -130,3 +164,5 @@ class LibraryEpisodeResolver( return null } } + +private val LOGICAL_EPISODE_PROGRESS_ID = Regex("^(.+)#S(\\d+)E(\\d+)$") 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 e349f072..a1297064 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 @@ -69,6 +69,12 @@ fun MediaIndexPosterGroup.toIndexedAnime(): Anime = summary = primaryEntry.plot.orEmpty(), ) +fun MediaIndexPosterGroup.preferredMetadataCacheKey(): String = + entries.firstNotNullOfOrNull { it.localMetadataOverrideKey()?.takeIf(String::isNotBlank) } + ?: entries.firstNotNullOfOrNull { it.metadataId?.takeIf(String::isNotBlank) } + ?: animeId.takeIf(String::isNotBlank) + ?: title + fun MediaIndexEntry.posterTitle(): String = metadataTitle?.takeIf { it.isNotBlank() } ?: animeName?.takeIf { it.isNotBlank() } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolver.kt index cad9054c..2003ecdf 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolver.kt @@ -1,15 +1,21 @@ package com.miruplay.tv.repository import com.miruplay.tv.model.Episode +import com.miruplay.tv.model.EpisodeVersion import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.model.ProgressRecord -import com.miruplay.tv.model.nextEpisodeAfter +import com.miruplay.tv.model.availableVersions +import com.miruplay.tv.model.groupEpisodeVersions +import com.miruplay.tv.model.nearestTo import com.miruplay.tv.model.toPlaybackSource +import com.miruplay.tv.model.withVersion class NextPlaybackSourceResolver( private val metadata: MetadataRepository, private val progress: PlaybackProgressRepository, private val mediaSources: MediaSourceRepository, + index: MediaIndexRepository? = null, + mergeSameAnimeEnabled: suspend () -> Boolean = { false }, private val playbackUriForEpisode: suspend (Episode) -> String = { episode -> resolvePlayableUri( path = episode.filePath, @@ -18,17 +24,59 @@ class NextPlaybackSourceResolver( ) }, ) { - suspend fun build(currentSource: PlaybackSource): PlaybackSource? = - build(currentSource.episodeId) + private val libraryEpisodeResolver = index?.let { + LibraryEpisodeResolver(mediaSources, metadata, it, progress, mergeSameAnimeEnabled) + } + private val libraryAnimeResolver = index?.let { + LibraryAnimeResolver(mediaSources, metadata, it, mergeSameAnimeEnabled) + } - suspend fun build(currentEpisodeId: String?): PlaybackSource? = - buildNextPlaybackSource( - currentEpisodeId = currentEpisodeId, - loadCurrentEpisode = { episodeId -> metadata.getCachedEpisode(episodeId).getOrNull() }, - loadEpisodes = { animeId -> metadata.getCachedEpisodes(animeId).getOrNull().orEmpty() }, - loadProgress = { episodeId -> progress.getProgress(episodeId).getOrNull() }, - resolvePlayableUri = playbackUriForEpisode, + suspend fun build(currentSource: PlaybackSource): PlaybackSource? { + val currentPath = currentSource.episodeId + ?.let { loadEpisode(it)?.filePath } + ?: currentSource.uri + val nextEpisode = nextEpisode(currentSource) ?: return null + val version = nextEpisode.availableVersions().nearestTo(currentPath) ?: return null + return build(nextEpisode, version) + } + + suspend fun build(currentEpisodeId: String?): PlaybackSource? { + val episodeId = currentEpisodeId ?: return null + val currentEpisode = loadEpisode(episodeId) ?: return null + return build( + PlaybackSource( + uri = currentEpisode.filePath, + mediaSourceId = currentEpisode.animeId, + episodeId = currentEpisode.id, + progressId = currentEpisode.progressId, + ), + ) + } + + suspend fun nextEpisode(currentSource: PlaybackSource): Episode? = + findNextLogicalEpisode( + currentEpisodeId = currentSource.episodeId, + currentProgressId = currentSource.progressId, + currentAnimeId = currentSource.mediaSourceId, + loadCurrentEpisode = ::loadEpisode, + loadEpisodes = ::loadEpisodes, ) + + private suspend fun loadEpisode(episodeId: String): Episode? = + metadata.getCachedEpisode(episodeId).getOrNull() + ?: libraryEpisodeResolver?.findEpisodeById(episodeId) + + private suspend fun loadEpisodes(animeId: String): List = + libraryAnimeResolver?.loadAnimeDetail(animeId)?.episodes + ?: metadata.getCachedEpisodes(animeId).getOrNull().orEmpty() + + suspend fun build(episode: Episode, version: EpisodeVersion): PlaybackSource { + val selected = episode.withVersion(version) + return selected.toPlaybackSource( + playableUri = playbackUriForEpisode(selected), + progress = progress.progressFor(episode), + ) + } } suspend fun buildNextPlaybackSource( @@ -37,14 +85,25 @@ suspend fun buildNextPlaybackSource( loadEpisodes: suspend (String) -> List, loadProgress: suspend (String) -> ProgressRecord?, resolvePlayableUri: suspend (Episode) -> String = { episode -> episode.filePath }, + currentPath: String? = null, + currentProgressId: String? = null, + currentAnimeId: String? = null, ): PlaybackSource? { - val episodeId = currentEpisodeId ?: return null - val currentEpisode = loadCurrentEpisode(episodeId) ?: return null - val episodes = loadEpisodes(currentEpisode.animeId) - val nextEpisode = episodes.nextEpisodeAfter(currentEpisode.id) ?: return null - val progress = loadProgress(nextEpisode.id) - return nextEpisode.toPlaybackSource( - playableUri = resolvePlayableUri(nextEpisode), + val nextEpisode = findNextLogicalEpisode( + currentEpisodeId = currentEpisodeId, + currentProgressId = currentProgressId, + currentAnimeId = currentAnimeId, + loadCurrentEpisode = loadCurrentEpisode, + loadEpisodes = loadEpisodes, + ) ?: return null + val version = nextEpisode.availableVersions().nearestTo(currentPath.orEmpty()) ?: return null + val selected = nextEpisode.withVersion(version) + val progress = (listOf(nextEpisode.progressId) + nextEpisode.availableVersions().map { it.episodeId }) + .distinct() + .mapNotNull { loadProgress(it) } + .maxByOrNull(ProgressRecord::lastWatched) + return selected.toPlaybackSource( + playableUri = resolvePlayableUri(selected), progress = progress, ) } @@ -58,8 +117,30 @@ suspend fun buildNextPlaybackSource( ): PlaybackSource? = buildNextPlaybackSource( currentEpisodeId = currentSource.episodeId, + currentProgressId = currentSource.progressId, + currentPath = currentSource.uri, + currentAnimeId = currentSource.mediaSourceId, loadCurrentEpisode = loadCurrentEpisode, loadEpisodes = loadEpisodes, loadProgress = loadProgress, resolvePlayableUri = resolvePlayableUri, ) + +private suspend fun findNextLogicalEpisode( + currentEpisodeId: String?, + currentProgressId: String?, + currentAnimeId: String? = null, + loadCurrentEpisode: suspend (String) -> Episode?, + loadEpisodes: suspend (String) -> List, +): Episode? { + val episodeId = currentEpisodeId ?: return null + val currentEpisode = loadCurrentEpisode(episodeId) ?: return null + val logicalAnimeId = currentAnimeId?.takeIf(String::isNotBlank) ?: currentEpisode.animeId + val episodes = loadEpisodes(logicalAnimeId).groupEpisodeVersions(logicalAnimeId) + val currentIndex = episodes.indexOfFirst { episode -> + episode.progressId == currentProgressId || + episode.availableVersions().any { it.episodeId == episodeId } + } + if (currentIndex < 0 || currentIndex >= episodes.lastIndex) return null + return episodes[currentIndex + 1] +} 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 2cb0e934..01df15c1 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 @@ -14,9 +14,10 @@ suspend fun resolvePlayableUri( episodeId: String, mediaRepository: MediaSourceRepository ): String { - if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("content://")) { - return path + if (path.startsWith("http://") || path.startsWith("https://")) { + return MediaPathConventions.canonicalizeRemoteUrl(path) } + if (path.startsWith("content://")) return path val sources = when (val result = mediaRepository.getSources()) { is Result.Success -> result.data diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt index 08b76d56..aa755b0f 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt @@ -1,5 +1,6 @@ package com.miruplay.tv.repository +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.SubtitleLanguagePreference @@ -7,6 +8,9 @@ import com.miruplay.tv.model.SubtitleLanguagePreference interface PlaybackPreferencesRepository { suspend fun getEndAction(): PlaybackEndAction suspend fun setEndAction(action: PlaybackEndAction) + suspend fun getEpisodeVersionSelectionPolicy(): EpisodeVersionSelectionPolicy = + EpisodeVersionSelectionPolicy.AUTO_NEAREST + suspend fun setEpisodeVersionSelectionPolicy(policy: EpisodeVersionSelectionPolicy) = Unit suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences 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 c9b544bc..75e14fc6 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 @@ -67,6 +67,59 @@ class LibraryAnimeResolverTest { assertEquals(28, anime.episodeCount) } + @Test + fun `loadAnime prefers bound metadata id over stale title cache without merging`() = runBlocking { + val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) + val resolver = resolver( + sources = listOf(source), + entries = listOf( + MediaIndexEntry( + sourceId = 1L, + path = "D:/Anime/Frieren/Episode 01.mkv", + animeName = "Frieren", + metadataId = "mlip:1:series-uuid", + metadataTitle = "Updated Frieren", + ), + ), + cachedAnime = mapOf( + "Frieren" to Anime(id = "Frieren", title = "Stale title", summary = "Old metadata"), + "mlip:1:series-uuid" to Anime(id = "mlip:1:series-uuid", title = "Updated Frieren"), + ), + ) + + val anime = resolver.loadAnime().single() + + assertEquals("mlip:1:series-uuid", anime.id) + assertEquals("Updated Frieren", anime.title) + } + + @Test + fun `loadAnime prefers preserved MLIP key over legacy external override`() = runBlocking { + val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) + val resolver = resolver( + sources = listOf(source), + entries = listOf( + MediaIndexEntry( + sourceId = 1L, + path = "D:/Anime/Frieren/Episode 01.mkv", + animeName = "Stale external title", + metadataSource = "Bangumi", + metadataId = "431767", + scrapeMessage = localMetadataOverrideMessage("mlip:1:series-uuid"), + ), + ), + cachedAnime = mapOf( + "431767" to Anime(id = "431767", title = "Stale external title"), + "mlip:1:series-uuid" to Anime(id = "mlip:1:series-uuid", title = "Authoritative MLIP title"), + ), + ) + + val anime = resolver.loadAnime().single() + + assertEquals("mlip:1:series-uuid", anime.id) + assertEquals("Authoritative MLIP title", anime.title) + } + @Test fun `loadAnime uses anime name as cache key when index has no metadata id`() = runBlocking { val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) @@ -250,7 +303,9 @@ class LibraryAnimeResolverTest { val detail = resolver.loadAnimeDetail("cached") assertEquals("Cached", detail?.anime?.title) - assertEquals(listOf(episode), detail?.episodes) + assertEquals(1, detail?.episodes?.size) + assertEquals("cached#S1E1", detail?.episodes?.single()?.progressId) + assertEquals(listOf("cached-episode"), detail?.episodes?.single()?.versions?.map { it.episodeId }) } @Test 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 573d613f..2b228129 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 @@ -90,6 +90,62 @@ class LibraryEpisodeResolverTest { assertEquals(1, items.single().episode.playCount) } + @Test + fun `continue watching resolves logical episode progress`() = runBlocking { + val web = Episode( + id = "web-1", + animeId = "show", + episodeNumber = 1, + duration = 100_000L, + filePath = "D:/Anime/Show/WEB/01.mkv", + fileName = "01.mkv", + ) + val bd = web.copy( + id = "bd-1", + filePath = "D:/Anime/Show/BD/01.mkv", + ) + val resolver = resolver( + cachedAnime = mapOf("show" to Anime(id = "show", title = "Show")), + cachedEpisodes = mapOf("show" to listOf(web, bd)), + progressRecords = listOf( + ProgressRecord("show#S1E1", positionMs = 40_000L, lastWatched = 10L), + ), + ) + + val item = resolver.loadContinueWatchingEpisodes().single() + + assertEquals("show#S1E1", item.progress.episodeId) + assertEquals("show#S1E1", item.episode.progressId) + assertEquals(2, item.episode.versions.size) + assertEquals(40_000L, item.episode.watchedPosition) + } + + @Test + fun `continue watching deduplicates legacy and logical progress`() = runBlocking { + val episode = Episode( + id = "physical-1", + animeId = "show", + episodeNumber = 1, + duration = 100_000L, + filePath = "D:/Anime/Show/01.mkv", + fileName = "01.mkv", + ) + val resolver = resolver( + cachedAnime = mapOf("show" to Anime(id = "show", title = "Show")), + cachedEpisodes = mapOf("show" to listOf(episode)), + progressRecords = listOf( + ProgressRecord("show#S1E1", positionMs = 50_000L, lastWatched = 20L), + ProgressRecord("physical-1", positionMs = 40_000L, lastWatched = 10L), + ), + ) + + val item = resolver.loadContinueWatchingEpisodes().single() + + assertEquals("show#S1E1", item.progress.episodeId) + assertEquals("show#S1E1", item.episode.progressId) + assertEquals(50_000L, item.episode.watchedPosition) + } + @Test fun `continue watching resolves indexed records`() = runBlocking { val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolverTest.kt index b3bb3a11..f67c5bb1 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/NextPlaybackSourceResolverTest.kt @@ -43,6 +43,7 @@ class NextPlaybackSourceResolverTest { mediaSourceId = "anime-1", startPosition = 30_000L, episodeId = "ep2", + progressId = "anime-1#S1E2", ), source, ) @@ -73,6 +74,7 @@ class NextPlaybackSourceResolverTest { mediaSourceId = "anime-1", startPosition = 0L, episodeId = "ep2", + progressId = "anime-1#S1E2", ), source, ) @@ -100,6 +102,7 @@ class NextPlaybackSourceResolverTest { mediaSourceId = "anime-1", startPosition = 0L, episodeId = "ep2", + progressId = "anime-1#S1E2", ), source, ) @@ -131,6 +134,7 @@ class NextPlaybackSourceResolverTest { mediaSourceId = "anime-1", startPosition = 42_000L, episodeId = next.id, + progressId = "anime-1#S1E2", ), source, ) @@ -157,11 +161,151 @@ class NextPlaybackSourceResolverTest { mediaSourceId = "anime-1", startPosition = 0L, episodeId = next.id, + progressId = "anime-1#S1E2", ), source, ) } + @Test + fun `auto next skips duplicate current versions and keeps nearest path`() = runBlocking { + val currentWeb = episode( + id = "41:/Show/WEB/Episode 01.mkv", + number = 1, + filePath = "/Show/WEB/Episode 01.mkv", + ) + val currentBd = episode( + id = "41:/Show/BD/Episode 01.mkv", + number = 1, + filePath = "/Show/BD/Episode 01.mkv", + ) + val nextWeb = episode( + id = "41:/Show/WEB/Episode 02.mkv", + number = 2, + filePath = "/Show/WEB/Episode 02.mkv", + ) + val nextBd = episode( + id = "41:/Show/BD/Episode 02.mkv", + number = 2, + filePath = "/Show/BD/Episode 02.mkv", + ) + + val source = buildNextPlaybackSource( + currentSource = PlaybackSource( + uri = currentWeb.filePath, + mediaSourceId = currentWeb.animeId, + episodeId = currentWeb.id, + progressId = "anime-1#S1E1", + ), + loadCurrentEpisode = { currentWeb }, + loadEpisodes = { listOf(currentWeb, currentBd, nextBd, nextWeb) }, + loadProgress = { null }, + ) + + assertEquals(nextWeb.id, source?.episodeId) + assertEquals("anime-1#S1E2", source?.progressId) + } + + @Test + fun `auto next uses logical anime queue across merged physical anime ids`() = runBlocking { + val current = episode(id = "part-a-1", number = 1).copy(animeId = "part-a") + val next = episode(id = "part-b-2", number = 2).copy(animeId = "part-b") + + val source = buildNextPlaybackSource( + currentSource = PlaybackSource( + uri = current.filePath, + mediaSourceId = "merged-show", + episodeId = current.id, + progressId = "merged-show#S1E1", + ), + loadCurrentEpisode = { current }, + loadEpisodes = { animeId -> + assertEquals("merged-show", animeId) + listOf(current, next) + }, + loadProgress = { null }, + ) + + assertEquals(next.id, source?.episodeId) + assertEquals("merged-show#S1E2", source?.progressId) + } + + @Test + fun `resolver auto next supports indexed episodes without metadata cache`() = runBlocking { + val source = MediaSourceInfoConventions.webDav( + url = "https://dav.example/anime", + name = "DAV", + ).copy(id = 41L) + val currentPath = "/Show/WEB/Episode 01.mkv" + val nextPath = "/Show/WEB/Episode 02.mkv" + val resolver = NextPlaybackSourceResolver( + metadata = FakeMetadataRepository(emptyMap()), + progress = FakeProgressRepository(null), + mediaSources = FakeMediaSourceRepository(listOf(source)), + index = FakeMediaIndexRepository( + listOf( + MediaIndexEntry(sourceId = 41L, path = currentPath, animeName = "Show", episodeNumber = 1), + MediaIndexEntry(sourceId = 41L, path = nextPath, animeName = "Show", episodeNumber = 2), + ), + ), + ) + + val next = resolver.build( + PlaybackSource( + uri = "https://dav.example/anime/Show/WEB/Episode%2001.mkv", + mediaSourceId = "Show", + episodeId = "41:$currentPath", + progressId = "Show#S1E1", + ), + ) + + assertEquals("41:$nextPath", next?.episodeId) + assertEquals("Show#S1E2", next?.progressId) + } + + @Test + fun `resolver compares database paths when current uri is remote`() = runBlocking { + val currentWeb = episode( + id = "41:/Show/WEB/Episode 01.mkv", + number = 1, + filePath = "/Show/WEB/Episode 01.mkv", + ) + val currentBd = episode( + id = "41:/Show/BD/Episode 01.mkv", + number = 1, + filePath = "/Show/BD/Episode 01.mkv", + ) + val nextWeb = episode( + id = "41:/Show/WEB/Episode 02.mkv", + number = 2, + filePath = "/Show/WEB/Episode 02.mkv", + ) + val nextBd = episode( + id = "41:/Show/BD/Episode 02.mkv", + number = 2, + filePath = "/Show/BD/Episode 02.mkv", + ) + val resolver = NextPlaybackSourceResolver( + metadata = FakeMetadataRepository( + mapOf("anime-1" to listOf(currentWeb, currentBd, nextBd, nextWeb)), + ), + progress = FakeProgressRepository(null), + mediaSources = FakeMediaSourceRepository(emptyList()), + playbackUriForEpisode = { it.filePath }, + ) + + val source = resolver.build( + PlaybackSource( + uri = "https://dav.example/anime/Show/WEB/Episode%2001.mkv", + mediaSourceId = currentWeb.animeId, + episodeId = currentWeb.id, + progressId = "anime-1#S1E1", + ), + ) + + assertEquals(nextWeb.id, source?.episodeId) + } + @Test fun `buildNextPlaybackSource returns null without episode id`() = runBlocking { val source = buildNextPlaybackSource( @@ -261,6 +405,35 @@ class NextPlaybackSourceResolverTest { Result.success(listOfNotNull(progress).take(limit)) } + private class FakeMediaIndexRepository( + 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 { entry -> + entry.sourceId == sourceId && + (query.isBlank() || entry.animeName?.contains(query, ignoreCase = true) == true || + entry.path.contains(query, ignoreCase = true)) + }, + ) + + override suspend fun getAnimeInIndex(sourceId: Long): Result> = + Result.success(entries.filter { it.sourceId == sourceId }.mapNotNull { it.animeName }.distinct()) + + 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 FakeMediaSourceRepository( private val sources: List, ) : MediaSourceRepository { 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 6ce66152..db7c6edb 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 @@ -25,6 +25,37 @@ class PlayableUriResolverTest { assertEquals("https://cdn.example/video.mkv", uri) } + @Test + fun `resolvePlayableUri encodes raw unicode bracketed http paths`() = runBlocking { + val repository = FakeMediaSourceRepository(emptyList()) + val path = "http://127.0.0.1:19798/dav/115open/影音/动漫/無職轉生~到了異世界就拿出真本事~/Season 3/[ANi] 無職轉生~到了異世界就拿出真本事~第三季 - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4" + + val uri = resolvePlayableUri( + path = path, + episodeId = "1:episode", + mediaRepository = repository, + ) + + assertEquals( + "http://127.0.0.1:19798/dav/115open/%E5%BD%B1%E9%9F%B3/%E5%8A%A8%E6%BC%AB/%E7%84%A1%E8%81%B7%E8%BD%89%E7%94%9F%EF%BD%9E%E5%88%B0%E4%BA%86%E7%95%B0%E4%B8%96%E7%95%8C%E5%B0%B1%E6%8B%BF%E5%87%BA%E7%9C%9F%E6%9C%AC%E4%BA%8B%EF%BD%9E/Season%203/%5BANi%5D%20%E7%84%A1%E8%81%B7%E8%BD%89%E7%94%9F%EF%BD%9E%E5%88%B0%E4%BA%86%E7%95%B0%E4%B8%96%E7%95%8C%E5%B0%B1%E6%8B%BF%E5%87%BA%E7%9C%9F%E6%9C%AC%E4%BA%8B%EF%BD%9E%E7%AC%AC%E4%B8%89%E5%AD%A3%20-%2001%20%5B1080P%5D%5BBaha%5D%5BWEB-DL%5D%5BAAC%20AVC%5D%5BCHT%5D.mp4", + uri, + ) + } + + @Test + fun `resolvePlayableUri preserves existing escapes in partially encoded paths`() = runBlocking { + val uri = resolvePlayableUri( + path = "http://127.0.0.1:19798/dav/%E5%BD%B1%E9%9F%B3/动漫/Season%203/[ANi] 01.mp4", + episodeId = "1:episode", + mediaRepository = FakeMediaSourceRepository(emptyList()), + ) + + assertEquals( + "http://127.0.0.1:19798/dav/%E5%BD%B1%E9%9F%B3/%E5%8A%A8%E6%BC%AB/Season%203/%5BANi%5D%2001.mp4", + uri, + ) + } + @Test fun `resolvePlayableUri joins WebDAV source URL using encoded path segments`() = 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 2f1ffca4..e9676349 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -19,6 +19,7 @@ import com.miruplay.tv.repository.MediaScrapeStatus import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.localMetadataOverrideKey import java.io.File +import java.io.InputStream import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton @@ -44,10 +45,11 @@ class MlipLibraryIndexImporter @Inject constructor( AppError.LibraryIndexError.InvalidSchema("MLIP is only supported for WebDAV sources"), ) } - val databaseFile = when (val copied = copyLibraryDatabase(mediaSource)) { + val databaseFile = when (val copied = copyLibraryDatabase(source, mediaSource)) { is Result.Success -> copied.data is Result.Error -> return@withContext copied } + val databaseSizeBytes = databaseFile.length() val snapshot = try { SQLiteDatabase.openDatabase( databaseFile.absolutePath, @@ -74,6 +76,8 @@ class MlipLibraryIndexImporter @Inject constructor( } val mediaFiles = snapshot.mediaFiles val entries = mediaFiles.map { it.indexEntry } + snapshot.extras + val previousPaths = previousEntries.mapTo(mutableSetOf(), MediaIndexEntry::path) + val newMediaFileCount = mediaFiles.count { it.indexEntry.path !in previousPaths } when (val rebuilt = indexRepository.rebuildIndex(source.id, entries)) { is Result.Success -> Unit is Result.Error -> return@withContext Result.failure(rebuilt.error) @@ -99,10 +103,14 @@ class MlipLibraryIndexImporter @Inject constructor( .groupBy { it.seasonNumber to it.episodeNumber } .mapNotNull { (key, matches) -> matches.singleOrNull()?.let { key to it } } .toMap() + val incomingCountsByNumber = seriesFiles.groupingBy { + it.episode.seasonNumber to it.episode.episodeNumber + }.eachCount() val episodes = seriesFiles.map { file -> val incoming = file.episode + val numberKey = incoming.seasonNumber to incoming.episodeNumber val cached = cachedEpisodesById[incoming.id] - ?: cachedEpisodesByNumber[incoming.seasonNumber to incoming.episodeNumber] + ?: cachedEpisodesByNumber[numberKey]?.takeIf { incomingCountsByNumber[numberKey] == 1 } incoming.copy( id = cached?.id ?: incoming.id, watchedPosition = cached?.watchedPosition ?: incoming.watchedPosition, @@ -140,6 +148,9 @@ class MlipLibraryIndexImporter @Inject constructor( artworkCachedCount = artworkCachedCount, nonIntegerEpisodeCount = snapshot.nonIntegerEpisodes, extraCount = snapshot.extras.size, + newMediaFileCount = newMediaFileCount, + databaseGeneratedAt = snapshot.databaseGeneratedAt, + databaseSizeBytes = databaseSizeBytes, ) MiruLog.i( TAG, @@ -152,17 +163,23 @@ class MlipLibraryIndexImporter @Inject constructor( "skipped_file_count" to result.skippedFileCount.toString(), "non_integer_episode_count" to result.nonIntegerEpisodeCount.toString(), "extra_count" to result.extraCount.toString(), + "new_media_file_count" to result.newMediaFileCount.toString(), + "database_generated_at" to result.databaseGeneratedAt.orEmpty(), + "database_size_bytes" to result.databaseSizeBytes.toString(), ), ) Result.success(result) } - private suspend fun copyLibraryDatabase(mediaSource: MediaSource): Result { - val stream = when (val opened = mediaSource.openStream(MLIP_DATABASE_PATH)) { + private suspend fun copyLibraryDatabase( + source: MediaSourceInfo, + mediaSource: MediaSource, + ): Result { + val stream = when (val opened = openLibraryDatabaseStream(source, mediaSource)) { is Result.Success -> opened.data is Result.Error -> { val error = opened.error - return if (error is AppError.MediaSourceError.NotFound) { + return if (error.isMissingLibraryDatabase()) { Result.failure(AppError.LibraryIndexError.Missing(MLIP_DATABASE_PATH)) } else { Result.failure(AppError.LibraryIndexError.ReadFailed(error.toUserMessage())) @@ -178,6 +195,30 @@ class MlipLibraryIndexImporter @Inject constructor( } } + private suspend fun openLibraryDatabaseStream( + source: MediaSourceInfo, + mediaSource: MediaSource, + ): Result { + val first = mediaSource.openStream(MLIP_DATABASE_PATH) + if (first !is Result.Error || !first.error.isMissingLibraryDatabase()) return first + + val refreshed = mediaSource.listFiles("") + MiruLog.w( + TAG, + "MLIP library.db missing; refreshed WebDAV root before retry", + attributes = mapOf( + "source_id" to source.id.toString(), + "source_name" to source.name, + "root_refresh_succeeded" to (refreshed is Result.Success).toString(), + ), + ) + return mediaSource.openStream(MLIP_DATABASE_PATH) + } + + private fun AppError.isMissingLibraryDatabase(): Boolean = + this is AppError.MediaSourceError.NotFound || + (this is AppError.NetworkError.HttpError && code == 404) + private fun validateMlipDatabase(database: SQLiteDatabase): Result { val userVersion = database.singleInt("PRAGMA user_version") ?: 0 if (userVersion !in SUPPORTED_MLIP_SCHEMA_VERSIONS) { @@ -315,6 +356,10 @@ class MlipLibraryIndexImporter @Inject constructor( }, skippedFiles = skippedFiles, nonIntegerEpisodes = nonIntegerEpisodes, + databaseGeneratedAt = database.singleString( + "SELECT value FROM meta WHERE key = ?", + "generated_at", + )?.takeIf(String::isNotBlank), ) } @@ -558,6 +603,9 @@ data class MlipImportResult( val artworkCachedCount: Int, val nonIntegerEpisodeCount: Int, val extraCount: Int, + val newMediaFileCount: Int, + val databaseGeneratedAt: String?, + val databaseSizeBytes: Long, ) internal fun normalizeMlipMediaPath(path: String): String? { @@ -615,6 +663,7 @@ private data class MlipSnapshot( val extras: List, val skippedFiles: Int, val nonIntegerEpisodes: Int, + val databaseGeneratedAt: String?, ) private data class MlipSeries( 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 8bbbbbfa..b0f3ab6e 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt @@ -153,10 +153,19 @@ class ScanCoordinator @Inject constructor( ScanResult( animeName = sourceInfo.name, episodesFound = importResult.mediaFileCount, - newEpisodes = importResult.mediaFileCount, + newEpisodes = importResult.newMediaFileCount, updatedEpisodes = 0, scraped = importResult.mediaFileCount, noMatch = importResult.skippedFileCount, + summary = buildString { + append("MLIP:") + append(importResult.seriesCount).append(" 部动漫,") + append(importResult.episodeCount).append(" 集,") + append(importResult.mediaFileCount).append(" 个文件") + importResult.databaseGeneratedAt?.let { + append(";library.db 生成于 ").append(it) + } + }, ), ) } 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 6fb7ccde..9843b037 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt @@ -57,6 +57,76 @@ class MlipLibraryIndexImporterTest { assertNull(normalizeMlipArtworkPath("../poster.jpg")) } + @Test + fun `missing library database refreshes webdav root and retries once`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val source = mlipSource() + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf("library.db" to { databaseFile.inputStream() }), + requireRootRefreshBeforeStreams = true, + ) + + try { + createMlipDatabase(databaseFile) + + val result = MlipLibraryIndexImporter(RecordingIndexRepository(), RecordingMetadataRepository()) + .importLibrary(source, mediaSource) + + assertTrue(result.isSuccess()) + assertEquals(listOf("library.db", "library.db"), mediaSource.openedPaths) + assertEquals(listOf(""), mediaSource.listedPaths) + assertEquals("2026-07-22T07:55:30Z", result.getOrNull()?.databaseGeneratedAt) + } finally { + databaseFile.delete() + } + } + + @Test + fun `multiple files for one episode keep distinct physical ids`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val source = mlipSource() + val metadataRepository = RecordingMetadataRepository().apply { + episodes += Episode( + id = "legacy-episode-id", + animeId = "mlip:7:series-uuid", + episodeNumber = 1, + filePath = "/Series/01.mkv", + fileName = "01.mkv", + ) + } + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf("library.db" to { databaseFile.inputStream() }), + ) + + try { + createMlipDatabase(databaseFile) + SQLiteDatabase.openDatabase( + databaseFile.absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { database -> + database.execSQL( + "UPDATE media_file SET episode_id = 10, path = 'Series/BD/01.mkv' WHERE id = 101", + ) + } + + val result = MlipLibraryIndexImporter(RecordingIndexRepository(), metadataRepository) + .importLibrary(source, mediaSource) + + assertTrue(result.isSuccess()) + assertEquals(1, result.getOrNull()?.episodeCount) + assertEquals(2, result.getOrNull()?.mediaFileCount) + assertEquals( + setOf("7:/Series/01.mkv", "7:/Series/BD/01.mkv"), + metadataRepository.episodes.map(Episode::id).toSet(), + ) + } finally { + databaseFile.delete() + } + } + @Test fun `unsupported mlip version fails clearly`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") @@ -531,7 +601,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', '$userVersion')") + database.execSQL("INSERT INTO meta (key, value) VALUES ('protocol', 'MLIP'), ('schema', '$userVersion'), ('generated_at', '2026-07-22T07:55:30Z')") 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)") @@ -630,19 +700,25 @@ class MlipLibraryIndexImporterTest { private class FakeMediaSource( override val info: MediaSourceInfo, private val streams: Map InputStream>, + private val requireRootRefreshBeforeStreams: Boolean = false, ) : MediaSource { override val id: String = "fake" override val capabilities: MediaCapabilities = MediaCapabilities() val openedPaths = mutableListOf() val listedPaths = mutableListOf() + private var rootRefreshed = false override suspend fun listFiles(path: String): Result> { listedPaths += path + if (path.isEmpty()) rootRefreshed = true return Result.success(emptyList()) } override suspend fun openStream(path: String): Result { openedPaths += path + if (requireRootRefreshBeforeStreams && !rootRefreshed) { + return Result.failure(AppError.NetworkError.HttpError(404, "Not Found")) + } return streams[path]?.let { Result.success(it()) } ?: Result.failure(AppError.MediaSourceError.NotFound(path)) } 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 8ea3e734..fd7d5268 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/ScanCoordinatorTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/ScanCoordinatorTest.kt @@ -77,7 +77,7 @@ class ScanCoordinatorTest { assertTrue("Missing library.db should fail explicitly", result is Result.Error) assertTrue((result as Result.Error).error is AppError.LibraryIndexError.Missing) - assertEquals(emptyList(), mediaSource.listedPaths) + assertEquals(listOf(""), mediaSource.listedPaths) assertEquals(null, scraper.normalizedName) assertEquals(null, mediaRepository.updatedSource) } diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/EpisodeVersionDialog.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/EpisodeVersionDialog.kt new file mode 100644 index 00000000..a26edaad --- /dev/null +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/EpisodeVersionDialog.kt @@ -0,0 +1,107 @@ +package com.miruplay.tv.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.tv.material3.Text +import com.miruplay.tv.model.Episode +import com.miruplay.tv.model.EpisodeVersion +import com.miruplay.tv.model.availableVersions +import com.miruplay.tv.ui.theme.AccentBlue +import com.miruplay.tv.ui.theme.CardBg +import com.miruplay.tv.ui.theme.DarkSurface +import com.miruplay.tv.ui.theme.FocusBorder +import com.miruplay.tv.ui.theme.TextPrimary +import com.miruplay.tv.ui.theme.TextSecondary +import com.miruplay.tv.ui.theme.TvTypography + +@Composable +fun EpisodeVersionDialog( + episode: Episode, + onDismiss: () -> Unit, + onPlay: (EpisodeVersion) -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .widthIn(min = 720.dp, max = 980.dp) + .heightIn(max = 680.dp) + .clip(RoundedCornerShape(8.dp)) + .background(DarkSurface) + .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(8.dp)) + .padding(24.dp), + ) { + Text( + text = "第 ${episode.episodeNumber} 集 · 选择播放版本", + style = TvTypography.subtitle, + color = TextPrimary, + ) + Spacer(Modifier.height(16.dp)) + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(episode.availableVersions(), key = EpisodeVersion::episodeId) { version -> + EpisodeVersionOption(version = version, onClick = { onPlay(version) }) + } + } + } + } +} + +@Composable +private fun EpisodeVersionOption( + version: EpisodeVersion, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (focused) AccentBlue.copy(alpha = 0.68f) else CardBg) + .border( + if (focused) 2.dp else 1.dp, + if (focused) FocusBorder else Color.White.copy(alpha = 0.08f), + RoundedCornerShape(8.dp), + ) + .tvFocusableClickable(interactionSource = interactionSource, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + text = version.fileName, + style = TvTypography.body, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = version.filePath, + fontSize = 12.sp, + color = TextSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} 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 c7b1c23b..46ad0247 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 @@ -36,7 +36,9 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -58,6 +60,7 @@ import com.miruplay.tv.model.Anime import com.miruplay.tv.model.Episode import com.miruplay.tv.model.ProgressRecord import com.miruplay.tv.model.ScraperResult +import com.miruplay.tv.model.availableVersions import com.miruplay.tv.model.confidencePercentLabel import com.miruplay.tv.model.continueActionLabel import com.miruplay.tv.model.continueEpisode @@ -82,6 +85,8 @@ import com.miruplay.tv.model.metadataSearchResultsPageLabel import com.miruplay.tv.model.isCompleted import com.miruplay.tv.model.progressFraction import com.miruplay.tv.model.progressLabel +import com.miruplay.tv.model.withVersion +import com.miruplay.tv.ui.components.EpisodeVersionDialog import com.miruplay.tv.ui.components.LoadingIndicator import com.miruplay.tv.ui.components.OverscanContainer import com.miruplay.tv.ui.components.RemoteImage @@ -118,6 +123,14 @@ fun AnimeDetailScreen( val actionMessage by viewModel.actionMessage.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() val manualMatch by viewModel.manualMatch.collectAsStateWithLifecycle() + var versionChoice by remember { mutableStateOf(null) } + val requestPlay: (Episode) -> Unit = { episode -> + if (episode.availableVersions().size > 1) { + versionChoice = episode + } else { + onPlayEpisode(episode) + } + } LaunchedEffect(animeId) { viewModel.loadAnime(animeId) @@ -127,7 +140,10 @@ fun AnimeDetailScreen( viewModel.closeRescrapeMatcher() } } - BackHandler(enabled = !manualMatch.isOpen, onBack = onNavigateBack) + BackHandler(enabled = versionChoice != null) { + versionChoice = null + } + BackHandler(enabled = !manualMatch.isOpen && versionChoice == null, onBack = onNavigateBack) Box(modifier = Modifier.fillMaxSize()) { OverscanContainer { @@ -143,7 +159,7 @@ fun AnimeDetailScreen( extras = extras, actionMessage = actionMessage, isSyncing = isSyncing, - onPlayEpisode = onPlayEpisode, + onPlayEpisode = requestPlay, onSelectSeason = viewModel::selectSeason, onRescrape = viewModel::openRescrapeMatcher, onSyncBangumi = viewModel::syncBangumi @@ -152,6 +168,17 @@ fun AnimeDetailScreen( } } + versionChoice?.let { episode -> + EpisodeVersionDialog( + episode = episode, + onDismiss = { versionChoice = null }, + onPlay = { version -> + versionChoice = null + onPlayEpisode(episode.withVersion(version)) + }, + ) + } + if (manualMatch.isOpen) { BangumiManualMatchDialog( state = manualMatch, @@ -780,10 +807,10 @@ private fun EpisodeListItem( ) Spacer(Modifier.height(5.dp)) Text( - text = episode.displayPath(), + text = episode.availableVersions().joinToString("\n") { it.filePath }, color = TextSecondary, fontSize = 12.sp, - maxLines = 2, + maxLines = episode.availableVersions().size.coerceIn(1, 4), overflow = TextOverflow.Ellipsis ) } 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 6a358635..17f6cbe1 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 @@ -360,8 +360,15 @@ class AnimeDetailViewModel @Inject constructor( _selectedSeason.value = selectedSeason val withProgress = epList.map { episode -> - val progress = progressRepository.getProgress(episode.id).getOrNull() - Pair(episode, progress) + val progress = episodeProgress(episode) + Pair( + episode.copy( + watchedPosition = progress?.positionMs ?: 0L, + lastWatchedTimestamp = progress?.lastWatched ?: 0L, + playCount = progress?.playCount ?: 0, + ), + progress, + ) } allEpisodesWithProgress = withProgress _episodesWithProgress.value = withProgress.episodesForSeason(selectedSeason) @@ -370,6 +377,12 @@ class AnimeDetailViewModel @Inject constructor( } } + private suspend fun episodeProgress(episode: Episode): ProgressRecord? = + (listOf(episode.progressId) + episode.versions.map { it.episodeId }) + .distinct() + .mapNotNull { progressRepository.getProgress(it).getOrNull() } + .maxByOrNull(ProgressRecord::lastWatched) + fun syncBangumi() { val current = _anime.value ?: return viewModelScope.launch { diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt index 774524ec..eefb49e2 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt @@ -171,6 +171,7 @@ fun LibraryScreen( recentlyAdded = state.recentlyAdded, allAnime = state.allAnime, posterWallArrangement = state.posterWallArrangement, + scanNotice = state.scanNotice, searchQuery = searchQuery, initialContentFocus = initialContentFocus, onPrimaryDirectionUp = { headerFocusRequester.requestFocus() }, @@ -383,6 +384,7 @@ private fun LibraryContent( recentlyAdded: List, allAnime: List, posterWallArrangement: PosterWallArrangement, + scanNotice: String?, searchQuery: String, initialContentFocus: InitialFocusHandle, onPrimaryDirectionUp: () -> Unit, @@ -408,6 +410,25 @@ private fun LibraryContent( val initialContentModifier = initialContentFocus.modifier() Column(modifier = Modifier.fillMaxWidth()) { + scanNotice?.takeIf(String::isNotBlank)?.let { notice -> + val noticeColor = if ("失败" in notice || "未找到" in notice) WarningYellow else ProgressGreen + Box( + modifier = Modifier + .fillMaxWidth() + .background(noticeColor.copy(alpha = 0.12f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = notice, + style = TvTypography.body, + color = TextPrimary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(18.dp)) + } + if (normalizedQuery.isNotEmpty()) { SectionHeader(title = "搜索结果", trailing = libraryCollectedCountLabel(library.size)) if (library.isEmpty()) { diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryViewModel.kt index 69bab4ef..0010f107 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryViewModel.kt @@ -9,17 +9,19 @@ import com.miruplay.tv.data.preferences.ScanPreferencesManager import com.miruplay.tv.model.Anime import com.miruplay.tv.model.Episode import com.miruplay.tv.model.MediaSourceInfo -import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.MediaContentMode import com.miruplay.tv.model.PosterWallArrangement import com.miruplay.tv.model.ProgressRecord -import com.miruplay.tv.model.isCompleted +import com.miruplay.tv.model.ScanResult import com.miruplay.tv.model.libraryNoContentAfterScanMessage import com.miruplay.tv.model.mergeSameAnimeForDisplay +import com.miruplay.tv.repository.LibraryEpisodeResolver import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaSourceRepository import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.PlaybackProgressRepository +import com.miruplay.tv.repository.preferredMetadataCacheKey +import com.miruplay.tv.repository.toMediaIndexPosterGroups import com.miruplay.tv.scanner.LibraryScanState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -57,6 +59,7 @@ sealed class LibraryUiState { val recentlyAdded: List, val allAnime: List, val posterWallArrangement: PosterWallArrangement, + val scanNotice: String? = null, ) : LibraryUiState() data class ScanError(val message: String) : LibraryUiState() } @@ -71,6 +74,14 @@ class LibraryViewModel @Inject constructor( private val scanPreferences: ScanPreferencesManager ) : ViewModel() { + private val libraryEpisodeResolver = LibraryEpisodeResolver( + mediaSources = mediaRepository, + metadata = metadataRepository, + index = indexRepository, + progress = progressRepository, + mergeSameAnimeEnabled = { scanPreferences.mergeSameAnimeEnabled }, + ) + private val _state = MutableStateFlow(LibraryUiState.Loading) val state: StateFlow = _state.asStateFlow() val scanState: StateFlow = libraryScanTask.state @@ -113,6 +124,7 @@ class LibraryViewModel @Inject constructor( if (!scanRefreshSessionActive) { scanRefreshSessionActive = true queuedScanContentVersion = -1 + applyScanNotice(null) } scheduleContentRefreshDuringScan(scanState) } @@ -129,6 +141,8 @@ class LibraryViewModel @Inject constructor( _state.value = LibraryUiState.ScanError( scanState.sourceFailures.firstOrNull() ?: libraryNoContentAfterScanMessage() ) + } else { + applyScanNotice(libraryScanNotice(scanState.results, scanState.sourceFailures)) } } is LibraryScanState.Failed -> { @@ -141,6 +155,8 @@ class LibraryViewModel @Inject constructor( val snapshot = loadLibraryContent(showLoading = false) if (snapshot.hasSources && !snapshot.hasContent) { _state.value = LibraryUiState.ScanError(scanState.message) + } else { + applyScanNotice("扫描失败:${scanState.message}") } } is LibraryScanState.Cancelled -> { @@ -170,6 +186,7 @@ class LibraryViewModel @Inject constructor( private suspend fun loadLibraryContent(showLoading: Boolean): LibraryLoadSnapshot { val startedAt = SystemClock.elapsedRealtime() + val scanNotice = (_state.value as? LibraryUiState.HasContent)?.scanNotice if (showLoading) { _state.value = LibraryUiState.Loading } @@ -206,6 +223,7 @@ class LibraryViewModel @Inject constructor( recentlyAdded = displayAnime.takeLast(10), allAnime = displayAnime, posterWallArrangement = scanPreferences.posterWallArrangement, + scanNotice = scanNotice, ) MiruLog.i( "LibraryViewModel", @@ -231,71 +249,34 @@ class LibraryViewModel @Inject constructor( } } - private suspend fun loadContinueWatching(): List { - val progressRecords = progressRepository.getContinueWatching().getOrNull() ?: emptyList() - return progressRecords.mapNotNull { record -> - val cachedEpisode = metadataRepository.getCachedEpisode(record.episodeId).getOrNull() - if (cachedEpisode != null) { - if (cachedEpisode.isCompleted(record)) return@mapNotNull null - val anime = metadataRepository.getCachedMetadata(cachedEpisode.animeId).getOrNull() - ?: return@mapNotNull null - return@mapNotNull ProgressWithEpisode( - progress = record, - episode = cachedEpisode.copy( - watchedPosition = record.positionMs, - lastWatchedTimestamp = record.lastWatched, - playCount = record.playCount - ), - anime = anime - ) - } - - val pathParts = record.episodeId.split(":", limit = 2) - val episodePath = pathParts.getOrNull(1) ?: record.episodeId - val sourceId = pathParts.getOrNull(0)?.toLongOrNull() - val animeName = MediaPathConventions.animeNameFromEpisodePath(episodePath) - if (animeName == null || sourceId == null) return@mapNotNull null - - val anime = metadataRepository.getCachedMetadata(animeName).getOrNull() - ?: return@mapNotNull null - - val matchedEntry = indexRepository.queryIndex(sourceId, animeName) - .getOrNull() - .orEmpty() - .find { it.path == episodePath } - ?: return@mapNotNull null - - val episode = Episode( - id = record.episodeId, - animeId = animeName, - seasonNumber = matchedEntry.seasonNumber ?: 1, - episodeNumber = matchedEntry.episodeNumber ?: 1, - title = "", - filePath = episodePath, - fileName = episodePath.substringAfterLast("/"), - duration = 0L, - watchedPosition = record.positionMs, - lastWatchedTimestamp = record.lastWatched, - playCount = record.playCount, - thumbnailPath = null + private suspend fun loadContinueWatching(): List = + libraryEpisodeResolver.loadContinueWatchingEpisodes().map { item -> + ProgressWithEpisode( + progress = item.progress, + episode = item.episode, + anime = item.anime, ) - - if (episode.isCompleted(record)) return@mapNotNull null - - ProgressWithEpisode(progress = record, episode = episode, anime = anime) } - } private suspend fun loadCachedAnime(sources: List): List { - val sourceAnimeNames = sources.flatMap { source -> - indexRepository.getAnimeInIndex(source.id).getOrNull().orEmpty() - } + val metadataKeys = sources.flatMap { source -> + indexRepository.queryIndex(source.id, "") + .getOrNull() + .orEmpty() + .toMediaIndexPosterGroups(scanPreferences.mergeSameAnimeEnabled) + .map { it.preferredMetadataCacheKey() } + }.distinct() MiruLog.d( "LibraryViewModel", - "Cached anime names loaded", - mapOf("name_count" to sourceAnimeNames.size.toString()) + "Preferred anime metadata keys loaded", + mapOf("key_count" to metadataKeys.size.toString()) ) - return metadataRepository.getCachedMetadata(sourceAnimeNames).getOrNull().orEmpty() + return metadataRepository.getCachedMetadata(metadataKeys).getOrNull().orEmpty() + } + + private fun applyScanNotice(message: String?) { + val content = _state.value as? LibraryUiState.HasContent ?: return + _state.value = content.copy(scanNotice = message) } private fun libraryLoadErrorMessage(): String { @@ -312,4 +293,21 @@ private data class LibraryLoadSnapshot( val hasContent: Boolean ) +internal fun libraryScanNotice( + results: List, + sourceFailures: List, +): String? { + val summarized = results.mapNotNull(ScanResult::summary) + val unsummarized = results.filter { it.summary.isNullOrBlank() } + val success = summarized + unsummarized + .takeIf(List::isNotEmpty) + ?.let { listOf("扫描完成:${it.sumOf(ScanResult::episodesFound)} 个文件") } + .orEmpty() + val failure = sourceFailures.takeIf(List::isNotEmpty)?.let { + val suffix = if (it.size > 1) "(共 ${it.size} 个源失败)" else "" + "扫描失败:${it.first()}$suffix" + } + return (success + listOfNotNull(failure)).joinToString(";").takeIf(String::isNotBlank) +} + private const val SCAN_CONTENT_REFRESH_DELAY_MS = 500L 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 feab3e0b..0e2743e1 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 @@ -134,6 +134,7 @@ import com.miruplay.tv.model.toneMappingPresetOptions import com.miruplay.tv.design.MiruPlayPlaybackInputAction import com.miruplay.tv.design.shouldRefreshTvPlaybackControls import com.miruplay.tv.design.tvPlaybackOverlayAction +import com.miruplay.tv.ui.components.EpisodeVersionDialog import com.miruplay.tv.ui.components.rememberInitialFocusHandle import com.miruplay.tv.ui.tv.R import com.miruplay.tv.ui.components.toMiruPlayInputIntent @@ -190,6 +191,7 @@ private fun PlayerScreenContent( ) { val playbackState by viewModel.playbackState.collectAsStateWithLifecycle() val activePlaybackSource by viewModel.activePlaybackSource.collectAsStateWithLifecycle() + val pendingNextEpisode by viewModel.pendingNextEpisode.collectAsStateWithLifecycle() val currentPosition by viewModel.currentPosition.collectAsStateWithLifecycle() val duration by viewModel.duration.collectAsStateWithLifecycle() val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle() @@ -614,6 +616,14 @@ private fun PlayerScreenContent( ) } + pendingNextEpisode?.let { episode -> + EpisodeVersionDialog( + episode = episode, + onDismiss = viewModel::cancelNextVersionSelection, + onPlay = viewModel::playNextVersion, + ) + } + if (errorMessage != null) { ErrorOverlay( message = errorMessage!!, 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 177f48ef..e1c5d9d5 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 @@ -5,6 +5,9 @@ import android.view.View import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.miruplay.tv.core.common.Result +import com.miruplay.tv.model.Episode +import com.miruplay.tv.model.EpisodeVersion +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend @@ -19,6 +22,7 @@ import com.miruplay.tv.model.ToneMappingRuleSet import com.miruplay.tv.model.VideoRenderRuleKey import com.miruplay.tv.model.VideoSignalDescriptor import com.miruplay.tv.model.adjustForSession +import com.miruplay.tv.model.availableVersions import com.miruplay.tv.model.buildToneMappingPreset import com.miruplay.tv.model.displayTitle import com.miruplay.tv.model.playbackDisplayTitle @@ -35,6 +39,7 @@ 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.ScanPreferencesRepository import com.miruplay.tv.repository.savePlaybackProgressOnCompletion import com.miruplay.tv.repository.savePlaybackProgressSnapshot import com.miruplay.tv.sync.BangumiSyncEngine @@ -58,7 +63,8 @@ class PlayerViewModel @Inject constructor( private val mediaRepository: Lazy, private val mediaIndexRepository: Lazy, private val bangumiSyncEngine: Lazy, - private val playbackPreferences: PlaybackPreferencesRepository + private val playbackPreferences: PlaybackPreferencesRepository, + private val scanPreferences: ScanPreferencesRepository, ) : ViewModel() { val playbackState: StateFlow = playbackController.state val currentVideoSignalDescriptor: StateFlow = playbackController.currentVideoSignalDescriptor @@ -150,6 +156,9 @@ class PlayerViewModel @Inject constructor( playbackController.unbindVlcVideoHost() } + private val _pendingNextEpisode = MutableStateFlow(null) + val pendingNextEpisode: StateFlow = _pendingNextEpisode.asStateFlow() + private var progressSaveJob: Job? = null private var positionPollJob: Job? = null private var finishObserverJob: Job? = null @@ -162,6 +171,8 @@ class PlayerViewModel @Inject constructor( metadata = metadataRepository.get(), progress = progressRepository, mediaSources = mediaRepository.get(), + index = mediaIndexRepository.get(), + mergeSameAnimeEnabled = { scanPreferences.getPreferences().mergeSameAnimeEnabled }, ) } private val playbackSubtitleResolver by lazy { @@ -179,6 +190,7 @@ class PlayerViewModel @Inject constructor( _currentPosition.value = resolvedSource.startPosition.coerceAtLeast(0L) activeSource = resolvedSource activeScreenOwnerToken = ownerToken + _pendingNextEpisode.value = null _activePlaybackSource.value = resolvedSource startPresentationResolution(resolvedSource) refreshFormatAwarePreferences() @@ -485,10 +497,12 @@ class PlayerViewModel @Inject constructor( incrementPlayCount: Boolean = false ) { val currentSource = source ?: return - val episodeId = currentSource.episodeId ?: extractEpisodeId(currentSource.uri) + val progressId = currentSource.progressId + ?: currentSource.episodeId + ?: extractEpisodeId(currentSource.uri) val position = positionMs ?: playbackController.getCurrentPosition() savePlaybackProgressSnapshot( - episodeId = episodeId, + episodeId = progressId, positionMs = position, incrementPlayCount = incrementPlayCount, saveProgress = progressRepository::saveProgress, @@ -497,9 +511,10 @@ class PlayerViewModel @Inject constructor( private suspend fun handlePlaybackEnded(source: PlaybackSource) { val episodeId = source.episodeId ?: extractEpisodeId(source.uri) + val progressId = source.progressId ?: episodeId savePlaybackProgressOnCompletion( session = PlaybackProgressSession( - episodeId = episodeId, + episodeId = progressId, startPositionMs = source.startPosition, ), queryDurationMs = { Result.success(playbackController.getDuration()) }, @@ -507,21 +522,40 @@ class PlayerViewModel @Inject constructor( saveProgress = progressRepository::saveProgress, ) - val nextSource = if (playbackPreferences.getEndAction() == PlaybackEndAction.PLAY_NEXT_EPISODE) { - buildNextPlaybackSource(source) - } else { - null - } - - if (nextSource != null) { - play(nextSource) - viewModelScope.launch { + if (playbackPreferences.getEndAction() == PlaybackEndAction.PLAY_NEXT_EPISODE) { + val nextEpisode = nextPlaybackSourceResolver.nextEpisode(source) + if (nextEpisode != null && + nextEpisode.availableVersions().size > 1 && + playbackPreferences.getEpisodeVersionSelectionPolicy() == EpisodeVersionSelectionPolicy.MANUAL + ) { bangumiSyncEngine.get().markEpisodeWatched(episodeId) + _pendingNextEpisode.value = nextEpisode + return + } + val nextSource = buildNextPlaybackSource(source) + if (nextSource != null) { + play(nextSource) + viewModelScope.launch { + bangumiSyncEngine.get().markEpisodeWatched(episodeId) + } + return } - } else { - bangumiSyncEngine.get().markEpisodeWatched(episodeId) - _finishEvents.emit(PlaybackFinishEvent.NavigateBack) } + + bangumiSyncEngine.get().markEpisodeWatched(episodeId) + _finishEvents.emit(PlaybackFinishEvent.NavigateBack) + } + + fun playNextVersion(version: EpisodeVersion) { + val episode = _pendingNextEpisode.value ?: return + viewModelScope.launch { + play(nextPlaybackSourceResolver.build(episode, version)) + } + } + + fun cancelNextVersionSelection() { + _pendingNextEpisode.value = null + _finishEvents.tryEmit(PlaybackFinishEvent.NavigateBack) } private suspend fun buildNextPlaybackSource(source: PlaybackSource): PlaybackSource? { @@ -570,6 +604,7 @@ class PlayerViewModel @Inject constructor( activeSource = null activeScreenOwnerToken = null _activePlaybackSource.value = null + _pendingNextEpisode.value = null _displayTitle.value = "" _displaySubtitle.value = "" _currentPosition.value = 0L diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt index 20808035..073b409f 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt @@ -87,6 +87,7 @@ import com.miruplay.tv.design.MiruPlayInputIntent import com.miruplay.tv.repository.canRunNow import com.miruplay.tv.repository.AppMode import com.miruplay.tv.repository.toMediaContentMode +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend import com.miruplay.tv.model.SubtitleLanguagePreference @@ -370,6 +371,7 @@ fun AddSourceScreen( val posterWallArrangement by viewModel.posterWallArrangement.collectAsStateWithLifecycle() val currentAppMode by viewModel.currentAppMode.collectAsStateWithLifecycle() val playbackEndAction by viewModel.playbackEndAction.collectAsStateWithLifecycle() + val episodeVersionSelectionPolicy by viewModel.episodeVersionSelectionPolicy.collectAsStateWithLifecycle() val preferredSubtitleLanguage by viewModel.preferredSubtitleLanguage.collectAsStateWithLifecycle() val formatAwareToneMappingPreferences by viewModel.formatAwareToneMappingPreferences.collectAsStateWithLifecycle() val savedTmdbToken by viewModel.tmdbToken.collectAsStateWithLifecycle() @@ -667,6 +669,8 @@ fun AddSourceScreen( onAppModeSelected = viewModel::setCurrentAppMode, playbackEndAction = playbackEndAction, onPlaybackEndActionSelected = viewModel::setPlaybackEndAction, + episodeVersionSelectionPolicy = episodeVersionSelectionPolicy, + onEpisodeVersionSelectionPolicySelected = viewModel::setEpisodeVersionSelectionPolicy, preferredSubtitleLanguage = preferredSubtitleLanguage, onPreferredSubtitleLanguageSelected = viewModel::setPreferredSubtitleLanguage, formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, @@ -1128,6 +1132,8 @@ private fun SettingsContent( onAppModeSelected: (AppMode) -> Unit, playbackEndAction: PlaybackEndAction, onPlaybackEndActionSelected: (PlaybackEndAction) -> Unit, + episodeVersionSelectionPolicy: EpisodeVersionSelectionPolicy, + onEpisodeVersionSelectionPolicySelected: (EpisodeVersionSelectionPolicy) -> Unit, preferredSubtitleLanguage: SubtitleLanguagePreference, onPreferredSubtitleLanguageSelected: (SubtitleLanguagePreference) -> Unit, formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, @@ -1387,6 +1393,8 @@ private fun SettingsContent( PlaybackPanel( endAction = playbackEndAction, onEndActionSelected = onPlaybackEndActionSelected, + episodeVersionSelectionPolicy = episodeVersionSelectionPolicy, + onEpisodeVersionSelectionPolicySelected = onEpisodeVersionSelectionPolicySelected, preferredSubtitleLanguage = preferredSubtitleLanguage, onPreferredSubtitleLanguageSelected = onPreferredSubtitleLanguageSelected, formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, @@ -3124,6 +3132,8 @@ private fun ScanPanel( private fun PlaybackPanel( endAction: PlaybackEndAction, onEndActionSelected: (PlaybackEndAction) -> Unit, + episodeVersionSelectionPolicy: EpisodeVersionSelectionPolicy, + onEpisodeVersionSelectionPolicySelected: (EpisodeVersionSelectionPolicy) -> Unit, preferredSubtitleLanguage: SubtitleLanguagePreference, onPreferredSubtitleLanguageSelected: (SubtitleLanguagePreference) -> Unit, formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, @@ -3178,6 +3188,36 @@ private fun PlaybackPanel( color = if (endAction == PlaybackEndAction.PLAY_NEXT_EPISODE) ProgressGreen else TextSecondary ) + Spacer(Modifier.height(24.dp)) + Text(text = "多版本下一集策略", style = TvTypography.subtitle, color = TextPrimary) + Spacer(Modifier.height(6.dp)) + Text( + text = "同一集存在多个文件时,决定自动续播如何选择下一集版本。", + style = TvTypography.body, + color = TextSecondary, + ) + Spacer(Modifier.height(14.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ScanOptionChip( + text = "自动选择相近路径", + icon = Icons.Filled.PlayArrow, + selected = episodeVersionSelectionPolicy == EpisodeVersionSelectionPolicy.AUTO_NEAREST, + enabled = true, + onClick = { + onEpisodeVersionSelectionPolicySelected(EpisodeVersionSelectionPolicy.AUTO_NEAREST) + }, + modifier = Modifier.width(220.dp), + ) + ScanOptionChip( + text = "每集手动选择版本", + icon = Icons.Filled.FolderOpen, + selected = episodeVersionSelectionPolicy == EpisodeVersionSelectionPolicy.MANUAL, + enabled = true, + onClick = { onEpisodeVersionSelectionPolicySelected(EpisodeVersionSelectionPolicy.MANUAL) }, + modifier = Modifier.width(220.dp), + ) + } + Spacer(Modifier.height(24.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Icon( diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt index 5904024f..c84bce15 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt @@ -14,6 +14,7 @@ import com.miruplay.tv.core.common.WebControlConfig import com.miruplay.tv.core.common.logging.MiruLog import com.miruplay.tv.data.preferences.ScanPreferencesManager import com.miruplay.tv.data.preferences.PlaybackPreferencesManager +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend @@ -165,6 +166,9 @@ class SettingsViewModel @Inject constructor( private val _playbackEndAction = MutableStateFlow(playbackPreferences.endAction) val playbackEndAction: StateFlow = _playbackEndAction.asStateFlow() + private val _episodeVersionSelectionPolicy = MutableStateFlow(playbackPreferences.episodeVersionSelectionPolicy) + val episodeVersionSelectionPolicy: StateFlow = + _episodeVersionSelectionPolicy.asStateFlow() private val _preferredSubtitleLanguage = MutableStateFlow(playbackPreferences.preferredSubtitleLanguage) val preferredSubtitleLanguage: StateFlow = _preferredSubtitleLanguage.asStateFlow() private val _formatAwareToneMappingPreferences = MutableStateFlow( @@ -862,6 +866,11 @@ class SettingsViewModel @Inject constructor( _playbackEndAction.value = action } + fun setEpisodeVersionSelectionPolicy(policy: EpisodeVersionSelectionPolicy) { + playbackPreferences.episodeVersionSelectionPolicy = policy + _episodeVersionSelectionPolicy.value = policy + } + fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) { playbackPreferences.preferredSubtitleLanguage = preference _preferredSubtitleLanguage.value = preference diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibraryScanNoticeTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibraryScanNoticeTest.kt new file mode 100644 index 00000000..7a449783 --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibraryScanNoticeTest.kt @@ -0,0 +1,55 @@ +package com.miruplay.tv.ui.library + +import com.miruplay.tv.model.ScanResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class LibraryScanNoticeTest { + @Test + fun `mlip scan notice reports database counts and generation time`() { + val notice = libraryScanNotice( + results = listOf( + ScanResult( + animeName = "动漫", + episodesFound = 4177, + newEpisodes = 12, + summary = "MLIP:259 部动漫,2672 集,4177 个文件;library.db 生成于 2026-07-22T07:55:30Z", + ), + ), + sourceFailures = emptyList(), + ) + + assertEquals( + "MLIP:259 部动漫,2672 集,4177 个文件;library.db 生成于 2026-07-22T07:55:30Z", + notice, + ) + } + + @Test + fun `mixed scan notice includes summarized and ordinary sources`() { + val notice = libraryScanNotice( + results = listOf( + ScanResult( + animeName = "MLIP", + episodesFound = 4177, + newEpisodes = 12, + summary = "MLIP:259 部动漫,2672 集,4177 个文件", + ), + ScanResult(animeName = "Local", episodesFound = 8, newEpisodes = 2), + ), + sourceFailures = emptyList(), + ) + + assertEquals("MLIP:259 部动漫,2672 集,4177 个文件;扫描完成:8 个文件", notice) + } + + @Test + fun `scan notice keeps failure visible when old content remains`() { + val notice = libraryScanNotice( + results = emptyList(), + sourceFailures = listOf("未找到媒体库索引:library.db"), + ) + + assertEquals("扫描失败:未找到媒体库索引:library.db", notice) + } +} diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt index 859d11ce..022ec4a3 100644 --- a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt @@ -7,6 +7,7 @@ import com.miruplay.tv.data.preferences.PlaybackPreferencesManager import com.miruplay.tv.data.preferences.ScanPreferencesManager import com.miruplay.tv.mediasource.MediaSourceFactory import com.miruplay.tv.model.CloudDriveAutomationConfig +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.SubtitleLanguagePreference @@ -89,6 +90,7 @@ class SettingsViewModelTokenTest { every { scanPreferences.posterWallArrangement } returns PosterWallArrangement.TITLE every { playbackPreferences.endAction } returns PlaybackEndAction.RETURN_TO_DETAIL + every { playbackPreferences.episodeVersionSelectionPolicy } returns EpisodeVersionSelectionPolicy.AUTO_NEAREST every { playbackPreferences.preferredSubtitleLanguage } returns SubtitleLanguagePreference.AUTO every { playbackPreferences.formatAwareToneMappingPreferences } returns FormatAwareToneMappingPreferences() @@ -158,6 +160,7 @@ class SettingsViewModelTokenTest { coVerify(exactly = 1) { appModePreferences.setCurrentAppMode(AppMode.DRAMA) } assertEquals(AppMode.DRAMA, viewModel.currentAppMode.value) + assertEquals(EpisodeVersionSelectionPolicy.AUTO_NEAREST, viewModel.episodeVersionSelectionPolicy.value) } private fun createViewModel(): SettingsViewModel = diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoader.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoader.kt index b9512f2c..b48c2c04 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoader.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoader.kt @@ -1,6 +1,9 @@ package com.miruplay.tv.webcontrol import com.miruplay.tv.model.Episode +import com.miruplay.tv.model.ProgressRecord +import com.miruplay.tv.model.availableVersions +import com.miruplay.tv.model.withVersion import com.miruplay.tv.repository.LibraryAnimeResolver import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaSourceRepository @@ -41,12 +44,21 @@ class WebControlLibraryLoader( val detail = animeResolver.loadAnimeDetail(animeId) ?: throw IllegalArgumentException("番剧不存在") return detail.anime.toWebControlAnimeDetail(detail.episodes) { episode -> - progress.getProgress(episode.id).getOrNull() + (listOf(episode.progressId) + episode.availableVersions().map { it.episodeId }) + .distinct() + .mapNotNull { progress.getProgress(it).getOrNull() } + .maxByOrNull(ProgressRecord::lastWatched) } } suspend fun findEpisodeById(episodeId: String): Episode? { - return episodeResolver.findEpisodeById(episodeId) + val physical = episodeResolver.findEpisodeById(episodeId) ?: return null + val logical = animeResolver.loadAnimeDetail(physical.animeId) + ?.episodes + ?.firstOrNull { episode -> episode.availableVersions().any { it.episodeId == episodeId } } + ?: return physical + val version = logical.availableVersions().first { it.episodeId == episodeId } + return logical.withVersion(version) } suspend fun loadContinueWatching(): List = diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt index 98ff7ac9..2d332a32 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt @@ -109,6 +109,7 @@ data class SourceScanResponse( val episodesFound: Int, val newEpisodes: Int, val updatedEpisodes: Int, + val summary: String? = null, val error: String? = null, ) @@ -334,9 +335,11 @@ data class ScanSettingsRequest( @Serializable data class PlaybackSettingsDto( val endAction: String, + val episodeVersionSelectionPolicy: String = "auto_nearest", val preferredSubtitleLanguage: String, val formatAwareToneMapping: FormatAwareToneMappingPreferences, val endActionOptions: List = listOf("return_to_detail", "play_next_episode"), + val episodeVersionSelectionPolicyOptions: List = listOf("auto_nearest", "manual"), val preferredSubtitleLanguageOptions: List = listOf("auto", "zh_hans", "zh_hant", "zh", "en", "ja"), ) @@ -344,6 +347,7 @@ data class PlaybackSettingsDto( @Serializable data class PlaybackSettingsRequest( val endAction: String? = null, + val episodeVersionSelectionPolicy: String? = null, val preferredSubtitleLanguage: String? = null, val formatAwareToneMapping: FormatAwareToneMappingPreferences? = null, ) @@ -654,6 +658,7 @@ data class WebPlaybackSource( val mediaSourceId: String, val startPositionMs: Long = 0L, val episodeId: String? = null, + val progressId: String? = episodeId, ) fun MediaSourceInfo.safeForApi(): MediaSourceInfo = copy( diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlPlayback.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlPlayback.kt index a3977dd8..c0849998 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlPlayback.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlPlayback.kt @@ -32,4 +32,5 @@ fun PlaybackSource.toWebPlaybackSource(): WebPlaybackSource = mediaSourceId = mediaSourceId, startPositionMs = startPosition, episodeId = episodeId, + progressId = progressId, ) diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequests.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequests.kt index 73bd395c..e2ebf4a1 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequests.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequests.kt @@ -200,6 +200,7 @@ fun ScanResult.toWebControlSourceScanResponse(sourceId: Long): SourceScanRespons episodesFound = episodesFound, newEpisodes = newEpisodes, updatedEpisodes = updatedEpisodes, + summary = summary, ) fun toWebControlSourceScanResponse( @@ -208,6 +209,7 @@ fun toWebControlSourceScanResponse( episodesFound: Int, newEpisodes: Int, updatedEpisodes: Int, + summary: String? = null, ): SourceScanResponse = SourceScanResponse( sourceId = sourceId, @@ -215,6 +217,7 @@ fun toWebControlSourceScanResponse( episodesFound = episodesFound.coerceAtLeast(0), newEpisodes = newEpisodes.coerceAtLeast(0), updatedEpisodes = updatedEpisodes.coerceAtLeast(0), + summary = summary?.takeIf(String::isNotBlank), ) fun MediaSourceInfo.toWebControlSourceScanErrorResponse(message: String): SourceScanResponse = diff --git a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoaderTest.kt b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoaderTest.kt index 77911e56..6ff27748 100644 --- a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoaderTest.kt +++ b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlLibraryLoaderTest.kt @@ -174,6 +174,30 @@ class WebControlLibraryLoaderTest { assertEquals(45_000L, item.positionMs) } + @Test + fun `find indexed version retains logical progress and sibling versions`() = runBlocking { + val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) + val webPath = "D:/Anime/Show/WEB/Episode 01.mkv" + val loader = loader( + sources = listOf(source), + entries = listOf( + MediaIndexEntry(sourceId = 1L, path = webPath, animeName = "Show", episodeNumber = 1), + MediaIndexEntry( + sourceId = 1L, + path = "D:/Anime/Show/BD/Episode 01.mkv", + animeName = "Show", + episodeNumber = 1, + ), + ), + ) + + val resolved = loader.findEpisodeById("1:$webPath") + + assertEquals("1:$webPath", resolved?.id) + assertEquals("Show#S1E1", resolved?.progressId) + assertEquals(2, resolved?.versions?.size) + } + @Test fun `find episode falls back to cached metadata when indexed lookup misses`() = runBlocking { val cached = Episode( @@ -188,7 +212,11 @@ class WebControlLibraryLoaderTest { cachedEpisodes = mapOf("cached" to listOf(cached)), ) - assertEquals(cached, loader.findEpisodeById("cached-episode")) + val resolved = loader.findEpisodeById("cached-episode") + + assertEquals(cached.id, resolved?.id) + assertEquals("cached#S1E1", resolved?.progressId) + assertEquals(listOf(cached.id), resolved?.versions?.map { it.episodeId }) assertNull(loader.findEpisodeById("missing")) } diff --git a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt index a9da19c8..f70b3c10 100644 --- a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt +++ b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt @@ -56,16 +56,19 @@ class WebControlSettingsRouteTest { session( method = NanoHTTPD.Method.PUT, uri = "/api/settings/playback", - body = """{"preferredSubtitleLanguage":"zh_hans"}""", + body = """{"episodeVersionSelectionPolicy":"manual","preferredSubtitleLanguage":"zh_hans"}""", ), ) assertEquals(NanoHTTPD.Response.Status.OK, put.status) + assertEquals("manual", service.capturedPlayback?.episodeVersionSelectionPolicy) assertEquals("zh_hans", service.capturedPlayback?.preferredSubtitleLanguage) val get = server.serve(session(method = NanoHTTPD.Method.GET, uri = "/api/settings/playback", body = "")) assertEquals(NanoHTTPD.Response.Status.OK, get.status) val body = get.bodyText() assertTrue(body.contains("\"endAction\":\"return_to_detail\"")) + assertTrue(body.contains("\"episodeVersionSelectionPolicy\":\"auto_nearest\"")) + assertTrue(body.contains("\"episodeVersionSelectionPolicyOptions\"")) assertTrue(body.contains("\"preferredSubtitleLanguage\":\"zh_hans\"")) assertTrue(body.contains("\"preferredSubtitleLanguageOptions\"")) assertTrue(body.contains("\"formatAwareToneMapping\"")) diff --git a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequestsTest.kt b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequestsTest.kt index 89e0f291..0b215995 100644 --- a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequestsTest.kt +++ b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSourceRequestsTest.kt @@ -462,6 +462,7 @@ class WebControlSourceRequestsTest { episodesFound = 3, newEpisodes = 2, updatedEpisodes = 1, + summary = "MLIP:1 部动漫,3 集,3 个文件", ).toWebControlSourceScanResponse(sourceId = 7L) assertEquals(7L, response.sourceId) @@ -469,6 +470,7 @@ class WebControlSourceRequestsTest { assertEquals(3, response.episodesFound) assertEquals(2, response.newEpisodes) assertEquals(1, response.updatedEpisodes) + assertEquals("MLIP:1 部动漫,3 集,3 个文件", response.summary) } @Test diff --git a/web-control/frontend/src/App.vue b/web-control/frontend/src/App.vue index a7126a2d..59c59bea 100644 --- a/web-control/frontend/src/App.vue +++ b/web-control/frontend/src/App.vue @@ -1080,6 +1080,12 @@ :options="playbackSettings.endActionOptions.map((value) => ({ value, label: endActionLabels[value] || value }))" /> + + + -
+
第 {{ item.episode.episodeNumber }} 集 · {{ episodeLabel(item.episode) }} {{ item.progressMs ? `已看到 ${formatTime(item.progressMs)}` : '未观看' }} +
+
+ {{ version.filePath }} + + 播放此版本 + +
+
- - 播放 -
@@ -1637,13 +1648,16 @@ const scanForm = reactive({ }) const playbackSettings = reactive({ endAction: 'return_to_detail', + episodeVersionSelectionPolicy: 'auto_nearest', preferredSubtitleLanguage: 'auto', formatAwareToneMapping: null, endActionOptions: ['return_to_detail', 'play_next_episode'], + episodeVersionSelectionPolicyOptions: ['auto_nearest', 'manual'], preferredSubtitleLanguageOptions: ['auto', 'zh_hans', 'zh_hant', 'zh', 'en', 'ja'] }) const playbackForm = reactive({ endAction: 'return_to_detail', + episodeVersionSelectionPolicy: 'auto_nearest', preferredSubtitleLanguage: 'auto', defaultBackend: '' }) @@ -1712,6 +1726,7 @@ let cloudRunTimer = 0 const appModeLabels = { anime: '动画', drama: '剧集' } const posterWallArrangementLabels = { TITLE: '按标题', RELEASE_SEASON: '按新番季' } const endActionLabels = { return_to_detail: '返回详情页', play_next_episode: '播放下一集' } +const episodeVersionSelectionPolicyLabels = { auto_nearest: '自动选择相近路径', manual: '每集手动选择版本' } const subtitleLanguageLabels = { auto: '自动', zh_hans: '简体中文', @@ -2404,7 +2419,7 @@ async function scanSource(sourceId) { if (result?.error) { ElMessage.warning(`扫描失败:${result.error}`) } else { - ElMessage.success(`扫描完成:${result.episodesFound} 个文件`) + ElMessage.success(result.summary || `扫描完成:${result.episodesFound} 个文件`) } await loadLibrary() } catch (e) { @@ -2419,6 +2434,7 @@ async function scanAll() { try { const results = await api('/api/sources/scan-all', { method: 'POST' }) const count = results.reduce((sum, item) => sum + item.episodesFound, 0) + const summaries = results.map(item => item?.summary).filter(Boolean) const failedItems = results.filter(item => item?.error) if (failedItems.length > 0) { const failedSourceNames = failedItems @@ -2428,7 +2444,7 @@ async function scanAll() { const moreSuffix = failedItems.length > 3 ? ` 等 ${failedItems.length} 个源` : '' ElMessage.warning(`扫描部分完成:${count} 个文件,${failedItems.length} 个源失败(${failedSourceNames}${moreSuffix})`) } else { - ElMessage.success(`扫描完成:${count} 个文件`) + ElMessage.success(summaries.length > 0 ? summaries.join(';') : `扫描完成:${count} 个文件`) } await loadLibrary() } catch (e) { @@ -2953,13 +2969,21 @@ async function saveScanSettings() { } } +function episodeVersions(episode) { + if (Array.isArray(episode?.versions) && episode.versions.length > 0) return episode.versions + return [{ episodeId: episode.id, filePath: episode.filePath, fileName: episode.fileName }] +} + function applyPlaybackSettings(data) { playbackSettings.endAction = data.endAction || 'return_to_detail' + playbackSettings.episodeVersionSelectionPolicy = data.episodeVersionSelectionPolicy || 'auto_nearest' playbackSettings.preferredSubtitleLanguage = data.preferredSubtitleLanguage || 'auto' playbackSettings.formatAwareToneMapping = data.formatAwareToneMapping || null playbackSettings.endActionOptions = data.endActionOptions || ['return_to_detail', 'play_next_episode'] + playbackSettings.episodeVersionSelectionPolicyOptions = data.episodeVersionSelectionPolicyOptions || ['auto_nearest', 'manual'] playbackSettings.preferredSubtitleLanguageOptions = data.preferredSubtitleLanguageOptions || ['auto', 'zh_hans', 'zh_hant', 'zh', 'en', 'ja'] playbackForm.endAction = playbackSettings.endAction + playbackForm.episodeVersionSelectionPolicy = playbackSettings.episodeVersionSelectionPolicy playbackForm.preferredSubtitleLanguage = playbackSettings.preferredSubtitleLanguage playbackForm.defaultBackend = playbackSettings.formatAwareToneMapping?.defaultBackend || '' } @@ -2979,6 +3003,7 @@ async function savePlaybackSettings() { const existing = playbackSettings.formatAwareToneMapping || {} const payload = { endAction: playbackForm.endAction, + episodeVersionSelectionPolicy: playbackForm.episodeVersionSelectionPolicy, preferredSubtitleLanguage: playbackForm.preferredSubtitleLanguage, formatAwareToneMapping: { ...existing, defaultBackend: playbackForm.defaultBackend } } diff --git a/web-control/frontend/src/styles.css b/web-control/frontend/src/styles.css index 2d4239d1..989a2fcf 100644 --- a/web-control/frontend/src/styles.css +++ b/web-control/frontend/src/styles.css @@ -742,6 +742,23 @@ body { display: block; } +.episode-summary { + grid-column: 1 / -1; +} + +.episode-versions { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; +} + +.episode-version { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; +} + .break-text { overflow-wrap: anywhere; word-break: break-word; diff --git a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt index 7d7ec797..cdfc1e69 100644 --- a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt +++ b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt @@ -12,6 +12,7 @@ import com.miruplay.tv.clouddrive.CloudDriveClient import com.miruplay.tv.core.common.logging.MiruLog import com.miruplay.tv.mediasource.MediaSourceFactory import com.miruplay.tv.model.Episode +import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.SubtitleLanguagePreference @@ -38,6 +39,7 @@ import com.miruplay.tv.repository.MediaSourceRepository import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.PlaybackProgressRepository import com.miruplay.tv.repository.PlaybackPreferencesRepository +import com.miruplay.tv.repository.progressFor import com.miruplay.tv.repository.ScanPreferencesRepository import com.miruplay.tv.repository.WebControlAccessManager import com.miruplay.tv.scanner.LibraryScanState @@ -297,7 +299,7 @@ class WebControlService @Inject constructor( request: PlayEpisodeRequest, episode: Episode, ): PlaybackStatusDto { - val progress = progressRepository.getProgress(episode.id).getOrNull() + val progress = progressRepository.progressFor(episode) navigator.openPlayer( request.toWebControlPlaybackSource(episode, progress).toWebPlaybackSource() ) @@ -706,10 +708,12 @@ class WebControlService @Inject constructor( override suspend fun getPlaybackSettings(): PlaybackSettingsDto = runOnIo { val endAction = playbackPreferencesRepository.getEndAction() + val episodeVersionSelectionPolicy = playbackPreferencesRepository.getEpisodeVersionSelectionPolicy() val preferredSubtitleLanguage = playbackPreferencesRepository.getPreferredSubtitleLanguage() val toneMapping = playbackPreferencesRepository.getFormatAwareToneMappingPreferences().normalized() PlaybackSettingsDto( endAction = endAction.storageValue, + episodeVersionSelectionPolicy = episodeVersionSelectionPolicy.storageValue, preferredSubtitleLanguage = preferredSubtitleLanguage.storageValue, formatAwareToneMapping = toneMapping, ) @@ -719,6 +723,11 @@ class WebControlService @Inject constructor( request.endAction?.let { value -> playbackPreferencesRepository.setEndAction(PlaybackEndAction.fromStorageValue(value)) } + request.episodeVersionSelectionPolicy?.let { value -> + playbackPreferencesRepository.setEpisodeVersionSelectionPolicy( + EpisodeVersionSelectionPolicy.fromStorageValue(value), + ) + } request.preferredSubtitleLanguage?.let { value -> playbackPreferencesRepository.setPreferredSubtitleLanguage( SubtitleLanguagePreference.fromStorageValue(value),