Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ fun detailBangumiManualCandidateTerms(
}
}
.mapNotNull { it?.detailBangumiNormalizedCandidate() }
.flatMap { listOfNotNull(it, it.detailBangumiSeasonlessCandidate()) }
.filter { it.isNotBlank() }
.distinctBy { it.lowercase() }
.take(limit.coerceAtLeast(1))
Expand Down Expand Up @@ -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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class DetailUiConventionsTest {
)

assertEquals(
listOf("Dr STONE 新石纪 第四季"),
listOf("Dr STONE 新石纪 第四季", "Dr STONE 新石纪"),
detailBangumiManualCandidateTerms(anime, episodes),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<ScraperResult>> =
api.searchAnime(query)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MetadataSearchProviderCandidate> {
val manualSearch = scraper as ManualMetadataSearchScraper
return hintedCandidatesForAnimeProvider(scraper, sourceName, plan) +
): List<MetadataSearchProviderCandidate> =
hintedCandidatesForAnimeProvider(scraper, sourceName, plan) +
searchScraperResults(
scraper = scraper,
plan = plan,
directSearch = { query -> manualSearch.searchManualAnime(query) },
allowAliasFallback = context.intent != MetadataSearchIntent.MANUAL_MATCH,
)
}
}

@Singleton
Expand All @@ -59,6 +62,7 @@ class AniListAnimeMetadataSearchProvider @Inject constructor(
scraper = scraper,
plan = plan,
directSearch = scraper::searchAnime,
allowAliasFallback = context.intent != MetadataSearchIntent.MANUAL_MATCH,
)
}

Expand Down Expand Up @@ -183,10 +187,11 @@ private suspend fun searchScraperResults(
scraper: MetadataScraper,
plan: MetadataQueryPlan,
directSearch: suspend (String) -> Result<List<ScraperResult>>,
allowAliasFallback: Boolean = true,
): List<MetadataSearchProviderCandidate> {
val candidates = mutableListOf<MetadataSearchProviderCandidate>()
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 ->
Expand All @@ -212,11 +217,12 @@ private suspend fun MetadataScraper.searchPreferredWith(
directSearch: suspend (String) -> Result<List<ScraperResult>>,
query: String,
candidates: List<String>,
allowAliasFallback: Boolean,
): Result<List<ScraperResult>> =
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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<String>>(), scraper.aliasSearches)
}
}

private class RecordingManualScraper : MetadataScraper, ManualMetadataSearchScraper {
val manualQueries = mutableListOf<String>()
val aliasSearches = mutableListOf<List<String>>()

override val sourceName: String = "Bangumi"

override suspend fun searchManualAnime(query: String): Result<List<ScraperResult>> {
manualQueries += query
return Result.success(listOf(result(query)))
}

override suspend fun searchAnime(query: String): Result<List<ScraperResult>> =
Result.success(listOf(result(query)))

override suspend fun getAnimeDetails(animeId: String): Result<Anime> =
Result.success(Anime(id = animeId, title = animeId))

override suspend fun getEpisodes(animeId: String): Result<List<BangumiEpisodeMetadata>> =
Result.success(emptyList())

override suspend fun searchByAlias(
normalizedName: String,
candidates: List<String>,
): Result<ScraperResult?> {
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,
)
}
Loading