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.
This commit is contained in:
jxxghp
2026-05-10 07:54:55 +08:00
parent ee9ea54ab7
commit 1d97f2e043
21 changed files with 505 additions and 111 deletions

View File

@@ -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

View File

@@ -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, "种子")

View File

@@ -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="无法识别媒体信息")
# 种子信息

View File

@@ -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)

View File

@@ -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,

View File

@@ -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} 未识别到媒体信息")

View File

@@ -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="未识别到媒体信息!")

View File

@@ -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)

View File

@@ -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
# 查询是否存在

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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} 未识别到媒体信息')
# 存储空的媒体信息

View File

@@ -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}"
# 重新执行整理

View File

@@ -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

View File

@@ -44,7 +44,7 @@ class AgentInitializer:
logger.info("AI智能体管理器已关闭")
except Exception as e:
logger.error(f"关闭AI智能体管理器时发生错误: {e}")
logger.debug(f"关闭AI智能体管理器时发生错误: {e}")
# 全局AI智能体初始化器实例

View File

@@ -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} 未识别到媒体信息,无法下载")

View File

@@ -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

View File

@@ -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

View File

@@ -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} 未识别到媒体信息,无法刮削")

View File

@@ -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()