feat: 统一音乐识别与影视共享上报,优化相关参数处理

This commit is contained in:
jxxghp
2026-08-09 13:23:26 +08:00
parent 391f7880f4
commit 29f1eb4313
4 changed files with 567 additions and 439 deletions

View File

@@ -14,7 +14,7 @@ from transmission_rpc import File
from app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh
from app.core.config import settings
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.event import EventManager
from app.core.meta import MetaBase
from app.core.module import ModuleManager
@@ -705,10 +705,8 @@ class ChainBase(metaclass=ABCMeta):
**module_kwargs,
)
if mediainfo:
# 音乐识别结果不参与影视共享上报
if isinstance(mediainfo, MusicInfo):
return mediainfo
if not mediainfo.recognize_cache_hit:
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
MoviePilotServerHelper.report_recognize_share(
meta=meta,
mediainfo=mediainfo,
@@ -716,7 +714,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share(
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
@@ -818,10 +816,8 @@ class ChainBase(metaclass=ABCMeta):
**module_kwargs,
)
if mediainfo:
# 音乐识别结果不参与影视共享上报
if isinstance(mediainfo, MusicInfo):
return mediainfo
if not mediainfo.recognize_cache_hit:
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
await MoviePilotServerHelper.async_report_recognize_share(
meta=meta,
mediainfo=mediainfo,
@@ -829,7 +825,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share(
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)

View File

@@ -2,12 +2,12 @@ import json
import platform
from pathlib import Path
from threading import Thread
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import parse_qs, quote, urlparse, urlsplit
from app.core.cache import cached
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaBase
from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
@@ -1366,11 +1366,11 @@ class MoviePilotServerHelper:
def report_recognize_share(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
上报共享识别结果。
上报共享识别结果,电影、电视剧、音乐共用
"""
if not settings.MEDIA_RECOGNIZE_SHARE:
return False
@@ -1388,11 +1388,11 @@ class MoviePilotServerHelper:
async def async_report_recognize_share(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
异步上报共享识别结果。
异步上报共享识别结果,电影、电视剧、音乐共用
"""
if not settings.MEDIA_RECOGNIZE_SHARE:
return False
@@ -1409,7 +1409,7 @@ class MoviePilotServerHelper:
@classmethod
def to_recognize_params(cls, item: Optional[dict]) -> Optional[dict]:
"""
将服务端返回的共享识别结果转成本地识别参数。
将服务端返回的共享识别结果转成本地识别参数,音乐仅携带数据源原生 ID
"""
if not isinstance(item, dict):
return None
@@ -1461,13 +1461,15 @@ class MoviePilotServerHelper:
统一媒体类型,兼容枚举、中文值和 agent 风格字符串。
"""
normalized = media_type_to_agent(media_type)
if normalized in {"movie", "tv"}:
if normalized in {"movie", "tv", "music"}:
return normalized
if isinstance(media_type, str):
if media_type == MediaType.MOVIE.value:
return "movie"
if media_type == MediaType.TV.value:
return "tv"
if media_type == MediaType.MUSIC.value:
return "music"
return None
@staticmethod
@@ -1495,9 +1497,10 @@ class MoviePilotServerHelper:
media_type = cls._normalize_media_type(mtype)
if media_type:
return media_type
if mediainfo and mediainfo.type in {MediaType.MOVIE, MediaType.TV}:
shareable_types = {MediaType.MOVIE, MediaType.TV, MediaType.MUSIC}
if mediainfo and mediainfo.type in shareable_types:
return mediainfo.type.to_agent()
if meta and meta.type in {MediaType.MOVIE, MediaType.TV}:
if meta and meta.type in shareable_types:
return meta.type.to_agent()
if meta and (meta.begin_season is not None or meta.begin_episode is not None):
return "tv"
@@ -1566,11 +1569,11 @@ class MoviePilotServerHelper:
def _build_recognize_report_payload(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> Optional[dict]:
"""
组装共享识别上报载荷。
组装共享识别上报载荷,电影、电视剧、音乐共用同一结构
"""
if not meta or not mediainfo:
return None

View File

@@ -1,461 +1,587 @@
import asyncio
import sys
import unittest
from types import ModuleType
from unittest.mock import AsyncMock, patch
"""共享媒体识别链路测试。
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list)
sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
setattr(sys.modules["transmission_rpc"], "File", object)
sys.modules.setdefault("psutil", ModuleType("psutil"))
覆盖本地识别成功后上报共享识别、本地识别失败后回查共享识别并二次识别、
共享识别成功后回填本地缓存、音乐识别上报/查询载荷,以及命中缓存不重复上报等场景。
"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
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.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.helper.server import MoviePilotServerHelper
from app.schemas.types import MediaType
class TestMediaRecognizeShare(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.chain = ChainBase()
cls.media_chain = MediaChain()
def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase:
"""构造测试用元数据。"""
meta = MetaBase(name)
meta.name = name
meta.type = media_type
return meta
@staticmethod
def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase:
"""
构造测试用元数据
"""
meta = MetaBase(name)
meta.name = name
meta.type = media_type
return meta
def test_report_shared_result_after_local_recognize_success(self):
"""
本地识别成功后应上报共享识别结果
"""
meta = self._build_meta("测试电影", MediaType.MOVIE)
mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=100, type=MediaType.MOVIE)
def test_report_shared_result_after_local_recognize_success():
"""本地识别成功后应上报共享识别结果。"""
chain = ChainBase()
meta = _build_meta("测试电影", MediaType.MOVIE)
mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=100, type=MediaType.MOVIE)
with patch.object(self.chain, "run_module", return_value=mediainfo) as run_module, patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
) as report_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share"
) as query_mock:
result = self.chain.recognize_media(meta=meta, cache=False)
with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
) as report_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share"
) as query_mock:
result = chain.recognize_media(meta=meta, cache=False)
self.assertIs(result, mediainfo)
run_module.assert_called_once()
report_mock.assert_called_once_with(meta=meta, mediainfo=mediainfo, keyword_meta=meta)
query_mock.assert_not_called()
assert result is mediainfo
run_module.assert_called_once()
report_mock.assert_called_once_with(meta=meta, mediainfo=mediainfo, keyword_meta=meta)
query_mock.assert_not_called()
def test_query_shared_result_when_local_recognize_failed(self):
"""
本地识别失败后应回查共享识别结果并按共享ID再次识别
"""
meta = self._build_meta("测试剧集")
shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=200, type=MediaType.TV)
def test_query_shared_result_when_local_recognize_failed():
"""本地识别失败后应回查共享识别结果并按共享ID再次识别。"""
chain = ChainBase()
meta = _build_meta("测试剧集")
shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=200, type=MediaType.TV)
with patch.object(
chain,
"run_module",
side_effect=[None, shared_media],
) as run_module, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "tv", "tmdbid": 200, "season": 1},
) as query_mock, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.TV,
"tmdbid": 200,
"doubanid": None,
"bangumiid": None,
"season": 1,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
), patch.object(
chain,
"_update_local_recognize_cache",
):
result = chain.recognize_media(meta=meta, cache=False)
assert result is shared_media
assert run_module.call_count == 2
query_mock.assert_called_once_with(meta=meta, mtype=None, keyword_meta=meta)
second_call = run_module.call_args_list[1]
assert second_call.kwargs["tmdbid"] == 200
assert second_call.kwargs["mtype"] == MediaType.TV
assert meta.begin_season is None
def test_async_query_shared_result_when_local_recognize_failed():
"""异步识别失败后也应回查共享识别结果。"""
chain = ChainBase()
meta = _build_meta("测试异步剧集")
shared_media = MediaInfo(title="测试异步剧集", year="2025", tmdb_id=300, type=MediaType.TV)
async_run_module = AsyncMock(side_effect=[None, shared_media])
async def runner():
with patch.object(
self.chain,
"run_module",
side_effect=[None, shared_media],
) as run_module, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "tv", "tmdbid": 200, "season": 1},
chain,
"async_run_module",
async_run_module,
), patch(
"app.chain.MoviePilotServerHelper.async_query_recognize_share",
AsyncMock(return_value={"type": "tv", "tmdbid": 300, "season": 2}),
) as query_mock, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.TV,
"tmdbid": 200,
"tmdbid": 300,
"doubanid": None,
"bangumiid": None,
"season": 1,
"season": 2,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
"app.chain.MoviePilotServerHelper.async_report_recognize_share",
AsyncMock(return_value=False),
), patch.object(
self.chain,
"_update_local_recognize_cache",
):
result = self.chain.recognize_media(meta=meta, cache=False)
chain,
"_async_update_local_recognize_cache",
AsyncMock(),
) as backfill_mock:
result = await chain.async_recognize_media(meta=meta, cache=False)
return result, query_mock, backfill_mock
self.assertIs(result, shared_media)
self.assertEqual(run_module.call_count, 2)
query_mock.assert_called_once_with(meta=meta, mtype=None, keyword_meta=meta)
second_call = run_module.call_args_list[1]
self.assertEqual(second_call.kwargs["tmdbid"], 200)
self.assertEqual(second_call.kwargs["mtype"], MediaType.TV)
self.assertIsNone(meta.begin_season)
result, query_mock, backfill_mock = asyncio.run(runner())
def test_async_query_shared_result_when_local_recognize_failed(self):
"""
异步识别失败后也应回查共享识别结果
"""
meta = self._build_meta("测试异步剧集")
shared_media = MediaInfo(title="测试异步剧集", year="2025", tmdb_id=300, type=MediaType.TV)
async_run_module = AsyncMock(side_effect=[None, shared_media])
assert result is shared_media
assert async_run_module.await_count == 2
query_mock.assert_awaited_once_with(meta=meta, mtype=None, keyword_meta=meta)
backfill_mock.assert_awaited_once()
assert meta.begin_season is None
async def runner():
with patch.object(
self.chain,
"async_run_module",
async_run_module,
), patch(
"app.chain.MoviePilotServerHelper.async_query_recognize_share",
AsyncMock(return_value={"type": "tv", "tmdbid": 300, "season": 2}),
) as query_mock, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.TV,
"tmdbid": 300,
"doubanid": None,
"bangumiid": None,
"season": 2,
},
), patch(
"app.chain.MoviePilotServerHelper.async_report_recognize_share",
AsyncMock(return_value=False),
), patch.object(
self.chain,
"_async_update_local_recognize_cache",
AsyncMock(),
) as backfill_mock:
result = await self.chain.async_recognize_media(meta=meta, cache=False)
return result, query_mock, backfill_mock
result, query_mock, backfill_mock = asyncio.run(runner())
def test_backfill_local_cache_after_shared_recognize_success():
"""共享识别后二次本地识别成功时,应回填原始名称对应的本地识别缓存。"""
chain = ChainBase()
meta = _build_meta("测试缓存回填", MediaType.MOVIE)
shared_media = MediaInfo(
title="测试缓存回填",
year="2024",
tmdb_id=700,
type=MediaType.MOVIE,
source="themoviedb",
tmdb_info={"id": 700, "media_type": MediaType.MOVIE, "title": "测试缓存回填"},
)
self.assertIs(result, shared_media)
self.assertEqual(async_run_module.await_count, 2)
query_mock.assert_awaited_once_with(meta=meta, mtype=None, keyword_meta=meta)
backfill_mock.assert_awaited_once()
self.assertIsNone(meta.begin_season)
with patch.object(
chain,
"run_module",
side_effect=[None, shared_media, None],
) as run_module_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "movie", "tmdbid": 700},
), patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.MOVIE,
"tmdbid": 700,
"doubanid": None,
"bangumiid": None,
"season": None,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
):
result = chain.recognize_media(meta=meta, cache=False)
def test_backfill_local_cache_after_shared_recognize_success(self):
"""
共享识别后二次本地识别成功时,应回填原始名称对应的本地识别缓存。
"""
meta = self._build_meta("测试缓存回填", MediaType.MOVIE)
shared_media = MediaInfo(
title="测试缓存回填",
year="2024",
tmdb_id=700,
type=MediaType.MOVIE,
source="themoviedb",
tmdb_info={"id": 700, "media_type": MediaType.MOVIE, "title": "测试缓存回填"},
assert result is shared_media
assert run_module_mock.call_count == 3
update_call = run_module_mock.call_args_list[2]
assert update_call.args[0] == "update_recognize_cache"
assert update_call.kwargs["meta"] is not meta
assert update_call.kwargs["meta"].name == meta.name
assert update_call.kwargs["meta"].type == meta.type
assert update_call.kwargs["mediainfo"] is shared_media
def test_query_and_report_prefer_original_name_keyword():
"""查询和上报共享识别时应优先使用未应用识别词的识别名称。"""
meta = _build_meta("应用识别词后的名称", MediaType.TV)
meta.original_name = "未应用识别词的名称"
meta.year = "2024"
meta.begin_season = 1
mediainfo = MediaInfo(
title="测试剧集",
year="2024",
tmdb_id=400,
type=MediaType.TV,
season=1,
)
query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta)
report_payload = MoviePilotServerHelper._build_recognize_report_payload(meta=meta, mediainfo=mediainfo)
assert query_params["keyword"] == "未应用识别词的名称"
assert report_payload["keyword"] == "未应用识别词的名称"
def test_query_and_report_can_use_distinct_keyword_meta():
"""共享识别应允许用原始关键字上报,同时保留辅助识别后的年份/季信息。"""
meta = _build_meta("辅助识别后的名称", MediaType.TV)
meta.year = "2024"
meta.begin_season = 2
keyword_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN)
keyword_meta.original_name = "辅助识别前的名称"
mediainfo = MediaInfo(
title="测试剧集",
year="2024",
tmdb_id=401,
type=MediaType.TV,
season=2,
)
query_params = MoviePilotServerHelper._build_recognize_query_params(
meta=meta,
mtype=None,
keyword_meta=keyword_meta,
)
report_payload = MoviePilotServerHelper._build_recognize_report_payload(
meta=meta,
mediainfo=mediainfo,
keyword_meta=keyword_meta,
)
assert query_params["keyword"] == "辅助识别前的名称"
assert query_params["year"] == "2024"
assert query_params["season"] == 2
assert report_payload["keyword"] == "辅助识别前的名称"
assert report_payload["year"] == "2024"
assert report_payload["season"] == 2
def test_query_and_report_preserve_special_season_zero():
"""共享识别查询和上报都必须保留显式特别季。"""
meta = _build_meta("测试剧特别篇", MediaType.TV)
meta.begin_season = 0
mediainfo = MediaInfo(title="测试剧", tmdb_id=402, type=MediaType.TV, season=0)
query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta)
report_payload = MoviePilotServerHelper._build_recognize_report_payload(
meta=meta,
mediainfo=mediainfo,
)
assert query_params["season"] == 0
assert report_payload["season"] == 0
def test_plugin_recognize_number_parser_preserves_zero():
"""插件辅助识别应同时接受整数和字符串形式的季 0。"""
media_chain = MediaChain()
assert media_chain._parse_recognize_event_number(0) == 0
assert media_chain._parse_recognize_event_number("0") == 0
assert media_chain._parse_recognize_event_number(None) is None
assert media_chain._parse_recognize_event_number("invalid") is None
def test_report_shared_result_with_distinct_keyword_meta():
"""辅助识别成功后应按辅助前名称上报共享结果。"""
chain = ChainBase()
meta = _build_meta("辅助识别后的名称", MediaType.TV)
meta.year = "2024"
meta.begin_season = 1
share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN)
share_meta.original_name = "辅助识别前的名称"
mediainfo = MediaInfo(title="测试剧集", year="2024", tmdb_id=402, type=MediaType.TV)
with patch.object(chain, "run_module", return_value=mediainfo), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
) as report_mock:
result = chain.recognize_media(meta=meta, share_meta=share_meta, cache=False)
assert result is mediainfo
report_mock.assert_called_once_with(
meta=meta,
mediainfo=mediainfo,
keyword_meta=share_meta,
)
def test_query_shared_result_with_distinct_keyword_meta():
"""本地识别失败后应按辅助前名称回查共享结果。"""
chain = ChainBase()
meta = _build_meta("辅助识别后的名称", MediaType.TV)
meta.year = "2024"
share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN)
share_meta.original_name = "辅助识别前的名称"
shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=403, type=MediaType.TV)
with patch.object(
chain,
"run_module",
side_effect=[None, shared_media],
), patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "tv", "tmdbid": 403, "season": 1},
) as query_mock, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.TV,
"tmdbid": 403,
"doubanid": None,
"bangumiid": None,
"season": 1,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
), patch.object(
chain,
"_update_local_recognize_cache",
):
result = chain.recognize_media(
meta=meta,
share_meta=share_meta,
cache=False,
)
assert result is shared_media
query_mock.assert_called_once_with(
meta=meta,
mtype=MediaType.TV,
keyword_meta=share_meta,
)
def test_skip_report_when_local_recognize_hits_cache():
"""本地识别命中缓存时不应上报共享识别。"""
chain = ChainBase()
meta = _build_meta("缓存电影", MediaType.MOVIE)
mediainfo = MediaInfo(title="缓存电影", year="2024", tmdb_id=500, type=MediaType.MOVIE)
mediainfo.recognize_cache_hit = True
with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
) as report_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share"
) as query_mock:
result = chain.recognize_media(meta=meta)
assert result is mediainfo
run_module.assert_called_once()
report_mock.assert_not_called()
query_mock.assert_not_called()
def test_async_skip_report_when_local_recognize_hits_cache():
"""异步本地识别命中缓存时不应上报共享识别。"""
chain = ChainBase()
meta = _build_meta("缓存剧集", MediaType.TV)
mediainfo = MediaInfo(title="缓存剧集", year="2025", tmdb_id=600, type=MediaType.TV)
mediainfo.recognize_cache_hit = True
async def runner():
with patch.object(
self.chain,
"run_module",
side_effect=[None, shared_media, None],
) as run_module_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "movie", "tmdbid": 700},
), patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.MOVIE,
"tmdbid": 700,
"doubanid": None,
"bangumiid": None,
"season": None,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
):
result = self.chain.recognize_media(meta=meta, cache=False)
self.assertIs(result, shared_media)
self.assertEqual(run_module_mock.call_count, 3)
update_call = run_module_mock.call_args_list[2]
self.assertEqual(update_call.args[0], "update_recognize_cache")
self.assertIsNot(update_call.kwargs["meta"], meta)
self.assertEqual(update_call.kwargs["meta"].name, meta.name)
self.assertEqual(update_call.kwargs["meta"].type, meta.type)
self.assertIs(update_call.kwargs["mediainfo"], shared_media)
def test_query_and_report_prefer_original_name_keyword(self):
"""
查询和上报共享识别时应优先使用未应用识别词的识别名称
"""
meta = self._build_meta("应用识别词后的名称", MediaType.TV)
meta.original_name = "未应用识别词的名称"
meta.year = "2024"
meta.begin_season = 1
mediainfo = MediaInfo(
title="测试剧集",
year="2024",
tmdb_id=400,
type=MediaType.TV,
season=1,
)
query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta)
report_payload = MoviePilotServerHelper._build_recognize_report_payload(meta=meta, mediainfo=mediainfo)
self.assertEqual(query_params["keyword"], "未应用识别词的名称")
self.assertEqual(report_payload["keyword"], "未应用识别词的名称")
def test_query_and_report_can_use_distinct_keyword_meta(self):
"""
共享识别应允许用原始关键字上报,同时保留辅助识别后的年份/季信息。
"""
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 = MoviePilotServerHelper._build_recognize_query_params(
meta=meta,
mtype=None,
keyword_meta=keyword_meta,
)
report_payload = MoviePilotServerHelper._build_recognize_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_query_and_report_preserve_special_season_zero(self):
"""共享识别查询和上报都必须保留显式特别季。"""
meta = self._build_meta("测试剧特别篇", MediaType.TV)
meta.begin_season = 0
mediainfo = MediaInfo(title="测试剧", tmdb_id=402, type=MediaType.TV, season=0)
query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta)
report_payload = MoviePilotServerHelper._build_recognize_report_payload(
meta=meta,
mediainfo=mediainfo,
)
self.assertEqual(query_params["season"], 0)
self.assertEqual(report_payload["season"], 0)
def test_plugin_recognize_number_parser_preserves_zero(self):
"""插件辅助识别应同时接受整数和字符串形式的季 0。"""
self.assertEqual(self.media_chain._parse_recognize_event_number(0), 0)
self.assertEqual(self.media_chain._parse_recognize_event_number("0"), 0)
self.assertIsNone(self.media_chain._parse_recognize_event_number(None))
self.assertIsNone(self.media_chain._parse_recognize_event_number("invalid"))
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.MoviePilotServerHelper.report_recognize_share",
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.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "tv", "tmdbid": 403, "season": 1},
) as query_mock, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.TV,
"tmdbid": 403,
"doubanid": None,
"bangumiid": None,
"season": 1,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
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=MediaType.TV,
keyword_meta=share_meta,
)
def test_skip_report_when_local_recognize_hits_cache(self):
"""
本地识别命中缓存时不应上报共享识别
"""
meta = self._build_meta("缓存电影", MediaType.MOVIE)
mediainfo = MediaInfo(title="缓存电影", year="2024", tmdb_id=500, type=MediaType.MOVIE)
mediainfo.recognize_cache_hit = True
with patch.object(self.chain, "run_module", return_value=mediainfo) as run_module, patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
chain,
"async_run_module",
AsyncMock(return_value=mediainfo),
) as async_run_module, patch(
"app.chain.MoviePilotServerHelper.async_report_recognize_share",
AsyncMock(return_value=True),
) as report_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share"
"app.chain.MoviePilotServerHelper.async_query_recognize_share",
AsyncMock(),
) as query_mock:
result = self.chain.recognize_media(meta=meta)
result = await chain.async_recognize_media(meta=meta)
return result, async_run_module, report_mock, query_mock
self.assertIs(result, mediainfo)
run_module.assert_called_once()
report_mock.assert_not_called()
query_mock.assert_not_called()
result, async_run_module, report_mock, query_mock = asyncio.run(runner())
def test_async_skip_report_when_local_recognize_hits_cache(self):
"""
异步本地识别命中缓存时不应上报共享识别
"""
meta = self._build_meta("缓存剧集", MediaType.TV)
mediainfo = MediaInfo(title="缓存剧集", year="2025", tmdb_id=600, type=MediaType.TV)
mediainfo.recognize_cache_hit = True
assert result is mediainfo
async_run_module.assert_awaited_once()
report_mock.assert_not_awaited()
query_mock.assert_not_awaited()
async def runner():
with patch.object(
self.chain,
"async_run_module",
AsyncMock(return_value=mediainfo),
) as async_run_module, patch(
"app.chain.MoviePilotServerHelper.async_report_recognize_share",
AsyncMock(return_value=True),
) as report_mock, patch(
"app.chain.MoviePilotServerHelper.async_query_recognize_share",
AsyncMock(),
) as query_mock:
result = await self.chain.async_recognize_media(meta=meta)
return result, async_run_module, report_mock, query_mock
result, async_run_module, report_mock, query_mock = asyncio.run(runner())
def test_recognize_by_meta_can_skip_obtain_images():
"""标题识别可显式关闭图片拉取。"""
media_chain = MediaChain()
meta = MetaInfo("测试电影")
mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=404, type=MediaType.MOVIE)
self.assertIs(result, mediainfo)
async_run_module.assert_awaited_once()
report_mock.assert_not_awaited()
query_mock.assert_not_awaited()
with patch.object(
media_chain,
"recognize_media",
return_value=mediainfo,
) as recognize_mock, patch.object(
media_chain,
"obtain_images",
) as obtain_images_mock:
result = media_chain.recognize_by_meta(meta, obtain_images=False)
def test_recognize_by_meta_can_skip_obtain_images(self):
"""
标题识别可显式关闭图片拉取。
"""
meta = MetaInfo("测试电影")
mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=404, type=MediaType.MOVIE)
assert result is mediainfo
recognize_mock.assert_called_once()
obtain_images_mock.assert_not_called()
def test_recognize_by_meta_reports_with_original_keyword_after_plugin_help():
"""辅助识别后应继续使用辅助前关键字进行共享上报。"""
media_chain = MediaChain()
meta = MetaInfo("辅助前名称")
plugin_media = MediaInfo(title="辅助后名称", year="2024", tmdb_id=405, type=MediaType.TV)
with patch.object(
media_chain,
"select_recognize_source",
side_effect=lambda **kwargs: kwargs["plugin_fn"](),
), patch.object(
media_chain,
"recognize_help",
return_value=plugin_media,
) as recognize_help_mock, patch.object(
media_chain,
"obtain_images",
):
result = media_chain.recognize_by_meta(meta, obtain_images=False)
assert result is plugin_media
assert recognize_help_mock.call_args.kwargs["share_meta"].name == "辅助前名称"
def test_async_recognize_by_meta_can_skip_obtain_images():
"""异步标题识别可显式关闭图片拉取。"""
media_chain = MediaChain()
meta = MetaInfo("测试异步电影")
mediainfo = MediaInfo(title="测试异步电影", year="2025", tmdb_id=406, type=MediaType.MOVIE)
async def runner():
with patch.object(
self.media_chain,
"recognize_media",
return_value=mediainfo,
media_chain,
"async_recognize_media",
AsyncMock(return_value=mediainfo),
) as recognize_mock, patch.object(
self.media_chain,
"obtain_images",
media_chain,
"async_obtain_images",
AsyncMock(),
) as obtain_images_mock:
result = self.media_chain.recognize_by_meta(meta, obtain_images=False)
result = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
return result, recognize_mock, obtain_images_mock
self.assertIs(result, mediainfo)
recognize_mock.assert_called_once()
obtain_images_mock.assert_not_called()
result, recognize_mock, obtain_images_mock = asyncio.run(runner())
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)
assert result is mediainfo
recognize_mock.assert_awaited_once()
obtain_images_mock.assert_not_called()
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 _music_info() -> MusicInfo:
"""构造带远端身份的标准音乐信息。"""
return MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
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
def test_music_report_payload_includes_source_and_media_id():
"""音乐上报载荷应携带 music 类型与数据源原生身份,其余媒体 ID 恒为 None。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
payload = MoviePilotServerHelper._build_recognize_report_payload(
meta=meta,
mediainfo=_music_info(),
)
result, recognize_mock, obtain_images_mock = asyncio.run(runner())
assert payload["keyword"] == "晴天"
assert payload["type"] == "music"
assert payload["title"] == "晴天"
assert payload["year"] == "2003"
assert payload["season"] is None
assert payload["tmdbid"] is None
assert payload["doubanid"] is None
assert payload["media_source"] == "musicbrainz"
assert payload["media_id"] == "recording-1"
self.assertIs(result, mediainfo)
recognize_mock.assert_awaited_once()
obtain_images_mock.assert_not_awaited()
def test_music_report_payload_skips_fallback_without_remote_identity():
"""无远端身份的兜底音乐结果不应上报。"""
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"])
payload = MoviePilotServerHelper._build_recognize_report_payload(
meta=meta,
mediainfo=fallback,
)
assert payload is None
def test_music_query_params_build_for_music_type():
"""音乐查询参数应携带 music 类型、年份且不包含季。"""
meta = MetaMusic(title="晴天", year=2003)
keyword_meta = MetaMusic(title="晴天")
params = MoviePilotServerHelper._build_recognize_query_params(
meta=meta,
mtype=MediaType.MUSIC,
keyword_meta=keyword_meta,
)
assert params["keyword"] == "晴天"
assert params["type"] == "music"
assert params["year"] == "2003"
assert "season" not in params
def test_chain_recognize_media_reports_music_share_result():
"""音乐识别成功且有远端身份时,应与影视一样走统一共享上报。"""
chain = ChainBase()
meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003)
music = _music_info()
with patch.object(chain, "run_module", return_value=music), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
) as report_mock, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share"
) as query_mock:
result = chain.recognize_media(meta=meta, cache=False)
assert result is music
report_mock.assert_called_once_with(meta=meta, mediainfo=music, keyword_meta=meta)
query_mock.assert_not_called()
def test_chain_recognize_media_queries_music_share_when_local_failed():
"""音乐本地识别失败后应回查共享识别并按数据源原生 ID 二次识别。"""
chain = ChainBase()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
music = _music_info()
with patch.object(
chain,
"run_module",
side_effect=[None, music],
) as run_module, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "music", "media_source": "musicbrainz", "media_id": "recording-1"},
), patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.MUSIC,
"source": "musicbrainz",
"mediaid": "recording-1",
"tmdbid": None,
"doubanid": None,
"bangumiid": None,
"anilistid": None,
"season": None,
},
), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
), patch.object(
chain,
"_update_local_recognize_cache",
):
result = chain.recognize_media(meta=meta, cache=False)
assert result is music
assert run_module.call_count == 2
second_call = run_module.call_args_list[1]
assert second_call.kwargs["mtype"] == MediaType.MUSIC
assert second_call.kwargs["source"] == "musicbrainz"
assert second_call.kwargs["mediaid"] == "recording-1"
def test_chain_recognize_media_skips_music_report_for_fallback_result():
"""兜底无远端身份的音乐结果不上报共享识别,由载荷构建返回空保证。"""
chain = ChainBase()
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"])
with patch.object(chain, "run_module", return_value=fallback), patch(
"app.chain.MoviePilotServerHelper.recognize_report"
) as report_mock, patch(
"app.chain.settings.MEDIA_RECOGNIZE_SHARE", True
):
result = chain.recognize_media(meta=meta, cache=False)
assert result is fallback
report_mock.assert_not_called()

View File

@@ -3,7 +3,7 @@
覆盖 MediaChain 同步/异步 ``recognize_by_meta`` 与 ``recognize_by_path`` 按
``MetaMusic`` 路由到音乐模块,以及 MusicBrainz 模块 ``recognize_media`` /
``async_recognize_media`` 对音乐请求的详情、搜索匹配与兜底分支ChainBase
对 MusicInfo 结果短路返回(不参与影视共享上报
对 MusicInfo 结果与影视统一走共享识别上报。
"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
@@ -148,8 +148,8 @@ def test_musicbrainz_module_async_recognize_media(monkeypatch):
assert result is expected
def test_chain_recognize_media_returns_musicinfo_without_shared_report():
"""ChainBase.recognize_media 收到 MusicInfo 结果应直接返回,不触发影视共享上报"""
def test_chain_recognize_media_returns_musicinfo_and_reports_share():
"""ChainBase.recognize_media 收到 MusicInfo 结果应与影视统一上报共享识别"""
expected = _music_info()
chain = MediaChain()
chain.run_module = Mock(return_value=expected)
@@ -158,22 +158,25 @@ def test_chain_recognize_media_returns_musicinfo_without_shared_report():
) as report_mock:
result = chain.recognize_media(meta=MetaMusic(title="晴天"))
report_mock.assert_not_called()
report_mock.assert_called_once()
assert report_mock.call_args.kwargs["mediainfo"] is expected
assert result is expected
def test_chain_async_recognize_media_returns_musicinfo_without_shared_report():
"""异步 ChainBase 收到 MusicInfo 结果应直接返回,不触发影视共享上报"""
def test_chain_async_recognize_media_returns_musicinfo_and_reports_share():
"""异步 ChainBase 收到 MusicInfo 结果应与影视统一上报共享识别"""
expected = _music_info()
chain = MediaChain()
chain.async_run_module = AsyncMock(return_value=expected)
with patch(
"app.helper.server.MoviePilotServerHelper.async_report_recognize_share"
"app.helper.server.MoviePilotServerHelper.async_report_recognize_share",
AsyncMock(),
) as report_mock:
async def runner():
return await chain.async_recognize_media(meta=MetaMusic(title="晴天"))
result = asyncio.run(runner())
report_mock.assert_not_called()
report_mock.assert_awaited_once()
assert report_mock.call_args.kwargs["mediainfo"] is expected
assert result is expected