refactor(chain): 处理链功能域 mixin 化,清理未使用导入并根治兼容层循环导入

- ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin
- TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py
- 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export
- 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名)
- 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间
- 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入
- 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError
- 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录
This commit is contained in:
jxxghp
2026-08-16 16:30:16 +08:00
parent 24671f8f18
commit 7e851dbfa7
102 changed files with 6041 additions and 5888 deletions
+518
View File
@@ -0,0 +1,518 @@
"""媒体识别管线 mixin。
从 ChainBase 拆出的识别域:原生模块识别路由、识别缓存回填、共享识别、
插件补充识别。方法经 MRO 解析,依赖 ChainBase 实例的 run_module/eventmanager
等协作对象。
"""
import copy
from typing import Optional
from fastapi.concurrency import run_in_threadpool
from app.adapters.external.server import MoviePilotServerHelper
from app.db.oper.systemconfig import SystemConfigOper
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.runtime.cache import fresh, async_fresh
from app.runtime.config import settings
from app.runtime.events import Event
from app.runtime.log import logger
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.types import ChainEventType, MediaSource, MediaType, SystemConfigKey
class RecognitionMixin:
@staticmethod
def _can_use_media_recognize_share(
meta: Optional[MetaBase],
media_source: Optional[MediaSource],
media_id: Optional[str],
) -> bool:
"""
仅在名称识别场景下使用共享识别,显式ID识别不再重复回查
"""
return bool(
settings.MEDIA_RECOGNIZE_SHARE
and meta
and not media_source
and not media_id
)
@staticmethod
def _snapshot_recognize_cache_meta(meta: Optional[MetaBase]) -> Optional[MetaBase]:
"""
保存共享识别前的本地缓存关键元数据,用于共享成功后回填正缓存覆盖负缓存。
"""
if not meta:
return None
return copy.deepcopy(meta)
def _update_local_recognize_cache(
self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
) -> None:
"""
共享识别成功后回填本地识别缓存,避免名称负缓存导致后续重复回查共享。
"""
if not meta or not mediainfo:
return
self.run_module(
"update_recognize_cache",
meta=meta,
mediainfo=mediainfo,
)
async def _async_update_local_recognize_cache(
self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
) -> None:
"""
异步回填本地识别缓存。
"""
if not meta or not mediainfo:
return
await self.async_run_module(
"async_update_recognize_cache",
meta=meta,
mediainfo=mediainfo,
)
@staticmethod
def _record_media_recognize_share_hit() -> None:
"""记录一次共享媒体识别成功命中,统计失败不影响识别结果。"""
try:
SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount)
except Exception as err:
logger.error(f"记录共享媒体识别命中次数失败:{str(err)}")
def _run_native_media_recognize(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""执行同步原生媒体模块识别,具体媒体领域可覆写该路由钩子。"""
with fresh(not cache):
return self.run_module("recognize_media", **module_kwargs)
async def _async_run_native_media_recognize(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""执行异步原生媒体模块识别,具体媒体领域可覆写该路由钩子。"""
async with async_fresh(not cache):
return await self.async_run_module(
"async_recognize_media", **module_kwargs
)
def recognize_media(
self,
meta: MetaBase = None,
mtype: Optional[MediaType] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
识别媒体信息,不含Fanart图片
:param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型
:param media_source: 请求级识别数据源
:param media_id: 数据源原生ID,必须与media_source成对提供
:param episode_group: 剧集组
:param cache: 是否使用缓存
:param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑
:return: 识别的媒体信息,包括剧集信息
"""
# 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对
explicit_identity = media_id is not None
requested_source = normalize_media_source(media_source) or media_source
media_source, media_id = resolve_media_identity(
media=meta,
media_source=media_source,
media_id=media_id,
)
if explicit_identity and (not media_source or not media_id):
logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id")
return None
if not media_id and requested_source is not None:
media_source = requested_source
# meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索
meta_source, meta_id = resolve_media_identity(media=meta)
if meta_id and meta_source == requested_source:
media_source, media_id = meta_source, meta_id
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
if not mtype and not (media_source and media_id) and meta and meta.type in [
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
]:
mtype = meta.type
share_query_meta = share_meta or meta
module_kwargs = {
"meta": meta,
"mtype": mtype,
"media_source": media_source,
"media_id": media_id,
"episode_group": episode_group,
"cache": cache,
}
if music_type is not None:
module_kwargs["music_type"] = music_type
mediainfo = self._run_native_media_recognize(module_kwargs, cache)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = self._supplement_media_recognize(
meta=meta, mtype=mtype, media_source=media_source,
media_id=media_id, mediainfo=mediainfo,
music_type=music_type,
)
fallback_mediainfo = (
mediainfo
if mediainfo and not self._media_info_has_identity(mediainfo)
else None
)
if mediainfo and self._media_info_has_identity(mediainfo):
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
MoviePilotServerHelper.report_recognize_share(
meta=meta,
mediainfo=mediainfo,
keyword_meta=share_query_meta,
)
return mediainfo
if self._can_use_media_recognize_share(
share_query_meta, media_source, media_id
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
share_query_kwargs = {
"meta": meta,
"mtype": mtype,
"keyword_meta": share_query_meta,
}
if music_type is not None:
share_query_kwargs["music_type"] = music_type
shared_item = MoviePilotServerHelper.query_recognize_share(
**share_query_kwargs,
)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
shared_module_kwargs = {
"meta": meta,
"mtype": shared_params.get("mtype") or mtype,
"media_source": shared_params.get("media_source"),
"media_id": shared_params.get("media_id"),
"episode_group": episode_group,
"cache": cache,
}
shared_music_type = shared_params.get("music_type") or music_type
if shared_music_type is not None:
shared_module_kwargs["music_type"] = shared_music_type
mediainfo = self._run_native_media_recognize(
shared_module_kwargs,
cache,
)
if mediainfo and self._media_info_has_identity(mediainfo):
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
self._record_media_recognize_share_hit()
return mediainfo
if mediainfo and not fallback_mediainfo:
fallback_mediainfo = mediainfo
return fallback_mediainfo
async def async_recognize_media(
self,
meta: MetaBase = None,
mtype: Optional[MediaType] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
识别媒体信息,不含Fanart图片(异步版本)
:param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型
:param media_source: 请求级识别数据源
:param media_id: 数据源原生ID,必须与media_source成对提供
:param episode_group: 剧集组
:param cache: 是否使用缓存
:param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑
:return: 识别的媒体信息,包括剧集信息
"""
# 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对
explicit_identity = media_id is not None
requested_source = normalize_media_source(media_source) or media_source
media_source, media_id = resolve_media_identity(
media=meta,
media_source=media_source,
media_id=media_id,
)
if explicit_identity and (not media_source or not media_id):
logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id")
return None
if not media_id and requested_source is not None:
media_source = requested_source
# meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索
meta_source, meta_id = resolve_media_identity(media=meta)
if meta_id and meta_source == requested_source:
media_source, media_id = meta_source, meta_id
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
if not mtype and not (media_source and media_id) and meta and meta.type in [
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
]:
mtype = meta.type
share_query_meta = share_meta or meta
module_kwargs = {
"meta": meta,
"mtype": mtype,
"media_source": media_source,
"media_id": media_id,
"episode_group": episode_group,
"cache": cache,
}
if music_type is not None:
module_kwargs["music_type"] = music_type
mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = await self._async_supplement_media_recognize(
meta=meta, mtype=mtype, media_source=media_source,
media_id=media_id, mediainfo=mediainfo,
music_type=music_type,
)
fallback_mediainfo = (
mediainfo
if mediainfo and not self._media_info_has_identity(mediainfo)
else None
)
if mediainfo and self._media_info_has_identity(mediainfo):
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
await MoviePilotServerHelper.async_report_recognize_share(
meta=meta,
mediainfo=mediainfo,
keyword_meta=share_query_meta,
)
return mediainfo
if self._can_use_media_recognize_share(
share_query_meta, media_source, media_id
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
share_query_kwargs = {
"meta": meta,
"mtype": mtype,
"keyword_meta": share_query_meta,
}
if music_type is not None:
share_query_kwargs["music_type"] = music_type
shared_item = await MoviePilotServerHelper.async_query_recognize_share(
**share_query_kwargs,
)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
shared_module_kwargs = {
"meta": meta,
"mtype": shared_params.get("mtype") or mtype,
"media_source": shared_params.get("media_source"),
"media_id": shared_params.get("media_id"),
"episode_group": episode_group,
"cache": cache,
}
shared_music_type = shared_params.get("music_type") or music_type
if shared_music_type is not None:
shared_module_kwargs["music_type"] = shared_music_type
mediainfo = await self._async_run_native_media_recognize(
shared_module_kwargs,
cache,
)
if mediainfo and self._media_info_has_identity(mediainfo):
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
await run_in_threadpool(self._record_media_recognize_share_hit)
return mediainfo
if mediainfo and not fallback_mediainfo:
fallback_mediainfo = mediainfo
return fallback_mediainfo
@staticmethod
def _media_recognize_plugin_payload(
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource],
media_id: Optional[str],
is_music: bool,
music_type: Optional[str] = None,
) -> dict:
"""
构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议,
仅要素字段随媒体类型不同
"""
if is_music:
return {
"title": getattr(meta, "title", None),
"artists": list(getattr(meta, "artists", None) or []),
"album": getattr(meta, "album", None),
"year": getattr(meta, "year", None),
"isrc": getattr(meta, "isrc", None),
"media_source": media_source,
"media_id": media_id,
"music_type": music_type,
}
return {
"title": getattr(meta, "title", None) or getattr(meta, "name", None),
"year": getattr(meta, "year", None),
"season": getattr(meta, "begin_season", None),
"type": mtype.value if isinstance(mtype, MediaType) else None,
"media_source": media_source,
"media_id": media_id,
}
@classmethod
def _media_info_from_plugin(
cls,
event_data: dict,
is_music: bool,
mtype: Optional[MediaType] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信;
音乐构造 MusicInfo,影视构造 MediaInfo
"""
if not isinstance(event_data, dict):
return None
plugin_info = event_data.get("mediainfo")
if not isinstance(plugin_info, dict):
return None
if not plugin_info.get("media_source"):
logger.warn("插件返回的媒体信息缺少数据源,忽略 ...")
return None
try:
if is_music:
if not plugin_info.get("media_id"):
logger.warn("插件返回的音乐媒体信息缺少媒体ID,忽略 ...")
return None
info: MediaInfo = MusicInfo.from_dict(plugin_info)
if not info.media_source or not info.media_id:
return None
if music_type and info.music_type != music_type:
logger.warn(
f"插件返回的音乐实体类型为 {info.music_type}"
f"与请求的 {music_type} 不一致,忽略 ..."
)
return None
return info
# 影视:插件未提供类型时使用请求推断的类型
if not plugin_info.get("type") and mtype:
plugin_info = {**plugin_info, "type": mtype}
info = MediaInfo()
info.from_dict(plugin_info)
except Exception as err:
logger.warn(f"插件返回的媒体信息格式错误:{err}")
return None
# 影视与音乐统一要求远端身份,无身份的结果不采信,避免未验证结果进入识别管线
if not info.media_source or not cls._media_info_has_identity(info):
logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...")
return None
return info
@staticmethod
def _media_info_has_identity(mediainfo) -> bool:
"""判断媒体信息是否具备完整的规范媒体身份。"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return bool(media_source and media_id)
def _supplement_media_recognize(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource],
media_id: Optional[str],
mediainfo,
music_type: Optional[str] = None,
):
"""
媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时,
广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息
"""
is_music = (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or isinstance(mediainfo, MusicInfo)
)
# 已有远端身份时无需插件介入
if mediainfo and self._media_info_has_identity(mediainfo):
return mediainfo
etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize
if not self.eventmanager.check(etype):
return mediainfo
result: Event = self.eventmanager.send_event(
etype,
self._media_recognize_plugin_payload(
meta, mtype, media_source, media_id, is_music, music_type
),
)
if not result:
return mediainfo
plugin_info = self._media_info_from_plugin(
result.event_data or {}, is_music, mtype, music_type
)
if not plugin_info:
return mediainfo
logger.info(
f"插件补充媒体识别成功:{plugin_info.title}"
f"{plugin_info.media_source}:{plugin_info.media_id}"
)
return plugin_info
async def _async_supplement_media_recognize(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource],
media_id: Optional[str],
mediainfo,
music_type: Optional[str] = None,
):
"""媒体识别插件补充的异步版本,影视与音乐统一流程"""
is_music = (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or isinstance(mediainfo, MusicInfo)
)
# 已有远端身份时无需插件介入
if mediainfo and self._media_info_has_identity(mediainfo):
return mediainfo
etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize
if not self.eventmanager.check(etype):
return mediainfo
result: Event = await self.eventmanager.async_send_event(
etype,
self._media_recognize_plugin_payload(
meta, mtype, media_source, media_id, is_music, music_type
),
)
if not result:
return mediainfo
plugin_info = self._media_info_from_plugin(
result.event_data or {}, is_music, mtype, music_type
)
if not plugin_info:
return mediainfo
logger.info(
f"插件补充媒体识别成功:{plugin_info.title}"
f"{plugin_info.media_source}:{plugin_info.media_id}"
)
return plugin_info