diff --git a/app/application/configuration.py b/app/application/configuration.py index 43ce0c09d..a682a3021 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -187,6 +187,7 @@ class ChainRuntimeConfig: ai_recommend_user_preference: str = "" max_search_name_limit: int = 3 search_multiple_name: bool = False + search_source: str = "themoviedb" search_threadpool_size: int = 1 transfer_threads: int = 1 transfer_failure_notification_aggregation: bool = True diff --git a/app/chain/media.py b/app/chain/media.py index 9bf6fa7bc..2d010b810 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -19,7 +19,7 @@ from app.domain.context import ( MusicArtistInfo, MusicInfo, ) -from app.domain.media import is_music_media_source +from app.domain.media import is_music_media_source, parse_media_source_selection from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath @@ -530,48 +530,6 @@ class MediaChain(ChainBase, metaclass=Singleton): logger.warn(f"{metainfo.title} 未识别到媒体信息") return mediainfo - @staticmethod - def _build_tmdb_supplement_meta( - mediainfo: MediaInfo, - metainfo: Optional[MetaBase] = None, - ) -> MetaBase: - """ - 根据主识别结果构造 TMDB 辅助识别参数。 - - :param mediainfo: 主识别源返回的媒体信息 - :param metainfo: 原始标题解析信息 - :return: 不携带主识别源身份的 TMDB 查询参数 - """ - title = mediainfo.title or getattr(metainfo, "name", None) or "" - tmdb_meta = MetaInfo(title) - if not tmdb_meta.cn_name and getattr(metainfo, "cn_name", None): - tmdb_meta.cn_name = metainfo.cn_name - if not tmdb_meta.en_name: - tmdb_meta.en_name = mediainfo.en_title or ( - getattr(metainfo, "en_name", None) - ) - tmdb_meta.type = mediainfo.type or ( - getattr(metainfo, "type", None) or MediaType.UNKNOWN - ) - season = ( - mediainfo.season - if mediainfo.season is not None - else getattr(metainfo, "begin_season", None) - ) - tmdb_meta.begin_season = season - season_year = None - if season is not None and mediainfo.season_years: - season_year = ( - mediainfo.season_years.get(season) - or mediainfo.season_years.get(str(season)) - ) - tmdb_meta.year = ( - season_year - or mediainfo.year - or getattr(metainfo, "year", None) - ) - return tmdb_meta - @staticmethod def _merge_tmdb_auxiliary( mediainfo: MediaInfo, @@ -602,6 +560,154 @@ class MediaChain(ChainBase, metaclass=Singleton): setattr(mediainfo, field, getattr(tmdb_media, field, None)) return mediainfo + @staticmethod + def _build_tmdb_supplement_meta( + mediainfo: MediaInfo, + metainfo: Optional[MetaBase] = None, + ) -> MetaBase: + """构造兼容旧调用方的 TMDB 附加识别参数。""" + title = mediainfo.title or getattr(metainfo, "name", None) or "" + tmdb_meta = MetaInfo(title) + tmdb_meta.en_name = mediainfo.en_title or getattr(metainfo, "en_name", None) + tmdb_meta.type = mediainfo.type or getattr(metainfo, "type", None) or MediaType.UNKNOWN + season = ( + mediainfo.season + if mediainfo.season is not None + else getattr(metainfo, "begin_season", None) + ) + tmdb_meta.begin_season = season + season_year = None + if season is not None and mediainfo.season_years: + season_year = ( + mediainfo.season_years.get(season) + or mediainfo.season_years.get(str(season)) + ) + tmdb_meta.year = season_year or mediainfo.year or getattr(metainfo, "year", None) + return tmdb_meta + + @staticmethod + def _media_alias_candidates(mediainfo: object) -> list[str]: + """按稳定字段顺序提取单个来源可参与搜索匹配的标题候选。""" + candidates = [ + getattr(mediainfo, field, None) + for field in ( + "title", + "original_title", + "en_title", + "hk_title", + "tw_title", + "sg_title", + ) + ] + candidates.extend(getattr(mediainfo, "names", None) or []) + return [str(candidate).strip() for candidate in candidates if str(candidate or "").strip()] + + @classmethod + def _merge_media_auxiliary( + cls, + mediainfo: MediaInfo, + auxiliary_medias: Iterable[object], + selected_sources: Optional[MediaSourceSelection], + ) -> MediaInfo: + """合并多来源别名,并仅接受 TMDB 的兼容字段补充。""" + aliases = cls._media_alias_candidates(mediainfo) + seen_aliases = {" ".join(alias.casefold().split()) for alias in aliases} + selected = ( + {selected_sources} + if isinstance(selected_sources, MediaSource) + else set(selected_sources or ()) + ) + for auxiliary in auxiliary_medias or []: + auxiliary_source = normalize_media_source( + getattr(auxiliary, "media_source", None) + ) + if not auxiliary_source or (selected and auxiliary_source not in selected): + continue + for alias in cls._media_alias_candidates(auxiliary): + normalized = " ".join(alias.casefold().split()) + if normalized in seen_aliases: + continue + aliases.append(alias) + seen_aliases.add(normalized) + if auxiliary_source == MediaSource.TMDB: + cls._merge_tmdb_auxiliary(mediainfo, auxiliary) + mediainfo.names = aliases + return mediainfo + + @staticmethod + def _resolve_auxiliary_sources( + media_source: Optional[MediaSourceSelection], + ) -> Optional[MediaSourceSelection]: + """解析请求级来源,未指定时使用用户配置的影视搜索来源集合。""" + if media_source: + return media_source + configured = get_chain_runtime_config_snapshot().search_source + try: + return parse_media_source_selection(configured) or None + except ValueError as err: + logger.warning(f"媒体附加信息来源配置无效,跳过补充:{err}") + return () + + def supplement_media_info( + self, + mediainfo: Optional[Union[MediaInfo, MusicInfo]], + media_source: Optional[MediaSourceSelection] = None, + metainfo: Optional[MetaBase] = None, + ) -> Optional[Union[MediaInfo, MusicInfo]]: + """按用户启用的数据源聚合影视别名和受控的 TMDB 附加字段。""" + if ( + not mediainfo + or isinstance(mediainfo, MusicInfo) + or mediainfo.type == MediaType.MUSIC + ): + return mediainfo + selected_sources = self._resolve_auxiliary_sources(media_source) + try: + auxiliary_medias = self.run_module( + "get_media_auxiliary_info", + mediainfo=mediainfo, + media_source=selected_sources, + metainfo=metainfo, + ) or [] + except Exception as err: + logger.warning(f"{mediainfo.title_year} 获取媒体附加信息失败:{err}") + return mediainfo + return self._merge_media_auxiliary( + mediainfo, + auxiliary_medias, + selected_sources, + ) + + async def async_supplement_media_info( + self, + mediainfo: Optional[Union[MediaInfo, MusicInfo]], + media_source: Optional[MediaSourceSelection] = None, + metainfo: Optional[MetaBase] = None, + ) -> Optional[Union[MediaInfo, MusicInfo]]: + """异步按用户启用的数据源聚合影视别名和受控 TMDB 字段。""" + if ( + not mediainfo + or isinstance(mediainfo, MusicInfo) + or mediainfo.type == MediaType.MUSIC + ): + return mediainfo + selected_sources = self._resolve_auxiliary_sources(media_source) + try: + auxiliary_medias = await self.async_run_module( + "async_get_media_auxiliary_info", + mediainfo=mediainfo, + media_source=selected_sources, + metainfo=metainfo, + ) or [] + except Exception as err: + logger.warning(f"{mediainfo.title_year} 异步获取媒体附加信息失败:{err}") + return mediainfo + return self._merge_media_auxiliary( + mediainfo, + auxiliary_medias, + selected_sources, + ) + def supplement_tmdb_info( self, mediainfo: Optional[Union[MediaInfo, MusicInfo]], @@ -614,35 +720,11 @@ class MediaChain(ChainBase, metaclass=Singleton): :param metainfo: 原始标题解析信息 :return: 已补充 TMDB 辅助字段的原媒体对象 """ - if not mediainfo: - return None - # 音乐原样返回:下面全是 TMDB 影视字段,MusicInfo 上根本没有。用 isinstance - # 而不只看 type,一来静态检查能据此收窄(.type == 的比较收窄不了类型),二来 - # type 没被正确赋值的 MusicInfo 也挡得住,不至于到下一行才 AttributeError - if isinstance(mediainfo, MusicInfo) or mediainfo.type == MediaType.MUSIC: - return mediainfo - if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids: - return mediainfo - tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo) - # 按 source 路由走统一 dispatch:宿主识别模块对非自身来源都会快速返回 None, - # 指定 TMDB 来源时只有 TheMovieDbModule 会应答,链层无需点名具体模块 - try: - tmdb_media = self.run_module( - "recognize_media", - meta=tmdb_meta, - mtype=mediainfo.type, - media_source=MediaSource.TMDB, - media_id=str(mediainfo.tmdb_id) if mediainfo.tmdb_id else None, - episode_group=mediainfo.episode_group, - cache=True, - ) - except Exception as err: - logger.warn(f"{mediainfo.title_year} 补充 TMDB 辅助信息失败:{err}") - return mediainfo - if not tmdb_media: - logger.warn(f"{mediainfo.title_year} 未匹配到 TMDB 辅助信息") - return mediainfo - return self._merge_tmdb_auxiliary(mediainfo, tmdb_media) + return self.supplement_media_info( + mediainfo=mediainfo, + media_source=MediaSource.TMDB, + metainfo=metainfo, + ) def _recognize_with_fallback_by_meta( self, diff --git a/app/chain/search.py b/app/chain/search.py index 4aee6207b..af3d7c5c1 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -1212,6 +1212,7 @@ class SearchChain(ChainBase): else: # 去重去空,但要保持顺序 keywords = list(dict.fromkeys([k for k in [mediainfo.title, + *(mediainfo.names or []), mediainfo.original_title, mediainfo.en_title, mediainfo.hk_title, @@ -1709,6 +1710,9 @@ class SearchChain(ChainBase): logger.error('媒体信息识别失败!') return [] + # 搜索前按用户启用的数据源聚合别名;分类、风格与外部 ID 仅由 TMDB 补充。 + mediainfo = MediaChain().supplement_media_info(mediainfo) or mediainfo + # 准备搜索参数 season_episodes, keywords = self.__prepare_params( mediainfo=mediainfo, @@ -1802,6 +1806,12 @@ class SearchChain(ChainBase): logger.error('媒体信息识别失败!') return [] + # 异步搜索与同步入口共享同一份多来源附加信息语义。 + mediainfo = ( + await MediaChain().async_supplement_media_info(mediainfo) + or mediainfo + ) + # 准备搜索参数 season_episodes, keywords = self.__prepare_params( mediainfo=mediainfo, @@ -1890,6 +1900,11 @@ class SearchChain(ChainBase): } return + mediainfo = ( + await MediaChain().async_supplement_media_info(mediainfo) + or mediainfo + ) + # 准备搜索参数 season_episodes, keywords = self.__prepare_params( mediainfo=mediainfo, diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index a0a39401d..281e648cc 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -1804,8 +1804,14 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if exist_flag: continue - # 清理多余信息 + # 匹配前聚合多来源别名;清理大字段时保留匹配所需的标题合集。 + mediainfo = ( + MediaChain().supplement_media_info(mediainfo) + or mediainfo + ) + auxiliary_names = list(getattr(mediainfo, "names", None) or []) mediainfo.clear() + mediainfo.names = auxiliary_names # 订阅识别词 if subscribe.custom_words: diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index c74062667..1c4474e06 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -1,15 +1,17 @@ from typing import List, Optional, Tuple, Union -from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.settings import RuntimeSettingsCompat +from app.schemas.context import MediaPerson as _SchemaMediaPerson settings = RuntimeSettingsCompat() from app.domain.context import MediaInfo +from app.domain.media import is_media_source_enabled from app.domain.meta.metabase import MetaBase from app.domain.scraper import MediaScraperHelper -from app.runtime.log import logger from app.modules import _ModuleBase from app.modules.anilist.anilist import AniListApi +from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin +from app.runtime.log import logger from app.schemas.types import ( MediaRecognizeType, MediaSource, @@ -17,14 +19,14 @@ from app.schemas.types import ( MediaType, ModuleType, ) -from app.domain.media import is_media_source_enabled -class AniListModule(_ModuleBase): +class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ AniList 动画媒体识别与刮削模块 """ + auxiliary_media_source = MediaSource.AniList CONFIG_WATCH = {"PROXY_HOST"} anilist_api: AniListApi = None diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index efe61921b..563177489 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -1,16 +1,19 @@ from dataclasses import dataclass from typing import Any, List, Optional, Tuple, Union -from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.settings import RuntimeSettingsCompat +from app.schemas.context import MediaPerson as _SchemaMediaPerson settings = RuntimeSettingsCompat() +from app.adapters.network.http import RequestUtils from app.domain.context import MediaInfo +from app.domain.media import is_media_source_enabled from app.domain.meta.metabase import MetaBase from app.domain.scraper import MediaScraperHelper -from app.runtime.log import logger from app.modules import _ModuleBase from app.modules.bangumi.bangumi import BangumiApi +from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin +from app.runtime.log import logger from app.schemas.types import ( MediaRecognizeType, MediaSource, @@ -18,8 +21,6 @@ from app.schemas.types import ( MediaType, ModuleType, ) -from app.adapters.network.http import RequestUtils -from app.domain.media import is_media_source_enabled @dataclass(frozen=True, slots=True) @@ -29,10 +30,11 @@ class BangumiConfigSnapshot: proxy: Any -class BangumiModule(_ModuleBase): +class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ Bangumi媒体信息匹配 """ + auxiliary_media_source = MediaSource.Bangumi CONFIG_WATCH = {"PROXY_HOST"} bangumiapi: BangumiApi = None diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 2f92d9661..7170efd27 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -3,43 +3,45 @@ from typing import Any, List, Optional, Tuple, Union import cn2an -from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.settings import RuntimeSettingsCompat +from app.schemas.context import MediaPerson as _SchemaMediaPerson settings = RuntimeSettingsCompat() +from app.adapters.network.http import RequestUtils from app.domain.context import ( MediaInfo, MusicAlbumInfo, MusicInfo, ) +from app.domain.media import is_media_source_enabled, is_media_source_selected from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo -from app.runtime.log import logger +from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase from app.modules.douban.apiv2 import DoubanApi from app.modules.douban.scraper import DoubanScraper +from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin +from app.runtime.execution import retry +from app.runtime.log import logger +from app.runtime.rate import rate_limit_exponential from app.schemas.context import MediaPerson from app.schemas.exception import APIRateLimitException from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, + MediaRecognizeType, MediaSource, MediaSourceSelection, MediaType, ModuleType, - MediaRecognizeType, ) -from app.runtime.execution import retry -from app.adapters.network.http import RequestUtils -from app.runtime.rate import rate_limit_exponential -from app.domain.media import is_media_source_enabled, is_media_source_selected -from app.foundation.text import convert as zhconv_convert -class DoubanModule(_ModuleBase): +class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """提供豆瓣影视与豆瓣音乐元数据识别能力。""" + auxiliary_media_source = MediaSource.Douban _music_source = MediaSource.DoubanMusic doubanapi: DoubanApi = None scraper: DoubanScraper = None diff --git a/app/modules/imdb/__init__.py b/app/modules/imdb/__init__.py index 7a4077823..05cf48548 100644 --- a/app/modules/imdb/__init__.py +++ b/app/modules/imdb/__init__.py @@ -11,6 +11,7 @@ from app.domain.meta.metabase import MetaBase from app.domain.scraper import MediaScraperHelper from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase +from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() @@ -43,9 +44,10 @@ class ImdbConfigSnapshot: proxy: Any -class ImdbModule(_ModuleBase): +class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """提供 IMDb 搜索、识别、详情补全与刮削能力。""" + auxiliary_media_source = MediaSource.IMDb CONFIG_WATCH = {"PROXY_HOST"} _IMDB_ID_PATTERN = re.compile(r"^tt\d+$", re.IGNORECASE) _MOVIE_TYPES = frozenset({"movie", "tvMovie"}) diff --git a/app/modules/media_auxiliary.py b/app/modules/media_auxiliary.py new file mode 100644 index 000000000..178bc416c --- /dev/null +++ b/app/modules/media_auxiliary.py @@ -0,0 +1,95 @@ +"""影视识别模块共享的附加信息能力实现。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Optional + +from app.domain.context import MediaInfo +from app.domain.media import is_media_source_enabled +from app.domain.meta.metabase import MetaBase +from app.domain.metainfo import MetaInfo +from app.schemas.types import MediaSource, MediaSourceSelection, MediaType + + +class MediaAuxiliaryProviderMixin: + """把现有识别接口适配为可聚合的影视附加信息能力。""" + + auxiliary_media_source: MediaSource + + @staticmethod + def _build_auxiliary_meta( + mediainfo: MediaInfo, + metainfo: Optional[MetaBase] = None, + ) -> MetaInfo: + """根据主识别结果构造不携带原来源身份的标题查询参数。""" + meta = MetaInfo(mediainfo.title or getattr(metainfo, "name", None) or "") + if not meta.en_name: + meta.en_name = mediainfo.en_title or getattr(metainfo, "en_name", None) + meta.type = mediainfo.type or getattr(metainfo, "type", None) or MediaType.UNKNOWN + meta.begin_season = ( + mediainfo.season + if mediainfo.season is not None + else getattr(metainfo, "begin_season", None) + ) + season_year = None + if meta.begin_season is not None and mediainfo.season_years: + season_year = ( + mediainfo.season_years.get(meta.begin_season) + or mediainfo.season_years.get(str(meta.begin_season)) + ) + meta.year = season_year or mediainfo.year or getattr(metainfo, "year", None) + return meta + + def _auxiliary_recognize_kwargs( + self, + mediainfo: MediaInfo, + metainfo: Optional[MetaBase] = None, + ) -> dict[str, object]: + """构造当前来源的识别参数,同源媒体优先使用来源原生 ID。""" + same_source = mediainfo.media_source == self.auxiliary_media_source + return { + "meta": self._build_auxiliary_meta(mediainfo, metainfo), + "mtype": mediainfo.type, + "media_source": self.auxiliary_media_source, + "media_id": mediainfo.media_id if same_source else None, + "episode_group": mediainfo.episode_group, + "cache": True, + } + + def get_media_auxiliary_info( + self, + mediainfo: MediaInfo, + media_source: Optional[MediaSourceSelection] = None, + metainfo: Optional[MetaBase] = None, + ) -> list[MediaInfo]: + """使用当前来源同步获取影视别名等附加信息。""" + if ( + not mediainfo + or mediainfo.type not in (MediaType.MOVIE, MediaType.TV) + or not is_media_source_enabled(media_source, self.auxiliary_media_source) + ): + return [] + recognize: Callable[..., Optional[MediaInfo]] = getattr(self, "recognize_media") + result = recognize(**self._auxiliary_recognize_kwargs(mediainfo, metainfo)) + return [result] if result else [] + + async def async_get_media_auxiliary_info( + self, + mediainfo: MediaInfo, + media_source: Optional[MediaSourceSelection] = None, + metainfo: Optional[MetaBase] = None, + ) -> list[MediaInfo]: + """使用当前来源异步获取影视别名等附加信息。""" + if ( + not mediainfo + or mediainfo.type not in (MediaType.MOVIE, MediaType.TV) + or not is_media_source_enabled(media_source, self.auxiliary_media_source) + ): + return [] + recognize: Callable[..., Awaitable[Optional[MediaInfo]]] = getattr( + self, + "async_recognize_media", + ) + result = await recognize(**self._auxiliary_recognize_kwargs(mediainfo, metainfo)) + return [result] if result else [] diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 6ba124ede..b5e1c0ccf 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -1,24 +1,29 @@ import re -from typing import Optional, List, Tuple, Union, Dict +from typing import Dict, List, Optional, Tuple, Union import cn2an -from app.schemas.context import MediaPerson as _SchemaMediaPerson -from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason -from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode from app.runtime.settings import RuntimeSettingsCompat +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode +from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason settings = RuntimeSettingsCompat() +from app.adapters.network.http import RequestUtils from app.domain.context import MediaInfo +from app.domain.media import is_media_source_enabled, is_media_source_selected from app.domain.meta.metabase import MetaBase -from app.runtime.log import logger +from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase +from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin from app.modules.themoviedb.category import CategoryHelper from app.modules.themoviedb.scraper import TmdbScraper from app.modules.themoviedb.tmdb_cache import TmdbCache from app.modules.themoviedb.tmdbapi import TmdbApi from app.modules.themoviedb.tmdbv3api.exceptions import TMDbConnectionError +from app.runtime.log import logger from app.schemas.category import CategoryConfig +from app.schemas.media import normalize_media_source from app.schemas.types import ( MediaImageType, MediaRecognizeType, @@ -27,20 +32,16 @@ from app.schemas.types import ( MediaType, ModuleType, ) -from app.adapters.network.http import RequestUtils -from app.domain.media import is_media_source_enabled, is_media_source_selected -from app.schemas.media import normalize_media_source -from app.foundation.text import convert as zhconv_convert - _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -class TheMovieDbModule(_ModuleBase): +class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ TMDB媒体信息匹配 """ CONFIG_WATCH = {"PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"} + auxiliary_media_source = MediaSource.TMDB # 元数据缓存 cache: TmdbCache = None diff --git a/app/modules/thetvdb/__init__.py b/app/modules/thetvdb/__init__.py index 065be8b02..7fd2bdb78 100644 --- a/app/modules/thetvdb/__init__.py +++ b/app/modules/thetvdb/__init__.py @@ -1,13 +1,23 @@ from threading import Lock -from typing import Optional, Tuple, Union +from typing import Optional, Tuple, Union, cast from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() -from app.runtime.log import logger +from app.domain.context import MediaInfo +from app.domain.media import is_media_source_enabled +from app.domain.meta.metabase import MetaBase from app.modules import _ModuleBase from app.modules.thetvdb import tvdb_v4_official -from app.schemas.types import ModuleType, MediaRecognizeType +from app.runtime.execution import run_in_threadpool +from app.runtime.log import logger +from app.schemas.types import ( + MediaRecognizeType, + MediaSource, + MediaSourceSelection, + MediaType, + ModuleType, +) class TheTvDbModule(_ModuleBase): @@ -179,6 +189,111 @@ class TheTvDbModule(_ModuleBase): logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}") return [] + @staticmethod + def _tvdb_aliases(info: dict[str, object]) -> list[str]: + """从 TVDB 搜索或详情响应中提取名称与翻译别名。""" + values: list[object] = [info.get("name")] + for field in ("aliases", "translations", "nameTranslations"): + raw_value = info.get(field) or [] + if isinstance(raw_value, dict): + raw_value = list(raw_value.values()) + if not isinstance(raw_value, (list, tuple, set)): + raw_value = [raw_value] + for item in raw_value: + values.append( + item.get("name") or item.get("value") + if isinstance(item, dict) + else item + ) + aliases: list[str] = [] + seen: set[str] = set() + for value in values: + alias = str(value or "").strip() + normalized = " ".join(alias.casefold().split()) + if alias and normalized not in seen: + aliases.append(alias) + seen.add(normalized) + return aliases + + @staticmethod + def _tvdb_media_id(info: dict[str, object]) -> Optional[str]: + """从不同形态的 TVDB 响应中提取纯数字来源 ID。""" + raw_id = info.get("tvdb_id") or info.get("id") + if raw_id is None: + return None + value = str(raw_id).rsplit("-", 1)[-1] + return value if value.isdigit() else None + + def get_media_auxiliary_info( + self, + mediainfo: MediaInfo, + media_source: Optional[MediaSourceSelection] = None, + metainfo: Optional[MetaBase] = None, + ) -> list[MediaInfo]: + """从 TVDB 补充电视剧别名,不向主媒体写入 TVDB 专用字段。""" + if ( + not mediainfo + or mediainfo.type != MediaType.TV + or not is_media_source_enabled(media_source, MediaSource.TVDB) + ): + return [] + del metainfo + if ( + mediainfo.media_source == MediaSource.TVDB + and str(mediainfo.media_id or "").isdigit() + ): + info = self.tvdb_info(int(mediainfo.media_id)) + candidates = [info] if info else [] + else: + candidates = self.search_tvdb(mediainfo.title) + target_names = { + " ".join(str(name).casefold().split()) + for name in [mediainfo.title, *(mediainfo.names or [])] + if name + } + for info in candidates: + candidate_year = str( + info.get("year") + or info.get("firstAired") + or info.get("first_air_time") + or "" + )[:4] + if mediainfo.year and candidate_year and str(mediainfo.year) != candidate_year: + continue + aliases = self._tvdb_aliases(info) + normalized_aliases = {" ".join(alias.casefold().split()) for alias in aliases} + if not target_names.intersection(normalized_aliases): + continue + media_id = self._tvdb_media_id(info) + if not media_id or not aliases: + continue + return [MediaInfo( + media_source=MediaSource.TVDB, + media_id=media_id, + type=MediaType.TV, + title=aliases[0], + year=str(info.get("year")) if info.get("year") else mediainfo.year, + names=aliases, + )] + return [] + + async def async_get_media_auxiliary_info( + self, + mediainfo: MediaInfo, + media_source: Optional[MediaSourceSelection] = None, + metainfo: Optional[MetaBase] = None, + ) -> list[MediaInfo]: + """在线程池中执行 TVDB 同步附加信息查询。""" + return cast( + list[MediaInfo], + await run_in_threadpool( + self.get_media_auxiliary_info, + mediainfo=mediainfo, + media_source=media_source, + metainfo=metainfo, + ), + ) + def clear_cache(self): """ 清除缓存 diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index 8b759abab..57b1d01de 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -89,6 +89,13 @@ _METHOD_CONTRACTS = { aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("meta", "media_source"), ), + "get_media_auxiliary_info": ModuleMethodContract( + family="media-recognition", input_contract="MediaAuxiliaryInfoRequest", + result_contract="list[MediaInfo]", result_shape=ModuleResultShape.LIST, + aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, + required_parameters=("mediainfo", "media_source", "metainfo"), + plugin_short_circuit=False, + ), "obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.PIPELINE_RELAY, required_parameters=("mediainfo",)), "media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="dict[str, list] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), "mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="Iterable[MediaServerItem] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "library_id", "start_index", "limit")), @@ -248,6 +255,7 @@ _METHOD_CONTRACTS.update({ "async_match_tmdbinfo": _METHOD_CONTRACTS["match_tmdbinfo"], "async_update_recognize_cache": _METHOD_CONTRACTS["update_recognize_cache"], "async_search_medias": _METHOD_CONTRACTS["search_medias"], + "async_get_media_auxiliary_info": _METHOD_CONTRACTS["get_media_auxiliary_info"], "async_obtain_images": _METHOD_CONTRACTS["obtain_images"], "async_movie_hot": _METHOD_CONTRACTS["movie_hot"], "async_movie_showing": _METHOD_CONTRACTS["movie_showing"], @@ -592,9 +600,9 @@ def _infer_observed_family(method: str) -> str: )): return "music" if method.startswith(( - "async_match_", "async_obtain_images", "async_recognize_media", + "async_get_media_auxiliary_info", "async_match_", "async_obtain_images", "async_recognize_media", "async_update_recognize_cache", "match_", "obtain_images", - "recognize_media", "update_recognize_cache", + "get_media_auxiliary_info", "recognize_media", "update_recognize_cache", )): return "media-recognition" if method.startswith(( diff --git a/app/startup/composition/configuration.py b/app/startup/composition/configuration.py index 5bb6c08b6..7382c8d7f 100644 --- a/app/startup/composition/configuration.py +++ b/app/startup/composition/configuration.py @@ -139,6 +139,7 @@ def build_chain_runtime_config(settings: Settings) -> ChainRuntimeConfig: ai_recommend_user_preference=settings.AI_RECOMMEND_USER_PREFERENCE, max_search_name_limit=settings.MAX_SEARCH_NAME_LIMIT, search_multiple_name=settings.SEARCH_MULTIPLE_NAME, + search_source=settings.SEARCH_SOURCE, search_threadpool_size=settings.CONF.threadpool, transfer_threads=settings.TRANSFER_THREADS, transfer_failure_notification_aggregation=( diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e7c09aee8..f6040a470 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 542613f29..3ee0fec7d 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -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, diff --git a/tests/test_media_auxiliary.py b/tests/test_media_auxiliary.py new file mode 100644 index 000000000..7060e486e --- /dev/null +++ b/tests/test_media_auxiliary.py @@ -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", + ] diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index d8133afe6..732a46fc4 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -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") diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 6a357d0c5..03f83c645 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -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) diff --git a/tests/test_tmdb_auxiliary.py b/tests/test_tmdb_auxiliary.py index d3aa9c547..a11208c6e 100644 --- a/tests/test_tmdb_auxiliary.py +++ b/tests/test_tmdb_auxiliary.py @@ -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, + )