feat(recognize): 影视与音乐统一插件辅助识别链路

- 音乐并入 select_recognize_source 统一选择流程,支持插件优先/原生优先,按 MusicNameRecognize 事件与远端身份谓词区分
- 新增 MediaRecognize 链式事件,与 MusicMediaRecognize 对称,统一为 _supplement_media_recognize 补充识别
- 插件回写统一要求 source 与远端身份,采信后统一上报共享识别
- 插件优先模式下辅助识别未取得身份时保留原生兜底结果
This commit is contained in:
jxxghp
2026-08-11 13:23:37 +08:00
parent a11001cf8b
commit 97a5197e63
6 changed files with 743 additions and 38 deletions

View File

@@ -14,9 +14,9 @@ from transmission_rpc import File
from app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh
from app.core.config import settings
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.event import EventManager
from app.core.meta import MetaBase
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.event import Event, EventManager
from app.core.meta import MetaBase, MetaMusic
from app.core.module import ModuleManager
from app.core.plugin import PluginManager
from app.db.message_oper import MessageOper
@@ -49,6 +49,7 @@ from app.schemas.types import (
MediaType,
MediaImageType,
EventType,
ChainEventType,
MessageChannel,
SystemConfigKey,
)
@@ -704,6 +705,11 @@ class ChainBase(metaclass=ABCMeta):
"recognize_media",
**module_kwargs,
)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = self._supplement_media_recognize(
meta=meta, mtype=mtype, source=source,
mediaid=requested_mediaid, mediainfo=mediainfo,
)
if mediainfo:
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
@@ -815,6 +821,11 @@ class ChainBase(metaclass=ABCMeta):
"async_recognize_media",
**module_kwargs,
)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = await self._async_supplement_media_recognize(
meta=meta, mtype=mtype, source=source,
mediaid=requested_mediaid, mediainfo=mediainfo,
)
if mediainfo:
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
@@ -856,6 +867,163 @@ class ChainBase(metaclass=ABCMeta):
return mediainfo
return None
@staticmethod
def _media_recognize_plugin_payload(
meta: Optional[MetaBase],
mtype: Optional[MediaType],
source: Optional[str],
mediaid: Optional[str],
is_music: bool,
) -> 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),
"source": source,
"media_id": mediaid,
}
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,
"source": source,
"media_id": mediaid,
}
@classmethod
def _media_info_from_plugin(
cls,
event_data: dict,
is_music: bool,
mtype: Optional[MediaType] = 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("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.source or not info.media_id:
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.source or not cls._media_info_has_identity(info):
logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...")
return None
return info
@staticmethod
def _media_info_has_identity(mediainfo) -> bool:
"""判断媒体信息是否具备远端身份(数据源原生 ID 或各元数据源 ID"""
return bool(
getattr(mediainfo, "media_id", None)
or getattr(mediainfo, "tmdb_id", None)
or getattr(mediainfo, "douban_id", None)
or getattr(mediainfo, "bangumi_id", None)
or getattr(mediainfo, "anilist_id", None)
)
def _supplement_media_recognize(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
source: Optional[str],
mediaid: Optional[str],
mediainfo,
):
"""
媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时,
广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息
"""
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, source, mediaid, is_music),
)
if not result:
return mediainfo
plugin_info = self._media_info_from_plugin(result.event_data or {}, is_music, mtype)
if not plugin_info:
return mediainfo
logger.info(
f"插件补充媒体识别成功:{plugin_info.title}"
f"{plugin_info.source}:{plugin_info.media_id or plugin_info.tmdb_id or plugin_info.douban_id}"
)
return plugin_info
async def _async_supplement_media_recognize(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
source: Optional[str],
mediaid: Optional[str],
mediainfo,
):
"""媒体识别插件补充的异步版本,影视与音乐统一流程"""
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, source, mediaid, is_music),
)
if not result:
return mediainfo
plugin_info = self._media_info_from_plugin(result.event_data or {}, is_music, mtype)
if not plugin_info:
return mediainfo
logger.info(
f"插件补充媒体识别成功:{plugin_info.title}"
f"{plugin_info.source}:{plugin_info.media_id or plugin_info.tmdb_id or plugin_info.douban_id}"
)
return plugin_info
def match_doubaninfo(
self,
name: str,

View File

@@ -587,7 +587,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
@staticmethod
def select_recognize_source(
log_name: str, log_context: str, native_fn, plugin_fn
log_name: str, log_context: str, native_fn, plugin_fn,
is_recognized=None,
plugin_event: ChainEventType = ChainEventType.NameRecognize,
) -> Optional[MediaInfo]:
"""
选择识别模式,插件优先或原生优先
@@ -596,27 +598,39 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title
:param native_fn: 原生识别函数
:param plugin_fn: 插件识别函数
:param is_recognized: 判定识别结果是否有效的谓词;音乐原生兜底结果无远端身份,
需视为未识别才会请求辅助识别,影视默认按非空判定
:param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件
"""
if is_recognized is None:
is_recognized = lambda result: bool(result)
mediainfo = None
plugin_available = eventmanager.check(ChainEventType.NameRecognize)
plugin_available = eventmanager.check(plugin_event)
if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available:
# 插件优先
logger.info(f"插件识别优先模式已开启。请求辅助识别,标题:{log_name} ...")
mediainfo = plugin_fn()
if not mediainfo:
helped = plugin_fn()
if is_recognized(helped):
mediainfo = helped
else:
logger.info(
f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别 ..."
)
mediainfo = native_fn()
# 辅助结果不采信时保留原生兜底,避免丢失已有识别结果(音乐原生兜底恒非空)
if helped and not mediainfo:
mediainfo = helped
else:
# 原生优先
logger.info(f"开始识别标题:{log_name} ...")
mediainfo = native_fn()
if not mediainfo and plugin_available:
if not is_recognized(mediainfo) and plugin_available:
logger.info(
f"未识别到 {log_context} 的媒体信息,尝试使用辅助识别 ..."
f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别 ..."
)
mediainfo = plugin_fn()
helped = plugin_fn()
if is_recognized(helped):
mediainfo = helped
return mediainfo
def recognize_by_meta(
@@ -771,14 +785,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐不经影视季集识别与辅助识别,直接走统一模块分发
if isinstance(metainfo, MetaMusic):
return self.recognize_media(
meta=metainfo,
source=source,
)
title = metainfo.title
share_meta = deepcopy(metainfo)
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
is_music = isinstance(metainfo, MetaMusic)
is_recognized = (
(lambda result: bool(result and result.source)) if is_music else None
)
def native_recognize() -> Optional[MediaInfo]:
"""使用请求级数据源执行原生识别。"""
@@ -799,12 +812,17 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode_group=episode_group,
)
# 按 config 中设置的识别顺序识别
# 按 config 中设置的识别顺序识别,影视与音乐共用同一选择流程
mediainfo = self.select_recognize_source(
log_name=title,
log_context=title,
native_fn=native_recognize,
plugin_fn=plugin_recognize,
is_recognized=is_recognized,
plugin_event=(
ChainEventType.MusicNameRecognize if is_music
else ChainEventType.NameRecognize
),
)
if not mediainfo:
return None
@@ -835,7 +853,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode_group: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求辅助识别,返回媒体信息
请求辅助识别,返回媒体信息;影视与音乐共用同一流程,仅要素事件与重组方式不同
:param title: 标题
:param org_meta: 原始元数据
@@ -843,6 +861,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件
if isinstance(org_meta, MetaMusic):
return self._recognize_music_help(
title=title,
org_meta=org_meta,
share_meta=share_meta,
source=source,
)
# 发送请求事件,等待结果
result: Event = eventmanager.send_event(
ChainEventType.NameRecognize,
@@ -888,6 +914,89 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode_group=episode_group,
)
def _recognize_music_help(
self,
title: str,
org_meta: MetaMusic,
share_meta: MetaBase = None,
source: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息
:param title: 原始音乐标题
:param org_meta: 原始音乐元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
"""
# 发送音乐名称识别事件,等待插件返回标题要素
result: Event = eventmanager.send_event(
ChainEventType.MusicNameRecognize,
{
"title": title,
"artist": org_meta.artist,
"album": org_meta.album,
"year": org_meta.year,
},
)
if not result:
return None
event_data = result.event_data or {}
logger.info(f"获取到音乐辅助识别结果:{event_data}")
name, artist, album, year = self._parse_music_recognize_event(event_data)
if not name:
return None
# 辅助识别要素与原始一致时无需重新匹配
if (
name == org_meta.title
and (not artist or artist in org_meta.artists)
and (not album or album == org_meta.album)
and (not year or year == org_meta.year)
):
logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息")
return None
logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...")
new_meta = MetaMusic(
org_string=org_meta.org_string,
title=name,
artists=[artist] if artist else list(org_meta.artists or []),
album=album or org_meta.album,
album_artist=artist or org_meta.album_artist,
year=year or org_meta.year,
isrc=org_meta.isrc,
)
# 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底
mediainfo = self.recognize_media(
meta=new_meta,
source=source,
share_meta=share_meta,
)
return mediainfo if mediainfo and mediainfo.source else None
@staticmethod
def _parse_music_recognize_event(
event_data: dict,
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[int]]:
"""
解析音乐辅助识别返回的标题要素,曲名为空或未知时返回 None
"""
name = None
if event_data.get("name"):
name = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
artist = None
if event_data.get("artist"):
artist = str(event_data["artist"]).split("/")[0].strip()
album = None
if event_data.get("album"):
album = str(event_data["album"]).split("/")[0].strip()
year = None
year_text = str(event_data.get("year") or "").split("/")[0].strip()
if year_text.isdigit():
year = int(year_text)
if not name or name == "Unknown":
name = None
return name, artist, album, year
def recognize_by_path(
self,
path: str,
@@ -2215,7 +2324,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
@staticmethod
async def async_select_recognize_source(
log_name: str, log_context: str, native_fn, plugin_fn
log_name: str, log_context: str, native_fn, plugin_fn,
is_recognized=None,
plugin_event: ChainEventType = ChainEventType.NameRecognize,
) -> Optional[MediaInfo]:
"""
选择识别模式,插件优先或原生优先(异步版本)
@@ -2224,27 +2335,38 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title
:param native_fn: 原生识别函数
:param plugin_fn: 插件识别函数
:param is_recognized: 判定识别结果是否有效的谓词,语义同同步版本
:param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件
"""
if is_recognized is None:
is_recognized = lambda result: bool(result)
mediainfo = None
plugin_available = eventmanager.check(ChainEventType.NameRecognize)
plugin_available = eventmanager.check(plugin_event)
if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available:
# 插件优先
logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...")
mediainfo = await plugin_fn()
if not mediainfo:
helped = await plugin_fn()
if is_recognized(helped):
mediainfo = helped
else:
logger.info(
f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别"
)
mediainfo = await native_fn()
# 辅助结果不采信时保留原生兜底,避免丢失已有识别结果(音乐原生兜底恒非空)
if helped and not mediainfo:
mediainfo = helped
else:
# 原生优先
logger.info(f"识别标题:{log_name} ...")
mediainfo = await native_fn()
if not mediainfo and plugin_available:
if not is_recognized(mediainfo) and plugin_available:
logger.info(
f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别"
)
mediainfo = await plugin_fn()
helped = await plugin_fn()
if is_recognized(helped):
mediainfo = helped
return mediainfo
async def async_recognize_by_meta(
@@ -2291,14 +2413,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐不经影视季集识别与辅助识别,直接走统一模块分发
if isinstance(metainfo, MetaMusic):
return await self.async_recognize_media(
meta=metainfo,
source=source,
)
title = metainfo.title
share_meta = deepcopy(metainfo)
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
is_music = isinstance(metainfo, MetaMusic)
is_recognized = (
(lambda result: bool(result and result.source)) if is_music else None
)
async def native_recognize() -> Optional[MediaInfo]:
"""异步使用请求级数据源执行原生识别。"""
@@ -2319,12 +2440,17 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode_group=episode_group,
)
# 按 config 中设置的识别顺序识别
# 按 config 中设置的识别顺序识别,影视与音乐共用同一选择流程
mediainfo = await self.async_select_recognize_source(
log_name=title,
log_context=title,
native_fn=native_recognize,
plugin_fn=plugin_recognize,
is_recognized=is_recognized,
plugin_event=(
ChainEventType.MusicNameRecognize if is_music
else ChainEventType.NameRecognize
),
)
if not mediainfo:
return None
@@ -2344,7 +2470,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode_group: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求辅助识别,返回媒体信息(异步版本)
请求辅助识别,返回媒体信息(异步版本);影视与音乐共用同一流程
:param title: 标题
:param org_meta: 原始元数据
@@ -2352,6 +2478,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件
if isinstance(org_meta, MetaMusic):
return await self._async_recognize_music_help(
title=title,
org_meta=org_meta,
share_meta=share_meta,
source=source,
)
# 发送请求事件,等待结果
result: Event = await eventmanager.async_send_event(
ChainEventType.NameRecognize,
@@ -2397,6 +2531,65 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode_group=episode_group,
)
async def _async_recognize_music_help(
self,
title: str,
org_meta: MetaMusic,
share_meta: MetaBase = None,
source: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息(异步版本)
:param title: 原始音乐标题
:param org_meta: 原始音乐元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
"""
# 发送音乐名称识别事件,等待插件返回标题要素
result: Event = await eventmanager.async_send_event(
ChainEventType.MusicNameRecognize,
{
"title": title,
"artist": org_meta.artist,
"album": org_meta.album,
"year": org_meta.year,
},
)
if not result:
return None
event_data = result.event_data or {}
logger.info(f"获取到音乐辅助识别结果:{event_data}")
name, artist, album, year = self._parse_music_recognize_event(event_data)
if not name:
return None
# 辅助识别要素与原始一致时无需重新匹配
if (
name == org_meta.title
and (not artist or artist in org_meta.artists)
and (not album or album == org_meta.album)
and (not year or year == org_meta.year)
):
logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息")
return None
logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...")
new_meta = MetaMusic(
org_string=org_meta.org_string,
title=name,
artists=[artist] if artist else list(org_meta.artists or []),
album=album or org_meta.album,
album_artist=artist or org_meta.album_artist,
year=year or org_meta.year,
isrc=org_meta.isrc,
)
# 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底
mediainfo = await self.async_recognize_media(
meta=new_meta,
source=source,
share_meta=share_meta,
)
return mediainfo if mediainfo and mediainfo.source else None
async def async_recognize_by_path(
self,
path: str,

View File

@@ -181,6 +181,12 @@ class ChainEventType(Enum):
PluginDataReset = "plugin.data.reset"
# 名称识别
NameRecognize = "name.recognize"
# 音乐名称识别:插件辅助解析音乐标题中的曲名、艺术家、专辑、年份要素
MusicNameRecognize = "music.name.recognize"
# 媒体识别:原生识别未取得远端身份时,插件按已知要素匹配补充电影、电视剧媒体信息
MediaRecognize = "media.recognize"
# 音乐媒体识别:原生识别未取得远端身份时,插件按已知要素匹配补充音乐媒体信息
MusicMediaRecognize = "music.media.recognize"
# 认证验证
AuthVerification = "auth.verification"
# 认证拦截

View File

@@ -40,9 +40,16 @@ def _mock_counter(monkeypatch) -> Mock:
return increment
def _bare_chain() -> ChainBase:
"""构造不执行初始化的识别链实例,并挂上无插件响应的事件管理器桩。"""
chain = object.__new__(ChainBase)
chain.eventmanager = Mock(check=Mock(return_value=False))
return chain
def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch):
"""同步共享识别二次识别成功后应累计一次命中。"""
chain = object.__new__(ChainBase)
chain = _bare_chain()
meta = _build_meta("共享识别电影")
media = MediaInfo(
title="共享识别电影",
@@ -73,7 +80,7 @@ def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch):
def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch):
"""共享接口返回数据但二次识别失败时不应累计命中。"""
chain = object.__new__(ChainBase)
chain = _bare_chain()
meta = _build_meta("共享识别失败电影")
increment = _mock_counter(monkeypatch)
monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True)
@@ -97,7 +104,7 @@ def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch):
def test_async_shared_recognize_success_increments_persisted_count(monkeypatch):
"""异步共享识别二次识别成功后应累计一次命中。"""
chain = object.__new__(ChainBase)
chain = _bare_chain()
meta = _build_meta("异步共享识别电影")
media = MediaInfo(
title="异步共享识别电影",

View File

@@ -0,0 +1,324 @@
"""媒体识别插件辅助测试(影视与音乐统一链路)。
覆盖 MediaChain 标题要素插件辅助识别NameRecognize / MusicNameRecognize 链式事件)
与 ChainBase 媒体识别插件补充MediaRecognize / MusicMediaRecognize 链式事件)。
"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.core.context import MediaInfo, MusicInfo
from app.core.event import Event
from app.core.meta import MetaBase, MetaMusic
from app.schemas.types import ChainEventType, MediaType
def _fallback_music(title: str = "晴天", **kwargs) -> MusicInfo:
"""构造无远端身份的离线兜底音乐结果。"""
return MusicInfo(title=title, **kwargs)
def _remote_music() -> MusicInfo:
"""构造带远端身份的标准音乐识别结果。"""
return MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
def test_music_recognize_help_sends_event_and_rematches(monkeypatch):
"""原生识别无远端身份时应发送音乐名称识别事件,并按修正要素重新匹配。"""
chain = MediaChain()
meta = MetaMusic(title="周杰伦 晴天 FLAC 24bit 48kHz")
remote = _remote_music()
recognize_calls = []
def fake_recognize_media(meta=None, source=None, **kwargs):
recognize_calls.append(meta)
# 首次返回无身份兜底,辅助识别修正要素后二次识别命中远端
return remote if len(recognize_calls) > 1 else _fallback_music(title=meta.title)
monkeypatch.setattr(chain, "recognize_media", fake_recognize_media)
event = Event(ChainEventType.MusicNameRecognize, {
"title": meta.title,
"name": "晴天",
"artist": "周杰伦",
"album": "叶惠美",
"year": "2003",
})
with patch("app.chain.media.eventmanager") as em:
em.check.return_value = True
em.send_event.return_value = event
result = chain.recognize_by_meta(meta, source="musicbrainz")
assert result is remote
assert em.check.call_args.args[0] == ChainEventType.MusicNameRecognize
assert em.send_event.call_args.args[0] == ChainEventType.MusicNameRecognize
# 重新匹配使用辅助识别修正后的要素
rematch_meta = recognize_calls[-1]
assert rematch_meta.title == "晴天"
assert rematch_meta.artists == ["周杰伦"]
assert rematch_meta.album == "叶惠美"
assert rematch_meta.year == 2003
def test_music_recognize_keeps_fallback_without_plugin(monkeypatch):
"""无插件响应音乐名称识别事件时应保留原生兜底结果。"""
chain = MediaChain()
meta = MetaMusic(title="未知曲目")
fallback = _fallback_music(title="未知曲目")
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=fallback))
with patch("app.chain.media.eventmanager") as em:
em.check.return_value = False
result = chain.recognize_by_meta(meta)
assert result is fallback
em.send_event.assert_not_called()
def test_music_recognize_help_same_elements_keeps_fallback(monkeypatch):
"""辅助识别要素与原始一致时不应重新识别。"""
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
fallback = _fallback_music(title="晴天", artists=["周杰伦"])
recognize_mock = Mock(return_value=fallback)
monkeypatch.setattr(chain, "recognize_media", recognize_mock)
event = Event(ChainEventType.MusicNameRecognize, {
"title": "晴天",
"name": "晴天",
"artist": "周杰伦",
})
with patch("app.chain.media.eventmanager") as em:
em.check.return_value = True
em.send_event.return_value = event
result = chain.recognize_by_meta(meta)
assert result is fallback
assert recognize_mock.call_count == 1
def test_music_recognize_help_keeps_fallback_when_rematch_fails(monkeypatch):
"""辅助要素重新匹配仍无远端身份时应保留原生兜底结果。"""
chain = MediaChain()
meta = MetaMusic(title="晴天")
fallback = _fallback_music(title="晴天")
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=fallback))
event = Event(ChainEventType.MusicNameRecognize, {
"title": "晴天",
"name": "另一个晴天",
"artist": "未知艺术家",
})
with patch("app.chain.media.eventmanager") as em:
em.check.return_value = True
em.send_event.return_value = event
result = chain.recognize_by_meta(meta)
assert result is fallback
def test_async_music_recognize_help(monkeypatch):
"""异步音乐识别同样应走插件辅助识别并重匹配。"""
chain = MediaChain()
meta = MetaMusic(title="周杰伦-晴天")
remote = _remote_music()
recognize_calls = []
async def fake_async_recognize_media(meta=None, source=None, **kwargs):
recognize_calls.append(meta)
return remote if len(recognize_calls) > 1 else _fallback_music(title=meta.title)
monkeypatch.setattr(chain, "async_recognize_media", fake_async_recognize_media)
event = Event(ChainEventType.MusicNameRecognize, {
"title": meta.title,
"name": "晴天",
"artist": "周杰伦",
})
with patch("app.chain.media.eventmanager") as em:
em.check.return_value = True
em.async_send_event = AsyncMock(return_value=event)
result = asyncio.run(chain.async_recognize_by_meta(meta))
assert result is remote
assert recognize_calls[-1].title == "晴天"
assert recognize_calls[-1].artists == ["周杰伦"]
def test_plugin_first_keeps_fallback_when_help_unidentified(monkeypatch):
"""插件优先模式下辅助识别未取得身份时,应回退原生识别并保留已有兜底结果。"""
chain = MediaChain()
meta = MetaMusic(title="晴天")
fallback = _fallback_music(title="晴天")
# 辅助识别重匹配仍无身份返回 None原生兜底不应被丢弃
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=fallback))
event = Event(ChainEventType.MusicNameRecognize, {
"title": "晴天",
"name": "另一个晴天",
})
with patch("app.chain.media.eventmanager") as em, \
patch("app.chain.media.settings") as settings_mock:
settings_mock.RECOGNIZE_PLUGIN_FIRST = True
em.check.return_value = True
em.send_event.return_value = event
result = chain.recognize_by_meta(meta)
assert result is fallback
def test_chain_supplement_music_recognize_uses_plugin_result():
"""音乐媒体识别事件应允许插件按已知要素返回标准音乐信息。"""
chain = ChainBase()
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
plugin_data = {
"mediainfo": {
"source": "qqmusic",
"media_id": "song-123",
"title": "晴天",
"artists": ["周杰伦"],
"album": "叶惠美",
"year": 2003,
}
}
event = Event(ChainEventType.MusicMediaRecognize, plugin_data)
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event) as sender:
result = chain._supplement_media_recognize(
meta=meta, mtype=None, source=None, mediaid=None, mediainfo=None
)
assert isinstance(result, MusicInfo)
assert result.source == "qqmusic"
assert result.media_id == "song-123"
# 音乐请求使用音乐媒体识别事件,载荷携带已知要素
assert sender.call_args.args[0] == ChainEventType.MusicMediaRecognize
payload = sender.call_args.args[1]
assert payload["title"] == "晴天"
assert payload["artists"] == ["周杰伦"]
assert payload["album"] == "叶惠美"
def test_chain_supplement_video_recognize_uses_plugin_result():
"""影视媒体识别事件应与音乐对称,允许插件按已知要素返回标准媒体信息。"""
chain = ChainBase()
meta = MetaBase("The.Matrix.1999.1080p.BluRay")
meta.type = MediaType.MOVIE
plugin_data = {
"mediainfo": {
"source": "themoviedb",
"tmdb_id": 603,
"title": "黑客帝国",
"year": "1999",
}
}
event = Event(ChainEventType.MediaRecognize, plugin_data)
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event) as sender:
result = chain._supplement_media_recognize(
meta=meta, mtype=MediaType.MOVIE, source=None, mediaid=None, mediainfo=None
)
assert isinstance(result, MediaInfo)
assert result.tmdb_id == 603
# 影视请求使用媒体识别事件,插件未提供类型时使用请求推断类型
assert sender.call_args.args[0] == ChainEventType.MediaRecognize
assert result.type == MediaType.MOVIE
def test_chain_supplement_media_recognize_requires_identity():
"""插件返回缺少数据源或远端身份的结果不采信,影视音乐统一。"""
chain = ChainBase()
# 音乐缺媒体ID
meta = MetaMusic(title="晴天")
fallback = _fallback_music(title="晴天")
event = Event(ChainEventType.MusicMediaRecognize, {
"mediainfo": {"source": "qqmusic", "title": "晴天"},
})
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event):
result = chain._supplement_media_recognize(
meta=meta, mtype=None, source=None, mediaid=None, mediainfo=fallback
)
assert result is fallback
# 影视缺远端身份
meta_video = MetaBase("Some.Movie")
meta_video.type = MediaType.MOVIE
event_video = Event(ChainEventType.MediaRecognize, {
"mediainfo": {"source": "themoviedb", "title": "某部电影"},
})
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event_video):
result = chain._supplement_media_recognize(
meta=meta_video, mtype=MediaType.MOVIE,
source=None, mediaid=None, mediainfo=None,
)
assert result is None
def test_chain_supplement_media_recognize_skips_identified_result():
"""已有远端身份的结果不应触发插件补充事件。"""
chain = ChainBase()
meta = MetaMusic(title="晴天")
remote = _remote_music()
with patch.object(chain.eventmanager, "check") as checker:
result = chain._supplement_media_recognize(
meta=meta, mtype=None, source=None, mediaid=None, mediainfo=remote
)
assert result is remote
checker.assert_not_called()
def test_chain_recognize_media_music_plugin_supplement():
"""统一识别入口应在原生音乐识别无身份时采信插件补充结果并统一上报。"""
chain = ChainBase()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
fallback = _fallback_music(title="晴天")
plugin_music = MusicInfo(
source="qqmusic",
media_id="song-123",
title="晴天",
artists=["周杰伦"],
)
event = Event(ChainEventType.MusicMediaRecognize, {"mediainfo": plugin_music.to_dict()})
with patch.object(chain, "run_module", return_value=fallback), \
patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event), \
patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock:
result = chain.recognize_media(meta=meta, cache=False)
assert result is not fallback
assert result.source == "qqmusic"
report_mock.assert_called_once()
assert report_mock.call_args.kwargs["mediainfo"] is result
def test_chain_async_supplement_media_recognize():
"""异步媒体识别补充应与同步行为一致(音乐)。"""
chain = ChainBase()
meta = MetaMusic(title="晴天")
event = Event(ChainEventType.MusicMediaRecognize, {
"mediainfo": {"source": "qqmusic", "media_id": "song-1", "title": "晴天"},
})
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "async_send_event", AsyncMock(return_value=event)):
result = asyncio.run(
chain._async_supplement_media_recognize(
meta=meta, mtype=None, source=None, mediaid=None, mediainfo=None
)
)
assert isinstance(result, MusicInfo)
assert result.source == "qqmusic"
assert result.media_id == "song-1"

View File

@@ -29,7 +29,7 @@ def _music_info() -> MusicInfo:
def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
"""MetaMusic 应绕过影视识别,由统一模块分发直接响应。"""
"""MetaMusic 应与影视共用选择流程,由统一模块分发直接响应。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
chain = MediaChain()
@@ -37,7 +37,11 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
result = chain.recognize_by_meta(meta, source="musicbrainz")
chain.recognize_media.assert_called_once_with(meta=meta, source="musicbrainz")
# 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数
chain.recognize_media.assert_called_once()
call_kwargs = chain.recognize_media.call_args.kwargs
assert call_kwargs["meta"] is meta
assert call_kwargs["source"] == "musicbrainz"
assert result is expected
@@ -52,7 +56,10 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa
return await chain.async_recognize_by_meta(meta, source="musicbrainz")
result = asyncio.run(runner())
chain.async_recognize_media.assert_awaited_once_with(meta=meta, source="musicbrainz")
chain.async_recognize_media.assert_awaited_once()
call_kwargs = chain.async_recognize_media.await_args.kwargs
assert call_kwargs["meta"] is meta
assert call_kwargs["source"] == "musicbrainz"
assert result is expected