mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
feat(media): aggregate aliases from enabled metadata sources
This commit is contained in:
+23
-3
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user