Files
MoviePilot/app/chain/_recognition.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

1079 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
媒体识别管线 混入类。
从 ChainBase 拆出的识别域:原生模块识别路由、识别缓存回填、共享识别、
插件补充识别。方法经 MRO 解析,依赖 ChainBase 实例的 run_module/eventmanager
等协作对象。
"""
import copy
import threading
from dataclasses import dataclass
from enum import Enum, auto
from typing import Any, ClassVar, Generator, Optional, Protocol, TypeVar, cast
from app.application.configuration import get_configured_system_config
from app.application.music.observation import music_recognition_needs_confirmation
from app.chain._contracts import ChainRuntimeMixinHost
from app.domain.context import (
MediaInfo,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
)
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.music import (
music_album_matches,
music_artist_matches,
music_title_matches,
music_version_matches,
music_year_matches,
)
from app.runtime.cache import async_fresh, fresh
from app.runtime.events import Event
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.schemas.category import ClassificationSelection
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.types import ChainEventType, MediaSource, MediaType, SystemConfigKey
_ClassificationSubjectT = TypeVar(
"_ClassificationSubjectT",
MediaInfo,
MusicInfo,
MusicAlbumInfo,
MusicArtistInfo,
)
class RecognitionSharePort(Protocol):
"""
媒体识别链访问共享识别服务所需的最小端口。
"""
def report_recognize_share(
self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo | MusicInfo],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
同步上报共享识别结果。
:param meta: 本地解析元数据,None 时由实现判断是否足够形成共享请求
:param mediainfo: 准备上报的识别结果,None 或缺少有效身份时不形成有效上报
:param keyword_meta: 原始标题线索,None 时使用 meta 中的线索
:return: 服务端确认上报成功时为 True,无有效载荷或上报未成功时为 False
"""
...
async def async_report_recognize_share(
self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo | MusicInfo],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
异步上报共享识别结果。
:param meta: 本地解析元数据,None 时由实现判断是否足够形成共享请求
:param mediainfo: 准备上报的识别结果,None 或缺少有效身份时不形成有效上报
:param keyword_meta: 原始标题线索,None 时使用 meta 中的线索
:return: 服务端确认上报成功时为 True,无有效载荷或上报未成功时为 False
"""
...
def query_recognize_share(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
同步查询共享识别结果。
:param meta: 用于构造查询线索的本地元数据,None 表示未提供
:param mtype: 限定的媒体类型,None 时由元数据推断
:param keyword_meta: 未被本地修正覆盖的原始标题线索,None 时使用 meta
:param music_type: 限定音乐实体类型,None 时按元数据或服务默认规则选择
:return: 共享服务返回的媒体身份字典,查询无有效结果时为 None
"""
...
async def async_query_recognize_share(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
异步查询共享识别结果。
:param meta: 用于构造查询线索的本地元数据,None 表示未提供
:param mtype: 限定的媒体类型,None 时由元数据推断
:param keyword_meta: 未被本地修正覆盖的原始标题线索,None 时使用 meta
:param music_type: 限定音乐实体类型,None 时按元数据或服务默认规则选择
:return: 共享服务返回的媒体身份字典,查询无有效结果时为 None
"""
...
def to_recognize_params(
self,
item: Optional[dict[str, Any]],
) -> Optional[dict[str, Any]]:
"""
把共享结果转换为本地识别参数。
:param item: 共享服务返回的媒体身份字典,允许为 None
:return: 包含类型、来源、媒体标识与季号的本地参数,无有效来源或标识时为 None
"""
...
class _SystemConfigCounter(Protocol):
"""
共享识别命中统计所需的最小原子计数端口。
"""
def increment(self, key: SystemConfigKey, step: int = 1) -> int:
"""
原子递增指定系统配置计数。
:param key: 需要原子更新的系统配置计数键
:param step: 本次增加的计数值,默认加一
:return: 递增后的最新计数
"""
...
_recognition_share_lock = threading.RLock()
_recognition_share_port: Optional[RecognitionSharePort] = None
def configure_recognition_share_port(
port: RecognitionSharePort,
) -> Optional[RecognitionSharePort]:
"""
装配共享识别端口,并返回旧实现供隔离环境恢复。
"""
global _recognition_share_port
with _recognition_share_lock:
previous = _recognition_share_port
_recognition_share_port = port
return previous
def reset_recognition_share_port(
port: Optional[RecognitionSharePort] = None,
) -> None:
"""
恢复指定共享识别端口;省略参数时回到未装配状态。
"""
global _recognition_share_port
with _recognition_share_lock:
_recognition_share_port = port
def _recognition_share_snapshot() -> RecognitionSharePort:
"""
获取当前共享识别端口,未装配时稳定失败。
"""
with _recognition_share_lock:
port = _recognition_share_port
if port is None:
raise RuntimeError("共享识别端口尚未由启动组合根装配")
return port
@dataclass(frozen=True, slots=True)
class _RecognitionPlan:
"""
冻结一次媒体识别请求的规范身份、模块参数和共享查询上下文。
:param meta: 实际交给识别模块的解析元数据,None 表示未提供
:param mtype: 限定的媒体类型,None 时由识别输入决定
:param media_source: 规范化的媒体来源,None 表示未指定
:param media_id: 对应来源的原生媒体标识,None 表示未指定
:param episode_group: 电视剧剧集组标识,None 表示未指定
:param cache: 是否允许复用识别缓存
:param share_meta: 保留原始查询线索的共享识别元数据,None 时未单独提供
:param music_type: 音乐实体类型,None 时沿用模块默认规则
:param identity_valid: 显式媒体身份是否完整有效,无效时拒绝继续识别
"""
meta: Optional[MetaBase]
mtype: Optional[MediaType]
media_source: Optional[MediaSource | str]
media_id: Optional[str]
episode_group: Optional[str]
cache: bool
share_meta: Optional[MetaBase]
music_type: Optional[str]
identity_valid: bool
def module_kwargs(self) -> dict[str, Any]:
"""
生成原生模块识别所需的稳定参数。
"""
kwargs: dict[str, Any] = {
"meta": self.meta,
"mtype": self.mtype,
"media_source": self.media_source,
"media_id": self.media_id,
"episode_group": self.episode_group,
"cache": self.cache,
}
if self.music_type is not None:
kwargs["music_type"] = self.music_type
return kwargs
def share_query_kwargs(self) -> dict[str, Any]:
"""
生成共享识别查询参数,保持原始关键字与规范媒体类型。
"""
kwargs: dict[str, Any] = {
"meta": self.meta,
"mtype": self.mtype,
"keyword_meta": self.share_meta,
}
if self.music_type is not None:
kwargs["music_type"] = self.music_type
return kwargs
def shared_module_kwargs(
self,
shared_params: dict[str, Any],
) -> dict[str, Any]:
"""
把共享识别身份投影为第二次原生模块识别参数。
"""
kwargs: dict[str, Any] = {
"meta": self.meta,
"mtype": shared_params.get("mtype") or self.mtype,
"media_source": shared_params.get("media_source"),
"media_id": shared_params.get("media_id"),
"episode_group": self.episode_group,
"cache": self.cache,
}
shared_music_type = shared_params.get("music_type") or self.music_type
if shared_music_type is not None:
kwargs["music_type"] = shared_music_type
return kwargs
@dataclass(frozen=True, slots=True)
class _RecognitionOutcome:
"""
表达候选结果是否终结识别,以及应保留的无身份回退结果。
:param candidate: 本次识别候选,未获得结果时为 None
:param fallback: 最早保留的无完整远端身份结果,无可用回退时为 None
:param has_identity: 当前候选是否具备规范化来源和媒体标识
"""
candidate: Optional[MediaInfo | MusicInfo]
fallback: Optional[MediaInfo | MusicInfo]
has_identity: bool
@classmethod
def decide(
cls,
candidate: Optional[MediaInfo | MusicInfo],
fallback: Optional[MediaInfo | MusicInfo] = None,
) -> "_RecognitionOutcome":
"""
按规范媒体身份决定终态,并稳定保留最早的有效回退结果。
"""
media_source, media_id = resolve_media_identity(media=candidate)
has_identity = bool(media_source and media_id)
return cls(
candidate=candidate,
fallback=fallback or (candidate if candidate and not has_identity else None),
has_identity=has_identity,
)
@property
def result(self) -> Optional[MediaInfo | MusicInfo]:
"""
返回已识别终态,未命中远端身份时返回保留的回退结果。
"""
return self.candidate if self.has_identity else self.fallback
@property
def should_report(self) -> bool:
"""
判断原生或插件终态是否需要上报共享识别。
"""
return bool(
self.has_identity
and self.candidate
and not getattr(self.candidate, "recognize_cache_hit", False)
)
def _shared_music_candidate_matches(
plan: _RecognitionPlan,
candidate: Optional[MediaInfo | MusicInfo],
) -> bool:
"""
共享音乐身份必须与本地文本证据一致,避免同名曲目跨艺人污染。
共享服务只提供远端身份,第二次原生查询能够证明这个身份存在,却不能
证明它属于当前文件。影视共享命中仍保持原有行为;音乐录音则至少核验
文件标签或目录上下文提供的艺人、曲名和录音版本。
"""
if not isinstance(plan.meta, MetaMusic) or not isinstance(candidate, MusicInfo):
return True
if plan.meta.artists and not music_artist_matches(candidate, plan.meta.artists):
return False
if plan.meta.title and not music_title_matches(candidate, plan.meta.title):
return False
if plan.meta.album and candidate.album and not music_album_matches(candidate, plan.meta.album):
return False
return music_version_matches(candidate, plan.meta) and music_year_matches(candidate, plan.meta)
def _validate_shared_music_candidate(
plan: _RecognitionPlan,
candidate: Optional[MediaInfo | MusicInfo],
) -> Optional[MediaInfo | MusicInfo]:
"""
校验共享音乐候选;冲突时记录原因并回退到本地识别结果。
"""
if _shared_music_candidate_matches(plan, candidate):
return candidate
logger.warning(
"共享音乐识别候选与本地艺人、曲名或版本证据冲突,已忽略:"
f"{getattr(candidate, 'artist', None)} - {getattr(candidate, 'title', None)}"
)
return None
class _RecognitionAction(Enum):
"""
标识媒体识别纯状态机请求同步或异步外壳执行的 I/O 动作。
"""
# 调用内置模块识别媒体。
NATIVE = auto()
# 通过插件辅助事件补充识别。
SUPPLEMENT = auto()
# 向共享服务上报有效识别结果。
REPORT = auto()
# 查询共享服务中的媒体身份线索。
QUERY_SHARE = auto()
# 回填已确认的识别结果缓存。
UPDATE_CACHE = auto()
# 累计共享识别命中次数。
RECORD_SHARE_HIT = auto()
@dataclass(frozen=True, slots=True)
class _RecognitionStep:
"""
描述媒体识别状态机的一次 I/O 请求及其稳定调用参数。
:param action: 需要同步或异步执行的识别、上报或缓存动作
:param kwargs: 对应动作的命名调用参数
:param cache: 调用原生识别时是否复用缓存,默认启用
"""
action: _RecognitionAction
kwargs: dict[str, Any]
cache: bool = True
@dataclass(frozen=True, slots=True)
class _PluginRecognitionPlan:
"""
冻结插件补充识别的事件类型、载荷与结果解析上下文。
:param payload: 交给插件辅助识别事件的媒体上下文
:param fallback: 插件未产生有效身份时保留的原始候选,允许为 None
:param is_music: 是否按音乐事件和结果协议解析
:param mtype: 原始请求限定的媒体类型,None 表示未限定
:param music_type: 音乐实体类型,None 表示未指定
"""
payload: dict[str, Any]
fallback: Optional[MediaInfo | MusicInfo]
is_music: bool
mtype: Optional[MediaType]
music_type: Optional[str]
class _RecognitionFinalizationOwner:
"""
隔离完整识别结果的同步与异步分类收口。
"""
def _finalize_recognition_result(
self,
mediainfo: Optional[_ClassificationSubjectT],
*,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
allow_enrichment: bool = True,
) -> Optional[_ClassificationSubjectT]:
"""
统一复制并分类结果,离线整理可禁止外部补充而不绕过用户分类规则。
"""
if mediainfo is None:
return None
service = getattr(cast(ChainRuntimeMixinHost, self), "classification_service", None)
if service is None:
return mediainfo
return cast(
_ClassificationSubjectT,
service.finalize(
mediainfo,
effective_override=effective_override,
refresh=refresh,
**({"allow_enrichment": False} if not allow_enrichment else {}),
),
)
async def _async_finalize_recognition_result(
self,
mediainfo: Optional[_ClassificationSubjectT],
*,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
allow_enrichment: bool = True,
) -> Optional[_ClassificationSubjectT]:
"""
异步复制并分类结果,保留调用方禁止外部补充的离线约束。
"""
if mediainfo is None:
return None
service = getattr(cast(ChainRuntimeMixinHost, self), "classification_service", None)
if service is None:
return mediainfo
return cast(
_ClassificationSubjectT,
await service.async_finalize(
mediainfo,
effective_override=effective_override,
refresh=refresh,
**({"allow_enrichment": False} if not allow_enrichment else {}),
),
)
class _RecognitionPlanningMixin:
"""
封装识别计划构建及宿主协议收窄。
"""
__mixin_host_protocol__: ClassVar[Any] = ChainRuntimeMixinHost
eventmanager: Any
_finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._finalize_recognition_result)
_async_finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._async_finalize_recognition_result)
def _runtime_host(self) -> ChainRuntimeMixinHost:
"""
把 MRO 注入的宿主能力收窄为识别混入类的静态协议。
"""
return cast(ChainRuntimeMixinHost, self)
@staticmethod
def _build_recognition_plan(
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource | str],
media_id: Optional[str],
episode_group: Optional[str],
cache: bool,
share_meta: Optional[MetaBase],
music_type: Optional[str],
) -> _RecognitionPlan:
"""
规范请求级来源和显式身份,并生成同步、异步共用的识别计划。
"""
explicit_identity = media_id is not None
requested_source = normalize_media_source(media_source) or media_source
resolved_source, resolved_id = resolve_media_identity(
media=meta,
media_source=media_source,
media_id=media_id,
)
identity_valid = not explicit_identity or bool(resolved_source and resolved_id)
planned_source: Optional[MediaSource | str] = resolved_source
if not resolved_id and requested_source is not None:
planned_source = requested_source
meta_source, meta_id = resolve_media_identity(media=meta)
if meta_id and meta_source == requested_source:
planned_source, resolved_id = meta_source, meta_id
planned_episode_group = episode_group
if not planned_episode_group and meta is not None:
planned_episode_group = getattr(meta, "episode_group", None)
planned_type = mtype
if (
not planned_type
and not (planned_source and resolved_id)
and meta
and meta.type in (MediaType.TV, MediaType.MOVIE, MediaType.MUSIC)
):
planned_type = meta.type
return _RecognitionPlan(
meta=meta,
mtype=planned_type,
media_source=planned_source,
media_id=resolved_id,
episode_group=planned_episode_group,
cache=cache,
share_meta=share_meta or meta,
music_type=music_type,
identity_valid=identity_valid,
)
class RecognitionMixin:
"""
为媒体 Chain 提供本地识别、共享识别和插件补充识别流程。
"""
__mixin_host_protocol__: ClassVar[Any] = ChainRuntimeMixinHost
eventmanager: Any
_finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._finalize_recognition_result)
_async_finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._async_finalize_recognition_result)
_runtime_host = cast(Any, _RecognitionPlanningMixin._runtime_host)
_build_recognition_plan = staticmethod(_RecognitionPlanningMixin._build_recognition_plan)
def _can_use_media_recognize_share(
self,
meta: Optional[MetaBase],
media_source: Optional[MediaSource | str],
media_id: Optional[str],
) -> bool:
"""
仅在名称识别场景下使用共享识别,显式ID识别不再重复回查
"""
return bool(
self._runtime_host().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 | MusicInfo],
) -> None:
"""
共享识别成功后回填本地识别缓存,避免名称负缓存导致后续重复回查共享。
"""
if not meta or not mediainfo:
return
self._runtime_host().run_module(
"update_recognize_cache",
meta=meta,
mediainfo=mediainfo,
)
async def _async_update_local_recognize_cache(
self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo | MusicInfo],
) -> None:
"""
异步回填本地识别缓存。
"""
if not meta or not mediainfo:
return
await self._runtime_host().async_run_module(
"async_update_recognize_cache",
meta=meta,
mediainfo=mediainfo,
)
@staticmethod
def _record_media_recognize_share_hit() -> None:
"""
记录一次共享媒体识别成功命中,统计失败不影响识别结果。
"""
try:
counter = cast(_SystemConfigCounter, get_configured_system_config())
counter.increment(SystemConfigKey.MediaRecognizeShareCount)
except Exception as err:
logger.error(f"记录共享媒体识别命中次数失败:{str(err)}")
def _run_native_media_recognize(
self,
module_kwargs: dict[str, Any],
cache: bool,
) -> Optional[MediaInfo]:
"""
执行同步原生媒体模块识别,具体媒体领域可覆写该路由钩子。
"""
with fresh(not cache):
return cast(
Optional[MediaInfo],
self._runtime_host().run_module("recognize_media", **module_kwargs),
)
async def _async_run_native_media_recognize(
self,
module_kwargs: dict[str, Any],
cache: bool,
) -> Optional[MediaInfo]:
"""
执行异步原生媒体模块识别,具体媒体领域可覆写该路由钩子。
"""
async with async_fresh(not cache):
return cast(
Optional[MediaInfo],
await self._runtime_host().async_run_module(
"async_recognize_media", **module_kwargs
),
)
@classmethod
def _recognition_steps(
cls,
plan: _RecognitionPlan,
use_share: bool,
) -> Generator[
_RecognitionStep, Any, Optional[MediaInfo | MusicInfo]
]:
"""
生成双 ABI 共用的识别状态机,仅把实际 I/O 留给入口外壳。
"""
mediainfo = yield _RecognitionStep(
action=_RecognitionAction.NATIVE,
kwargs=plan.module_kwargs(),
cache=plan.cache,
)
mediainfo = yield _RecognitionStep(
action=_RecognitionAction.SUPPLEMENT,
kwargs={
"meta": plan.meta,
"mtype": plan.mtype,
"media_source": plan.media_source,
"media_id": plan.media_id,
"mediainfo": mediainfo,
"music_type": plan.music_type,
},
)
outcome = _RecognitionOutcome.decide(mediainfo)
if music_recognition_needs_confirmation(outcome.result):
return outcome.result
if outcome.has_identity:
if outcome.should_report:
yield _RecognitionStep(
action=_RecognitionAction.REPORT,
kwargs={
"meta": plan.meta,
"mediainfo": outcome.candidate,
"keyword_meta": plan.share_meta,
},
)
return outcome.result
if use_share:
shared_cache_meta = cls._snapshot_recognize_cache_meta(plan.meta)
shared_params = yield _RecognitionStep(
action=_RecognitionAction.QUERY_SHARE,
kwargs=plan.share_query_kwargs(),
)
if shared_params:
mediainfo = yield _RecognitionStep(
action=_RecognitionAction.NATIVE,
kwargs=plan.shared_module_kwargs(shared_params),
cache=plan.cache,
)
mediainfo = _validate_shared_music_candidate(plan, mediainfo)
outcome = _RecognitionOutcome.decide(mediainfo, outcome.fallback)
if outcome.has_identity:
yield _RecognitionStep(
action=_RecognitionAction.UPDATE_CACHE,
kwargs={
"meta": shared_cache_meta,
"mediainfo": outcome.candidate,
},
)
yield _RecognitionStep(
action=_RecognitionAction.RECORD_SHARE_HIT,
kwargs={},
)
return outcome.result
def _run_recognition_steps(self, plan: _RecognitionPlan) -> Optional[MediaInfo]:
"""
用同步端口驱动共享识别状态机。
"""
steps = self._recognition_steps(
plan,
self._can_use_media_recognize_share(
plan.share_meta, plan.media_source, plan.media_id
),
)
result: Any = None
while True:
try:
step = steps.send(result)
except StopIteration as completed:
return cast(Optional[MediaInfo], completed.value)
if step.action == _RecognitionAction.NATIVE:
result = self._run_native_media_recognize(step.kwargs, step.cache)
elif step.action == _RecognitionAction.SUPPLEMENT:
result = self._supplement_media_recognize(**step.kwargs)
elif step.action == _RecognitionAction.REPORT:
result = _recognition_share_snapshot().report_recognize_share(
**step.kwargs
)
elif step.action == _RecognitionAction.QUERY_SHARE:
share_port = _recognition_share_snapshot()
shared_item = share_port.query_recognize_share(**step.kwargs)
result = share_port.to_recognize_params(shared_item)
elif step.action == _RecognitionAction.UPDATE_CACHE:
self._update_local_recognize_cache(**step.kwargs)
result = None
else:
result = self._record_media_recognize_share_hit()
async def _async_run_recognition_steps(
self, plan: _RecognitionPlan
) -> Optional[MediaInfo]:
"""
用异步端口驱动共享识别状态机,不在线程间复用模块对象。
"""
steps = self._recognition_steps(
plan,
self._can_use_media_recognize_share(
plan.share_meta, plan.media_source, plan.media_id
),
)
result: Any = None
while True:
try:
step = steps.send(result)
except StopIteration as completed:
return cast(Optional[MediaInfo], completed.value)
if step.action == _RecognitionAction.NATIVE:
result = await self._async_run_native_media_recognize(
step.kwargs, step.cache
)
elif step.action == _RecognitionAction.SUPPLEMENT:
result = await self._async_supplement_media_recognize(**step.kwargs)
elif step.action == _RecognitionAction.REPORT:
result = await (
_recognition_share_snapshot().async_report_recognize_share(
**step.kwargs
)
)
elif step.action == _RecognitionAction.QUERY_SHARE:
share_port = _recognition_share_snapshot()
shared_item = await share_port.async_query_recognize_share(**step.kwargs)
result = share_port.to_recognize_params(shared_item)
elif step.action == _RecognitionAction.UPDATE_CACHE:
await self._async_update_local_recognize_cache(**step.kwargs)
result = None
else:
result = await run_in_threadpool(
self._record_media_recognize_share_hit
)
def recognize_media(
self,
meta: Optional[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: Optional[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: 识别的媒体信息,包括剧集信息
"""
plan = self._build_recognition_plan(
meta=meta,
mtype=mtype,
media_source=media_source,
media_id=media_id,
episode_group=episode_group,
cache=cache,
share_meta=share_meta,
music_type=music_type,
)
if not plan.identity_valid:
logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id")
return None
return cast(Optional[MediaInfo], self._finalize_recognition_result(self._run_recognition_steps(plan)))
async def async_recognize_media(
self,
meta: Optional[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: Optional[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: 识别的媒体信息,包括剧集信息
"""
plan = self._build_recognition_plan(
meta=meta,
mtype=mtype,
media_source=media_source,
media_id=media_id,
episode_group=episode_group,
cache=cache,
share_meta=share_meta,
music_type=music_type,
)
if not plan.identity_valid:
logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id")
return None
return cast(Optional[MediaInfo], await self._async_finalize_recognition_result(await self._async_run_recognition_steps(plan)))
@staticmethod
def _media_recognize_plugin_payload(
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource | str],
media_id: Optional[str],
is_music: bool,
music_type: Optional[str] = None,
) -> dict[str, Any]:
"""
构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议,
仅要素字段随媒体类型不同
"""
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: object,
is_music: bool,
mtype: Optional[MediaType] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo | MusicInfo]:
"""
解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信;
音乐构造 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
music_info = MusicInfo.from_dict(plugin_info)
if not music_info.media_source or not music_info.media_id:
return None
if music_type and music_info.music_type != music_type:
logger.warn(
f"插件返回的音乐实体类型为 {music_info.music_type},"
f"与请求的 {music_type} 不一致,忽略 ..."
)
return None
return music_info
# 影视:插件未提供类型时使用请求推断的类型
if not plugin_info.get("type") and mtype:
plugin_info = {**plugin_info, "type": mtype}
media_info = MediaInfo()
media_info.from_dict(plugin_info)
except Exception as err:
logger.warn(f"插件返回的媒体信息格式错误:{err}")
return None
# 影视与音乐统一要求远端身份,无身份的结果不采信,避免未验证结果进入识别管线
if not media_info.media_source or not cls._media_info_has_identity(media_info):
logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...")
return None
return media_info
@staticmethod
def _media_info_has_identity(
mediainfo: Optional[MediaInfo | MusicInfo],
) -> bool:
"""
判断媒体信息是否具备完整的规范媒体身份。
"""
return _RecognitionOutcome.decide(mediainfo).has_identity
def _build_plugin_recognition_plan(
self,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource | str],
media_id: Optional[str],
mediainfo: Optional[MediaInfo | MusicInfo],
music_type: Optional[str],
) -> Optional[_PluginRecognitionPlan]:
"""
为缺少规范身份的候选结果生成插件补充识别计划。
"""
if _RecognitionOutcome.decide(mediainfo).has_identity or music_recognition_needs_confirmation(mediainfo):
return None
is_music = (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or isinstance(mediainfo, MusicInfo)
)
return _PluginRecognitionPlan(
payload=self._media_recognize_plugin_payload(
meta, mtype, media_source, media_id, is_music, music_type
),
fallback=mediainfo,
is_music=is_music,
mtype=mtype,
music_type=music_type,
)
def _apply_plugin_recognition_result(
self,
plan: _PluginRecognitionPlan,
result: Optional[Event],
) -> Optional[MediaInfo | MusicInfo]:
"""
解析插件事件结果;无响应或非法身份时稳定保留原候选。
"""
if not result:
return plan.fallback
plugin_info = self._media_info_from_plugin(
result.event_data or {},
plan.is_music,
plan.mtype,
plan.music_type,
)
if not plugin_info:
return plan.fallback
logger.info(
f"插件补充媒体识别成功:{plugin_info.title}"
f"({plugin_info.media_source}:{plugin_info.media_id})"
)
return plugin_info
def _supplement_media_recognize(
self, meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource | str],
media_id: Optional[str],
mediainfo: Optional[MediaInfo | MusicInfo],
music_type: Optional[str] = None,
) -> Optional[MediaInfo | MusicInfo]:
"""
媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时,
广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息
"""
plan = self._build_plugin_recognition_plan(
meta=meta,
mtype=mtype,
media_source=media_source,
media_id=media_id,
mediainfo=mediainfo,
music_type=music_type,
)
if plan is None:
return mediainfo
event_type = (
ChainEventType.MusicMediaRecognize
if plan.is_music
else ChainEventType.MediaRecognize
)
if not self.eventmanager.check(event_type):
return mediainfo
result: Event = self.eventmanager.send_event(
event_type,
plan.payload,
)
return self._apply_plugin_recognition_result(plan, result)
async def _async_supplement_media_recognize(
self, meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource | str],
media_id: Optional[str],
mediainfo: Optional[MediaInfo | MusicInfo],
music_type: Optional[str] = None,
) -> Optional[MediaInfo | MusicInfo]:
"""
媒体识别插件补充的异步版本,影视与音乐统一流程
"""
plan = self._build_plugin_recognition_plan(
meta=meta,
mtype=mtype,
media_source=media_source,
media_id=media_id,
mediainfo=mediainfo,
music_type=music_type,
)
if plan is None:
return mediainfo
event_type = (
ChainEventType.MusicMediaRecognize
if plan.is_music
else ChainEventType.MediaRecognize
)
if not self.eventmanager.check(event_type):
return mediainfo
result: Event = await self.eventmanager.async_send_event(
event_type,
plan.payload,
)
return self._apply_plugin_recognition_result(plan, result)