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
+1
View File
@@ -187,6 +187,7 @@ class ChainRuntimeConfig:
ai_recommend_user_preference: str = "" ai_recommend_user_preference: str = ""
max_search_name_limit: int = 3 max_search_name_limit: int = 3
search_multiple_name: bool = False search_multiple_name: bool = False
search_source: str = "themoviedb"
search_threadpool_size: int = 1 search_threadpool_size: int = 1
transfer_threads: int = 1 transfer_threads: int = 1
transfer_failure_notification_aggregation: bool = True transfer_failure_notification_aggregation: bool = True
+154 -72
View File
@@ -19,7 +19,7 @@ from app.domain.context import (
MusicArtistInfo, MusicArtistInfo,
MusicInfo, 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.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo, MetaInfoPath from app.domain.metainfo import MetaInfo, MetaInfoPath
@@ -530,48 +530,6 @@ class MediaChain(ChainBase, metaclass=Singleton):
logger.warn(f"{metainfo.title} 未识别到媒体信息") logger.warn(f"{metainfo.title} 未识别到媒体信息")
return mediainfo 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 @staticmethod
def _merge_tmdb_auxiliary( def _merge_tmdb_auxiliary(
mediainfo: MediaInfo, mediainfo: MediaInfo,
@@ -602,6 +560,154 @@ class MediaChain(ChainBase, metaclass=Singleton):
setattr(mediainfo, field, getattr(tmdb_media, field, None)) setattr(mediainfo, field, getattr(tmdb_media, field, None))
return mediainfo 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( def supplement_tmdb_info(
self, self,
mediainfo: Optional[Union[MediaInfo, MusicInfo]], mediainfo: Optional[Union[MediaInfo, MusicInfo]],
@@ -614,35 +720,11 @@ class MediaChain(ChainBase, metaclass=Singleton):
:param metainfo: 原始标题解析信息 :param metainfo: 原始标题解析信息
:return: 已补充 TMDB 辅助字段的原媒体对象 :return: 已补充 TMDB 辅助字段的原媒体对象
""" """
if not mediainfo: return self.supplement_media_info(
return None mediainfo=mediainfo,
# 音乐原样返回:下面全是 TMDB 影视字段,MusicInfo 上根本没有。用 isinstance media_source=MediaSource.TMDB,
# 而不只看 type,一来静态检查能据此收窄(.type == 的比较收窄不了类型),二来 metainfo=metainfo,
# 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)
def _recognize_with_fallback_by_meta( def _recognize_with_fallback_by_meta(
self, self,
+15
View File
@@ -1212,6 +1212,7 @@ class SearchChain(ChainBase):
else: else:
# 去重去空,但要保持顺序 # 去重去空,但要保持顺序
keywords = list(dict.fromkeys([k for k in [mediainfo.title, keywords = list(dict.fromkeys([k for k in [mediainfo.title,
*(mediainfo.names or []),
mediainfo.original_title, mediainfo.original_title,
mediainfo.en_title, mediainfo.en_title,
mediainfo.hk_title, mediainfo.hk_title,
@@ -1709,6 +1710,9 @@ class SearchChain(ChainBase):
logger.error('媒体信息识别失败!') logger.error('媒体信息识别失败!')
return [] return []
# 搜索前按用户启用的数据源聚合别名;分类、风格与外部 ID 仅由 TMDB 补充。
mediainfo = MediaChain().supplement_media_info(mediainfo) or mediainfo
# 准备搜索参数 # 准备搜索参数
season_episodes, keywords = self.__prepare_params( season_episodes, keywords = self.__prepare_params(
mediainfo=mediainfo, mediainfo=mediainfo,
@@ -1802,6 +1806,12 @@ class SearchChain(ChainBase):
logger.error('媒体信息识别失败!') logger.error('媒体信息识别失败!')
return [] return []
# 异步搜索与同步入口共享同一份多来源附加信息语义。
mediainfo = (
await MediaChain().async_supplement_media_info(mediainfo)
or mediainfo
)
# 准备搜索参数 # 准备搜索参数
season_episodes, keywords = self.__prepare_params( season_episodes, keywords = self.__prepare_params(
mediainfo=mediainfo, mediainfo=mediainfo,
@@ -1890,6 +1900,11 @@ class SearchChain(ChainBase):
} }
return return
mediainfo = (
await MediaChain().async_supplement_media_info(mediainfo)
or mediainfo
)
# 准备搜索参数 # 准备搜索参数
season_episodes, keywords = self.__prepare_params( season_episodes, keywords = self.__prepare_params(
mediainfo=mediainfo, mediainfo=mediainfo,
+7 -1
View File
@@ -1804,8 +1804,14 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if exist_flag: if exist_flag:
continue continue
# 清理多余信息 # 匹配前聚合多来源别名;清理大字段时保留匹配所需的标题合集。
mediainfo = (
MediaChain().supplement_media_info(mediainfo)
or mediainfo
)
auxiliary_names = list(getattr(mediainfo, "names", None) or [])
mediainfo.clear() mediainfo.clear()
mediainfo.names = auxiliary_names
# 订阅识别词 # 订阅识别词
if subscribe.custom_words: if subscribe.custom_words:
+6 -4
View File
@@ -1,15 +1,17 @@
from typing import List, Optional, Tuple, Union from typing import List, Optional, Tuple, Union
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
from app.schemas.context import MediaPerson as _SchemaMediaPerson
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.domain.context import MediaInfo 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.meta.metabase import MetaBase
from app.domain.scraper import MediaScraperHelper from app.domain.scraper import MediaScraperHelper
from app.runtime.log import logger
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.modules.anilist.anilist import AniListApi 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 ( from app.schemas.types import (
MediaRecognizeType, MediaRecognizeType,
MediaSource, MediaSource,
@@ -17,14 +19,14 @@ from app.schemas.types import (
MediaType, MediaType,
ModuleType, ModuleType,
) )
from app.domain.media import is_media_source_enabled
class AniListModule(_ModuleBase): class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase):
""" """
AniList 动画媒体识别与刮削模块 AniList 动画媒体识别与刮削模块
""" """
auxiliary_media_source = MediaSource.AniList
CONFIG_WATCH = {"PROXY_HOST"} CONFIG_WATCH = {"PROXY_HOST"}
anilist_api: AniListApi = None anilist_api: AniListApi = None
+7 -5
View File
@@ -1,16 +1,19 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, List, Optional, Tuple, Union from typing import Any, List, Optional, Tuple, Union
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
from app.schemas.context import MediaPerson as _SchemaMediaPerson
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.adapters.network.http import RequestUtils
from app.domain.context import MediaInfo 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.meta.metabase import MetaBase
from app.domain.scraper import MediaScraperHelper from app.domain.scraper import MediaScraperHelper
from app.runtime.log import logger
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi 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 ( from app.schemas.types import (
MediaRecognizeType, MediaRecognizeType,
MediaSource, MediaSource,
@@ -18,8 +21,6 @@ from app.schemas.types import (
MediaType, MediaType,
ModuleType, ModuleType,
) )
from app.adapters.network.http import RequestUtils
from app.domain.media import is_media_source_enabled
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -29,10 +30,11 @@ class BangumiConfigSnapshot:
proxy: Any proxy: Any
class BangumiModule(_ModuleBase): class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase):
""" """
Bangumi媒体信息匹配 Bangumi媒体信息匹配
""" """
auxiliary_media_source = MediaSource.Bangumi
CONFIG_WATCH = {"PROXY_HOST"} CONFIG_WATCH = {"PROXY_HOST"}
bangumiapi: BangumiApi = None bangumiapi: BangumiApi = None
+11 -9
View File
@@ -3,43 +3,45 @@ from typing import Any, List, Optional, Tuple, Union
import cn2an import cn2an
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
from app.schemas.context import MediaPerson as _SchemaMediaPerson
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.adapters.network.http import RequestUtils
from app.domain.context import ( from app.domain.context import (
MediaInfo, MediaInfo,
MusicAlbumInfo, MusicAlbumInfo,
MusicInfo, 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.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo 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 import _ModuleBase
from app.modules.douban.apiv2 import DoubanApi from app.modules.douban.apiv2 import DoubanApi
from app.modules.douban.scraper import DoubanScraper 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.context import MediaPerson
from app.schemas.exception import APIRateLimitException from app.schemas.exception import APIRateLimitException
from app.schemas.types import ( from app.schemas.types import (
MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaSource, MediaSource,
MediaSourceSelection, MediaSourceSelection,
MediaType, MediaType,
ModuleType, 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 _music_source = MediaSource.DoubanMusic
doubanapi: DoubanApi = None doubanapi: DoubanApi = None
scraper: DoubanScraper = None scraper: DoubanScraper = None
+3 -1
View File
@@ -11,6 +11,7 @@ from app.domain.meta.metabase import MetaBase
from app.domain.scraper import MediaScraperHelper from app.domain.scraper import MediaScraperHelper
from app.foundation.text import convert as zhconv_convert from app.foundation.text import convert as zhconv_convert
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
@@ -43,9 +44,10 @@ class ImdbConfigSnapshot:
proxy: Any proxy: Any
class ImdbModule(_ModuleBase): class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
"""提供 IMDb 搜索、识别、详情补全与刮削能力。""" """提供 IMDb 搜索、识别、详情补全与刮削能力。"""
auxiliary_media_source = MediaSource.IMDb
CONFIG_WATCH = {"PROXY_HOST"} CONFIG_WATCH = {"PROXY_HOST"}
_IMDB_ID_PATTERN = re.compile(r"^tt\d+$", re.IGNORECASE) _IMDB_ID_PATTERN = re.compile(r"^tt\d+$", re.IGNORECASE)
_MOVIE_TYPES = frozenset({"movie", "tvMovie"}) _MOVIE_TYPES = frozenset({"movie", "tvMovie"})
+95
View File
@@ -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 []
+12 -11
View File
@@ -1,24 +1,29 @@
import re import re
from typing import Optional, List, Tuple, Union, Dict from typing import Dict, List, Optional, Tuple, Union
import cn2an 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.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() settings = RuntimeSettingsCompat()
from app.adapters.network.http import RequestUtils
from app.domain.context import MediaInfo 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.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 import _ModuleBase
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
from app.modules.themoviedb.category import CategoryHelper from app.modules.themoviedb.category import CategoryHelper
from app.modules.themoviedb.scraper import TmdbScraper from app.modules.themoviedb.scraper import TmdbScraper
from app.modules.themoviedb.tmdb_cache import TmdbCache from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbapi import TmdbApi from app.modules.themoviedb.tmdbapi import TmdbApi
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbConnectionError from app.modules.themoviedb.tmdbv3api.exceptions import TMDbConnectionError
from app.runtime.log import logger
from app.schemas.category import CategoryConfig from app.schemas.category import CategoryConfig
from app.schemas.media import normalize_media_source
from app.schemas.types import ( from app.schemas.types import (
MediaImageType, MediaImageType,
MediaRecognizeType, MediaRecognizeType,
@@ -27,20 +32,16 @@ from app.schemas.types import (
MediaType, MediaType,
ModuleType, 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}$") _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
class TheMovieDbModule(_ModuleBase): class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
""" """
TMDB媒体信息匹配 TMDB媒体信息匹配
""" """
CONFIG_WATCH = {"PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"} CONFIG_WATCH = {"PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"}
auxiliary_media_source = MediaSource.TMDB
# 元数据缓存 # 元数据缓存
cache: TmdbCache = None cache: TmdbCache = None
+118 -3
View File
@@ -1,13 +1,23 @@
from threading import Lock from threading import Lock
from typing import Optional, Tuple, Union from typing import Optional, Tuple, Union, cast
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = 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 import _ModuleBase
from app.modules.thetvdb import tvdb_v4_official 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): class TheTvDbModule(_ModuleBase):
@@ -179,6 +189,111 @@ class TheTvDbModule(_ModuleBase):
logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}") logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}")
return [] 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): def clear_cache(self):
""" """
清除缓存 清除缓存
+10 -2
View File
@@ -89,6 +89,13 @@ _METHOD_CONTRACTS = {
aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE,
required_parameters=("meta", "media_source"), 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",)), "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), "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")), "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_match_tmdbinfo": _METHOD_CONTRACTS["match_tmdbinfo"],
"async_update_recognize_cache": _METHOD_CONTRACTS["update_recognize_cache"], "async_update_recognize_cache": _METHOD_CONTRACTS["update_recognize_cache"],
"async_search_medias": _METHOD_CONTRACTS["search_medias"], "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_obtain_images": _METHOD_CONTRACTS["obtain_images"],
"async_movie_hot": _METHOD_CONTRACTS["movie_hot"], "async_movie_hot": _METHOD_CONTRACTS["movie_hot"],
"async_movie_showing": _METHOD_CONTRACTS["movie_showing"], "async_movie_showing": _METHOD_CONTRACTS["movie_showing"],
@@ -592,9 +600,9 @@ def _infer_observed_family(method: str) -> str:
)): )):
return "music" return "music"
if method.startswith(( 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", "async_update_recognize_cache", "match_", "obtain_images",
"recognize_media", "update_recognize_cache", "get_media_auxiliary_info", "recognize_media", "update_recognize_cache",
)): )):
return "media-recognition" return "media-recognition"
if method.startswith(( if method.startswith((
+1
View File
@@ -139,6 +139,7 @@ def build_chain_runtime_config(settings: Settings) -> ChainRuntimeConfig:
ai_recommend_user_preference=settings.AI_RECOMMEND_USER_PREFERENCE, ai_recommend_user_preference=settings.AI_RECOMMEND_USER_PREFERENCE,
max_search_name_limit=settings.MAX_SEARCH_NAME_LIMIT, max_search_name_limit=settings.MAX_SEARCH_NAME_LIMIT,
search_multiple_name=settings.SEARCH_MULTIPLE_NAME, search_multiple_name=settings.SEARCH_MULTIPLE_NAME,
search_source=settings.SEARCH_SOURCE,
search_threadpool_size=settings.CONF.threadpool, search_threadpool_size=settings.CONF.threadpool,
transfer_threads=settings.TRANSFER_THREADS, transfer_threads=settings.TRANSFER_THREADS,
transfer_failure_notification_aggregation=( transfer_failure_notification_aggregation=(
+23 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6785, "edge_count": 6804,
"edge_sha256": "077e003bf195195e4e75dc65c9596e0b5a45c0e33441e3a22829f0cdb8d98e45", "edge_sha256": "3e9a93795c6cfb5bc78e4871ae7c11b064ec0a2ea4f545ce94a6c0c696800df4",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -4155,6 +4155,7 @@
"app.modules.anilist -> app.domain.scraper", "app.modules.anilist -> app.domain.scraper",
"app.modules.anilist -> app.modules", "app.modules.anilist -> app.modules",
"app.modules.anilist -> app.modules.anilist.anilist", "app.modules.anilist -> app.modules.anilist.anilist",
"app.modules.anilist -> app.modules.media_auxiliary",
"app.modules.anilist -> app.runtime", "app.modules.anilist -> app.runtime",
"app.modules.anilist -> app.runtime.log", "app.modules.anilist -> app.runtime.log",
"app.modules.anilist -> app.runtime.settings", "app.modules.anilist -> app.runtime.settings",
@@ -4179,6 +4180,7 @@
"app.modules.bangumi -> app.domain.scraper", "app.modules.bangumi -> app.domain.scraper",
"app.modules.bangumi -> app.modules", "app.modules.bangumi -> app.modules",
"app.modules.bangumi -> app.modules.bangumi.bangumi", "app.modules.bangumi -> app.modules.bangumi.bangumi",
"app.modules.bangumi -> app.modules.media_auxiliary",
"app.modules.bangumi -> app.runtime", "app.modules.bangumi -> app.runtime",
"app.modules.bangumi -> app.runtime.log", "app.modules.bangumi -> app.runtime.log",
"app.modules.bangumi -> app.runtime.settings", "app.modules.bangumi -> app.runtime.settings",
@@ -4251,6 +4253,7 @@
"app.modules.douban -> app.modules", "app.modules.douban -> app.modules",
"app.modules.douban -> app.modules.douban.apiv2", "app.modules.douban -> app.modules.douban.apiv2",
"app.modules.douban -> app.modules.douban.scraper", "app.modules.douban -> app.modules.douban.scraper",
"app.modules.douban -> app.modules.media_auxiliary",
"app.modules.douban -> app.runtime", "app.modules.douban -> app.runtime",
"app.modules.douban -> app.runtime.execution", "app.modules.douban -> app.runtime.execution",
"app.modules.douban -> app.runtime.log", "app.modules.douban -> app.runtime.log",
@@ -4546,6 +4549,7 @@
"app.modules.imdb -> app.foundation.text", "app.modules.imdb -> app.foundation.text",
"app.modules.imdb -> app.modules", "app.modules.imdb -> app.modules",
"app.modules.imdb -> app.modules.imdb.api", "app.modules.imdb -> app.modules.imdb.api",
"app.modules.imdb -> app.modules.media_auxiliary",
"app.modules.imdb -> app.runtime", "app.modules.imdb -> app.runtime",
"app.modules.imdb -> app.runtime.log", "app.modules.imdb -> app.runtime.log",
"app.modules.imdb -> app.runtime.settings", "app.modules.imdb -> app.runtime.settings",
@@ -4916,6 +4920,14 @@
"app.modules.lrclib -> app.runtime.settings", "app.modules.lrclib -> app.runtime.settings",
"app.modules.lrclib -> app.schemas", "app.modules.lrclib -> app.schemas",
"app.modules.lrclib -> app.schemas.types", "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",
"app.modules.musicbrainz -> app.adapters.network", "app.modules.musicbrainz -> app.adapters.network",
"app.modules.musicbrainz -> app.adapters.network.http", "app.modules.musicbrainz -> app.adapters.network.http",
@@ -5248,6 +5260,7 @@
"app.modules.themoviedb -> app.foundation", "app.modules.themoviedb -> app.foundation",
"app.modules.themoviedb -> app.foundation.text", "app.modules.themoviedb -> app.foundation.text",
"app.modules.themoviedb -> app.modules", "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.category",
"app.modules.themoviedb -> app.modules.themoviedb.scraper", "app.modules.themoviedb -> app.modules.themoviedb.scraper",
"app.modules.themoviedb -> app.modules.themoviedb.tmdb_cache", "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",
"app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.cache", "app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.cache",
"app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.settings", "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",
"app.modules.thetvdb -> app.modules.thetvdb.tvdb_v4_official", "app.modules.thetvdb -> app.modules.thetvdb.tvdb_v4_official",
"app.modules.thetvdb -> app.runtime", "app.modules.thetvdb -> app.runtime",
"app.modules.thetvdb -> app.runtime.execution",
"app.modules.thetvdb -> app.runtime.log", "app.modules.thetvdb -> app.runtime.log",
"app.modules.thetvdb -> app.runtime.settings", "app.modules.thetvdb -> app.runtime.settings",
"app.modules.thetvdb -> app.schemas", "app.modules.thetvdb -> app.schemas",
@@ -6802,7 +6821,7 @@
"app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions" "app.workflow.actions.transfer_file -> app.workflow.actions"
], ],
"module_count": 834, "module_count": 835,
"modules": [ "modules": [
"app", "app",
"app.adapters", "app.adapters",
@@ -7369,6 +7388,7 @@
"app.modules.jellyfin.jellyfin", "app.modules.jellyfin.jellyfin",
"app.modules.listenbrainz", "app.modules.listenbrainz",
"app.modules.lrclib", "app.modules.lrclib",
"app.modules.media_auxiliary",
"app.modules.musicbrainz", "app.modules.musicbrainz",
"app.modules.musicbrainz.music_cache", "app.modules.musicbrainz.music_cache",
"app.modules.musixmatch", "app.modules.musixmatch",
+56 -7
View File
@@ -3220,6 +3220,26 @@
"timeout_policy": "caller_budget", "timeout_policy": "caller_budget",
"version": 1 "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": { "async_identify_music_by_fingerprint": {
"aggregation": "first_non_empty", "aggregation": "first_non_empty",
"error_policy": "isolate_provider", "error_policy": "isolate_provider",
@@ -4696,6 +4716,26 @@
"timeout_policy": "caller_budget", "timeout_policy": "caller_budget",
"version": 1 "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": { "get_parent_item": {
"aggregation": "first_non_empty", "aggregation": "first_non_empty",
"error_policy": "isolate_provider", "error_policy": "isolate_provider",
@@ -6722,10 +6762,10 @@
} }
}, },
"run_module": { "run_module": {
"call_count": 263, "call_count": 264,
"dynamic_call_count": 0, "dynamic_call_count": 0,
"dynamic_calls": [], "dynamic_calls": [],
"method_count": 212, "method_count": 214,
"methods": { "methods": {
"anilist_credits": [ "anilist_credits": [
{ {
@@ -6966,6 +7006,13 @@
"mode": "async" "mode": "async"
} }
], ],
"async_get_media_auxiliary_info": [
{
"caller": "app.chain.media",
"count": 1,
"mode": "async"
}
],
"async_identify_music_by_fingerprint": [ "async_identify_music_by_fingerprint": [
{ {
"caller": "app.chain.acoustid", "caller": "app.chain.acoustid",
@@ -7565,6 +7612,13 @@
"mode": "sync" "mode": "sync"
} }
], ],
"get_media_auxiliary_info": [
{
"caller": "app.chain.media",
"count": 1,
"mode": "sync"
}
],
"get_parent_item": [ "get_parent_item": [
{ {
"caller": "app.chain.storage", "caller": "app.chain.storage",
@@ -7962,11 +8016,6 @@
"count": 1, "count": 1,
"mode": "sync" "mode": "sync"
}, },
{
"caller": "app.chain.media",
"count": 1,
"mode": "sync"
},
{ {
"caller": "app.chain.musicbrainz", "caller": "app.chain.musicbrainz",
"count": 1, "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, list_explicit_module_contracts,
) )
RUNTIME_BASELINE = ( RUNTIME_BASELINE = (
Path(__file__).parent / "fixtures" / "architecture" / "runtime-contract-baseline.json" 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 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: def test_torrent_filter_contract_preserves_original_argument_list_merge() -> None:
"""种子过滤 provider 应接收原始参数并有序合并结果,不得误用单参数接力。""" """种子过滤 provider 应接收原始参数并有序合并结果,不得误用单参数接力。"""
contract = get_module_method_contract("filter_torrents") contract = get_module_method_contract("filter_torrents")
+8 -1
View File
@@ -74,6 +74,10 @@ def _load_subscribe_chain_class():
"""异步按元数据识别默认返回空结果。""" """异步按元数据识别默认返回空结果。"""
return None 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")) interaction_module = ensure_module("app.application.messaging.interaction", types.ModuleType("app.application.messaging.interaction"))
class _SlashInteractionManager: class _SlashInteractionManager:
@@ -455,7 +459,10 @@ SubscribeInteractionHandler = SUBSCRIBE_CHAIN_MODULE.SubscribeInteractionHandler
def _patch_media_recognize(module, result): def _patch_media_recognize(module, result):
"""将隔离测试中的统一媒体识别入口替换为指定结果或回调。""" """将隔离测试中的统一媒体识别入口替换为指定结果或回调。"""
recognizer = result if callable(result) else lambda **_kwargs: 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) return patch.object(module, "MediaChain", return_value=media_chain)
+79 -5
View File
@@ -1,5 +1,5 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock, patch
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.domain.context import MediaInfo from app.domain.context import MediaInfo
@@ -23,11 +23,11 @@ class _FakeTmdbModule:
"""返回模块调度优先级。""" """返回模块调度优先级。"""
return 0 return 0
def recognize_media(self, **kwargs): def get_media_auxiliary_info(self, **kwargs):
"""与宿主识别模块一致:只应答 TMDB 来源的请求。""" """与宿主附加信息模块一致:只应答 TMDB 来源的请求。"""
if kwargs.get("media_source") != MediaSource.TMDB: if kwargs.get("media_source") != MediaSource.TMDB:
return None return []
return self.result return [self.result]
def _make_chain(tmdb_media: MediaInfo) -> MediaChain: 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.begin_season == 0
assert tmdb_meta.year == "2024" 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,
)