From 1d97f2e0436150158dc6757d19b790f2abc8d1d6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 10 May 2026 07:54:55 +0800 Subject: [PATCH] fix: align media recognition fallback and shared reporting Route title and path lookups through the fallback-aware entrypoints so auxiliary matches can reuse pre-assist keywords without forcing image fetches in lightweight flows. Also reduce noisy agent shutdown logging during cleanup. --- app/agent/tools/impl/add_download.py | 8 +- app/agent/tools/impl/recognize_media.py | 10 +- app/api/endpoints/download.py | 17 ++- app/api/endpoints/media.py | 11 +- app/api/endpoints/search.py | 10 +- app/api/endpoints/storage.py | 6 +- app/api/endpoints/system.py | 6 +- app/api/endpoints/transfer.py | 5 +- app/api/servarr.py | 8 +- app/chain/__init__.py | 38 ++++- app/chain/media.py | 184 +++++++++++++++-------- app/chain/subscribe.py | 24 ++- app/chain/torrents.py | 5 +- app/chain/transfer.py | 12 +- app/helper/recognize.py | 62 ++++++-- app/startup/agent_initializer.py | 2 +- app/workflow/actions/add_download.py | 5 +- app/workflow/actions/fetch_rss.py | 8 +- app/workflow/actions/fetch_torrents.py | 5 +- app/workflow/actions/scrape_file.py | 5 +- tests/test_media_recognize_share.py | 185 ++++++++++++++++++++++++ 21 files changed, 505 insertions(+), 111 deletions(-) diff --git a/app/agent/tools/impl/add_download.py b/app/agent/tools/impl/add_download.py index 04102d92..fbd909bc 100644 --- a/app/agent/tools/impl/add_download.py +++ b/app/agent/tools/impl/add_download.py @@ -6,7 +6,8 @@ from typing import List, Optional, Type from pydantic import BaseModel, Field -from app.agent.tools.base import MoviePilotTool, ToolChain +from app.agent.tools.base import MoviePilotTool +from app.chain.media import MediaChain from app.chain.search import SearchChain from app.chain.download import DownloadChain from app.core.config import settings @@ -275,7 +276,10 @@ class AddDownloadTool(MoviePilotTool): meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description) media_info = cached_context.media_info if cached_context.media_info else None if not media_info: - media_info = await ToolChain().async_recognize_media(meta=meta_info) + media_info = await MediaChain().async_recognize_by_meta( + meta_info, + obtain_images=False, + ) if not media_info: failed_messages.append(f"{torrent_input} 无法识别媒体信息") continue diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py index 37e6b6e6..1a0a33e7 100644 --- a/app/agent/tools/impl/recognize_media.py +++ b/app/agent/tools/impl/recognize_media.py @@ -60,7 +60,10 @@ class RecognizeMediaTool(MoviePilotTool): "message": "文件路径不能为空" }, ensure_ascii=False) - context = await media_chain.async_recognize_by_path(path) + context = await media_chain.async_recognize_by_path( + path, + obtain_images=False, + ) if context: return self._format_context_result(context, "文件") else: @@ -73,7 +76,10 @@ class RecognizeMediaTool(MoviePilotTool): elif title: # 种子标题识别 metainfo = MetaInfo(title, subtitle) - mediainfo = await media_chain.async_recognize_by_meta(metainfo) + mediainfo = await media_chain.async_recognize_by_meta( + metainfo, + obtain_images=False, + ) if mediainfo: context = Context(meta_info=metainfo, media_info=mediainfo) return self._format_context_result(context, "种子") diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index e3daf6ea..1ed0dc0a 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -76,12 +76,17 @@ def add( # 元数据 metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) # 媒体信息 - mediainfo = MediaChain().select_recognize_source( - log_name=torrent_in.title, - log_context=torrent_in.title, - native_fn=lambda: MediaChain().recognize_media(meta=metainfo, tmdbid=tmdbid, doubanid=doubanid), - plugin_fn=lambda: MediaChain().recognize_help(title=torrent_in.title, org_meta=metainfo) - ) + if tmdbid or doubanid: + mediainfo = MediaChain().recognize_media( + meta=metainfo, + tmdbid=tmdbid, + doubanid=doubanid, + ) + else: + mediainfo = MediaChain().recognize_by_meta( + metainfo, + obtain_images=False, + ) if not mediainfo: return schemas.Response(success=False, message="无法识别媒体信息") # 种子信息 diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 69fbc1bb..e838795d 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -202,7 +202,11 @@ async def seasons(mediaid: Optional[str] = None, meta = MetaInfo(title) if year: meta.year = year - mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV) + meta.type = MediaType.TV + mediainfo = await MediaChain().async_recognize_by_meta( + meta, + obtain_images=False, + ) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id) @@ -261,7 +265,10 @@ async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year meta.year = year if mtype: meta.type = mtype - mediainfo = await mediachain.async_recognize_media(meta=meta) + mediainfo = await mediachain.async_recognize_by_meta( + meta, + obtain_images=False, + ) # 识别 if mediainfo: await mediachain.async_obtain_images(mediainfo) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index b88c3fbf..56f696c5 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -255,7 +255,10 @@ async def search_by_id_stream(request: Request, if media_season: meta.type = MediaType.TV meta.begin_season = media_season - mediainfo = await media_chain.async_recognize_media(meta=meta) + mediainfo = await media_chain.async_recognize_by_meta( + meta, + obtain_images=False, + ) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": torrents = search_chain.async_search_by_id_stream(tmdbid=mediainfo.tmdb_id, @@ -388,7 +391,10 @@ async def search_by_id(mediaid: str, if media_season: meta.type = MediaType.TV meta.begin_season = media_season - mediainfo = await media_chain.async_recognize_media(meta=meta) + mediainfo = await media_chain.async_recognize_by_meta( + meta, + obtain_images=False, + ) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type, diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index dd68b857..8a47bc47 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from starlette.responses import FileResponse, Response from app import schemas +from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.config import settings @@ -199,7 +200,10 @@ def rename(fileitem: schemas.FileItem, continue sub_path = Path(f"{fileitem.path}{sub_file.name}") meta = MetaInfoPath(sub_path) - mediainfo = transferchain.recognize_media(meta) + mediainfo = MediaChain().recognize_by_meta( + meta, + obtain_images=False, + ) if not mediainfo: progress.end() return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 4157ac76..c7830b3b 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Re from fastapi.responses import StreamingResponse from app import schemas +from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain from app.chain.search import SearchChain from app.chain.system import SystemChain @@ -785,7 +786,10 @@ def ruletest( ) # 根据标题查询媒体信息 - media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle)) + media_info = MediaChain().recognize_by_meta( + MetaInfo(title=title, subtitle=subtitle), + obtain_images=False, + ) if not media_info: return schemas.Response(success=False, message="未识别到媒体信息!") diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 926001d8..080292d5 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -31,7 +31,10 @@ def query_name(path: str, filetype: str, :param _: Token校验 """ meta = MetaInfoPath(Path(path)) - mediainfo = MediaChain().recognize_media(meta) + mediainfo = MediaChain().recognize_by_meta( + meta, + obtain_images=False, + ) if not mediainfo: return schemas.Response(success=False, message="未识别到媒体信息") new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo) diff --git a/app/api/servarr.py b/app/api/servarr.py index 387bd323..1fdcbcb5 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -548,8 +548,12 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: seas = list(range(1, int(sea_num) + 1)) # 根据TVDB查询媒体信息 - mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')), - mtype=MediaType.TV) + meta = MetaInfo(tvdbinfo.get('name')) + meta.type = MediaType.TV + mediainfo = MediaChain().recognize_by_meta( + meta, + obtain_images=False, + ) if not mediainfo: continue # 查询是否存在 diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 02c5274c..7f7ab4f8 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -465,10 +465,12 @@ class ChainBase(metaclass=ABCMeta): bangumiid: Optional[int] = None, episode_group: Optional[str] = None, cache: bool = True, + share_meta: MetaBase = None, ) -> Optional[MediaInfo]: """ 识别媒体信息,不含Fanart图片 :param meta: 识别的元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 :param mtype: 识别的媒体类型,与tmdbid配套 :param tmdbid: tmdbid :param doubanid: 豆瓣ID @@ -488,6 +490,7 @@ class ChainBase(metaclass=ABCMeta): bangumiid = None elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: mtype = meta.type + share_query_meta = share_meta or meta share_helper = MediaRecognizeShareHelper() with fresh(not cache): mediainfo = self.run_module( @@ -502,12 +505,22 @@ class ChainBase(metaclass=ABCMeta): ) if mediainfo: if not mediainfo.recognize_cache_hit: - share_helper.report(meta=meta, mediainfo=mediainfo) + share_helper.report( + meta=meta, + mediainfo=mediainfo, + keyword_meta=share_query_meta, + ) return mediainfo - if self._can_use_media_recognize_share(meta, tmdbid, doubanid, bangumiid): + if self._can_use_media_recognize_share( + share_query_meta, tmdbid, doubanid, bangumiid + ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) - shared_item = share_helper.query(meta=meta, mtype=mtype) + shared_item = share_helper.query( + meta=meta, + mtype=mtype, + keyword_meta=share_query_meta, + ) shared_params = share_helper.to_recognize_params(shared_item) if shared_params: with fresh(not cache): @@ -535,10 +548,12 @@ class ChainBase(metaclass=ABCMeta): bangumiid: Optional[int] = None, episode_group: Optional[str] = None, cache: bool = True, + share_meta: MetaBase = None, ) -> Optional[MediaInfo]: """ 识别媒体信息,不含Fanart图片(异步版本) :param meta: 识别的元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 :param mtype: 识别的媒体类型,与tmdbid配套 :param tmdbid: tmdbid :param doubanid: 豆瓣ID @@ -558,6 +573,7 @@ class ChainBase(metaclass=ABCMeta): bangumiid = None elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: mtype = meta.type + share_query_meta = share_meta or meta share_helper = MediaRecognizeShareHelper() async with async_fresh(not cache): mediainfo = await self.async_run_module( @@ -572,12 +588,22 @@ class ChainBase(metaclass=ABCMeta): ) if mediainfo: if not mediainfo.recognize_cache_hit: - await share_helper.async_report(meta=meta, mediainfo=mediainfo) + await share_helper.async_report( + meta=meta, + mediainfo=mediainfo, + keyword_meta=share_query_meta, + ) return mediainfo - if self._can_use_media_recognize_share(meta, tmdbid, doubanid, bangumiid): + if self._can_use_media_recognize_share( + share_query_meta, tmdbid, doubanid, bangumiid + ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) - shared_item = await share_helper.async_query(meta=meta, mtype=mtype) + shared_item = await share_helper.async_query( + meta=meta, + mtype=mtype, + keyword_meta=share_query_meta, + ) shared_params = share_helper.to_recognize_params(shared_item) if shared_params: async with async_fresh(not cache): diff --git a/app/chain/media.py b/app/chain/media.py index d4685089..f607e4a4 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -1,4 +1,5 @@ import os +from copy import deepcopy from pathlib import Path from tempfile import NamedTemporaryFile from threading import Lock @@ -447,39 +448,83 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return mediainfo def recognize_by_meta( - self, metainfo: MetaBase, episode_group: Optional[str] = None + self, + metainfo: MetaBase, + episode_group: Optional[str] = None, + obtain_images: bool = True, ) -> Optional[MediaInfo]: """ 根据主副标题识别媒体信息 """ + mediainfo = self._recognize_with_fallback_by_meta( + metainfo=metainfo, + episode_group=episode_group, + obtain_images=obtain_images, + ) + if not mediainfo: + logger.warn(f"{metainfo.title} 未识别到媒体信息") + return mediainfo + + def _recognize_with_fallback_by_meta( + self, + metainfo: MetaBase, + episode_group: Optional[str] = None, + obtain_images: bool = False, + ) -> Optional[MediaInfo]: + """ + 根据标题识别媒体信息,必要时回退到辅助识别。 + """ + if not metainfo: + return None title = metainfo.title + share_meta = deepcopy(metainfo) + + def native_recognize() -> Optional[MediaInfo]: + return self.recognize_media( + meta=metainfo, + share_meta=share_meta, + episode_group=episode_group, + ) + + def plugin_recognize() -> Optional[MediaInfo]: + return self.recognize_help( + title=title, + org_meta=metainfo, + share_meta=share_meta, + episode_group=episode_group, + ) + # 按 config 中设置的识别顺序识别 mediainfo = self.select_recognize_source( log_name=title, log_context=title, - native_fn=lambda: self.recognize_media( - meta=metainfo, episode_group=episode_group - ), - plugin_fn=lambda: self.recognize_help(title=title, org_meta=metainfo), + native_fn=native_recognize, + plugin_fn=plugin_recognize, ) if not mediainfo: - logger.warn(f"{title} 未识别到媒体信息") return None # 识别成功 logger.info( f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}" ) - # 更新媒体图片 - self.obtain_images(mediainfo=mediainfo) - # 返回上下文 + if obtain_images: + self.obtain_images(mediainfo=mediainfo) return mediainfo - def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]: + def recognize_help( + self, + title: str, + org_meta: MetaBase, + share_meta: MetaBase = None, + episode_group: Optional[str] = None, + ) -> Optional[MediaInfo]: """ 请求辅助识别,返回媒体信息 :param title: 标题 :param org_meta: 原始元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 + :param episode_group: 剧集组 """ # 发送请求事件,等待结果 result: Event = eventmanager.send_event( @@ -521,10 +566,17 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if org_meta.begin_season is not None or org_meta.begin_episode is not None: org_meta.type = MediaType.TV # 重新识别 - return self.recognize_media(meta=org_meta) + return self.recognize_media( + meta=org_meta, + share_meta=share_meta, + episode_group=episode_group, + ) def recognize_by_path( - self, path: str, episode_group: Optional[str] = None + self, + path: str, + episode_group: Optional[str] = None, + obtain_images: bool = True, ) -> Optional[Context]: """ 根据文件路径识别媒体信息 @@ -533,23 +585,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_path = Path(path) # 元数据 file_meta = MetaInfoPath(file_path) - # 按 config 中设置的识别顺序识别 - mediainfo = self.select_recognize_source( - log_name=file_path.name, - log_context=path, - native_fn=lambda: self.recognize_media( - meta=file_meta, episode_group=episode_group - ), - plugin_fn=lambda: self.recognize_help(title=path, org_meta=file_meta), + mediainfo = self._recognize_with_fallback_by_meta( + metainfo=file_meta, + episode_group=episode_group, + obtain_images=obtain_images, ) if not mediainfo: logger.warn(f"{path} 未识别到媒体信息") return Context(meta_info=file_meta) - logger.info( - f"{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}" - ) - # 更新媒体图片 - self.obtain_images(mediainfo=mediainfo) # 返回上下文 return Context(meta_info=file_meta, media_info=mediainfo) @@ -1340,21 +1383,51 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return mediainfo async def async_recognize_by_meta( - self, metainfo: MetaBase, episode_group: Optional[str] = None + self, + metainfo: MetaBase, + episode_group: Optional[str] = None, + obtain_images: bool = True, ) -> Optional[MediaInfo]: """ 根据主副标题识别媒体信息(异步版本) """ - title = metainfo.title + mediainfo = await self._async_recognize_with_fallback_by_meta( + metainfo=metainfo, + episode_group=episode_group, + obtain_images=obtain_images, + ) + if not mediainfo: + logger.warn(f"{metainfo.title} 未识别到媒体信息") + return mediainfo + + async def _async_recognize_with_fallback_by_meta( + self, + metainfo: MetaBase, + episode_group: Optional[str] = None, + obtain_images: bool = False, + ) -> Optional[MediaInfo]: + """ + 异步根据标题识别媒体信息,必要时回退到辅助识别。 + """ + if not metainfo: + return None + title = metainfo.title + share_meta = deepcopy(metainfo) - # 定义识别函数 async def native_recognize(): return await self.async_recognize_media( - meta=metainfo, episode_group=episode_group + meta=metainfo, + share_meta=share_meta, + episode_group=episode_group, ) async def plugin_recognize(): - return await self.async_recognize_help(title=title, org_meta=metainfo) + return await self.async_recognize_help( + title=title, + org_meta=metainfo, + share_meta=share_meta, + episode_group=episode_group, + ) # 按 config 中设置的识别顺序识别 mediainfo = await self.async_select_recognize_source( @@ -1364,25 +1437,28 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): plugin_fn=plugin_recognize, ) if not mediainfo: - logger.warn(f"{title} 未识别到媒体信息") return None - # 识别成功 logger.info( f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}" ) - # 更新媒体图片 - await self.async_obtain_images(mediainfo=mediainfo) - # 返回上下文 + if obtain_images: + await self.async_obtain_images(mediainfo=mediainfo) return mediainfo async def async_recognize_help( - self, title: str, org_meta: MetaBase + self, + title: str, + org_meta: MetaBase, + share_meta: MetaBase = None, + episode_group: Optional[str] = None, ) -> Optional[MediaInfo]: """ 请求辅助识别,返回媒体信息(异步版本) :param title: 标题 :param org_meta: 原始元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 + :param episode_group: 剧集组 """ # 发送请求事件,等待结果 result: Event = await eventmanager.async_send_event( @@ -1424,10 +1500,17 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if org_meta.begin_season or org_meta.begin_episode: org_meta.type = MediaType.TV # 重新识别 - return await self.async_recognize_media(meta=org_meta) + return await self.async_recognize_media( + meta=org_meta, + share_meta=share_meta, + episode_group=episode_group, + ) async def async_recognize_by_path( - self, path: str, episode_group: Optional[str] = None + self, + path: str, + episode_group: Optional[str] = None, + obtain_images: bool = True, ) -> Optional[Context]: """ 根据文件路径识别媒体信息(异步版本) @@ -1436,31 +1519,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_path = Path(path) # 元数据 file_meta = MetaInfoPath(file_path) - - # 定义识别函数 - async def native_recognize(): - return await self.async_recognize_media( - meta=file_meta, episode_group=episode_group - ) - - async def plugin_recognize(): - return await self.async_recognize_help(title=path, org_meta=file_meta) - - # 按 config 中设置的识别顺序识别 - mediainfo = await self.async_select_recognize_source( - log_name=file_path.name, - log_context=path, - native_fn=native_recognize, - plugin_fn=plugin_recognize, + mediainfo = await self._async_recognize_with_fallback_by_meta( + metainfo=file_meta, + episode_group=episode_group, + obtain_images=obtain_images, ) if not mediainfo: logger.warn(f"{path} 未识别到媒体信息") return Context(meta_info=file_meta) - logger.info( - f"{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}" - ) - # 更新媒体图片 - await self.async_obtain_images(mediainfo=mediainfo) # 返回上下文 return Context(meta_info=file_meta, media_info=mediainfo) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 99539d5b..b6adc801 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -194,7 +194,11 @@ class SubscribeChain(ChainBase): # 使用名称识别兜底 if not mediainfo: - mediainfo = self.recognize_media(meta=metainfo, episode_group=episode_group) + mediainfo = MediaChain().recognize_by_meta( + metainfo, + episode_group=episode_group, + obtain_images=False, + ) # 识别失败 if not mediainfo: @@ -371,7 +375,11 @@ class SubscribeChain(ChainBase): # 使用名称识别兜底 if not mediainfo: - mediainfo = await self.async_recognize_media(meta=metainfo, episode_group=episode_group) + mediainfo = await MediaChain().async_recognize_by_meta( + metainfo, + episode_group=episode_group, + obtain_images=False, + ) # 识别失败 if not mediainfo: @@ -827,7 +835,10 @@ class SubscribeChain(ChainBase): and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3: logger.debug( f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3') - re_mediainfo = self.recognize_media(meta=context.meta_info) + re_mediainfo = MediaChain().recognize_by_meta( + context.meta_info, + obtain_images=False, + ) if re_mediainfo: # 清理多余信息 re_mediainfo.clear() @@ -939,8 +950,11 @@ class SubscribeChain(ChainBase): # 更新元数据缓存 _context.meta_info = torrent_meta # 重新识别媒体信息 - torrent_mediainfo = self.recognize_media(meta=torrent_meta, - episode_group=subscribe.episode_group) + torrent_mediainfo = MediaChain().recognize_by_meta( + torrent_meta, + episode_group=subscribe.episode_group, + obtain_images=False, + ) if torrent_mediainfo: # 清理多余信息 torrent_mediainfo.clear() diff --git a/app/chain/torrents.py b/app/chain/torrents.py index 956d8a9e..756ab1f2 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -278,7 +278,10 @@ class TorrentsChain(ChainBase): and torrent.category == MediaType.TV.value: meta.type = MediaType.TV # 识别媒体信息 - mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta) + mediainfo: MediaInfo = MediaChain().recognize_by_meta( + meta, + obtain_images=False, + ) if not mediainfo: logger.warn(f'{torrent.title} 未识别到媒体信息') # 存储空的媒体信息 diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 16576c3b..ff76b272 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1289,7 +1289,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mediainfo.category = download_history.media_category else: # 识别媒体信息 - mediainfo = MediaChain().recognize_by_meta(task.meta) + mediainfo = MediaChain().recognize_by_meta( + task.meta, + obtain_images=False, + ) # 更新媒体图片 if mediainfo: @@ -2269,9 +2272,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) else: - mediainfo = MediaChain().recognize_by_path( - str(src_path), episode_group=history.episode_group + recognize_context = MediaChain().recognize_by_path( + str(src_path), + episode_group=history.episode_group, + obtain_images=False, ) + mediainfo = recognize_context.media_info if recognize_context else None if not mediainfo: return False, f"未识别到媒体信息,类型:{mtype.value},id:{mediaid}" # 重新执行整理 diff --git a/app/helper/recognize.py b/app/helper/recognize.py index e5053288..52e06b0a 100644 --- a/app/helper/recognize.py +++ b/app/helper/recognize.py @@ -114,12 +114,15 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): @classmethod def _build_query_params( - cls, meta: Optional[MetaBase], mtype: Optional[MediaType] = None + cls, + meta: Optional[MetaBase], + mtype: Optional[MediaType] = None, + keyword_meta: Optional[MetaBase] = None, ) -> Optional[dict]: """ 组装共享识别查询参数 """ - keyword = cls._extract_keyword(meta) + keyword = cls._extract_keyword(keyword_meta or meta) if not keyword: return None @@ -137,7 +140,10 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): @classmethod def _build_report_payload( - cls, meta: Optional[MetaBase], mediainfo: Optional[MediaInfo] + cls, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo], + keyword_meta: Optional[MetaBase] = None, ) -> Optional[dict]: """ 组装共享识别上报载荷 @@ -145,7 +151,7 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): if not meta or not mediainfo: return None - keyword = cls._extract_keyword(meta) + keyword = cls._extract_keyword(keyword_meta or meta) media_type = cls._extract_media_type(meta=meta, mediainfo=mediainfo) if not keyword or not media_type: return None @@ -197,7 +203,12 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): """ return bool(settings.MEDIA_RECOGNIZE_SHARE) - def query(self, meta: Optional[MetaBase], mtype: Optional[MediaType] = None) -> Optional[dict]: + def query( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType] = None, + keyword_meta: Optional[MetaBase] = None, + ) -> Optional[dict]: """ 查询共享识别结果 """ @@ -205,7 +216,11 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): return None api_url = self._build_api_url() - params = self._build_query_params(meta=meta, mtype=mtype) + params = self._build_query_params( + meta=meta, + mtype=mtype, + keyword_meta=keyword_meta, + ) if not api_url or not params: return None @@ -236,7 +251,10 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): return item async def async_query( - self, meta: Optional[MetaBase], mtype: Optional[MediaType] = None + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType] = None, + keyword_meta: Optional[MetaBase] = None, ) -> Optional[dict]: """ 异步查询共享识别结果 @@ -245,7 +263,11 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): return None api_url = self._build_api_url() - params = self._build_query_params(meta=meta, mtype=mtype) + params = self._build_query_params( + meta=meta, + mtype=mtype, + keyword_meta=keyword_meta, + ) if not api_url or not params: return None @@ -275,7 +297,12 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): logger.info(f"共享媒体识别命中:{params.get('keyword')} - {item}") return item - def report(self, meta: Optional[MetaBase], mediainfo: Optional[MediaInfo]) -> bool: + def report( + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo], + keyword_meta: Optional[MetaBase] = None, + ) -> bool: """ 上报共享识别结果 """ @@ -283,7 +310,11 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): return False api_url = self._build_api_url() - payload = self._build_report_payload(meta=meta, mediainfo=mediainfo) + payload = self._build_report_payload( + meta=meta, + mediainfo=mediainfo, + keyword_meta=keyword_meta, + ) if not api_url or not payload: return False @@ -309,7 +340,10 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): return result.get("code") == 0 async def async_report( - self, meta: Optional[MetaBase], mediainfo: Optional[MediaInfo] + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo], + keyword_meta: Optional[MetaBase] = None, ) -> bool: """ 异步上报共享识别结果 @@ -318,7 +352,11 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton): return False api_url = self._build_api_url() - payload = self._build_report_payload(meta=meta, mediainfo=mediainfo) + payload = self._build_report_payload( + meta=meta, + mediainfo=mediainfo, + keyword_meta=keyword_meta, + ) if not api_url or not payload: return False diff --git a/app/startup/agent_initializer.py b/app/startup/agent_initializer.py index 83d951e5..21c9e753 100644 --- a/app/startup/agent_initializer.py +++ b/app/startup/agent_initializer.py @@ -44,7 +44,7 @@ class AgentInitializer: logger.info("AI智能体管理器已关闭") except Exception as e: - logger.error(f"关闭AI智能体管理器时发生错误: {e}") + logger.debug(f"关闭AI智能体管理器时发生错误: {e}") # 全局AI智能体初始化器实例 diff --git a/app/workflow/actions/add_download.py b/app/workflow/actions/add_download.py index 5c4a58bd..c097c274 100644 --- a/app/workflow/actions/add_download.py +++ b/app/workflow/actions/add_download.py @@ -67,7 +67,10 @@ class AddDownloadAction(BaseAction): if not t.meta_info: t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description) if not t.media_info: - t.media_info = MediaChain().recognize_media(meta=t.meta_info) + t.media_info = MediaChain().recognize_by_meta( + t.meta_info, + obtain_images=False, + ) if not t.media_info: self._has_error = True logger.warning(f"{t.torrent_info.title} 未识别到媒体信息,无法下载") diff --git a/app/workflow/actions/fetch_rss.py b/app/workflow/actions/fetch_rss.py index 1f2336ff..bce88597 100644 --- a/app/workflow/actions/fetch_rss.py +++ b/app/workflow/actions/fetch_rss.py @@ -2,7 +2,8 @@ from typing import Optional from pydantic import Field -from app.workflow.actions import BaseAction, ActionChain +from app.workflow.actions import BaseAction +from app.chain.media import MediaChain from app.core.config import settings, global_vars from app.core.context import Context from app.core.metainfo import MetaInfo @@ -98,7 +99,10 @@ class FetchRssAction(BaseAction): meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description) mediainfo = None if params.match_media: - mediainfo = ActionChain().recognize_media(meta) + mediainfo = MediaChain().recognize_by_meta( + meta, + obtain_images=False, + ) if not mediainfo: logger.warning(f"{torrentinfo.title} 未识别到媒体信息") continue diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index e4d0806a..6ae78f73 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -72,7 +72,10 @@ class FetchTorrentsAction(BaseAction): continue # 识别媒体信息 if params.match_media: - torrent.media_info = searchchain.recognize_media(torrent.meta_info) + torrent.media_info = searchchain.recognize_by_meta( + torrent.meta_info, + obtain_images=False, + ) if not torrent.media_info: logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息") continue diff --git a/app/workflow/actions/scrape_file.py b/app/workflow/actions/scrape_file.py index d06cddd4..6a2c1dc1 100644 --- a/app/workflow/actions/scrape_file.py +++ b/app/workflow/actions/scrape_file.py @@ -65,7 +65,10 @@ class ScrapeFileAction(BaseAction): continue meta = MetaInfoPath(Path(fileitem.path)) mediachain = MediaChain() - mediainfo = mediachain.recognize_media(meta) + mediainfo = mediachain.recognize_by_meta( + meta, + obtain_images=False, + ) if not mediainfo: _failed_count += 1 logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削") diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index a490ba98..9010235d 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -13,6 +13,8 @@ sys.modules.setdefault("psutil", ModuleType("psutil")) from app.chain import ChainBase from app.core.context import MediaInfo from app.core.meta import MetaBase +from app.core.metainfo import MetaInfo +from app.chain.media import MediaChain from app.helper.recognize import MediaRecognizeShareHelper from app.schemas.types import MediaType @@ -21,6 +23,7 @@ class TestMediaRecognizeShare(unittest.TestCase): @classmethod def setUpClass(cls): cls.chain = ChainBase() + cls.media_chain = MediaChain() @staticmethod def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase: @@ -204,6 +207,114 @@ class TestMediaRecognizeShare(unittest.TestCase): self.assertEqual(query_params["keyword"], "未应用识别词的名称") self.assertEqual(report_payload["keyword"], "未应用识别词的名称") + def test_query_and_report_can_use_distinct_keyword_meta(self): + """ + 共享识别应允许用原始关键字上报,同时保留辅助识别后的年份/季信息。 + """ + helper = MediaRecognizeShareHelper() + meta = self._build_meta("辅助识别后的名称", MediaType.TV) + meta.year = "2024" + meta.begin_season = 2 + + keyword_meta = self._build_meta("辅助识别前的名称", MediaType.UNKNOWN) + keyword_meta.original_name = "辅助识别前的名称" + + mediainfo = MediaInfo( + title="测试剧集", + year="2024", + tmdb_id=401, + type=MediaType.TV, + season=2, + ) + + query_params = helper._build_query_params( + meta=meta, + mtype=None, + keyword_meta=keyword_meta, + ) + report_payload = helper._build_report_payload( + meta=meta, + mediainfo=mediainfo, + keyword_meta=keyword_meta, + ) + + self.assertEqual(query_params["keyword"], "辅助识别前的名称") + self.assertEqual(query_params["year"], "2024") + self.assertEqual(query_params["season"], 2) + self.assertEqual(report_payload["keyword"], "辅助识别前的名称") + self.assertEqual(report_payload["year"], "2024") + self.assertEqual(report_payload["season"], 2) + + def test_report_shared_result_with_distinct_keyword_meta(self): + """ + 辅助识别成功后应按辅助前名称上报共享结果。 + """ + meta = self._build_meta("辅助识别后的名称", MediaType.TV) + meta.year = "2024" + meta.begin_season = 1 + share_meta = self._build_meta("辅助识别前的名称", MediaType.UNKNOWN) + share_meta.original_name = "辅助识别前的名称" + mediainfo = MediaInfo(title="测试剧集", year="2024", tmdb_id=402, type=MediaType.TV) + + with patch.object(self.chain, "run_module", return_value=mediainfo), patch( + "app.chain.MediaRecognizeShareHelper.report", + return_value=True, + ) as report_mock: + result = self.chain.recognize_media(meta=meta, share_meta=share_meta, cache=False) + + self.assertIs(result, mediainfo) + report_mock.assert_called_once_with( + meta=meta, + mediainfo=mediainfo, + keyword_meta=share_meta, + ) + + def test_query_shared_result_with_distinct_keyword_meta(self): + """ + 本地识别失败后应按辅助前名称回查共享结果。 + """ + meta = self._build_meta("辅助识别后的名称", MediaType.TV) + meta.year = "2024" + share_meta = self._build_meta("辅助识别前的名称", MediaType.UNKNOWN) + share_meta.original_name = "辅助识别前的名称" + shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=403, type=MediaType.TV) + + with patch.object( + self.chain, + "run_module", + side_effect=[None, shared_media], + ), patch( + "app.chain.MediaRecognizeShareHelper.query", + return_value={"type": "tv", "tmdbid": 403, "season": 1}, + ) as query_mock, patch( + "app.chain.MediaRecognizeShareHelper.to_recognize_params", + return_value={ + "mtype": MediaType.TV, + "tmdbid": 403, + "doubanid": None, + "bangumiid": None, + "season": 1, + }, + ), patch( + "app.chain.MediaRecognizeShareHelper.report", + return_value=False, + ), patch.object( + self.chain, + "_update_local_recognize_cache", + ): + result = self.chain.recognize_media( + meta=meta, + share_meta=share_meta, + cache=False, + ) + + self.assertIs(result, shared_media) + query_mock.assert_called_once_with( + meta=meta, + mtype=None, + keyword_meta=share_meta, + ) + def test_skip_report_when_local_recognize_hits_cache(self): """ 本地识别命中缓存时不应上报共享识别 @@ -255,6 +366,80 @@ class TestMediaRecognizeShare(unittest.TestCase): report_mock.assert_not_awaited() query_mock.assert_not_awaited() + def test_recognize_by_meta_can_skip_obtain_images(self): + """ + 标题识别可显式关闭图片拉取。 + """ + meta = MetaInfo("测试电影") + mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=404, type=MediaType.MOVIE) + + with patch.object( + self.media_chain, + "recognize_media", + return_value=mediainfo, + ) as recognize_mock, patch.object( + self.media_chain, + "obtain_images", + ) as obtain_images_mock: + result = self.media_chain.recognize_by_meta(meta, obtain_images=False) + + self.assertIs(result, mediainfo) + recognize_mock.assert_called_once() + obtain_images_mock.assert_not_called() + + def test_recognize_by_meta_reports_with_original_keyword_after_plugin_help(self): + """ + 辅助识别后应继续使用辅助前关键字进行共享上报。 + """ + meta = MetaInfo("辅助前名称") + plugin_media = MediaInfo(title="辅助后名称", year="2024", tmdb_id=405, type=MediaType.TV) + + with patch.object( + self.media_chain, + "select_recognize_source", + side_effect=lambda **kwargs: kwargs["plugin_fn"](), + ), patch.object( + self.media_chain, + "recognize_help", + return_value=plugin_media, + ) as recognize_help_mock, patch.object( + self.media_chain, + "obtain_images", + ): + result = self.media_chain.recognize_by_meta(meta, obtain_images=False) + + self.assertIs(result, plugin_media) + self.assertEqual(recognize_help_mock.call_args.kwargs["share_meta"].name, "辅助前名称") + + def test_async_recognize_by_meta_can_skip_obtain_images(self): + """ + 异步标题识别可显式关闭图片拉取。 + """ + meta = MetaInfo("测试异步电影") + mediainfo = MediaInfo(title="测试异步电影", year="2025", tmdb_id=406, type=MediaType.MOVIE) + + async def runner(): + with patch.object( + self.media_chain, + "async_recognize_media", + AsyncMock(return_value=mediainfo), + ) as recognize_mock, patch.object( + self.media_chain, + "async_obtain_images", + AsyncMock(), + ) as obtain_images_mock: + result = await self.media_chain.async_recognize_by_meta( + meta, + obtain_images=False, + ) + return result, recognize_mock, obtain_images_mock + + result, recognize_mock, obtain_images_mock = asyncio.run(runner()) + + self.assertIs(result, mediainfo) + recognize_mock.assert_awaited_once() + obtain_images_mock.assert_not_awaited() + if __name__ == "__main__": unittest.main()