diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e02aca7..e131106a 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 = "0.11.0" +val baseAppVersionName = "1.0.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/MediaSourceInfoConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/MediaSourceInfoConventions.kt index 4c8c27ff..7cb8fc4c 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/MediaSourceInfoConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/MediaSourceInfoConventions.kt @@ -83,9 +83,6 @@ object MediaSourceInfoConventions { if (recognitionMode != MediaRecognitionMode.DIRECTORY) { put(CONNECTION_RECOGNITION_MODE, recognitionMode.name) } - if (recognitionMode == MediaRecognitionMode.MLIP && mlipMetadataMode != MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY) { - put(CONNECTION_MLIP_METADATA_MODE, mlipMetadataMode.name) - } } fun sourceConnectionInfoFromPersistence( @@ -233,23 +230,10 @@ fun MediaSourceInfo.withRecognitionMode(mode: MediaRecognitionMode): MediaSource ) fun MediaSourceInfo.mlipMetadataMode(): MlipMetadataMode = - if (recognitionMode() != MediaRecognitionMode.MLIP) { - MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY - } else { - connectionInfo[MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE] - ?.trim() - ?.let { value -> MlipMetadataMode.entries.firstOrNull { it.name.equals(value, ignoreCase = true) } } - ?: MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY - } + MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY fun MediaSourceInfo.withMlipMetadataMode(mode: MlipMetadataMode): MediaSourceInfo = - copy( - connectionInfo = if (recognitionMode() != MediaRecognitionMode.MLIP || mode == MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY) { - connectionInfo - MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE - } else { - connectionInfo + (MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE to mode.name) - }, - ) + copy(connectionInfo = connectionInfo - MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE) fun MediaSourceInfo.sourceLocation(): String? = localRootPath() ?: remoteUrl() diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/MediaSourceInfoConventionsTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/MediaSourceInfoConventionsTest.kt index d0b5af94..29e39520 100644 --- a/core/model/src/test/kotlin/com/miruplay/tv/model/MediaSourceInfoConventionsTest.kt +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/MediaSourceInfoConventionsTest.kt @@ -225,7 +225,7 @@ class MediaSourceInfoConventionsTest { } @Test - fun `mlip metadata mode defaults to library db local priority and stores files only`() { + fun `legacy mlip metadata mode is ignored`() { val defaultSource = source(connectionInfo = emptyMap()) val filesOnlyInfo = MediaSourceInfoConventions.sourceConnectionInfo( type = MediaSourceType.WEBDAV, @@ -242,8 +242,8 @@ class MediaSourceInfoConventionsTest { val directorySource = filesOnlySource.withRecognitionMode(MediaRecognitionMode.DIRECTORY) assertEquals(MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY, defaultSource.mlipMetadataMode()) - assertEquals(MlipMetadataMode.FILES_ONLY, filesOnlySource.mlipMetadataMode()) - assertEquals("FILES_ONLY", filesOnlyInfo[MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE]) + assertEquals(MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY, filesOnlySource.mlipMetadataMode()) + assertFalse(MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE in filesOnlyInfo) assertFalse(MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE in defaultMlipInfo) assertFalse(MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE in directorySource.connectionInfo) assertFalse( diff --git a/data/src/main/kotlin/com/miruplay/tv/data/dao/EpisodeDao.kt b/data/src/main/kotlin/com/miruplay/tv/data/dao/EpisodeDao.kt index ad9a13d5..002e03b5 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/dao/EpisodeDao.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/dao/EpisodeDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.miruplay.tv.data.entity.EpisodeEntity private const val EPISODE_COLUMNS = "id, anime_id, season_number, episode_number, title, file_path, file_name, duration, thumbnail_path, bangumi_episode_id, bangumi_collection_type, last_updated" @@ -13,6 +14,12 @@ interface EpisodeDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(episodes: List) + @Transaction + suspend fun replaceByAnimeId(animeId: String, episodes: List) { + deleteByAnimeId(animeId) + insertAll(episodes) + } + @Query("SELECT $EPISODE_COLUMNS FROM episode WHERE anime_id = :animeId ORDER BY season_number ASC, episode_number ASC") suspend fun getByAnimeId(animeId: String): List diff --git a/data/src/main/kotlin/com/miruplay/tv/data/repository/MetadataRepositoryImpl.kt b/data/src/main/kotlin/com/miruplay/tv/data/repository/MetadataRepositoryImpl.kt index 3a20997d..8484e3d2 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/repository/MetadataRepositoryImpl.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/repository/MetadataRepositoryImpl.kt @@ -104,9 +104,8 @@ class MetadataRepositoryImpl @Inject constructor( override suspend fun cacheEpisodes(animeId: String, episodes: List): Result = withContext(Dispatchers.IO) { try { - episodeDao.deleteByAnimeId(animeId) val entities = episodes.map { it.toEntity(animeId, it.seasonNumber) } - episodeDao.insertAll(entities) + episodeDao.replaceByAnimeId(animeId, entities) // Also update episode count in cached anime metadata animeDao.getById(animeId)?.let { animeEntity -> animeDao.insert(animeEntity.copy( diff --git a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopSourceScan.kt b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopSourceScan.kt index e8088ad2..c70b0e68 100644 --- a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopSourceScan.kt +++ b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopSourceScan.kt @@ -1,12 +1,15 @@ package com.miruplay.tv.desktop +import com.miruplay.tv.core.common.AppError import com.miruplay.tv.core.common.Result import com.miruplay.tv.mediasource.desktop.desktopSourceFromInfo +import com.miruplay.tv.model.MediaRecognitionMode import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.ScanResult import com.miruplay.tv.model.cloudRssRescanStartedStatus import com.miruplay.tv.model.localRootPath +import com.miruplay.tv.model.recognitionMode import com.miruplay.tv.model.scanResultDisplayName import com.miruplay.tv.model.sourcePickerTitle import com.miruplay.tv.repository.MediaIndexEntry @@ -47,6 +50,9 @@ internal suspend fun scanAndIndexDesktopSource( metadataRepository: MetadataRepository, scanner: DesktopMediaLibraryScanner = DesktopMediaLibraryScanner(), ): Result { + if (sourceInfo.recognitionMode() == MediaRecognitionMode.MLIP) { + return Result.failure(AppError.LibraryIndexError.ReadFailed("桌面版暂不支持导入 MLIP library.db")) + } val source = desktopSourceFromInfo(sourceInfo) val rootPath = when (sourceInfo.type) { MediaSourceType.LOCAL -> sourceInfo.localRootPath().orEmpty() diff --git a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt index 7d4115a6..798b15c6 100644 --- a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt +++ b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt @@ -55,6 +55,7 @@ import com.miruplay.tv.model.CloudDriveLibraryMode import com.miruplay.tv.model.PLAYBACK_SEEK_BACK_SECONDS import com.miruplay.tv.model.PLAYBACK_SEEK_FORWARD_SECONDS import com.miruplay.tv.model.PLAYBACK_SPEED_NORMAL +import com.miruplay.tv.model.MediaRecognitionMode import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceInfoConventions import com.miruplay.tv.model.MediaPathConventions @@ -68,6 +69,7 @@ import com.miruplay.tv.model.RssSubscriptionFormResult import com.miruplay.tv.model.RssSubscriptionInfo import com.miruplay.tv.model.ScraperResult import com.miruplay.tv.model.cloudRssScheduledSyncCompleteStatus +import com.miruplay.tv.model.recognitionMode import com.miruplay.tv.model.cloudRssInitialStatus import com.miruplay.tv.model.cloudRssRescanStartedStatus import com.miruplay.tv.model.cloudRssLinkedScanSourceStatus @@ -480,6 +482,9 @@ internal fun MiruPlayDesktopComposeApp( indexRepository = repositories.index, metadataRepository = repositories.metadata, bangumiScraper = bangumiScraper, + isSourceMetadataReadOnly = { sourceId -> + repositories.mediaSources.getSourceById(sourceId).getOrNull()?.recognitionMode() == MediaRecognitionMode.MLIP + }, ) } val bangumiSyncCore = remember(bangumiScraper, repositories) { diff --git a/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopScanIndexIntegrationTest.kt b/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopScanIndexIntegrationTest.kt index 598eac90..99692ab2 100644 --- a/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopScanIndexIntegrationTest.kt +++ b/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopScanIndexIntegrationTest.kt @@ -2,7 +2,10 @@ package com.miruplay.tv.desktop import com.miruplay.tv.core.common.Result import com.miruplay.tv.mediasource.desktop.DesktopLocalMediaSource +import com.miruplay.tv.model.MediaRecognitionMode +import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceInfoConventions +import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.libraryScanCompleteStatus import com.miruplay.tv.model.cloudRssScheduledSyncCompleteStatus import com.miruplay.tv.model.libraryRescanCompleteStatus @@ -62,6 +65,34 @@ class DesktopScanIndexIntegrationTest { } } + @Test + fun `desktop scan rejects MLIP instead of treating it as a directory source`() = runBlocking { + val storeRoot = Files.createTempDirectory("miruplay-desktop-store") + try { + val repositories = DesktopRepositories.fileBacked(storeRoot.resolve("store.json")) + val source = MediaSourceInfo( + id = 7L, + name = "MLIP WebDAV", + type = MediaSourceType.WEBDAV, + connectionInfo = MediaSourceInfoConventions.sourceConnectionInfo( + type = MediaSourceType.WEBDAV, + location = "https://example.test/anime", + recognitionMode = MediaRecognitionMode.MLIP, + ), + ) + + val result = scanAndIndexDesktopSource( + sourceInfo = source, + indexRepository = repositories.index, + metadataRepository = repositories.metadata, + ) + + assertTrue(result is Result.Error) + } finally { + storeRoot.toFile().deleteRecursively() + } + } + @Test fun `cloud rss linked source rescan refreshes repository index`() = runBlocking { val mediaRoot = Files.createTempDirectory("miruplay-cloud-rss-rescan") diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexDisplay.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexDisplay.kt index 7f0a9d10..8da54ab6 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexDisplay.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MediaIndexDisplay.kt @@ -59,6 +59,11 @@ fun MediaIndexEntry.clearExternalMetadata(sourceId: Long = this.sourceId): Media scrapedAt = 0L, ) +fun MediaIndexEntry.isMlipManagedMetadata(): Boolean = + metadataSource.equals("MLIP", ignoreCase = true) || + metadataId?.startsWith("mlip:") == true || + localMetadataOverrideKey()?.startsWith("mlip:") == true + fun MediaIndexEntry.localMetadataOverrideKey(): String? = scrapeMessage ?.takeIf { it.startsWith(LOCAL_METADATA_OVERRIDE_PREFIX) } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataBatchPlanner.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataBatchPlanner.kt index b9d0dd6e..59f7a8aa 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataBatchPlanner.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataBatchPlanner.kt @@ -139,6 +139,9 @@ fun metadataSearchResultStatus( fun metadataSourceRequiredStatus(): String = metadataSourceRequiredTvStatus() +fun mlipMetadataReadOnlyStatus(): String = + "MLIP 元数据由 library.db 管理,请在远端修正后重新扫描。" + fun MetadataBatchMatch.selectedReviewStatus(): String = metadataSelectedBatchReviewTvStatus(query) diff --git a/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedMediaIndexRepository.kt b/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedMediaIndexRepository.kt index dce39e81..95382023 100644 --- a/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedMediaIndexRepository.kt +++ b/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedMediaIndexRepository.kt @@ -59,7 +59,8 @@ internal class FileBackedMediaIndexRepository( state.index .asSequence() .filter { it.sourceId == sourceId } - .mapNotNull { it.animeName?.takeIf(String::isNotBlank) } + .flatMap { entry -> sequenceOf(entry.metadataId, entry.animeName, entry.metadataTitle) } + .mapNotNull { it?.takeIf(String::isNotBlank) } .distinct() .sorted() .toList() diff --git a/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt b/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt index aeab5a4b..c6ce5cb4 100644 --- a/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt +++ b/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt @@ -370,6 +370,8 @@ class DesktopRepositoriesTest { animeName = "Show B", episodeTitle = "A Good Episode", plot = "The indexed plot", + metadataId = "mlip:7:show-b", + metadataTitle = "节目 B", ), ), ) @@ -384,7 +386,7 @@ class DesktopRepositoriesTest { assertEquals("D:/Anime/B/01.mkv", plotQuery.data.single().path) val names = repositories.index.getAnimeInIndex(7L) as Result.Success - assertEquals(listOf("Show A", "Show B"), names.data) + assertEquals(setOf("Show A", "Show B", "mlip:7:show-b", "节目 B"), names.data.toSet()) repositories.index.clearIndex(7L) val cleared = repositories.index.queryIndex(7L, "") as Result.Success 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 be6178fe..3a76ec87 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -11,15 +11,12 @@ import com.miruplay.tv.model.MediaFileConventions import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceType -import com.miruplay.tv.model.MlipMetadataMode import com.miruplay.tv.model.Episode -import com.miruplay.tv.model.mlipMetadataMode import com.miruplay.tv.repository.MediaIndexEntry import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaScrapeStatus import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.localMetadataOverrideKey -import com.miruplay.tv.repository.localMetadataOverrideMessage import java.io.File import java.security.MessageDigest import javax.inject.Inject @@ -70,36 +67,69 @@ class MlipLibraryIndexImporter @Inject constructor( databaseFile.delete() } - val previousEntries = indexRepository.queryIndex(source.id, "").getOrNull().orEmpty() - val localOverrides = MlipLocalMetadataOverrides(previousEntries) - val metadataMode = source.mlipMetadataMode() - val mediaFiles = snapshot.mediaFiles.map { mediaFile -> - mediaFile.copy( - indexEntry = mediaFile.indexEntry.withMlipMetadataPolicy(metadataMode, localOverrides), - ) + val previousEntries = when (val queried = indexRepository.queryIndex(source.id, "")) { + is Result.Success -> queried.data + is Result.Error -> return@withContext Result.failure(queried.error) } + val mediaFiles = snapshot.mediaFiles val entries = mediaFiles.map { it.indexEntry } when (val rebuilt = indexRepository.rebuildIndex(source.id, entries)) { is Result.Success -> Unit is Result.Error -> return@withContext Result.failure(rebuilt.error) } + val incomingAnimeIds = snapshot.series.mapTo(mutableSetOf()) { it.anime.id } + previousEntries.mapNotNull(MediaIndexEntry::mlipAnimeIdOrNull) + .filterNot(incomingAnimeIds::contains) + .distinct() + .forEach { animeId -> + when (val invalidated = metadataRepository.invalidateCache(animeId)) { + is Result.Success -> Unit + is Result.Error -> return@withContext Result.failure(invalidated.error) + } + } + var artworkCachedCount = 0 for (series in snapshot.series) { val seriesFiles = mediaFiles.filter { it.seriesId == series.id } - if (metadataMode == MlipMetadataMode.FILES_ONLY || seriesFiles.any { it.indexEntry.hasLocalMetadataOverride() }) { - continue + val cachedAnime = metadataRepository.getCachedMetadata(series.anime.id).getOrNull() + val cachedEpisodeList = metadataRepository.getCachedEpisodes(series.anime.id).getOrNull().orEmpty() + val cachedEpisodesById = cachedEpisodeList.associateBy(Episode::id) + val cachedEpisodesByNumber = cachedEpisodeList + .groupBy { it.seasonNumber to it.episodeNumber } + .mapNotNull { (key, matches) -> matches.singleOrNull()?.let { key to it } } + .toMap() + val episodes = seriesFiles.map { file -> + val incoming = file.episode + val cached = cachedEpisodesById[incoming.id] + ?: cachedEpisodesByNumber[incoming.seasonNumber to incoming.episodeNumber] + incoming.copy( + id = cached?.id ?: incoming.id, + watchedPosition = cached?.watchedPosition ?: incoming.watchedPosition, + lastWatchedTimestamp = cached?.lastWatchedTimestamp ?: incoming.lastWatchedTimestamp, + playCount = cached?.playCount ?: incoming.playCount, + thumbnailPath = cached?.thumbnailPath ?: incoming.thumbnailPath, + bangumiEpisodeId = cached?.bangumiEpisodeId, + bangumiCollectionType = cached?.bangumiCollectionType, + ) + } + when (val cached = metadataRepository.cacheEpisodes(series.anime.id, episodes)) { + is Result.Success -> Unit + is Result.Error -> return@withContext Result.failure(cached.error) } - val episodes = seriesFiles.map { it.episode } - metadataRepository.cacheEpisodes(series.anime.id, episodes) val posterLocalPath = series.posterPath ?.let { poster -> cacheArtwork(mediaSource, poster, posterCacheDirectory, source.id, series.uuid) } ?.also { artworkCachedCount += 1 } - metadataRepository.cacheMetadata( + when (val cached = metadataRepository.cacheMetadata( series.anime.copy( episodeCount = episodes.size, posterLocalPath = posterLocalPath ?: series.anime.posterLocalPath, + bangumiCollectionType = cachedAnime?.bangumiCollectionType, + bangumiEpStatus = cachedAnime?.bangumiEpStatus ?: series.anime.bangumiEpStatus, ), - ) + )) { + is Result.Success -> Unit + is Result.Error -> return@withContext Result.failure(cached.error) + } } val result = MlipImportResult( seriesCount = snapshot.series.size, @@ -507,96 +537,10 @@ private data class MlipMediaFile( val episode: Episode, ) -private class MlipLocalMetadataOverrides(entries: List) { - private val overrides = entries - .mapNotNull { it.toLocalOverride() } - .distinctBy { it.key } - private val byKey = overrides.associateBy { it.key } - private val byPath = overrides.associateBy { it.path } - private val byTitle = overrides - .flatMap { override -> override.titleKeys.map { key -> key to override } } - .groupBy({ it.first }, { it.second }) - .mapNotNull { (key, matches) -> matches.singleOrNull()?.let { key to it } } - .toMap() - - fun forEntry(entry: MediaIndexEntry): MlipLocalMetadataOverride? = - entry.metadataId?.let(byKey::get) - ?: byPath[entry.path] - ?: entry.titleKeys().firstNotNullOfOrNull(byTitle::get) -} - -private data class MlipLocalMetadataOverride( - val key: String, - val path: String, - val animeName: String?, - val metadataSource: String?, - val metadataId: String?, - val metadataTitle: String?, - val scrapedAt: Long, -) { - val titleKeys: List = listOfNotNull(animeName, metadataTitle) - .mapNotNull(::mlipOverrideTitleKey) - .distinct() -} - -private fun MediaIndexEntry.titleKeys(): List = listOfNotNull(animeName, metadataTitle) - .mapNotNull(::mlipOverrideTitleKey) - .distinct() - -private fun mlipOverrideTitleKey(title: String): String? = title - .trim() - .lowercase() - .takeIf { it.isNotBlank() } - -private fun MediaIndexEntry.toLocalOverride(): MlipLocalMetadataOverride? { - val source = metadataSource?.takeIf { it.isNotBlank() } ?: return null - if (source.equals(MLIP_METADATA_SOURCE, ignoreCase = true)) return null - val id = metadataId?.takeIf { it.isNotBlank() } ?: return null - val pathKey = path.takeIf { it.isNotBlank() } - val key = localMetadataOverrideKey() ?: pathKey ?: return null - return MlipLocalMetadataOverride( - key = key, - path = path, - animeName = animeName, - metadataSource = source, - metadataId = id, - metadataTitle = metadataTitle, - scrapedAt = scrapedAt, - ) -} - -private fun MediaIndexEntry.withMlipMetadataPolicy( - mode: MlipMetadataMode, - overrides: MlipLocalMetadataOverrides, -): MediaIndexEntry { - overrides.forEntry(this)?.let { override -> - return copy( - animeName = override.animeName ?: override.metadataTitle ?: animeName, - metadataSource = override.metadataSource, - metadataId = override.metadataId, - metadataTitle = override.metadataTitle ?: override.animeName, - scrapeStatus = MediaScrapeStatus.SCRAPED, - scrapeMessage = metadataId?.let(::localMetadataOverrideMessage), - scrapedAt = override.scrapedAt.takeIf { it > 0L } ?: System.currentTimeMillis(), - ) - } - if (mode != MlipMetadataMode.FILES_ONLY) return this - val indexedTitle = MediaPathConventions.animeNameFromEpisodePath(path) - ?: animeName - ?: MediaPathConventions.stem(path) - return copy( - animeName = indexedTitle, - metadataSource = null, - metadataId = null, - metadataTitle = null, - scrapeStatus = MediaScrapeStatus.PENDING, - scrapeMessage = metadataId?.let(::localMetadataOverrideMessage) ?: "MLIP metadata ignored by source policy", - scrapedAt = 0L, - ) -} - -private fun MediaIndexEntry.hasLocalMetadataOverride(): Boolean = - !metadataSource.isNullOrBlank() && !metadataSource.equals(MLIP_METADATA_SOURCE, ignoreCase = true) +private fun MediaIndexEntry.mlipAnimeIdOrNull(): String? = + metadataId + ?.takeIf { metadataSource.equals(MLIP_METADATA_SOURCE, ignoreCase = true) && it.startsWith("mlip:") } + ?: localMetadataOverrideKey()?.takeIf { it.startsWith("mlip:") } private data class MlipExternalIds( val bangumiId: Int? = null, 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 1a659b28..09b26db3 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt @@ -19,8 +19,6 @@ import com.miruplay.tv.repository.MediaIndexEntry import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaScrapeStatus import com.miruplay.tv.repository.MetadataRepository -import com.miruplay.tv.repository.localMetadataOverrideKey -import com.miruplay.tv.repository.localMetadataOverrideMessage import java.io.ByteArrayInputStream import java.io.File import java.io.InputStream @@ -104,7 +102,7 @@ class MlipLibraryIndexImporterTest { } @Test - fun `files only policy imports paths and skips library metadata cache`() = runBlocking { + fun `legacy files only policy is ignored and library metadata remains authoritative`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") val source = mlipSource(metadataMode = MlipMetadataMode.FILES_ONLY) val indexRepository = RecordingIndexRepository() @@ -123,14 +121,13 @@ class MlipLibraryIndexImporterTest { assertTrue(result.isSuccess()) val entry = indexRepository.entries.single() assertEquals("/Series/01.mkv", entry.path) - assertEquals("Series", entry.animeName) - assertEquals(null, entry.metadataSource) - assertEquals(null, entry.metadataId) - assertEquals(null, entry.metadataTitle) - assertEquals(MediaScrapeStatus.PENDING, entry.scrapeStatus) - assertEquals("mlip:7:series-uuid", entry.localMetadataOverrideKey()) - assertTrue(metadataRepository.anime.isEmpty()) - assertTrue(metadataRepository.episodes.isEmpty()) + assertEquals("中文标题", entry.animeName) + assertEquals("MLIP", entry.metadataSource) + assertEquals("mlip:7:series-uuid", entry.metadataId) + assertEquals("中文标题", entry.metadataTitle) + assertEquals(MediaScrapeStatus.SCRAPED, entry.scrapeStatus) + assertEquals("中文标题", metadataRepository.anime.single().titleCn) + assertEquals("第 1 集", metadataRepository.episodes.single().title) assertEquals(listOf("library.db"), mediaSource.openedPaths) } finally { databaseFile.delete() @@ -138,7 +135,7 @@ class MlipLibraryIndexImporterTest { } @Test - fun `local metadata override survives mlip reimport`() = runBlocking { + fun `mlip reimport replaces local metadata override and preserves user state`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") val source = mlipSource() val indexRepository = RecordingIndexRepository( @@ -151,38 +148,129 @@ class MlipLibraryIndexImporterTest { metadataId = "431767", metadataTitle = "葬送的芙莉莲", scrapeStatus = MediaScrapeStatus.SCRAPED, - scrapeMessage = localMetadataOverrideMessage("mlip:7:series-uuid"), + scrapeMessage = "Local metadata override for mlip:7:series-uuid", scrapedAt = 42L, ) ) ) - val metadataRepository = RecordingMetadataRepository() + val metadataRepository = RecordingMetadataRepository().apply { + anime += Anime( + id = "mlip:7:series-uuid", + title = "旧标题", + bangumiCollectionType = 2, + bangumiEpStatus = 5, + ) + episodes += Episode( + id = "7:/Series/01.mkv", + animeId = "mlip:7:series-uuid", + episodeNumber = 1, + filePath = "/Series/01.mkv", + fileName = "01.mkv", + watchedPosition = 12_000L, + lastWatchedTimestamp = 34_000L, + playCount = 3, + thumbnailPath = "/cache/episode.jpg", + bangumiEpisodeId = 10, + bangumiCollectionType = 2, + ) + } val mediaSource = FakeMediaSource( info = source, streams = mapOf("library.db" to { databaseFile.inputStream() }), ) try { - createMlipDatabase(databaseFile) + createMlipDatabase(databaseFile, mediaPath = "Series/Renamed 01.mkv") val result = MlipLibraryIndexImporter(indexRepository, metadataRepository) .importLibrary(source, mediaSource) assertTrue(result.isSuccess()) val entry = indexRepository.entries.single() - assertEquals("BANGUMI", entry.metadataSource) - assertEquals("431767", entry.metadataId) - assertEquals("葬送的芙莉莲", entry.metadataTitle) - assertEquals("mlip:7:series-uuid", entry.localMetadataOverrideKey()) - assertEquals(42L, entry.scrapedAt) - assertTrue(metadataRepository.anime.isEmpty()) - assertTrue(metadataRepository.episodes.isEmpty()) + assertEquals("/Series/Renamed 01.mkv", entry.path) + assertEquals("MLIP", entry.metadataSource) + assertEquals("mlip:7:series-uuid", entry.metadataId) + assertEquals("中文标题", entry.metadataTitle) + val anime = metadataRepository.anime.single() + assertEquals("中文标题", anime.titleCn) + assertEquals(2, anime.bangumiCollectionType) + assertEquals(5, anime.bangumiEpStatus) + val episode = metadataRepository.episodes.single() + assertEquals("7:/Series/01.mkv", episode.id) + assertEquals("/Series/Renamed 01.mkv", episode.filePath) + assertEquals("第 1 集", episode.title) + assertEquals(12_000L, episode.watchedPosition) + assertEquals(34_000L, episode.lastWatchedTimestamp) + assertEquals(3, episode.playCount) + assertEquals("/cache/episode.jpg", episode.thumbnailPath) + assertEquals(10, episode.bangumiEpisodeId) + assertEquals(2, episode.bangumiCollectionType) assertEquals(listOf("library.db"), mediaSource.openedPaths) } finally { databaseFile.delete() } } + @Test + fun `mlip reimport removes metadata for series missing from incoming database`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val source = mlipSource() + val indexRepository = RecordingIndexRepository( + initialEntries = listOf( + MediaIndexEntry( + sourceId = 7L, + path = "/Removed/01.mkv", + metadataSource = "MLIP", + metadataId = "mlip:7:removed-series", + ), + ), + ) + val metadataRepository = RecordingMetadataRepository().apply { + anime += Anime(id = "mlip:7:removed-series", title = "Removed") + } + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf("library.db" to { databaseFile.inputStream() }), + ) + + try { + createMlipDatabase(databaseFile) + + val result = MlipLibraryIndexImporter(indexRepository, metadataRepository) + .importLibrary(source, mediaSource) + + assertTrue(result.isSuccess()) + assertEquals(listOf("mlip:7:removed-series"), metadataRepository.invalidatedAnimeIds) + assertEquals(listOf("mlip:7:series-uuid"), metadataRepository.anime.map(Anime::id)) + } finally { + databaseFile.delete() + } + } + + @Test + fun `mlip import reports metadata cache failure`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val source = mlipSource() + val metadataRepository = RecordingMetadataRepository( + cacheMetadataError = AppError.SyncError.WriteFailed("cache", "disk full"), + ) + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf("library.db" to { databaseFile.inputStream() }), + ) + + try { + createMlipDatabase(databaseFile) + + val result = MlipLibraryIndexImporter(RecordingIndexRepository(), metadataRepository) + .importLibrary(source, mediaSource) + + assertTrue(result is Result.Error) + } finally { + databaseFile.delete() + } + } + @Test fun `valid mlip database imports index metadata episodes and poster`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") @@ -356,7 +444,7 @@ class MlipLibraryIndexImporterTest { } override suspend fun upsertEntry(sourceId: Long, entry: MediaIndexEntry): Result = Result.success(Unit) - override suspend fun queryIndex(sourceId: Long, query: String): Result> = Result.success(entries) + override suspend fun queryIndex(sourceId: Long, query: String): Result> = Result.success(entries.toList()) override suspend fun getAnimeInIndex(sourceId: Long): Result> = Result.success(entries.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) @@ -364,11 +452,17 @@ class MlipLibraryIndexImporterTest { override suspend fun clearLastBatchUndo(sourceId: Long): Result = Result.success(Unit) } - private class RecordingMetadataRepository : MetadataRepository { + private class RecordingMetadataRepository( + private val cacheMetadataError: AppError? = null, + private val cacheEpisodesError: AppError? = null, + ) : MetadataRepository { val anime = mutableListOf() val episodes = mutableListOf() + val invalidatedAnimeIds = mutableListOf() override suspend fun cacheMetadata(anime: Anime): Result { + cacheMetadataError?.let { return Result.failure(it) } + this.anime.removeAll { it.id == anime.id } this.anime += anime return Result.success(Unit) } @@ -378,11 +472,18 @@ class MlipLibraryIndexImporterTest { override suspend fun getCachedEpisode(episodeId: String): Result = Result.success(episodes.firstOrNull { it.id == episodeId }) override suspend fun getCachedEpisodes(animeId: String): Result> = Result.success(episodes.filter { it.animeId == animeId }) override suspend fun cacheEpisodes(animeId: String, episodes: List): Result { + cacheEpisodesError?.let { return Result.failure(it) } + this.episodes.removeAll { it.animeId == animeId } this.episodes += episodes return Result.success(Unit) } override suspend fun cacheDramaSeries(seriesId: String, series: DramaSeries): Result = Result.success(Unit) - override suspend fun invalidateCache(animeId: String): Result = Result.success(Unit) + override suspend fun invalidateCache(animeId: String): Result { + invalidatedAnimeIds += animeId + anime.removeAll { it.id == animeId } + episodes.removeAll { it.animeId == animeId } + return Result.success(Unit) + } } private class FakeMediaSource( diff --git a/sync-engine-shared/src/main/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinator.kt b/sync-engine-shared/src/main/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinator.kt index 5594c51b..0c704d69 100644 --- a/sync-engine-shared/src/main/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinator.kt +++ b/sync-engine-shared/src/main/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinator.kt @@ -27,6 +27,8 @@ import com.miruplay.tv.repository.metadataSearchResultStatus import com.miruplay.tv.repository.metadataSearchSelectionRequiredStatus import com.miruplay.tv.repository.metadataSearchStartedStatus import com.miruplay.tv.repository.metadataSourceRequiredStatus +import com.miruplay.tv.repository.isMlipManagedMetadata +import com.miruplay.tv.repository.mlipMetadataReadOnlyStatus import com.miruplay.tv.repository.noMetadataBatchEntriesStatus import com.miruplay.tv.repository.noMetadataBatchPreviewStatus import com.miruplay.tv.repository.noMetadataBatchUndoStatus @@ -91,6 +93,7 @@ class BangumiIndexMetadataCoordinator( private val bangumiScraper: MetadataScraper, private val queryLimit: Int = BANGUMI_BATCH_QUERY_LIMIT, private val sourceName: String = BANGUMI_METADATA_SOURCE_NAME, + private val isSourceMetadataReadOnly: suspend (Long) -> Boolean = { false }, ) { private val metadataRefreshCore = BangumiMetadataRefreshCore( metadataRepository = metadataRepository, @@ -192,11 +195,17 @@ class BangumiIndexMetadataCoordinator( if (matches.isEmpty()) { return Result.success(emptyBatchApplyResult(status = noMetadataBatchPreviewStatus())) } + if (isSourceMetadataReadOnly(sourceId)) { + return Result.success(emptyBatchApplyResult(status = mlipMetadataReadOnlyStatus())) + } return when (val entriesResult = indexRepository.queryIndex(sourceId, "")) { is Result.Error -> entriesResult is Result.Success -> { val entries = entriesResult.data.mediaFilesOnly() + if (entries.any(MediaIndexEntry::isMlipManagedMetadata)) { + return Result.success(emptyBatchApplyResult(status = mlipMetadataReadOnlyStatus())) + } val plan = MetadataBatchPlanner.planFor(entries, matches) if (plan.readyUpdates.isEmpty()) { return Result.success( @@ -226,6 +235,9 @@ class BangumiIndexMetadataCoordinator( if (sourceId == null) { return Result.success(emptyBatchUndoResult(status = metadataSourceRequiredStatus())) } + if (isSourceMetadataReadOnly(sourceId) || rollbackEntries.any(MediaIndexEntry::isMlipManagedMetadata)) { + return Result.success(emptyBatchUndoResult(status = mlipMetadataReadOnlyStatus())) + } return when (val restore = indexRepository.restoreMetadataBatchUndo(sourceId, rollbackEntries)) { is Result.Error -> restore @@ -297,11 +309,17 @@ class BangumiIndexMetadataCoordinator( if (match == null || result == null) { return Result.success(emptyBatchApplyResult(status = metadataBatchResultRequiredStatus(sourceName))) } + if (isSourceMetadataReadOnly(sourceId)) { + return Result.success(emptyBatchApplyResult(status = mlipMetadataReadOnlyStatus())) + } return when (val entriesResult = indexRepository.queryIndex(sourceId, "")) { is Result.Error -> entriesResult is Result.Success -> { val entries = entriesResult.data.mediaFilesOnly() + if (entries.any(MediaIndexEntry::isMlipManagedMetadata)) { + return Result.success(emptyBatchApplyResult(status = mlipMetadataReadOnlyStatus())) + } val reviewed = match.copy(result = result.copy(confidence = 1f)) val plan = MetadataBatchPlanner.planFor(entries, listOf(reviewed)) when { @@ -347,6 +365,13 @@ class BangumiIndexMetadataCoordinator( if (match == null) { return Result.success(BangumiMetadataEntryActionResult(null, metadataSearchSelectionRequiredStatus(sourceName))) } + if ( + isSourceMetadataReadOnly(sourceId) || + entry.isMlipManagedMetadata() || + relatedEntries.any(MediaIndexEntry::isMlipManagedMetadata) + ) { + return Result.success(BangumiMetadataEntryActionResult(null, mlipMetadataReadOnlyStatus())) + } val relatedFiles = relatedEntries .filterNot { it.isDirectory } @@ -386,6 +411,9 @@ class BangumiIndexMetadataCoordinator( if (entry == null || entry.isDirectory) { return Result.success(BangumiMetadataEntryActionResult(null, metadataClearEntryRequiredStatus())) } + if (isSourceMetadataReadOnly(sourceId) || entry.isMlipManagedMetadata()) { + return Result.success(BangumiMetadataEntryActionResult(null, mlipMetadataReadOnlyStatus())) + } val updated = entry.clearExternalMetadata(sourceId = sourceId) return when (val result = indexRepository.upsertEntry(sourceId, updated)) { diff --git a/sync-engine-shared/src/test/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinatorTest.kt b/sync-engine-shared/src/test/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinatorTest.kt index 0e1ed129..8a34316f 100644 --- a/sync-engine-shared/src/test/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinatorTest.kt +++ b/sync-engine-shared/src/test/kotlin/com/miruplay/tv/sync/BangumiIndexMetadataCoordinatorTest.kt @@ -134,8 +134,6 @@ class BangumiIndexMetadataCoordinatorTest { val entry = mediaEntry( path = "D:/Anime/Frieren/01.mkv", animeName = "Frieren", - metadataId = "mlip:1:series-uuid", - metadataSource = "MLIP", episodeNumber = 1, ) val related = listOf( @@ -143,8 +141,6 @@ class BangumiIndexMetadataCoordinatorTest { mediaEntry( path = "D:/Anime/Frieren/02.mkv", animeName = "Frieren", - metadataId = "mlip:1:series-uuid", - metadataSource = "MLIP", episodeNumber = 2, ), ) @@ -169,12 +165,56 @@ class BangumiIndexMetadataCoordinatorTest { assertNotNull(applied.data.updatedEntry) assertEquals("已将 Bangumi 元数据应用到 D:/Anime/Frieren/01.mkv。", applied.data.status) assertEquals(listOf("431767", "431767"), index.entries.sortedBy { it.path }.map { it.metadataId }) - assertEquals(listOf("mlip:1:series-uuid", "mlip:1:series-uuid"), index.entries.sortedBy { it.path }.map { it.localMetadataOverrideKey() }) + assertEquals(listOf(null, null), index.entries.sortedBy { it.path }.map { it.localMetadataOverrideKey() }) assertEquals("431767", metadata.anime?.id) assertEquals(listOf("1:D:/Anime/Frieren/01.mkv", "1:D:/Anime/Frieren/02.mkv"), metadata.episodes.map { it.id }) assertEquals(listOf("Start", "Magic"), metadata.episodes.map { it.title }) } + @Test + fun `applyEntryMetadata refuses MLIP metadata override`() = runBlocking { + val entry = mediaEntry( + path = "D:/Anime/Frieren/01.mkv", + animeName = "Frieren", + metadataId = "mlip:1:series-uuid", + metadataSource = "MLIP", + episodeNumber = 1, + ) + val index = FakeMediaIndexRepository(entries = mutableListOf(entry)) + val metadata = FakeMetadataRepository() + val coordinator = coordinator(index = index, metadata = metadata) + + val applied = coordinator.applyEntryMetadata( + sourceId = 1L, + entry = entry, + match = result(confidence = 0.98f), + relatedEntries = listOf(entry), + ) as Result.Success + + assertEquals(null, applied.data.updatedEntry) + assertEquals("MLIP 元数据由 library.db 管理,请在远端修正后重新扫描。", applied.data.status) + assertEquals("MLIP", index.entries.single().metadataSource) + assertEquals(null, metadata.anime) + } + + @Test + fun `source policy blocks stale batch undo after switching to MLIP`() = runBlocking { + val rollback = mediaEntry( + path = "D:/Anime/Frieren/01.mkv", + animeName = "Frieren", + metadataId = "431767", + metadataSource = "BANGUMI", + ) + val index = FakeMediaIndexRepository() + val coordinator = coordinator(index = index, isSourceMetadataReadOnly = { true }) + + val undo = coordinator.undoBatch(sourceId = 1L, rollbackEntries = listOf(rollback)) as Result.Success + + assertEquals(emptyList(), undo.data.restore.rollbackEntries) + assertEquals("MLIP 元数据由 library.db 管理,请在远端修正后重新扫描。", undo.data.status) + assertEquals(emptyList(), index.entries) + } + @Test fun `clearEntryMetadata updates index and invalidates old cache`() = runBlocking { val entry = mediaEntry( @@ -218,11 +258,13 @@ class BangumiIndexMetadataCoordinatorTest { index: FakeMediaIndexRepository = FakeMediaIndexRepository(), metadata: FakeMetadataRepository = FakeMetadataRepository(), scraper: FakeMetadataScraper = FakeMetadataScraper(), + isSourceMetadataReadOnly: suspend (Long) -> Boolean = { false }, ): BangumiIndexMetadataCoordinator = BangumiIndexMetadataCoordinator( indexRepository = index, metadataRepository = metadata, bangumiScraper = scraper, + isSourceMetadataReadOnly = isSourceMetadataReadOnly, ) private class FakeMediaIndexRepository( 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 ebfe5aa1..b81caa77 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 @@ -266,13 +266,15 @@ private fun DetailContent( .width(230.dp) .then(primaryActionFocus.modifier()) ) - TvButton( - text = detailRescrapeActionLabel(), - icon = Icons.Filled.Refresh, - enabled = !isSyncing, - onClick = onRescrape, - modifier = Modifier.width(170.dp) - ) + if (!anime.id.startsWith("mlip:")) { + TvButton( + text = detailRescrapeActionLabel(), + icon = Icons.Filled.Refresh, + enabled = !isSyncing, + onClick = onRescrape, + modifier = Modifier.width(170.dp) + ) + } TvButton( text = detailSyncProgressActionLabel(isSyncing), icon = Icons.Filled.Sync, 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 f5ab92bf..aa9ced19 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 @@ -38,6 +38,8 @@ import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.PlaybackProgressRepository import com.miruplay.tv.repository.ScanPreferencesRepository import com.miruplay.tv.repository.withExternalMetadata +import com.miruplay.tv.repository.isMlipManagedMetadata +import com.miruplay.tv.repository.mlipMetadataReadOnlyStatus import com.miruplay.tv.scraper.MetadataScraper import com.miruplay.tv.sync.BangumiMetadataRefreshCore import com.miruplay.tv.sync.BangumiSyncEngine @@ -125,6 +127,10 @@ class AnimeDetailViewModel @Inject constructor( fun openRescrapeMatcher() { val current = _anime.value ?: return + if (current.id.startsWith("mlip:")) { + _actionMessage.value = mlipMetadataReadOnlyStatus() + return + } val localEpisodes = allEpisodesWithProgress.map { it.first } val candidates = detailBangumiManualCandidateTerms(current, localEpisodes) _manualMatch.value = BangumiManualMatchUiState( @@ -236,6 +242,10 @@ class AnimeDetailViewModel @Inject constructor( fun applyManualMatch() { val current = _anime.value ?: return + if (current.id.startsWith("mlip:")) { + _manualMatch.value = _manualMatch.value.copy(statusMessage = mlipMetadataReadOnlyStatus()) + return + } val match = _manualMatch.value.selectedResult if (match == null) { _manualMatch.value = _manualMatch.value.copy(statusMessage = detailBangumiManualSelectionRequiredMessage()) @@ -258,6 +268,15 @@ class AnimeDetailViewModel @Inject constructor( ?.takeIf { it.isNotEmpty() } ?: allEpisodesWithProgress.map { it.first } val indexedEntries = indexedEntriesFor(localEpisodes) + if (indexedEntries.any(MediaIndexEntry::isMlipManagedMetadata)) { + _manualMatch.value = _manualMatch.value.copy( + isApplying = false, + statusMessage = mlipMetadataReadOnlyStatus(), + ) + _actionMessage.value = mlipMetadataReadOnlyStatus() + _isSyncing.value = false + return@launch + } var reloadAnimeId = current.id when ( val refreshed = PerformanceLog.measureSuspendResult( 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 35ecdf5b..2fe0461b 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 @@ -95,7 +95,6 @@ import com.miruplay.tv.model.CLOUD_DRIVE_ROOT_DISPLAY_NAME import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.MediaContentMode import com.miruplay.tv.model.MediaRecognitionMode -import com.miruplay.tv.model.MlipMetadataMode import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceInfoConventions import com.miruplay.tv.model.MediaSourceType @@ -108,7 +107,6 @@ import com.miruplay.tv.model.androidTvSettingsSectionOrder import com.miruplay.tv.model.connectionDisplayName import com.miruplay.tv.model.connectionUsername import com.miruplay.tv.model.recognitionMode -import com.miruplay.tv.model.mlipMetadataMode import com.miruplay.tv.model.cloudDriveRssApiTokenCredentialLabel import com.miruplay.tv.model.cloudDriveRssApiTokenFieldLabel import com.miruplay.tv.model.cloudDriveRssChooseDirectoryActionLabel @@ -393,7 +391,6 @@ fun AddSourceScreen( var selectedType by remember { mutableStateOf(MediaSourceType.LOCAL) } var selectedContentMode by remember { mutableStateOf(MediaContentMode.ANIME) } var selectedRecognitionMode by remember { mutableStateOf(MediaRecognitionMode.DIRECTORY) } - var selectedMlipMetadataMode by remember { mutableStateOf(MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY) } var name by remember { mutableStateOf(sourceNameOrDefault("", MediaSourceType.LOCAL)) } var location by remember { mutableStateOf(DEFAULT_LOCAL_PATH) } var locationDisplayName by remember { mutableStateOf(mediaSourceLocalPathDisplayName(DEFAULT_LOCAL_PATH)) } @@ -479,7 +476,6 @@ fun AddSourceScreen( selectedType = type selectedContentMode = currentAppMode.toMediaContentMode() selectedRecognitionMode = MediaRecognitionMode.DIRECTORY - selectedMlipMetadataMode = MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY name = sourceNameOrDefault("", type) location = type.defaultSourceLocation(DEFAULT_LOCAL_PATH) locationDisplayName = if (type == MediaSourceType.LOCAL) mediaSourceLocalPathDisplayName(location) else "" @@ -504,7 +500,6 @@ fun AddSourceScreen( selectedType = source.type selectedContentMode = source.contentMode selectedRecognitionMode = source.recognitionMode() - selectedMlipMetadataMode = source.mlipMetadataMode() name = source.name.ifBlank { sourceNameOrDefault("", source.type) } location = source.sourceLocation().orEmpty() locationDisplayName = source.connectionDisplayName().ifBlank { @@ -535,11 +530,6 @@ fun AddSourceScreen( username = username, password = password, recognitionMode = effectiveRecognitionMode, - mlipMetadataMode = if (effectiveRecognitionMode == MediaRecognitionMode.MLIP) { - selectedMlipMetadataMode - } else { - MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY - }, ) ) if (editingSourceId == null) { @@ -619,7 +609,6 @@ fun AddSourceScreen( selectedType = type selectedContentMode = currentAppMode.toMediaContentMode() selectedRecognitionMode = MediaRecognitionMode.DIRECTORY - selectedMlipMetadataMode = MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY name = sourceNameOrDefault("", type) location = type.defaultSourceLocation(DEFAULT_LOCAL_PATH) locationDisplayName = if (type == MediaSourceType.LOCAL) mediaSourceLocalPathDisplayName(location) else "" @@ -635,18 +624,10 @@ fun AddSourceScreen( selectedContentMode = it if (it != MediaContentMode.ANIME) { selectedRecognitionMode = MediaRecognitionMode.DIRECTORY - selectedMlipMetadataMode = MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY } }, selectedRecognitionMode = selectedRecognitionMode, - onRecognitionModeSelected = { - selectedRecognitionMode = it - if (it != MediaRecognitionMode.MLIP) { - selectedMlipMetadataMode = MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY - } - }, - selectedMlipMetadataMode = selectedMlipMetadataMode, - onMlipMetadataModeSelected = { selectedMlipMetadataMode = it }, + onRecognitionModeSelected = { selectedRecognitionMode = it }, location = location, onLocationChange = { location = it }, locationDisplayName = locationDisplayName, @@ -1111,8 +1092,6 @@ private fun SettingsContent( onContentModeSelected: (MediaContentMode) -> Unit, selectedRecognitionMode: MediaRecognitionMode, onRecognitionModeSelected: (MediaRecognitionMode) -> Unit, - selectedMlipMetadataMode: MlipMetadataMode, - onMlipMetadataModeSelected: (MlipMetadataMode) -> Unit, location: String, onLocationChange: (String) -> Unit, locationDisplayName: String, @@ -1283,8 +1262,6 @@ private fun SettingsContent( onContentModeSelected = onContentModeSelected, selectedRecognitionMode = selectedRecognitionMode, onRecognitionModeSelected = onRecognitionModeSelected, - selectedMlipMetadataMode = selectedMlipMetadataMode, - onMlipMetadataModeSelected = onMlipMetadataModeSelected, location = location, onLocationChange = onLocationChange, locationDisplayName = locationDisplayName, @@ -2655,8 +2632,6 @@ private fun SourceFormPanel( onContentModeSelected: (MediaContentMode) -> Unit, selectedRecognitionMode: MediaRecognitionMode, onRecognitionModeSelected: (MediaRecognitionMode) -> Unit, - selectedMlipMetadataMode: MlipMetadataMode, - onMlipMetadataModeSelected: (MlipMetadataMode) -> Unit, location: String, onLocationChange: (String) -> Unit, locationDisplayName: String, @@ -2785,38 +2760,9 @@ private fun SourceFormPanel( color = if (selectedRecognitionMode == MediaRecognitionMode.MLIP) ProgressGreen else TextSecondary ) if (selectedRecognitionMode == MediaRecognitionMode.MLIP) { - Spacer(Modifier.height(12.dp)) - Text( - text = "元数据来源", - style = TvTypography.caption, - color = TextSecondary - ) - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ScanOptionChip( - text = "library.db,本地优先", - icon = Icons.Filled.Dns, - selected = selectedMlipMetadataMode == MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY, - enabled = true, - onClick = { onMlipMetadataModeSelected(MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY) }, - modifier = Modifier.width(220.dp) - ) - ScanOptionChip( - text = "仅文件清单", - icon = Icons.Filled.Storage, - selected = selectedMlipMetadataMode == MlipMetadataMode.FILES_ONLY, - enabled = true, - onClick = { onMlipMetadataModeSelected(MlipMetadataMode.FILES_ONLY) }, - modifier = Modifier.width(170.dp) - ) - } StatusMessage( icon = Icons.Filled.Info, - text = if (selectedMlipMetadataMode == MlipMetadataMode.FILES_ONLY) { - "只导入路径和集数,显示元数据由本地匹配或重新刮削维护。" - } else { - "导入 library.db 元数据,但重新刮削后的本地修正优先。" - }, + text = "library.db 是只读元数据来源;请在远端数据库修正内容后重新扫描。", color = TextSecondary ) } diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModelTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModelTest.kt index 41a5da47..61dfc86a 100644 --- a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModelTest.kt +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/detail/AnimeDetailViewModelTest.kt @@ -25,7 +25,6 @@ import com.miruplay.tv.repository.BangumiUser import com.miruplay.tv.repository.MediaIndexEntry import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MetadataRepository -import com.miruplay.tv.repository.localMetadataOverrideKey import com.miruplay.tv.repository.MediaSourceRepository import com.miruplay.tv.repository.PlaybackProgressRepository import com.miruplay.tv.repository.ScanPreferencesRepository @@ -113,7 +112,7 @@ class AnimeDetailViewModelTest { } @Test - fun `manual match writes indexed Bangumi override for future MLIP imports`() = runTest { + fun `manual match cannot override MLIP metadata`() = runTest { val indexRepository = AnimeDetailFakeMediaIndexRepository( entries = mutableListOf( MediaIndexEntry( @@ -165,11 +164,11 @@ class AnimeDetailViewModelTest { viewModel.applyManualMatch() advanceUntilIdle() - val updated = indexRepository.upsertedEntries.single() - assertEquals("BANGUMI", updated.metadataSource) - assertEquals("431767", updated.metadataId) - assertEquals("葬送的芙莉莲", updated.metadataTitle) - assertEquals("mlip:7:series-uuid", updated.localMetadataOverrideKey()) + assertEquals(emptyList(), indexRepository.upsertedEntries) + assertEquals( + "MLIP 元数据由 library.db 管理,请在远端修正后重新扫描。", + viewModel.manualMatch.value.statusMessage, + ) assertEquals("Frieren", viewModel.anime.value?.id) } 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 c9d42a80..89e0f291 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 @@ -51,7 +51,7 @@ class WebControlSourceRequestsTest { } @Test - fun `source request stores mlip metadata mode for WebDAV anime sources`() { + fun `source request ignores legacy mlip metadata mode for WebDAV anime sources`() { val source = SourceRequest( name = "Remote", type = "WEBDAV", @@ -62,8 +62,8 @@ class WebControlSourceRequestsTest { ).toMediaSourceInfo() assertEquals(MediaRecognitionMode.MLIP, source.recognitionMode()) - assertEquals(MlipMetadataMode.FILES_ONLY, source.mlipMetadataMode()) - assertEquals("FILES_ONLY", source.connectionInfo[MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE]) + assertEquals(MlipMetadataMode.LIBRARY_DB_LOCAL_PRIORITY, source.mlipMetadataMode()) + assertFalse(MediaSourceInfoConventions.CONNECTION_MLIP_METADATA_MODE in source.connectionInfo) } @Test diff --git a/web-control/frontend/src/App.vue b/web-control/frontend/src/App.vue index d5c980af..5437d4de 100644 --- a/web-control/frontend/src/App.vue +++ b/web-control/frontend/src/App.vue @@ -220,7 +220,7 @@ {{ source.name }} {{ sourceContentModeLabel(source.contentMode) }} - MLIP{{ source.connectionInfo?.mlipMetadataMode === 'FILES_ONLY' ? ' · 仅文件清单' : '' }} + MLIP · library.db 只读 {{ displayPath(sourceLocation(source)) }} @@ -263,13 +263,7 @@ - - - - - - - + @@ -1560,7 +1554,6 @@ const sourceForm = reactive({ type: 'LOCAL', contentMode: 'ANIME', recognitionMode: 'DIRECTORY', - mlipMetadataMode: 'LIBRARY_DB_LOCAL_PRIORITY', name: '', location: '/storage/emulated/0/Download', displayName: 'Download', @@ -2210,7 +2203,6 @@ function resetSourceForm() { type: 'LOCAL', contentMode: 'ANIME', recognitionMode: 'DIRECTORY', - mlipMetadataMode: 'LIBRARY_DB_LOCAL_PRIORITY', name: '', location: '/storage/emulated/0/Download', displayName: 'Download', @@ -2225,7 +2217,6 @@ async function editSource(source) { type: source.type, contentMode: source.contentMode || 'ANIME', recognitionMode: source.connectionInfo?.recognitionMode || 'DIRECTORY', - mlipMetadataMode: source.connectionInfo?.mlipMetadataMode || 'LIBRARY_DB_LOCAL_PRIORITY', name: source.name, location: sourceLocation(source), displayName: source.connectionInfo?.displayName || folderName(sourceLocation(source)), @@ -2258,9 +2249,6 @@ function sourcePayload() { recognitionMode: sourceForm.type === 'WEBDAV' && sourceForm.contentMode === 'ANIME' ? sourceForm.recognitionMode || 'DIRECTORY' : 'DIRECTORY', - mlipMetadataMode: sourceForm.type === 'WEBDAV' && sourceForm.contentMode === 'ANIME' && sourceForm.recognitionMode === 'MLIP' - ? sourceForm.mlipMetadataMode || 'LIBRARY_DB_LOCAL_PRIORITY' - : 'LIBRARY_DB_LOCAL_PRIORITY', name: sourceForm.name.trim(), location: sourceForm.location.trim(), displayName: sourceForm.displayName.trim() || folderName(sourceForm.location),