mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
feat(media): aggregate aliases from enabled metadata sources
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
清除缓存
|
清除缓存
|
||||||
|
|||||||
@@ -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((
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""多媒体来源附加信息 provider 的单元测试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.domain.context import MediaInfo
|
||||||
|
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAuxiliaryProvider(MediaAuxiliaryProviderMixin):
|
||||||
|
"""记录通用 mixin 下传给现有识别接口的参数。"""
|
||||||
|
|
||||||
|
auxiliary_media_source = MediaSource.AniList
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""初始化同步和异步调用记录。"""
|
||||||
|
self.sync_kwargs = None
|
||||||
|
self.async_kwargs = None
|
||||||
|
|
||||||
|
def recognize_media(self, **kwargs) -> MediaInfo:
|
||||||
|
"""记录同步识别参数并返回固定 AniList 媒体。"""
|
||||||
|
self.sync_kwargs = kwargs
|
||||||
|
return MediaInfo(
|
||||||
|
media_source=MediaSource.AniList,
|
||||||
|
media_id="154587",
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="Sousou no Frieren",
|
||||||
|
names=["Frieren"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_recognize_media(self, **kwargs) -> MediaInfo:
|
||||||
|
"""记录异步识别参数并返回固定 AniList 媒体。"""
|
||||||
|
self.async_kwargs = kwargs
|
||||||
|
return self.recognize_media(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_only_runs_when_its_source_is_enabled() -> None:
|
||||||
|
"""provider 只处理用户选中的自身来源,未选中时不得发起识别。"""
|
||||||
|
provider = _FakeAuxiliaryProvider()
|
||||||
|
media = MediaInfo(
|
||||||
|
media_source=MediaSource.Douban,
|
||||||
|
media_id="1",
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="葬送的芙莉莲",
|
||||||
|
year="2023",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert provider.get_media_auxiliary_info(
|
||||||
|
media,
|
||||||
|
media_source=(MediaSource.TMDB,),
|
||||||
|
) == []
|
||||||
|
assert provider.sync_kwargs is None
|
||||||
|
|
||||||
|
result = provider.get_media_auxiliary_info(
|
||||||
|
media,
|
||||||
|
media_source=(MediaSource.TMDB, MediaSource.AniList),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0].media_source == MediaSource.AniList
|
||||||
|
assert provider.sync_kwargs["media_source"] == MediaSource.AniList
|
||||||
|
assert provider.sync_kwargs["media_id"] is None
|
||||||
|
assert provider.sync_kwargs["meta"].year == "2023"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_uses_native_identity_for_same_source_async() -> None:
|
||||||
|
"""同来源补充应使用来源原生 ID,避免再次依赖标题消歧。"""
|
||||||
|
provider = _FakeAuxiliaryProvider()
|
||||||
|
media = MediaInfo(
|
||||||
|
media_source=MediaSource.AniList,
|
||||||
|
media_id="154587",
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="葬送的芙莉莲",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
provider.async_get_media_auxiliary_info(
|
||||||
|
media,
|
||||||
|
media_source=(MediaSource.AniList,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0].media_id == "154587"
|
||||||
|
assert provider.async_kwargs["media_id"] == "154587"
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_search_keywords_include_aggregated_aliases() -> None:
|
||||||
|
"""站点搜索参数应优先使用附加信息聚合后的别名列表。"""
|
||||||
|
media = MediaInfo(
|
||||||
|
media_source=MediaSource.Douban,
|
||||||
|
media_id="1",
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="葬送的芙莉莲",
|
||||||
|
names=["Frieren", "Frieren: Beyond Journey's End"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.search.get_chain_runtime_config_snapshot",
|
||||||
|
return_value=SimpleNamespace(max_search_name_limit=3),
|
||||||
|
):
|
||||||
|
_, keywords = SearchChain._SearchChain__prepare_params(media)
|
||||||
|
|
||||||
|
assert keywords == [
|
||||||
|
"葬送的芙莉莲",
|
||||||
|
"Frieren",
|
||||||
|
"Frieren: Beyond Journey's End",
|
||||||
|
]
|
||||||
@@ -15,7 +15,6 @@ from app.runtime.extensions.module.contracts import (
|
|||||||
list_explicit_module_contracts,
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user