mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
516 lines
22 KiB
Python
516 lines
22 KiB
Python
"""媒体识别管线 mixin。
|
||
|
||
从 ChainBase 拆出的识别域:原生模块识别路由、识别缓存回填、共享识别、
|
||
插件补充识别。方法经 MRO 解析,依赖 ChainBase 实例的 run_module/eventmanager
|
||
等协作对象。
|
||
"""
|
||
import copy
|
||
from typing import Optional
|
||
|
||
from app.runtime.execution import run_in_threadpool
|
||
from app.adapters.external.server import MoviePilotServerHelper
|
||
from app.application.configuration import get_configured_system_config
|
||
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.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:
|
||
|
||
def _can_use_media_recognize_share(
|
||
self,
|
||
meta: Optional[MetaBase],
|
||
media_source: Optional[MediaSource],
|
||
media_id: Optional[str],
|
||
) -> bool:
|
||
"""
|
||
仅在名称识别场景下使用共享识别,显式ID识别不再重复回查
|
||
"""
|
||
return bool(
|
||
self.runtime_config.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:
|
||
get_configured_system_config().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
|