""" 插件识别顺序选择、链式事件解析与辅助识别编排。 """ from dataclasses import dataclass from enum import Enum from typing import ( TYPE_CHECKING, Awaitable, Callable, Generator, Mapping, Optional, Protocol, Tuple, cast, ) from app.application.configuration import get_chain_runtime_config_snapshot from app.chain.media.contract import _MediaOwnerBase from app.domain.context import ( MediaInfo, ) from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.runtime.log import logger from app.schemas.types import ( ChainEventType, MediaSource, MediaType, ) RecognitionCallback = Callable[[], Optional[MediaInfo]] AsyncRecognitionCallback = Callable[[], Awaitable[Optional[MediaInfo]]] RecognitionPredicate = Callable[[Optional[MediaInfo]], bool] class _RecognitionSource(Enum): """ 标识一次识别选择流程当前需要调用的真实 I/O 来源。 """ # 调用项目内置媒体识别来源。 NATIVE = "native" # 调用插件链式辅助识别事件。 PLUGIN = "plugin" @dataclass(frozen=True, slots=True) class _RecognitionSelection: """ 统一同步与异步识别入口的来源顺序、回退和结果采信决策。 :param plugin_first: 是否先尝试插件辅助识别再回退内置来源 :param plugin_available: 当前媒体辅助识别事件是否存在处理器 :param is_recognized: 判断结果是否具有可采信媒体身份的谓词 """ plugin_first: bool plugin_available: bool is_recognized: RecognitionPredicate def flow( self, ) -> Generator[_RecognitionSource, Optional[MediaInfo], Optional[MediaInfo]]: """ 按统一顺序产出识别来源,并通过 send 接收对应入口的识别结果。 :yields: 下一步应调用的内置来源或插件来源 :return: 按身份有效性和回退顺序选出的结果,均未命中时为 None """ if self.plugin_first and self.plugin_available: helped = yield _RecognitionSource.PLUGIN if self.is_recognized(helped): return helped native = yield _RecognitionSource.NATIVE return native or helped native = yield _RecognitionSource.NATIVE if self.is_recognized(native) or not self.plugin_available: return native helped = yield _RecognitionSource.PLUGIN return helped if self.is_recognized(helped) else native @dataclass(frozen=True, slots=True) class _VideoHelpElements: """ 保存影视辅助识别事件解析出的规范化标题要素。 :param title: 插件辅助识别后确认的媒体标题 :param year: 插件提供的发行年份,未知时为 None :param season: 插件提供的季号,未指定时为 None :param episode: 插件提供的集号,未指定时为 None """ title: str year: Optional[str] season: Optional[int] episode: Optional[int] def _normalize_music_year(value: object) -> Optional[int]: """ 把本地或插件音乐年份的数字文本转为整数,空值和非数字文本返回 None。 """ text = str(value or "").strip() return int(text) if text.isdigit() else None class _EventResult(Protocol): """ 声明插件链式事件返回值所需的最小读取合同。 """ @property def event_data(self) -> Mapping[str, object]: """ 返回插件写入的链式事件数据。 :return: 插件处理后的链式事件数据,包含修正的媒体识别要素 """ ... class _EventManagerPort(Protocol): """ 声明插件辅助识别需要的事件管理器能力。 """ def check(self, event_type: ChainEventType) -> bool: """ 返回指定链式事件是否存在可用处理器。 :param event_type: 待检查的影视或音乐辅助识别事件类型 :return: 至少存在一个已注册处理器时为 True """ ... def send_event( self, event_type: ChainEventType, data: Mapping[str, object], ) -> Optional[_EventResult]: """ 同步发送链式事件并返回插件处理结果。 :param event_type: 本次需要调用的辅助识别事件类型 :param data: 交给插件读取和修正的媒体识别上下文 :return: 携带插件处理数据的链式事件结果,无有效结果时为 None """ ... async def async_send_event( self, event_type: ChainEventType, data: Mapping[str, object], ) -> Optional[_EventResult]: """ 异步发送链式事件并返回插件处理结果。 :param event_type: 本次需要调用的辅助识别事件类型 :param data: 交给插件读取和修正的媒体识别上下文 :return: 携带插件处理数据的链式事件结果,无有效结果时为 None """ ... if TYPE_CHECKING: class _MediaPluginOwnerBase: """ 声明插件辅助识别依赖的其他媒体组件能力。 """ eventmanager: _EventManagerPort def recognize_media( self, *, meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, share_meta: Optional[MetaBase] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, ) -> Optional[MediaInfo]: """ 按修正后的元数据同步重新识别媒体。 """ ... async def async_recognize_media( self, *, meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, share_meta: Optional[MetaBase] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, ) -> Optional[MediaInfo]: """ 按修正后的元数据异步重新识别媒体。 """ ... else: _MediaPluginOwnerBase = _MediaOwnerBase class MediaPluginOwner(_MediaPluginOwnerBase): """ 插件识别顺序选择、链式事件解析与辅助识别编排。 """ def _recognition_selection( self, plugin_event: ChainEventType, is_recognized: Optional[RecognitionPredicate], ) -> _RecognitionSelection: """ 冻结本次识别的插件可用性、优先级和结果采信谓词。 """ return _RecognitionSelection( plugin_first=get_chain_runtime_config_snapshot().recognize_plugin_first, plugin_available=self.eventmanager.check(plugin_event), is_recognized=is_recognized or bool, ) @staticmethod def _log_recognition_action( action: _RecognitionSource, *, plugin_first: bool, log_name: str, log_context: str, ) -> None: """ 在状态机请求真实 I/O 前记录统一的来源切换原因。 """ if action is _RecognitionSource.PLUGIN: if plugin_first: logger.info( f"插件识别优先模式已开启。请求辅助识别,标题:{log_name} ..." ) else: logger.info( f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别 ..." ) return if plugin_first: logger.info( f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别 ..." ) else: logger.info(f"开始识别标题:{log_name} ...") def _run_recognition_selection( self, selection: _RecognitionSelection, *, log_name: str, log_context: str, native_fn: RecognitionCallback, plugin_fn: RecognitionCallback, ) -> Optional[MediaInfo]: """ 同步驱动共享选择状态机,并只在动作边界调用同步实现。 """ flow = selection.flow() try: action = next(flow) while True: MediaPluginOwner._log_recognition_action( action, plugin_first=selection.plugin_first, log_name=log_name, log_context=log_context, ) result = ( plugin_fn() if action is _RecognitionSource.PLUGIN else native_fn() ) action = flow.send(result) except StopIteration as completed: return cast(Optional[MediaInfo], completed.value) async def _async_run_recognition_selection( self, selection: _RecognitionSelection, *, log_name: str, log_context: str, native_fn: AsyncRecognitionCallback, plugin_fn: AsyncRecognitionCallback, ) -> Optional[MediaInfo]: """ 异步驱动共享选择状态机,并只在动作边界等待异步实现。 """ flow = selection.flow() try: action = next(flow) while True: MediaPluginOwner._log_recognition_action( action, plugin_first=selection.plugin_first, log_name=log_name, log_context=log_context, ) result = ( await plugin_fn() if action is _RecognitionSource.PLUGIN else await native_fn() ) action = flow.send(result) except StopIteration as completed: return cast(Optional[MediaInfo], completed.value) def select_recognize_source( self, log_name: str, log_context: str, native_fn: RecognitionCallback, plugin_fn: RecognitionCallback, is_recognized: Optional[RecognitionPredicate] = None, plugin_event: ChainEventType = ChainEventType.NameRecognize, ) -> Optional[MediaInfo]: """ 选择识别模式,插件优先或原生优先 :param log_name: 用于日志“标题:...”处的名称(如 file_path.name 或 title) :param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title) :param native_fn: 原生识别函数 :param plugin_fn: 插件识别函数 :param is_recognized: 判定识别结果是否有效的谓词;音乐原生兜底结果无远端身份, 需视为未识别才会请求辅助识别,影视默认按非空判定 :param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件 """ selection = MediaPluginOwner._recognition_selection( self, plugin_event, is_recognized ) return MediaPluginOwner._run_recognition_selection( self, selection, log_name=log_name, log_context=log_context, native_fn=native_fn, plugin_fn=plugin_fn, ) @staticmethod def _parse_recognize_event_number(value: object) -> Optional[int]: """ 解析辅助识别返回的季集号,兼容整数和数字字符串并保留数值 0。 """ if value is None: return None text = str(value).strip() return int(text) if text.isdigit() else None @staticmethod def _video_help_elements( event_data: Mapping[str, object], ) -> Optional[_VideoHelpElements]: """ 规范化影视辅助识别结果,标题为空或未知时拒绝该结果。 """ recognized_title: Optional[str] = None recognized_year: Optional[str] = None if event_data.get("name"): recognized_title = ( str(event_data["name"]).split("/")[0].strip().replace(".", " ") ) if not recognized_title or recognized_title == "Unknown": return None if event_data.get("year"): recognized_year = str(event_data["year"]).split("/")[0].strip() if not str(recognized_year).isdigit(): recognized_year = None return _VideoHelpElements( title=recognized_title, year=recognized_year, season=MediaPluginOwner._parse_recognize_event_number( event_data.get("season") ), episode=MediaPluginOwner._parse_recognize_event_number( event_data.get("episode") ), ) @staticmethod def _prepare_video_help_meta( org_meta: MetaBase, event_data: Mapping[str, object], ) -> Optional[MetaBase]: """ 统一校验影视辅助要素并把有效变化应用到本次元数据。 """ elements = MediaPluginOwner._video_help_elements(event_data) if elements is None: return None if elements.title == org_meta.name and elements.year == org_meta.year: logger.info("辅助识别与原始识别结果一致,无需重新识别媒体信息") return None logger.info("辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") org_meta.name = elements.title org_meta.year = elements.year org_meta.begin_season = elements.season org_meta.begin_episode = elements.episode if org_meta.begin_season is not None or org_meta.begin_episode is not None: org_meta.type = MediaType.TV return org_meta def recognize_help( self, title: str, org_meta: MetaBase, share_meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, ) -> Optional[MediaInfo]: """ 请求辅助识别,返回媒体信息;影视与音乐共用同一流程,仅要素事件与重组方式不同 :param title: 标题 :param org_meta: 原始元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 :param media_source: 请求级识别数据源 :param episode_group: 剧集组 :param music_type: 音乐实体类型,仅音乐辅助识别使用 """ # 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件 if isinstance(org_meta, MetaMusic): return self._recognize_music_help( title=title, org_meta=org_meta, share_meta=share_meta, media_source=media_source, music_type=music_type, ) result = self.eventmanager.send_event( ChainEventType.NameRecognize, { "title": title, }, ) if not result: return None event_data = result.event_data or {} logger.info(f"获取到辅助识别结果:{event_data}") rematch_meta = MediaPluginOwner._prepare_video_help_meta(org_meta, event_data) if rematch_meta is None: return None return self.recognize_media( meta=rematch_meta, media_source=media_source, share_meta=share_meta, episode_group=episode_group, ) def _recognize_music_help( self, title: str, org_meta: MetaMusic, share_meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, music_type: Optional[str] = None, ) -> Optional[MediaInfo]: """ 请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息 :param title: 原始音乐标题 :param org_meta: 原始音乐元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 :param media_source: 请求级识别数据源 :param music_type: 音乐实体类型 """ result = self.eventmanager.send_event( ChainEventType.MusicNameRecognize, MediaPluginOwner._music_help_payload(title, org_meta, music_type), ) if not result: return None event_data = result.event_data or {} logger.info(f"获取到音乐辅助识别结果:{event_data}") new_meta = MediaPluginOwner._prepare_music_help_meta(org_meta, event_data) if new_meta is None: return None # 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底 mediainfo = self.recognize_media( meta=new_meta, media_source=media_source, share_meta=share_meta, music_type=music_type, ) return mediainfo if mediainfo and mediainfo.media_source else None @staticmethod def _music_help_payload( title: str, org_meta: MetaMusic, music_type: Optional[str], ) -> Mapping[str, object]: """ 为同步与异步音乐辅助识别生成完全相同的事件载荷。 """ return { "title": title, "artist": org_meta.artist, "album": org_meta.album, "year": org_meta.year, "music_type": music_type, } @staticmethod def _prepare_music_help_meta( org_meta: MetaMusic, event_data: Mapping[str, object], ) -> Optional[MetaMusic]: """ 统一校验音乐辅助要素,并仅为真实变化构造重新匹配元数据。 """ name, artist, album, year = MediaPluginOwner._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 == _normalize_music_year(org_meta.year)) ): logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息") return None logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") return MediaPluginOwner._build_music_help_meta( org_meta=org_meta, name=name, artist=artist, album=album, year=year, ) @staticmethod def _parse_music_recognize_event( event_data: Mapping[str, object], ) -> 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 @staticmethod def _build_music_help_meta( org_meta: MetaMusic, name: str, artist: Optional[str], album: Optional[str], year: Optional[int], ) -> MetaMusic: """ 按插件修正标题要素,并保留本地轨道与音频判定证据。 """ return 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 _normalize_music_year(org_meta.year), disc_number=org_meta.disc_number, track_number=org_meta.track_number, total_discs=org_meta.total_discs, total_tracks=org_meta.total_tracks, version=org_meta.version, audio_format=org_meta.audio_format, audio_lossless=org_meta.audio_lossless, bit_depth=org_meta.bit_depth, sample_rate=org_meta.sample_rate, bitrate=org_meta.bitrate, duration=org_meta.duration, isrc=org_meta.isrc, ) async def async_select_recognize_source( self, log_name: str, log_context: str, native_fn: AsyncRecognitionCallback, plugin_fn: AsyncRecognitionCallback, is_recognized: Optional[RecognitionPredicate] = None, plugin_event: ChainEventType = ChainEventType.NameRecognize, ) -> Optional[MediaInfo]: """ 选择识别模式,插件优先或原生优先(异步版本) :param log_name: 用于日志“标题:...”处的名称(如 file_path.name 或 title) :param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title) :param native_fn: 原生识别函数 :param plugin_fn: 插件识别函数 :param is_recognized: 判定识别结果是否有效的谓词,语义同同步版本 :param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件 """ selection = MediaPluginOwner._recognition_selection( self, plugin_event, is_recognized ) return await MediaPluginOwner._async_run_recognition_selection( self, selection, log_name=log_name, log_context=log_context, native_fn=native_fn, plugin_fn=plugin_fn, ) async def async_recognize_help( self, title: str, org_meta: MetaBase, share_meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, ) -> Optional[MediaInfo]: """ 请求辅助识别,返回媒体信息(异步版本);影视与音乐共用同一流程 :param title: 标题 :param org_meta: 原始元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 :param media_source: 请求级识别数据源 :param episode_group: 剧集组 :param music_type: 音乐实体类型,仅音乐辅助识别使用 """ # 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件 if isinstance(org_meta, MetaMusic): return await self._async_recognize_music_help( title=title, org_meta=org_meta, share_meta=share_meta, media_source=media_source, music_type=music_type, ) result = await self.eventmanager.async_send_event( ChainEventType.NameRecognize, { "title": title, }, ) if not result: return None event_data = result.event_data or {} logger.info(f"获取到辅助识别结果:{event_data}") rematch_meta = MediaPluginOwner._prepare_video_help_meta(org_meta, event_data) if rematch_meta is None: return None return await self.async_recognize_media( meta=rematch_meta, media_source=media_source, share_meta=share_meta, episode_group=episode_group, ) async def _async_recognize_music_help( self, title: str, org_meta: MetaMusic, share_meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, music_type: Optional[str] = None, ) -> Optional[MediaInfo]: """ 请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息(异步版本) :param title: 原始音乐标题 :param org_meta: 原始音乐元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 :param media_source: 请求级识别数据源 :param music_type: 音乐实体类型 """ result = await self.eventmanager.async_send_event( ChainEventType.MusicNameRecognize, MediaPluginOwner._music_help_payload(title, org_meta, music_type), ) if not result: return None event_data = result.event_data or {} logger.info(f"获取到音乐辅助识别结果:{event_data}") new_meta = MediaPluginOwner._prepare_music_help_meta(org_meta, event_data) if new_meta is None: return None # 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底 mediainfo = await self.async_recognize_media( meta=new_meta, media_source=media_source, share_meta=share_meta, music_type=music_type, ) return mediainfo if mediainfo and mediainfo.media_source else None