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 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.search import SearchChain
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.core.config import settings from app.core.config import settings
@@ -275,7 +276,10 @@ class AddDownloadTool(MoviePilotTool):
meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description) meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)
media_info = cached_context.media_info if cached_context.media_info else None media_info = cached_context.media_info if cached_context.media_info else None
if not media_info: 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: if not media_info:
failed_messages.append(f"{torrent_input} 无法识别媒体信息") failed_messages.append(f"{torrent_input} 无法识别媒体信息")
continue continue

View File

@@ -60,7 +60,10 @@ class RecognizeMediaTool(MoviePilotTool):
"message": "文件路径不能为空" "message": "文件路径不能为空"
}, ensure_ascii=False) }, 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: if context:
return self._format_context_result(context, "文件") return self._format_context_result(context, "文件")
else: else:
@@ -73,7 +76,10 @@ class RecognizeMediaTool(MoviePilotTool):
elif title: elif title:
# 种子标题识别 # 种子标题识别
metainfo = MetaInfo(title, subtitle) 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: if mediainfo:
context = Context(meta_info=metainfo, media_info=mediainfo) context = Context(meta_info=metainfo, media_info=mediainfo)
return self._format_context_result(context, "种子") return self._format_context_result(context, "种子")

View File

@@ -76,12 +76,17 @@ def add(
# 元数据 # 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息 # 媒体信息
mediainfo = MediaChain().select_recognize_source( if tmdbid or doubanid:
log_name=torrent_in.title, mediainfo = MediaChain().recognize_media(
log_context=torrent_in.title, meta=metainfo,
native_fn=lambda: MediaChain().recognize_media(meta=metainfo, tmdbid=tmdbid, doubanid=doubanid), tmdbid=tmdbid,
plugin_fn=lambda: MediaChain().recognize_help(title=torrent_in.title, org_meta=metainfo) doubanid=doubanid,
) )
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
return schemas.Response(success=False, message="无法识别媒体信息") return schemas.Response(success=False, message="无法识别媒体信息")
# 种子信息 # 种子信息

View File

@@ -202,7 +202,11 @@ async def seasons(mediaid: Optional[str] = None,
meta = MetaInfo(title) meta = MetaInfo(title)
if year: if year:
meta.year = 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 mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb": if settings.RECOGNIZE_SOURCE == "themoviedb":
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id) 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 meta.year = year
if mtype: if mtype:
meta.type = 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: if mediainfo:
await mediachain.async_obtain_images(mediainfo) await mediachain.async_obtain_images(mediainfo)

View File

@@ -255,7 +255,10 @@ async def search_by_id_stream(request: Request,
if media_season: if media_season:
meta.type = MediaType.TV meta.type = MediaType.TV
meta.begin_season = media_season 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 mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb": if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = search_chain.async_search_by_id_stream(tmdbid=mediainfo.tmdb_id, 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: if media_season:
meta.type = MediaType.TV meta.type = MediaType.TV
meta.begin_season = media_season 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 mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb": if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type, 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 starlette.responses import FileResponse, Response
from app import schemas from app import schemas
from app.chain.media import MediaChain
from app.chain.storage import StorageChain from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain from app.chain.transfer import TransferChain
from app.core.config import settings from app.core.config import settings
@@ -199,7 +200,10 @@ def rename(fileitem: schemas.FileItem,
continue continue
sub_path = Path(f"{fileitem.path}{sub_file.name}") sub_path = Path(f"{fileitem.path}{sub_file.name}")
meta = MetaInfoPath(sub_path) meta = MetaInfoPath(sub_path)
mediainfo = transferchain.recognize_media(meta) mediainfo = MediaChain().recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
progress.end() progress.end()
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") 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 fastapi.responses import StreamingResponse
from app import schemas from app import schemas
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain from app.chain.mediaserver import MediaServerChain
from app.chain.search import SearchChain from app.chain.search import SearchChain
from app.chain.system import SystemChain 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: if not media_info:
return schemas.Response(success=False, message="未识别到媒体信息!") return schemas.Response(success=False, message="未识别到媒体信息!")

View File

@@ -31,7 +31,10 @@ def query_name(path: str, filetype: str,
:param _: Token校验 :param _: Token校验
""" """
meta = MetaInfoPath(Path(path)) meta = MetaInfoPath(Path(path))
mediainfo = MediaChain().recognize_media(meta) mediainfo = MediaChain().recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
return schemas.Response(success=False, message="未识别到媒体信息") return schemas.Response(success=False, message="未识别到媒体信息")
new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo) 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)) seas = list(range(1, int(sea_num) + 1))
# 根据TVDB查询媒体信息 # 根据TVDB查询媒体信息
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')), meta = MetaInfo(tvdbinfo.get('name'))
mtype=MediaType.TV) meta.type = MediaType.TV
mediainfo = MediaChain().recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
continue continue
# 查询是否存在 # 查询是否存在

View File

@@ -465,10 +465,12 @@ class ChainBase(metaclass=ABCMeta):
bangumiid: Optional[int] = None, bangumiid: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
cache: bool = True, cache: bool = True,
share_meta: MetaBase = None,
) -> Optional[MediaInfo]: ) -> Optional[MediaInfo]:
""" """
识别媒体信息不含Fanart图片 识别媒体信息不含Fanart图片
:param meta: 识别的元数据 :param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型与tmdbid配套 :param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid :param tmdbid: tmdbid
:param doubanid: 豆瓣ID :param doubanid: 豆瓣ID
@@ -488,6 +490,7 @@ class ChainBase(metaclass=ABCMeta):
bangumiid = None bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type mtype = meta.type
share_query_meta = share_meta or meta
share_helper = MediaRecognizeShareHelper() share_helper = MediaRecognizeShareHelper()
with fresh(not cache): with fresh(not cache):
mediainfo = self.run_module( mediainfo = self.run_module(
@@ -502,12 +505,22 @@ class ChainBase(metaclass=ABCMeta):
) )
if mediainfo: if mediainfo:
if not mediainfo.recognize_cache_hit: 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 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_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) shared_params = share_helper.to_recognize_params(shared_item)
if shared_params: if shared_params:
with fresh(not cache): with fresh(not cache):
@@ -535,10 +548,12 @@ class ChainBase(metaclass=ABCMeta):
bangumiid: Optional[int] = None, bangumiid: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
cache: bool = True, cache: bool = True,
share_meta: MetaBase = None,
) -> Optional[MediaInfo]: ) -> Optional[MediaInfo]:
""" """
识别媒体信息不含Fanart图片异步版本 识别媒体信息不含Fanart图片异步版本
:param meta: 识别的元数据 :param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型与tmdbid配套 :param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid :param tmdbid: tmdbid
:param doubanid: 豆瓣ID :param doubanid: 豆瓣ID
@@ -558,6 +573,7 @@ class ChainBase(metaclass=ABCMeta):
bangumiid = None bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type mtype = meta.type
share_query_meta = share_meta or meta
share_helper = MediaRecognizeShareHelper() share_helper = MediaRecognizeShareHelper()
async with async_fresh(not cache): async with async_fresh(not cache):
mediainfo = await self.async_run_module( mediainfo = await self.async_run_module(
@@ -572,12 +588,22 @@ class ChainBase(metaclass=ABCMeta):
) )
if mediainfo: if mediainfo:
if not mediainfo.recognize_cache_hit: 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 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_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) shared_params = share_helper.to_recognize_params(shared_item)
if shared_params: if shared_params:
async with async_fresh(not cache): async with async_fresh(not cache):

View File

@@ -1,4 +1,5 @@
import os import os
from copy import deepcopy
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from threading import Lock from threading import Lock
@@ -447,39 +448,83 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return mediainfo return mediainfo
def recognize_by_meta( 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]: ) -> 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 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 中设置的识别顺序识别 # 按 config 中设置的识别顺序识别
mediainfo = self.select_recognize_source( mediainfo = self.select_recognize_source(
log_name=title, log_name=title,
log_context=title, log_context=title,
native_fn=lambda: self.recognize_media( native_fn=native_recognize,
meta=metainfo, episode_group=episode_group plugin_fn=plugin_recognize,
),
plugin_fn=lambda: self.recognize_help(title=title, org_meta=metainfo),
) )
if not mediainfo: if not mediainfo:
logger.warn(f"{title} 未识别到媒体信息")
return None return None
# 识别成功 # 识别成功
logger.info( logger.info(
f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}" f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}"
) )
# 更新媒体图片 if obtain_images:
self.obtain_images(mediainfo=mediainfo) self.obtain_images(mediainfo=mediainfo)
# 返回上下文
return 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 title: 标题
:param org_meta: 原始元数据 :param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param episode_group: 剧集组
""" """
# 发送请求事件,等待结果 # 发送请求事件,等待结果
result: Event = eventmanager.send_event( 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: if org_meta.begin_season is not None or org_meta.begin_episode is not None:
org_meta.type = MediaType.TV 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( 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]: ) -> Optional[Context]:
""" """
根据文件路径识别媒体信息 根据文件路径识别媒体信息
@@ -533,23 +585,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_path = Path(path) file_path = Path(path)
# 元数据 # 元数据
file_meta = MetaInfoPath(file_path) file_meta = MetaInfoPath(file_path)
# 按 config 中设置的识别顺序识别 mediainfo = self._recognize_with_fallback_by_meta(
mediainfo = self.select_recognize_source( metainfo=file_meta,
log_name=file_path.name, episode_group=episode_group,
log_context=path, obtain_images=obtain_images,
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),
) )
if not mediainfo: if not mediainfo:
logger.warn(f"{path} 未识别到媒体信息") logger.warn(f"{path} 未识别到媒体信息")
return Context(meta_info=file_meta) 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) return Context(meta_info=file_meta, media_info=mediainfo)
@@ -1340,21 +1383,51 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return mediainfo return mediainfo
async def async_recognize_by_meta( 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]: ) -> 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(): async def native_recognize():
return await self.async_recognize_media( 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(): 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 中设置的识别顺序识别 # 按 config 中设置的识别顺序识别
mediainfo = await self.async_select_recognize_source( mediainfo = await self.async_select_recognize_source(
@@ -1364,25 +1437,28 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
plugin_fn=plugin_recognize, plugin_fn=plugin_recognize,
) )
if not mediainfo: if not mediainfo:
logger.warn(f"{title} 未识别到媒体信息")
return None return None
# 识别成功
logger.info( logger.info(
f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}" f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}"
) )
# 更新媒体图片 if obtain_images:
await self.async_obtain_images(mediainfo=mediainfo) await self.async_obtain_images(mediainfo=mediainfo)
# 返回上下文
return mediainfo return mediainfo
async def async_recognize_help( 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]: ) -> Optional[MediaInfo]:
""" """
请求辅助识别,返回媒体信息(异步版本) 请求辅助识别,返回媒体信息(异步版本)
:param title: 标题 :param title: 标题
:param org_meta: 原始元数据 :param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param episode_group: 剧集组
""" """
# 发送请求事件,等待结果 # 发送请求事件,等待结果
result: Event = await eventmanager.async_send_event( 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: if org_meta.begin_season or org_meta.begin_episode:
org_meta.type = MediaType.TV 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( 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]: ) -> Optional[Context]:
""" """
根据文件路径识别媒体信息(异步版本) 根据文件路径识别媒体信息(异步版本)
@@ -1436,31 +1519,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_path = Path(path) file_path = Path(path)
# 元数据 # 元数据
file_meta = MetaInfoPath(file_path) file_meta = MetaInfoPath(file_path)
mediainfo = await self._async_recognize_with_fallback_by_meta(
# 定义识别函数 metainfo=file_meta,
async def native_recognize(): episode_group=episode_group,
return await self.async_recognize_media( obtain_images=obtain_images,
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,
) )
if not mediainfo: if not mediainfo:
logger.warn(f"{path} 未识别到媒体信息") logger.warn(f"{path} 未识别到媒体信息")
return Context(meta_info=file_meta) 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) return Context(meta_info=file_meta, media_info=mediainfo)

View File

@@ -194,7 +194,11 @@ class SubscribeChain(ChainBase):
# 使用名称识别兜底 # 使用名称识别兜底
if not mediainfo: 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: if not mediainfo:
@@ -371,7 +375,11 @@ class SubscribeChain(ChainBase):
# 使用名称识别兜底 # 使用名称识别兜底
if not mediainfo: 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: if not mediainfo:
@@ -827,7 +835,10 @@ class SubscribeChain(ChainBase):
and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3: and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3:
logger.debug( logger.debug(
f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3') 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: if re_mediainfo:
# 清理多余信息 # 清理多余信息
re_mediainfo.clear() re_mediainfo.clear()
@@ -939,8 +950,11 @@ class SubscribeChain(ChainBase):
# 更新元数据缓存 # 更新元数据缓存
_context.meta_info = torrent_meta _context.meta_info = torrent_meta
# 重新识别媒体信息 # 重新识别媒体信息
torrent_mediainfo = self.recognize_media(meta=torrent_meta, torrent_mediainfo = MediaChain().recognize_by_meta(
episode_group=subscribe.episode_group) torrent_meta,
episode_group=subscribe.episode_group,
obtain_images=False,
)
if torrent_mediainfo: if torrent_mediainfo:
# 清理多余信息 # 清理多余信息
torrent_mediainfo.clear() torrent_mediainfo.clear()

View File

@@ -278,7 +278,10 @@ class TorrentsChain(ChainBase):
and torrent.category == MediaType.TV.value: and torrent.category == MediaType.TV.value:
meta.type = MediaType.TV meta.type = MediaType.TV
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta) mediainfo: MediaInfo = MediaChain().recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
logger.warn(f'{torrent.title} 未识别到媒体信息') logger.warn(f'{torrent.title} 未识别到媒体信息')
# 存储空的媒体信息 # 存储空的媒体信息

View File

@@ -1289,7 +1289,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mediainfo.category = download_history.media_category mediainfo.category = download_history.media_category
else: else:
# 识别媒体信息 # 识别媒体信息
mediainfo = MediaChain().recognize_by_meta(task.meta) mediainfo = MediaChain().recognize_by_meta(
task.meta,
obtain_images=False,
)
# 更新媒体图片 # 更新媒体图片
if mediainfo: if mediainfo:
@@ -2269,9 +2272,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 更新媒体图片 # 更新媒体图片
self.obtain_images(mediainfo=mediainfo) self.obtain_images(mediainfo=mediainfo)
else: else:
mediainfo = MediaChain().recognize_by_path( recognize_context = MediaChain().recognize_by_path(
str(src_path), episode_group=history.episode_group str(src_path),
episode_group=history.episode_group,
obtain_images=False,
) )
mediainfo = recognize_context.media_info if recognize_context else None
if not mediainfo: if not mediainfo:
return False, f"未识别到媒体信息,类型:{mtype.value}id{mediaid}" return False, f"未识别到媒体信息,类型:{mtype.value}id{mediaid}"
# 重新执行整理 # 重新执行整理

View File

@@ -114,12 +114,15 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
@classmethod @classmethod
def _build_query_params( 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]: ) -> Optional[dict]:
""" """
组装共享识别查询参数 组装共享识别查询参数
""" """
keyword = cls._extract_keyword(meta) keyword = cls._extract_keyword(keyword_meta or meta)
if not keyword: if not keyword:
return None return None
@@ -137,7 +140,10 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
@classmethod @classmethod
def _build_report_payload( 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]: ) -> Optional[dict]:
""" """
组装共享识别上报载荷 组装共享识别上报载荷
@@ -145,7 +151,7 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
if not meta or not mediainfo: if not meta or not mediainfo:
return None 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) media_type = cls._extract_media_type(meta=meta, mediainfo=mediainfo)
if not keyword or not media_type: if not keyword or not media_type:
return None return None
@@ -197,7 +203,12 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
""" """
return bool(settings.MEDIA_RECOGNIZE_SHARE) 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 return None
api_url = self._build_api_url() 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: if not api_url or not params:
return None return None
@@ -236,7 +251,10 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
return item return item
async def async_query( 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]: ) -> Optional[dict]:
""" """
异步查询共享识别结果 异步查询共享识别结果
@@ -245,7 +263,11 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
return None return None
api_url = self._build_api_url() 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: if not api_url or not params:
return None return None
@@ -275,7 +297,12 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
logger.info(f"共享媒体识别命中:{params.get('keyword')} - {item}") logger.info(f"共享媒体识别命中:{params.get('keyword')} - {item}")
return 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 return False
api_url = self._build_api_url() 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: if not api_url or not payload:
return False return False
@@ -309,7 +340,10 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
return result.get("code") == 0 return result.get("code") == 0
async def async_report( async def async_report(
self, meta: Optional[MetaBase], mediainfo: Optional[MediaInfo] self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
keyword_meta: Optional[MetaBase] = None,
) -> bool: ) -> bool:
""" """
异步上报共享识别结果 异步上报共享识别结果
@@ -318,7 +352,11 @@ class MediaRecognizeShareHelper(metaclass=WeakSingleton):
return False return False
api_url = self._build_api_url() 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: if not api_url or not payload:
return False return False

View File

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

View File

@@ -67,7 +67,10 @@ class AddDownloadAction(BaseAction):
if not t.meta_info: if not t.meta_info:
t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description) t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description)
if not t.media_info: 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: if not t.media_info:
self._has_error = True self._has_error = True
logger.warning(f"{t.torrent_info.title} 未识别到媒体信息,无法下载") logger.warning(f"{t.torrent_info.title} 未识别到媒体信息,无法下载")

View File

@@ -2,7 +2,8 @@ from typing import Optional
from pydantic import Field 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.config import settings, global_vars
from app.core.context import Context from app.core.context import Context
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
@@ -98,7 +99,10 @@ class FetchRssAction(BaseAction):
meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description) meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description)
mediainfo = None mediainfo = None
if params.match_media: if params.match_media:
mediainfo = ActionChain().recognize_media(meta) mediainfo = MediaChain().recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
logger.warning(f"{torrentinfo.title} 未识别到媒体信息") logger.warning(f"{torrentinfo.title} 未识别到媒体信息")
continue continue

View File

@@ -72,7 +72,10 @@ class FetchTorrentsAction(BaseAction):
continue continue
# 识别媒体信息 # 识别媒体信息
if params.match_media: 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: if not torrent.media_info:
logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息") logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息")
continue continue

View File

@@ -65,7 +65,10 @@ class ScrapeFileAction(BaseAction):
continue continue
meta = MetaInfoPath(Path(fileitem.path)) meta = MetaInfoPath(Path(fileitem.path))
mediachain = MediaChain() mediachain = MediaChain()
mediainfo = mediachain.recognize_media(meta) mediainfo = mediachain.recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo: if not mediainfo:
_failed_count += 1 _failed_count += 1
logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削") logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削")

View File

@@ -13,6 +13,8 @@ sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain import ChainBase from app.chain import ChainBase
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.meta import MetaBase 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.helper.recognize import MediaRecognizeShareHelper
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -21,6 +23,7 @@ class TestMediaRecognizeShare(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.chain = ChainBase() cls.chain = ChainBase()
cls.media_chain = MediaChain()
@staticmethod @staticmethod
def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase: 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(query_params["keyword"], "未应用识别词的名称")
self.assertEqual(report_payload["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): def test_skip_report_when_local_recognize_hits_cache(self):
""" """
本地识别命中缓存时不应上报共享识别 本地识别命中缓存时不应上报共享识别
@@ -255,6 +366,80 @@ class TestMediaRecognizeShare(unittest.TestCase):
report_mock.assert_not_awaited() report_mock.assert_not_awaited()
query_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__": if __name__ == "__main__":
unittest.main() unittest.main()