From 8ba02eb4f7e2ee9f74ba4cd5bd8314d7ae4deeec Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Fri, 10 Jul 2026 09:54:48 +0800 Subject: [PATCH] Fix Bangumi rescrape proxy and keyword search --- .../miruplay/tv/model/DetailUiConventions.kt | 10 +++ .../tv/model/DetailUiConventionsTest.kt | 2 +- .../tv/desktop/MiruPlayDesktopComposeApp.kt | 20 ++++++ .../repository/MetadataSearchAggregation.kt | 12 ++-- .../MetadataSearchAggregationTest.kt | 14 ++++ .../scraper/desktop/DesktopBangumiScraper.kt | 5 ++ .../desktop/DesktopBangumiScraperTest.kt | 32 +++++++++ .../tv/scraper/MetadataSearchAggregators.kt | 22 +++--- .../scraper/MetadataSearchAggregatorsTest.kt | 70 +++++++++++++++++++ 9 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 scraper/src/test/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregatorsTest.kt diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/DetailUiConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/DetailUiConventions.kt index ace46226..89286b95 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/DetailUiConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/DetailUiConventions.kt @@ -229,6 +229,7 @@ fun detailBangumiManualCandidateTerms( } } .mapNotNull { it?.detailBangumiNormalizedCandidate() } + .flatMap { listOfNotNull(it, it.detailBangumiSeasonlessCandidate()) } .filter { it.isNotBlank() } .distinctBy { it.lowercase() } .take(limit.coerceAtLeast(1)) @@ -317,3 +318,12 @@ private fun String.detailBangumiNormalizedCandidate(): String? { .trim(' ', '-', '_', '.', '·') return normalized.takeIf { it.length >= 2 } } + +private fun String.detailBangumiSeasonlessCandidate(): String? { + val seasonless = replace(Regex("""(?i)\bseason\s*\d+\b"""), " ") + .replace(Regex("""(?i)\bs\s*\d+\b"""), " ") + .replace(Regex("""第\s*[一二三四五六七八九十\d]+\s*[季期]"""), " ") + .replace(Regex("""\s+"""), " ") + .trim() + return seasonless.takeIf { it.length >= 2 && !it.equals(this, ignoreCase = true) } +} diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/DetailUiConventionsTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/DetailUiConventionsTest.kt index 44020532..58df906a 100644 --- a/core/model/src/test/kotlin/com/miruplay/tv/model/DetailUiConventionsTest.kt +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/DetailUiConventionsTest.kt @@ -95,7 +95,7 @@ class DetailUiConventionsTest { ) assertEquals( - listOf("Dr STONE 新石纪 第四季"), + listOf("Dr STONE 新石纪 第四季", "Dr STONE 新石纪"), detailBangumiManualCandidateTerms(anime, episodes), ) } 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 f8fab9b5..7d4115a6 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 @@ -888,6 +888,11 @@ internal fun MiruPlayDesktopComposeApp( rssProxyEnabled = config.data.rssProxyEnabled rssProxyHost = config.data.rssProxyHost rssProxyPort = config.data.rssProxyPort.toString() + bangumiScraper.configureProxy( + config.data.rssProxyEnabled, + config.data.rssProxyHost, + config.data.rssProxyPort, + ) cloudRssScheduler.syncPeriodicWork(config.data) } is Result.Error -> cloudRssStatus = config.error.toUserMessage() @@ -940,6 +945,11 @@ internal fun MiruPlayDesktopComposeApp( rssProxyEnabled = config.rssProxyEnabled rssProxyHost = config.rssProxyHost rssProxyPort = config.rssProxyPort.toString() + bangumiScraper.configureProxy( + config.rssProxyEnabled, + config.rssProxyHost, + config.rssProxyPort, + ) cloudRssScheduler.syncPeriodicWork(config) } } @@ -1359,6 +1369,11 @@ internal fun MiruPlayDesktopComposeApp( is CloudDriveConfigActionResult.Saved -> { cloudIntervalMinutes = interval.toString() rssProxyPort = proxyPort.toString() + bangumiScraper.configureProxy( + result.config.rssProxyEnabled, + result.config.rssProxyHost, + result.config.rssProxyPort, + ) cloudRssScheduler.syncPeriodicWork(result.config) cloudRssStatus = result.status result @@ -1479,6 +1494,11 @@ internal fun MiruPlayDesktopComposeApp( onConfigSaved = { saved -> cloudIntervalMinutes = interval.toString() rssProxyPort = proxyPort.toString() + bangumiScraper.configureProxy( + saved.config.rssProxyEnabled, + saved.config.rssProxyHost, + saved.config.rssProxyPort, + ) cloudRssScheduler.syncPeriodicWork(saved.config) cloudRssStatus = saved.status }, diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataSearchAggregation.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataSearchAggregation.kt index a460f4ff..44b23ffb 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataSearchAggregation.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/MetadataSearchAggregation.kt @@ -8,6 +8,7 @@ import com.miruplay.tv.model.MediaContentMode import com.miruplay.tv.model.MetadataProviderRef import com.miruplay.tv.model.MetadataQueryPlan import com.miruplay.tv.model.MetadataSearchContext +import com.miruplay.tv.model.MetadataSearchIntent import com.miruplay.tv.model.MetadataSearchProviderCandidate import com.miruplay.tv.model.MetadataSearchQuery import com.miruplay.tv.model.ScraperResult @@ -72,22 +73,23 @@ object MetadataQueryPlanner { addQuery(variant, reason) } + val expandSeasonless = context.intent != MetadataSearchIntent.MANUAL_MATCH context.boundProviderRef?.let { ref -> providerRefHints.putIfAbsent("${ref.source.lowercase()}:${ref.id}", ref) } addQuery(context.manualQuery, "manual") - addSeasonlessVariant(context.manualQuery, "manual-seasonless") + if (expandSeasonless) addSeasonlessVariant(context.manualQuery, "manual-seasonless") addQuery(context.title, "title") - addSeasonlessVariant(context.title, "title-seasonless") + if (expandSeasonless) addSeasonlessVariant(context.title, "title-seasonless") addQuery(context.localizedTitle, "localized") addQuery(context.originalTitle, "original") - addSeasonlessVariant(context.originalTitle, "original-seasonless") + if (expandSeasonless) addSeasonlessVariant(context.originalTitle, "original-seasonless") context.aliases.forEach { alias -> addQuery(alias, "alias") - addSeasonlessVariant(alias, "alias-seasonless") + if (expandSeasonless) addSeasonlessVariant(alias, "alias-seasonless") } addQuery(context.metadataTitle, "cached-metadata") - addSeasonlessVariant(context.metadataTitle, "cached-metadata-seasonless") + if (expandSeasonless) addSeasonlessVariant(context.metadataTitle, "cached-metadata-seasonless") context.filePathSamples .flatMap(::metadataDerivePathQueries) .forEach { addQuery(it, "path") } diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/MetadataSearchAggregationTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/MetadataSearchAggregationTest.kt index d76ffe6c..7370c4d6 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/MetadataSearchAggregationTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/MetadataSearchAggregationTest.kt @@ -4,6 +4,7 @@ import com.miruplay.tv.model.MediaContentMode import com.miruplay.tv.model.MatchRecommendation import com.miruplay.tv.model.MetadataProviderRef import com.miruplay.tv.model.MetadataSearchContext +import com.miruplay.tv.model.MetadataSearchIntent import com.miruplay.tv.model.MetadataSearchProviderCandidate import com.miruplay.tv.model.ScraperSource import com.miruplay.tv.model.displayTitle @@ -43,6 +44,19 @@ class MetadataSearchAggregationTest { assertTrue(plan.queryTexts.none { it.contains(":") }) } + @Test + fun `manual match planner only searches explicit UI keywords`() { + val plan = MetadataQueryPlanner.plan( + MetadataSearchContext( + contentMode = MediaContentMode.ANIME, + intent = MetadataSearchIntent.MANUAL_MATCH, + aliases = listOf("Dr STONE 新石纪 第四季", "Dr STONE 新石纪"), + ), + ) + + assertEquals(listOf("Dr STONE 新石纪 第四季", "Dr STONE 新石纪"), plan.queryTexts) + } + @Test fun `aggregate dedupes same provider hits and clusters cross provider equivalents`() { val context = MetadataSearchContext( diff --git a/scraper-desktop/src/main/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraper.kt b/scraper-desktop/src/main/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraper.kt index afaa334e..b4e4c74c 100644 --- a/scraper-desktop/src/main/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraper.kt +++ b/scraper-desktop/src/main/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraper.kt @@ -12,6 +12,7 @@ import com.miruplay.tv.repository.BangumiSubjectCollectionType import com.miruplay.tv.repository.BangumiUser import com.miruplay.tv.scraper.MetadataScraper import com.miruplay.tv.scraper.core.BangumiApiClient +import com.miruplay.tv.scraper.core.BangumiHttpProxyConfig import com.miruplay.tv.scraper.core.searchByAlias import com.ibm.icu.text.Transliterator import okhttp3.HttpUrl @@ -42,6 +43,10 @@ class DesktopBangumiScraper internal constructor( override val sourceName: String = "Bangumi" + fun configureProxy(enabled: Boolean, host: String, port: Int) { + api.configureProxy(BangumiHttpProxyConfig.normalize(enabled, host, port)) + } + override suspend fun searchAnime(query: String): Result> = api.searchAnime(query) diff --git a/scraper-desktop/src/test/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraperTest.kt b/scraper-desktop/src/test/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraperTest.kt index d9531663..ddb53814 100644 --- a/scraper-desktop/src/test/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraperTest.kt +++ b/scraper-desktop/src/test/kotlin/com/miruplay/tv/scraper/desktop/DesktopBangumiScraperTest.kt @@ -18,6 +18,38 @@ class DesktopBangumiScraperTest { assertEquals("Bangumi", scraper.sourceName) } + @Test + fun `desktop wrapper uses configured HTTP proxy`() = runBlocking { + MockWebServer().use { proxy -> + proxy.enqueue( + MockResponse().setBody( + """ + { + "data": [ + { + "id": 431767, + "name": "葬送のフリーレン", + "name_cn": "葬送的芙莉莲", + "rating": { "score": 8.8 } + } + ] + } + """.trimIndent() + ) + ) + val scraper = DesktopBangumiScraper(baseUrl = "http://api.bgm.tv") + scraper.configureProxy(enabled = true, host = proxy.hostName, port = proxy.port) + + val result = scraper.searchAnime("葬送的芙莉莲") + + assertTrue(result is Result.Success) + assertEquals("431767", (result as Result.Success).data.single().animeId) + val request = proxy.takeRequest() + assertEquals("api.bgm.tv", request.headers["Host"]) + assertTrue(request.requestLine.contains("/v0/search/subjects")) + } + } + @Test fun `desktop wrapper delegates search to shared Bangumi client`() = runBlocking { MockWebServer().use { server -> diff --git a/scraper/src/main/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregators.kt b/scraper/src/main/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregators.kt index 5a268808..4a213179 100644 --- a/scraper/src/main/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregators.kt +++ b/scraper/src/main/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregators.kt @@ -7,6 +7,7 @@ import com.miruplay.tv.model.DramaSeriesMetadata import com.miruplay.tv.model.MetadataProviderRef import com.miruplay.tv.model.MetadataQueryPlan import com.miruplay.tv.model.MetadataSearchContext +import com.miruplay.tv.model.MetadataSearchIntent import com.miruplay.tv.model.MetadataSearchProviderCandidate import com.miruplay.tv.model.ScraperResult import com.miruplay.tv.repository.AnimeMetadataSearchAggregator @@ -23,24 +24,26 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class BangumiAnimeMetadataSearchProvider @Inject constructor( - private val scraper: BangumiScraper, +class BangumiAnimeMetadataSearchProvider( + private val scraper: MetadataScraper, + private val manualSearch: ManualMetadataSearchScraper, ) : AnimeMetadataSearchProvider { + @Inject constructor(scraper: BangumiScraper) : this(scraper, scraper) + override val sourceName: String = scraper.sourceName override val priority: Float = 1.06f override suspend fun search( context: MetadataSearchContext, plan: MetadataQueryPlan, - ): List { - val manualSearch = scraper as ManualMetadataSearchScraper - return hintedCandidatesForAnimeProvider(scraper, sourceName, plan) + + ): List = + hintedCandidatesForAnimeProvider(scraper, sourceName, plan) + searchScraperResults( scraper = scraper, plan = plan, directSearch = { query -> manualSearch.searchManualAnime(query) }, + allowAliasFallback = context.intent != MetadataSearchIntent.MANUAL_MATCH, ) - } } @Singleton @@ -59,6 +62,7 @@ class AniListAnimeMetadataSearchProvider @Inject constructor( scraper = scraper, plan = plan, directSearch = scraper::searchAnime, + allowAliasFallback = context.intent != MetadataSearchIntent.MANUAL_MATCH, ) } @@ -183,10 +187,11 @@ private suspend fun searchScraperResults( scraper: MetadataScraper, plan: MetadataQueryPlan, directSearch: suspend (String) -> Result>, + allowAliasFallback: Boolean = true, ): List { val candidates = mutableListOf() plan.queryTexts.forEach { query -> - when (val result = scraper.searchPreferredWith(directSearch, query, plan.queryTexts)) { + when (val result = scraper.searchPreferredWith(directSearch, query, plan.queryTexts, allowAliasFallback)) { is Result.Error -> Unit is Result.Success -> { result.data.forEachIndexed { index, item -> @@ -212,11 +217,12 @@ private suspend fun MetadataScraper.searchPreferredWith( directSearch: suspend (String) -> Result>, query: String, candidates: List, + allowAliasFallback: Boolean, ): Result> = when (val directResults = directSearch(query)) { is Result.Error -> directResults is Result.Success -> { - val aliasMatch = if ((directResults.data.firstOrNull()?.confidence ?: 0f) < METADATA_ALIAS_CONFIDENCE_THRESHOLD) { + val aliasMatch = if (allowAliasFallback && (directResults.data.firstOrNull()?.confidence ?: 0f) < METADATA_ALIAS_CONFIDENCE_THRESHOLD) { searchByAlias( normalizedName = "", candidates = candidates.excludeQuery(query), diff --git a/scraper/src/test/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregatorsTest.kt b/scraper/src/test/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregatorsTest.kt new file mode 100644 index 00000000..96cf30f5 --- /dev/null +++ b/scraper/src/test/kotlin/com/miruplay/tv/scraper/MetadataSearchAggregatorsTest.kt @@ -0,0 +1,70 @@ +package com.miruplay.tv.scraper + +import com.miruplay.tv.core.common.Result +import com.miruplay.tv.model.Anime +import com.miruplay.tv.model.MediaContentMode +import com.miruplay.tv.model.MetadataSearchContext +import com.miruplay.tv.model.MetadataSearchIntent +import com.miruplay.tv.model.ScraperResult +import com.miruplay.tv.model.ScraperSource +import com.miruplay.tv.repository.BangumiEpisodeMetadata +import com.miruplay.tv.repository.MetadataQueryPlanner +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class MetadataSearchAggregatorsTest { + @Test + fun `manual Bangumi provider searches explicit queries without alias fallback`() = runBlocking { + val scraper = RecordingManualScraper() + val provider = BangumiAnimeMetadataSearchProvider(scraper, scraper) + val context = MetadataSearchContext( + contentMode = MediaContentMode.ANIME, + intent = MetadataSearchIntent.MANUAL_MATCH, + aliases = listOf("Dr STONE 新石纪 第四季", "Dr STONE 新石纪"), + ) + + provider.search(context, MetadataQueryPlanner.plan(context)) + + assertEquals(listOf("Dr STONE 新石纪 第四季", "Dr STONE 新石纪"), scraper.manualQueries) + assertEquals(emptyList>(), scraper.aliasSearches) + } +} + +private class RecordingManualScraper : MetadataScraper, ManualMetadataSearchScraper { + val manualQueries = mutableListOf() + val aliasSearches = mutableListOf>() + + override val sourceName: String = "Bangumi" + + override suspend fun searchManualAnime(query: String): Result> { + manualQueries += query + return Result.success(listOf(result(query))) + } + + override suspend fun searchAnime(query: String): Result> = + Result.success(listOf(result(query))) + + override suspend fun getAnimeDetails(animeId: String): Result = + Result.success(Anime(id = animeId, title = animeId)) + + override suspend fun getEpisodes(animeId: String): Result> = + Result.success(emptyList()) + + override suspend fun searchByAlias( + normalizedName: String, + candidates: List, + ): Result { + aliasSearches += candidates + return Result.success(null) + } + + private fun result(query: String): ScraperResult = + ScraperResult( + animeId = query, + title = query, + matchedTitle = query, + confidence = 0.1f, + source = ScraperSource.BANGUMI, + ) +}