feat(media): aggregate aliases from enabled metadata sources

This commit is contained in:
jxxghp
2026-08-26 13:45:03 +08:00
parent ac2a2b1129
commit e02418bace
19 changed files with 727 additions and 125 deletions
+23 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6785,
"edge_sha256": "077e003bf195195e4e75dc65c9596e0b5a45c0e33441e3a22829f0cdb8d98e45",
"edge_count": 6804,
"edge_sha256": "3e9a93795c6cfb5bc78e4871ae7c11b064ec0a2ea4f545ce94a6c0c696800df4",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -4155,6 +4155,7 @@
"app.modules.anilist -> app.domain.scraper",
"app.modules.anilist -> app.modules",
"app.modules.anilist -> app.modules.anilist.anilist",
"app.modules.anilist -> app.modules.media_auxiliary",
"app.modules.anilist -> app.runtime",
"app.modules.anilist -> app.runtime.log",
"app.modules.anilist -> app.runtime.settings",
@@ -4179,6 +4180,7 @@
"app.modules.bangumi -> app.domain.scraper",
"app.modules.bangumi -> app.modules",
"app.modules.bangumi -> app.modules.bangumi.bangumi",
"app.modules.bangumi -> app.modules.media_auxiliary",
"app.modules.bangumi -> app.runtime",
"app.modules.bangumi -> app.runtime.log",
"app.modules.bangumi -> app.runtime.settings",
@@ -4251,6 +4253,7 @@
"app.modules.douban -> app.modules",
"app.modules.douban -> app.modules.douban.apiv2",
"app.modules.douban -> app.modules.douban.scraper",
"app.modules.douban -> app.modules.media_auxiliary",
"app.modules.douban -> app.runtime",
"app.modules.douban -> app.runtime.execution",
"app.modules.douban -> app.runtime.log",
@@ -4546,6 +4549,7 @@
"app.modules.imdb -> app.foundation.text",
"app.modules.imdb -> app.modules",
"app.modules.imdb -> app.modules.imdb.api",
"app.modules.imdb -> app.modules.media_auxiliary",
"app.modules.imdb -> app.runtime",
"app.modules.imdb -> app.runtime.log",
"app.modules.imdb -> app.runtime.settings",
@@ -4916,6 +4920,14 @@
"app.modules.lrclib -> app.runtime.settings",
"app.modules.lrclib -> app.schemas",
"app.modules.lrclib -> app.schemas.types",
"app.modules.media_auxiliary -> app.domain",
"app.modules.media_auxiliary -> app.domain.context",
"app.modules.media_auxiliary -> app.domain.media",
"app.modules.media_auxiliary -> app.domain.meta",
"app.modules.media_auxiliary -> app.domain.meta.metabase",
"app.modules.media_auxiliary -> app.domain.metainfo",
"app.modules.media_auxiliary -> app.schemas",
"app.modules.media_auxiliary -> app.schemas.types",
"app.modules.musicbrainz -> app.adapters",
"app.modules.musicbrainz -> app.adapters.network",
"app.modules.musicbrainz -> app.adapters.network.http",
@@ -5248,6 +5260,7 @@
"app.modules.themoviedb -> app.foundation",
"app.modules.themoviedb -> app.foundation.text",
"app.modules.themoviedb -> app.modules",
"app.modules.themoviedb -> app.modules.media_auxiliary",
"app.modules.themoviedb -> app.modules.themoviedb.category",
"app.modules.themoviedb -> app.modules.themoviedb.scraper",
"app.modules.themoviedb -> app.modules.themoviedb.tmdb_cache",
@@ -5445,9 +5458,15 @@
"app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime",
"app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.cache",
"app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.settings",
"app.modules.thetvdb -> app.domain",
"app.modules.thetvdb -> app.domain.context",
"app.modules.thetvdb -> app.domain.media",
"app.modules.thetvdb -> app.domain.meta",
"app.modules.thetvdb -> app.domain.meta.metabase",
"app.modules.thetvdb -> app.modules",
"app.modules.thetvdb -> app.modules.thetvdb.tvdb_v4_official",
"app.modules.thetvdb -> app.runtime",
"app.modules.thetvdb -> app.runtime.execution",
"app.modules.thetvdb -> app.runtime.log",
"app.modules.thetvdb -> app.runtime.settings",
"app.modules.thetvdb -> app.schemas",
@@ -6802,7 +6821,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 834,
"module_count": 835,
"modules": [
"app",
"app.adapters",
@@ -7369,6 +7388,7 @@
"app.modules.jellyfin.jellyfin",
"app.modules.listenbrainz",
"app.modules.lrclib",
"app.modules.media_auxiliary",
"app.modules.musicbrainz",
"app.modules.musicbrainz.music_cache",
"app.modules.musixmatch",
+56 -7
View File
@@ -3220,6 +3220,26 @@
"timeout_policy": "caller_budget",
"version": 1
},
"async_get_media_auxiliary_info": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-recognition",
"input_contract": "MediaAuxiliaryInfoRequest",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [
"media_source",
"mediainfo",
"metainfo"
],
"result_contract": "list[MediaInfo]",
"result_shape": "list",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"async_identify_music_by_fingerprint": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
@@ -4696,6 +4716,26 @@
"timeout_policy": "caller_budget",
"version": 1
},
"get_media_auxiliary_info": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-recognition",
"input_contract": "MediaAuxiliaryInfoRequest",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [
"media_source",
"mediainfo",
"metainfo"
],
"result_contract": "list[MediaInfo]",
"result_shape": "list",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"get_parent_item": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
@@ -6722,10 +6762,10 @@
}
},
"run_module": {
"call_count": 263,
"call_count": 264,
"dynamic_call_count": 0,
"dynamic_calls": [],
"method_count": 212,
"method_count": 214,
"methods": {
"anilist_credits": [
{
@@ -6966,6 +7006,13 @@
"mode": "async"
}
],
"async_get_media_auxiliary_info": [
{
"caller": "app.chain.media",
"count": 1,
"mode": "async"
}
],
"async_identify_music_by_fingerprint": [
{
"caller": "app.chain.acoustid",
@@ -7565,6 +7612,13 @@
"mode": "sync"
}
],
"get_media_auxiliary_info": [
{
"caller": "app.chain.media",
"count": 1,
"mode": "sync"
}
],
"get_parent_item": [
{
"caller": "app.chain.storage",
@@ -7962,11 +8016,6 @@
"count": 1,
"mode": "sync"
},
{
"caller": "app.chain.media",
"count": 1,
"mode": "sync"
},
{
"caller": "app.chain.musicbrainz",
"count": 1,
+109
View File
@@ -0,0 +1,109 @@
"""多媒体来源附加信息 provider 的单元测试。"""
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from app.chain.search import SearchChain
from app.domain.context import MediaInfo
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
from app.schemas.types import MediaSource, MediaType
class _FakeAuxiliaryProvider(MediaAuxiliaryProviderMixin):
"""记录通用 mixin 下传给现有识别接口的参数。"""
auxiliary_media_source = MediaSource.AniList
def __init__(self) -> None:
"""初始化同步和异步调用记录。"""
self.sync_kwargs = None
self.async_kwargs = None
def recognize_media(self, **kwargs) -> MediaInfo:
"""记录同步识别参数并返回固定 AniList 媒体。"""
self.sync_kwargs = kwargs
return MediaInfo(
media_source=MediaSource.AniList,
media_id="154587",
type=MediaType.TV,
title="Sousou no Frieren",
names=["Frieren"],
)
async def async_recognize_media(self, **kwargs) -> MediaInfo:
"""记录异步识别参数并返回固定 AniList 媒体。"""
self.async_kwargs = kwargs
return self.recognize_media(**kwargs)
def test_provider_only_runs_when_its_source_is_enabled() -> None:
"""provider 只处理用户选中的自身来源,未选中时不得发起识别。"""
provider = _FakeAuxiliaryProvider()
media = MediaInfo(
media_source=MediaSource.Douban,
media_id="1",
type=MediaType.TV,
title="葬送的芙莉莲",
year="2023",
)
assert provider.get_media_auxiliary_info(
media,
media_source=(MediaSource.TMDB,),
) == []
assert provider.sync_kwargs is None
result = provider.get_media_auxiliary_info(
media,
media_source=(MediaSource.TMDB, MediaSource.AniList),
)
assert result[0].media_source == MediaSource.AniList
assert provider.sync_kwargs["media_source"] == MediaSource.AniList
assert provider.sync_kwargs["media_id"] is None
assert provider.sync_kwargs["meta"].year == "2023"
def test_provider_uses_native_identity_for_same_source_async() -> None:
"""同来源补充应使用来源原生 ID,避免再次依赖标题消歧。"""
provider = _FakeAuxiliaryProvider()
media = MediaInfo(
media_source=MediaSource.AniList,
media_id="154587",
type=MediaType.TV,
title="葬送的芙莉莲",
)
result = asyncio.run(
provider.async_get_media_auxiliary_info(
media,
media_source=(MediaSource.AniList,),
)
)
assert result[0].media_id == "154587"
assert provider.async_kwargs["media_id"] == "154587"
def test_site_search_keywords_include_aggregated_aliases() -> None:
"""站点搜索参数应优先使用附加信息聚合后的别名列表。"""
media = MediaInfo(
media_source=MediaSource.Douban,
media_id="1",
type=MediaType.TV,
title="葬送的芙莉莲",
names=["Frieren", "Frieren: Beyond Journey's End"],
)
with patch(
"app.chain.search.get_chain_runtime_config_snapshot",
return_value=SimpleNamespace(max_search_name_limit=3),
):
_, keywords = SearchChain._SearchChain__prepare_params(media)
assert keywords == [
"葬送的芙莉莲",
"Frieren",
"Frieren: Beyond Journey's End",
]
+12 -1
View File
@@ -15,7 +15,6 @@ from app.runtime.extensions.module.contracts import (
list_explicit_module_contracts,
)
RUNTIME_BASELINE = (
Path(__file__).parent / "fixtures" / "architecture" / "runtime-contract-baseline.json"
)
@@ -284,6 +283,18 @@ def test_recognition_match_and_cache_contracts_share_sync_async_semantics() -> N
assert contract.required_parameters
def test_media_auxiliary_contract_merges_every_enabled_provider() -> None:
"""附加信息能力应合并全部 provider 列表,不能被首个插件结果短路。"""
contract = get_module_method_contract("get_media_auxiliary_info")
assert contract is get_module_method_contract("async_get_media_auxiliary_info")
assert contract.family == "media-recognition"
assert contract.aggregation is ModuleResultAggregation.ORDERED_LIST_MERGE
assert contract.result_shape is ModuleResultShape.LIST
assert contract.plugin_short_circuit is False
assert contract.required_parameters == ("mediainfo", "media_source", "metainfo")
def test_torrent_filter_contract_preserves_original_argument_list_merge() -> None:
"""种子过滤 provider 应接收原始参数并有序合并结果,不得误用单参数接力。"""
contract = get_module_method_contract("filter_torrents")
+8 -1
View File
@@ -74,6 +74,10 @@ def _load_subscribe_chain_class():
"""异步按元数据识别默认返回空结果。"""
return None
def supplement_media_info(self, mediainfo, *args, **kwargs):
"""隔离测试不访问外部附加信息源,原样返回识别结果。"""
return mediainfo
interaction_module = ensure_module("app.application.messaging.interaction", types.ModuleType("app.application.messaging.interaction"))
class _SlashInteractionManager:
@@ -455,7 +459,10 @@ SubscribeInteractionHandler = SUBSCRIBE_CHAIN_MODULE.SubscribeInteractionHandler
def _patch_media_recognize(module, result):
"""将隔离测试中的统一媒体识别入口替换为指定结果或回调。"""
recognizer = result if callable(result) else lambda **_kwargs: result
media_chain = SimpleNamespace(recognize_media=recognizer)
media_chain = SimpleNamespace(
recognize_media=recognizer,
supplement_media_info=lambda mediainfo: mediainfo,
)
return patch.object(module, "MediaChain", return_value=media_chain)
+79 -5
View File
@@ -1,5 +1,5 @@
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import Mock, patch
from app.chain.media import MediaChain
from app.domain.context import MediaInfo
@@ -23,11 +23,11 @@ class _FakeTmdbModule:
"""返回模块调度优先级。"""
return 0
def recognize_media(self, **kwargs):
"""与宿主识别模块一致:只应答 TMDB 来源的请求。"""
def get_media_auxiliary_info(self, **kwargs):
"""与宿主附加信息模块一致:只应答 TMDB 来源的请求。"""
if kwargs.get("media_source") != MediaSource.TMDB:
return None
return self.result
return []
return [self.result]
def _make_chain(tmdb_media: MediaInfo) -> MediaChain:
@@ -128,3 +128,77 @@ def test_tmdb_supplement_uses_current_season_year_and_keeps_season_zero() -> Non
assert tmdb_meta.begin_season == 0
assert tmdb_meta.year == "2024"
def test_multi_source_auxiliary_merges_aliases_but_only_tmdb_special_fields() -> None:
"""多来源只合并标题候选,分类、风格和外部 ID 必须由 TMDB 独占。"""
primary = MediaInfo(
media_source=MediaSource.Douban,
media_id="1",
type=MediaType.TV,
title="葬送的芙莉莲",
names=["Frieren"],
)
anilist = MediaInfo(
media_source=MediaSource.AniList,
media_id="154587",
type=MediaType.TV,
title="Sousou no Frieren",
names=["FRIEREN", "Frieren: Beyond Journey's End"],
category="动画冲突分类",
genre_ids=[99],
imdb_id="tt-conflict",
)
tmdb = MediaInfo(
tmdb_info={
"id": 209867,
"media_type": MediaType.TV,
"name": "Frieren: Beyond Journey's End",
"genre_ids": [16, 18],
"external_ids": {"imdb_id": "tt22248376", "tvdb_id": 424536},
}
)
tmdb.category = "日本动画"
result = MediaChain._merge_media_auxiliary(
primary,
[anilist, tmdb],
(MediaSource.AniList, MediaSource.TMDB),
)
assert result.names == [
"葬送的芙莉莲",
"Frieren",
"Sousou no Frieren",
"Frieren: Beyond Journey's End",
]
assert result.category == "日本动画"
assert result.genre_ids == [16, 18]
assert result.imdb_id == "tt22248376"
assert result.tvdb_id == 424536
def test_supplement_media_info_uses_configured_source_union() -> None:
"""未显式传来源时,Chain 应把用户的 SEARCH_SOURCE 多选完整传给 provider。"""
primary = MediaInfo(
media_source=MediaSource.Douban,
media_id="1",
type=MediaType.TV,
title="测试剧",
)
chain = object.__new__(MediaChain)
chain.run_module = Mock(return_value=[])
with patch(
"app.chain.media.get_chain_runtime_config_snapshot",
return_value=SimpleNamespace(search_source="douban,themoviedb,anilist"),
):
result = chain.supplement_media_info(primary)
assert result is primary
chain.run_module.assert_called_once_with(
"get_media_auxiliary_info",
mediainfo=primary,
media_source=(MediaSource.Douban, MediaSource.TMDB, MediaSource.AniList),
metainfo=None,
)