Files
MoviePilot/tests/test_music_recognize_routing.py

1078 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""音乐识别统一入口路由测试。
覆盖 MediaChain 同步/异步 ``recognize_by_meta`` 与 ``recognize_by_path`` 按
``MetaMusic`` 路由到音乐模块,以及 MusicBrainz 模块 ``recognize_media`` /
``async_recognize_media`` 对音乐请求的详情、搜索匹配与兜底分支MediaChain
对 MusicInfo 结果与影视统一走共享识别上报。
"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from app.chain.acoustid import AcoustIdChain
from app.chain.media import MediaChain
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.modules.anilist import AniListModule
from app.modules.bangumi import BangumiModule
from app.modules.musicbrainz import MusicBrainzModule
from app.modules.theaudiodb import TheAudioDbModule
from app.modules.themoviedb import TheMovieDbModule
from app.runtime.config import ConfigModel
from app.schemas.types import MediaSource, MediaType
def _music_info() -> MusicInfo:
"""构造带远端身份的标准音乐信息,用于断言路由返回值。"""
return MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
"""MetaMusic 应与影视共用选择流程,由统一模块分发直接响应。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
chain = MediaChain()
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected))
result = chain.recognize_by_meta(
meta, media_source="musicbrainz", mtype=MediaType.MUSIC
)
# 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数
chain.recognize_media.assert_called_once()
call_kwargs = chain.recognize_media.call_args.kwargs
assert call_kwargs["meta"] is meta
assert call_kwargs["mtype"] == MediaType.MUSIC
assert call_kwargs["media_source"] == "musicbrainz"
assert result is expected
def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
"""异步识别同样应把 MetaMusic 路由到统一模块识别入口。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
chain = MediaChain()
monkeypatch.setattr(chain, "async_recognize_media", AsyncMock(return_value=expected))
async def runner():
return await chain.async_recognize_by_meta(
meta, media_source="musicbrainz", mtype=MediaType.MUSIC
)
result = asyncio.run(runner())
chain.async_recognize_media.assert_awaited_once()
call_kwargs = chain.async_recognize_media.await_args.kwargs
assert call_kwargs["meta"] is meta
assert call_kwargs["mtype"] == MediaType.MUSIC
assert call_kwargs["media_source"] == "musicbrainz"
assert result is expected
def test_media_chain_recognize_by_path_routes_audio_file_to_music_chain(monkeypatch):
"""音频文件路径应经统一入口分发到 MediaChain 的音乐路径识别实现。"""
expected_meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected_info = _music_info()
recognize_music = Mock(return_value=(expected_meta, expected_info))
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize_music)
context = MediaChain().recognize_by_path("/music/周杰伦 - 晴天.flac")
recognize_music.assert_called_once()
assert recognize_music.call_args.args[0] == "/music/周杰伦 - 晴天.flac"
assert context.meta_info is expected_meta
assert context.media_info is expected_info
def test_media_chain_recognize_by_path_routes_musicbrainz_source_to_music_chain(monkeypatch):
"""显式指定 MusicBrainz 数据源的路径识别也应分发到音乐实现。"""
expected_meta = MetaMusic(title="晴天")
expected_info = _music_info()
recognize_music = Mock(return_value=(expected_meta, expected_info))
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize_music)
context = MediaChain().recognize_by_path("/downloads/晴天", media_source="musicbrainz")
recognize_music.assert_called_once()
assert recognize_music.call_args.kwargs["media_source"] == "musicbrainz"
assert context.media_info is expected_info
def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
"""本地音频识别应使用内嵌标签补全艺术家、专辑并保留音频质量参数。"""
audio_path = tmp_path / "02. 眼泪成诗.m4a"
audio_path.write_bytes(b"audio")
meta = MetaMusic(
title="眼泪成诗",
artists=["孙燕姿"],
album="完美的一天",
track_number=2,
duration=221,
)
info = _music_info()
chain = MediaChain()
recognize = AsyncMock(return_value=info)
filename_meta = MetaMusic(title="02. 眼泪成诗")
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(meta, meta, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"async_identify_music_by_fingerprint",
AsyncMock(return_value=None),
)
monkeypatch.setattr(chain, "async_recognize_media", recognize)
recognized_meta, recognized_info = asyncio.run(
chain.async_recognize_music_by_path(audio_path)
)
assert recognized_meta is meta
assert recognized_info is info
recognize.assert_awaited_once_with(
meta=meta,
media_source=None,
music_type="recording",
)
def test_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkeypatch):
"""AcoustID 命中后应按 MBID 直查,且不再执行标签和文件名匹配。"""
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
merged = MetaMusic(title="Get Lucky", audio_format="FLAC")
tag_meta = MetaMusic(title="Get Lucky", artists=["Daft Punk"])
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Get Lucky",
artists=["Daft Punk"],
)
chain = MediaChain()
direct = Mock(return_value=expected)
later_tier = Mock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, tag_meta, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", direct)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
recognized_meta, recognized_info = chain.recognize_music_by_path("track.flac")
assert recognized_meta is merged
assert recognized_info is expected
direct.assert_called_once_with(merged, recording_id)
later_tier.assert_not_called()
def test_recognize_music_by_path_tag_mbid_skips_multi_source_matching(monkeypatch):
"""指纹未命中但标签含 MBID 时应直查详情,不进入多来源标题匹配。"""
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
tag_meta = MetaMusic(
title="Tagged Title",
media_source="musicbrainz",
media_id=recording_id,
)
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Tagged Title",
)
chain = MediaChain()
direct = Mock(return_value=expected)
generic = Mock()
tier = Mock(wraps=chain._recognize_music_meta_tier)
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(tag_meta, tag_meta, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=None),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", direct)
monkeypatch.setattr(chain, "recognize_media", generic)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", tier)
_, recognized_info = chain.recognize_music_by_path("track.flac")
assert recognized_info is expected
direct.assert_called_once_with(meta=tag_meta, recording_id=recording_id)
generic.assert_not_called()
assert tier.call_count == 1
assert tier.call_args.kwargs["tier_name"] == "文件标签"
def test_recognize_music_by_path_falls_back_from_tags_to_filename(monkeypatch):
"""标签层未获得远端身份时应继续使用文件名层,且顺序不可反转。"""
tag_meta = MetaMusic(
title="Tagged Title",
artists=["Taylor Swift"],
album="Speak Now",
year=2010,
)
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
media_source="musicbrainz",
media_id="recording-from-filename",
title="Filename Title",
)
chain = MediaChain()
recognize = Mock(side_effect=[MusicInfo(title="Offline Tag"), expected])
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(tag_meta, tag_meta, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=None),
)
monkeypatch.setattr(chain, "recognize_media", recognize)
_, recognized_info = chain.recognize_music_by_path("track.flac")
assert recognized_info is expected
calls = [call.kwargs["meta"] for call in recognize.call_args_list]
assert calls[0] is tag_meta
assert calls[1].title == "Filename Title"
assert calls[1].artists == ["Taylor Swift"]
assert calls[1].album == "Speak Now"
assert calls[1].year == 2010
assert all(
call.kwargs["music_type"] == "recording"
for call in recognize.call_args_list
)
def test_async_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkeypatch):
"""异步路径也应在 AcoustID 命中后直查 MBID 并停止后续层级。"""
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
merged = MetaMusic(title="Get Lucky", artists=["Daft Punk"])
expected = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Get Lucky",
artists=["Daft Punk"],
)
chain = MediaChain()
direct = AsyncMock(return_value=expected)
later_tier = AsyncMock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, MetaMusic(title="Get Lucky", artists=["Daft Punk"]), MetaMusic(title="track"))),
)
monkeypatch.setattr(
AcoustIdChain,
"async_identify_music_by_fingerprint",
AsyncMock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_async_recognize_musicbrainz_recording", direct)
monkeypatch.setattr(chain, "_async_recognize_music_meta_tier", later_tier)
recognized_meta, recognized_info = asyncio.run(
chain.async_recognize_music_by_path("track.flac")
)
assert recognized_meta is merged
assert recognized_info is expected
direct.assert_awaited_once_with(merged, recording_id)
later_tier.assert_not_awaited()
def test_recognize_music_by_path_rejects_same_title_fingerprint_without_artist(monkeypatch):
"""只有同名曲名不足以证明 AcoustID 候选属于正确艺人。"""
wrong_id = "wrong-dear-john"
merged = MetaMusic(title="Dear John")
wrong = MusicInfo(
media_source="musicbrainz",
media_id=wrong_id,
title="Dear John",
artists=["Tamia"],
)
expected = MusicInfo(
media_source="musicbrainz",
media_id="fallback-recording",
title="Dear John",
artists=["Taylor Swift"],
)
chain = MediaChain()
tier = Mock(return_value=expected)
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, merged, MetaMusic(title="Dear John"))),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=wrong_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", Mock(return_value=wrong))
monkeypatch.setattr(chain, "_recognize_music_meta_tier", tier)
_, recognized_info = chain.recognize_music_by_path("Dear John.flac")
assert recognized_info is expected
tier.assert_called_once()
def test_recognize_music_by_path_applies_context_artist_to_filename_fallback(monkeypatch):
"""标签无曲名时,同目录艺人共识仍必须约束文件名搜索。"""
merged = MetaMusic(title="Enchanted", year=2011)
empty_tag = MetaMusic(year=2011)
filename_meta = MetaMusic(title="Enchanted")
contextual = MetaMusic(
artists=["Taylor Swift"],
album_artist="Taylor Swift",
album="Speak Now",
year=2023,
)
expected = MusicInfo(
media_source="musicbrainz",
media_id="taylor-enchanted",
title="Enchanted",
artists=["Taylor Swift"],
)
chain = MediaChain()
tier = Mock(side_effect=[None, expected])
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, empty_tag, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=None),
)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", tier)
_, recognized_info = chain.recognize_music_by_path(
"Enchanted.flac",
contextual_meta=contextual,
)
assert recognized_info is expected
assert tier.call_args_list[0].kwargs["meta"].year == 2023
filename_call = tier.call_args_list[1]
assert filename_call.kwargs["meta"].artists == ["Taylor Swift"]
assert filename_call.kwargs["meta"].album == "Speak Now"
assert filename_call.kwargs["meta"].year == 2023
def test_recognize_music_by_path_rejects_fingerprint_text_mismatch(monkeypatch):
"""A high-confidence but wrongly mapped AcoustID result must fall back."""
wrong_id = "f5ac0d7b-8540-4534-93cf-ac9642e43e4d"
merged = MetaMusic(title="Christmas Tree Farm", artists=["Taylor Swift"])
filename_meta = MetaMusic(title="Christmas Tree Farm", artists=["Taylor Swift"])
wrong = MusicInfo(
media_source="musicbrainz",
media_id=wrong_id,
title="Wrapped in Red (My Heart for You) [Christmas in the Trees Mix]",
artists=["Justin Lovemaker"],
)
expected = MusicInfo(
media_source="musicbrainz",
media_id="correct-recording",
title="Christmas Tree Farm",
artists=["Taylor Swift"],
)
chain = MediaChain()
tier = Mock(return_value=expected)
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, merged, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=wrong_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", Mock(return_value=wrong))
monkeypatch.setattr(chain, "_recognize_music_meta_tier", tier)
_, recognized_info = chain.recognize_music_by_path("Christmas Tree Farm.mp3")
assert recognized_info is expected
tier.assert_called_once_with(
meta=merged,
media_source=None,
tier_name="文件标签",
)
def test_recognize_music_by_path_accepts_fingerprint_radio_version_suffix(monkeypatch):
"""同艺人的 Radio Single Version 标签应接受正确的基础录音指纹。"""
recording_id = "08f535c5-b651-4cc2-a8a8-349aa6060b16"
merged = MetaMusic(
title="Our Song (Radio Single Version)",
artists=["Taylor Swift"],
)
expected = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Our Song",
artists=["Taylor Swift"],
)
chain = MediaChain()
later_tier = Mock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, merged, MetaMusic(title="Our Song"))),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", Mock(return_value=expected))
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
_, recognized_info = chain.recognize_music_by_path("Our Song.flac")
assert recognized_info is expected
later_tier.assert_not_called()
def test_recognize_music_by_path_accepts_minor_tag_typo_with_same_artist(monkeypatch):
"""同艺人且高度相似的旧标签拼写差异不应丢失远端分类。"""
recording_id = "25aa1b18-5e85-454e-a15b-145d5559d0fe"
merged = MetaMusic(title="Cold As You Are", artists=["Taylor Swift"])
expected = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Cold as You",
artists=["Taylor Swift"],
)
chain = MediaChain()
later_tier = Mock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, merged, MetaMusic(title="Cold As You Are"))),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", Mock(return_value=expected))
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
_, recognized_info = chain.recognize_music_by_path("Cold As You Are.flac")
assert recognized_info is expected
later_tier.assert_not_called()
def test_recognize_music_by_path_accepts_equivalent_recording_qualifiers(monkeypatch):
"""同艺人同基础曲名的 radio/pop 版本应保留指纹提供的实际录音身份。"""
recording_id = "teardrops-pop-version"
merged = MetaMusic(
title="Teardrops On My Guitar (Radio Single Version)",
artists=["Taylor Swift"],
)
expected = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Teardrops on My Guitar (pop version)",
artists=["Taylor Swift"],
)
chain = MediaChain()
later_tier = Mock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, merged, MetaMusic(title=merged.title))),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", Mock(return_value=expected))
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
_, recognized_info = chain.recognize_music_by_path("Teardrops.flac")
assert recognized_info is expected
later_tier.assert_not_called()
def test_recognize_music_by_path_reconciles_standalone_single_release(monkeypatch):
"""指纹录音属于多个发行版时,应尊重本地同名单曲标签。"""
recording_id = "131b296c-3533-4e55-9800-a6dd83b90737"
single_title = 'Beautiful Ghosts (From The Motion Picture "Cats")'
tagged = MetaMusic(
title=single_title,
artists=["Taylor Swift"],
album=f"{single_title} - Single",
album_artist="Taylor Swift",
year=2019,
track_number=1,
total_tracks=1,
)
soundtrack = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="Beautiful Ghosts",
artists=["Taylor Swift"],
album="Cats: Highlights From the Motion Picture Soundtrack",
album_artist="Andrew Lloyd Webber",
album_id="cats-release-group",
album_type="Album",
secondary_types=["Soundtrack"],
year=2019,
track_number=3,
total_tracks=16,
cover_url="https://cover.example/cats.jpg",
)
chain = MediaChain()
later_tier = Mock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(tagged, tagged, MetaMusic(title="Beautiful Ghosts"))),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(
chain,
"_recognize_musicbrainz_recording",
Mock(return_value=soundtrack),
)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
_, recognized = chain.recognize_music_by_path("Beautiful Ghosts.mp3")
assert recognized is not soundtrack
assert recognized.media_id == recording_id
assert recognized.album == f"{single_title} - Single"
assert recognized.album_artist == "Taylor Swift"
assert recognized.album_id is None
assert recognized.album_type == "Single"
assert recognized.secondary_types == []
assert recognized.year == 2019
assert recognized.track_number == 1
assert recognized.total_tracks == 1
assert recognized.cover_url is None
assert recognized.metadata_category == "Single"
later_tier.assert_not_called()
def test_recognize_music_by_path_ignores_single_content_rating_qualifier(monkeypatch):
"""Explicit/Clean 只描述内容分级,不应阻止同名单曲发行证据。"""
recording_id = "7b35656c-5589-48d3-9a96-467a637e9bd2"
title = "All Too Well (10 Minute Version) (Taylor's Version) (Live Acoustic|Explicit)"
album = "All Too Well (10 Minute Version) [Taylor's Version] [Live Acoustic] [Explicit] - Single"
tagged = MetaMusic(
title=title,
artists=["Taylor Swift"],
album=album,
year=2023,
track_number=1,
)
candidate = MusicInfo(
media_source="musicbrainz",
media_id=recording_id,
title="All Too Well (10 Minute version) (Taylor's version) (live acoustic)",
artists=["Taylor Swift"],
album="Red (Taylor's Version)",
album_id="red-release-group",
album_type="Album",
year=2019,
)
chain = MediaChain()
later_tier = Mock()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(tagged, tagged, MetaMusic(title=title))),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(
chain,
"_recognize_musicbrainz_recording",
Mock(return_value=candidate),
)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
_, recognized = chain.recognize_music_by_path("All Too Well.mp3")
assert recognized.media_id == recording_id
assert recognized.album == album
assert recognized.album_id is None
assert recognized.album_type == "Single"
assert recognized.year == 2023
assert recognized.track_number == 1
later_tier.assert_not_called()
def test_musicbrainz_module_recognize_media_ignores_non_music():
"""非音乐请求应直接返回 None不占用影视识别管线。"""
result = MusicBrainzModule().recognize_media(
meta=None,
mtype=MediaType.MOVIE,
media_source=MediaSource.TMDB,
media_id="123",
)
assert result is None
def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatch):
"""显式音乐类型和来源应只调用对应音乐模块,不遍历通用影视模块。"""
expected = _music_info()
chain = MediaChain()
recognize_source = Mock(return_value=expected)
generic_dispatch = Mock()
monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source)
monkeypatch.setattr(chain, "run_module", generic_dispatch)
monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False))
with patch(
"app.adapters.external.server.MoviePilotServerHelper.report_recognize_share"
):
result = chain.recognize_media(
mtype=MediaType.MUSIC,
media_source="musicbrainz",
media_id="recording-1",
)
assert result is expected
recognize_source.assert_called_once_with(
media_source=MediaSource.MusicBrainz,
meta=None,
media_id="recording-1",
cache=True,
)
generic_dispatch.assert_not_called()
def test_media_chain_rejects_cross_entity_detail_result(monkeypatch):
"""指定专辑实体时,即使来源返回同 ID 的单曲也不得采信。"""
chain = MediaChain()
source_chain = Mock()
source_chain.recognize_music.return_value = _music_info()
monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain))
result = chain.recognize_music_from_source(
media_source="musicbrainz",
media_id="recording-1",
music_type=MUSIC_ENTITY_ALBUM,
)
assert result is None
assert source_chain.recognize_music.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
def test_default_music_source_preserves_explicit_album_intent(monkeypatch):
"""没有原生 ID 或显式来源时,用户选择的专辑实体不能被默认单曲模式覆盖。"""
chain = MediaChain()
expected = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="album-1",
music_type=MUSIC_ENTITY_ALBUM, title="Album", artists=["Artist"])
recognize = Mock(return_value=expected)
monkeypatch.setattr(chain, "recognize_music_from_source", recognize)
with patch("app.adapters.external.server.MoviePilotServerHelper.report_recognize_share"), \
patch("app.adapters.external.server.MoviePilotServerHelper.query_recognize_share", return_value=None):
result = chain.recognize_media(meta=MetaMusic(title="Album", artists=["Artist"]),
mtype=MediaType.MUSIC, music_type=MUSIC_ENTITY_ALBUM)
assert result.music_type == MUSIC_ENTITY_ALBUM
assert recognize.call_args.kwargs["media_source"] == MediaSource.MusicBrainz
assert recognize.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
def test_music_metadata_simplified_conversion_defaults_to_enabled():
"""音乐识别结果转简体开关应默认开启。"""
assert ConfigModel.model_fields["MUSIC_METADATA_TO_SIMPLIFIED"].default is True
def test_music_release_preference_defaults_favor_mainland_simplified():
"""默认发行排序应先大陆、再台港,并优先简体字形。"""
assert ConfigModel.model_fields["MUSIC_RELEASE_REGION_PRIORITY"].default == "CN,TW,HK"
assert ConfigModel.model_fields["MUSIC_RELEASE_SCRIPT_PRIORITY"].default == "Hans,Hant,Latn"
def test_media_chain_converts_recognized_music_metadata_without_mutating_source(monkeypatch):
"""开启开关时应转换标准音乐字段,并保留模块缓存对象和歌词原文。"""
source_info = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="後來的我們",
artists=["張學友"],
album="歲月如歌",
album_artist="張學友",
category="華語流行",
genres=["華語"],
names=["後來的我們", "歲月如歌"],
lyrics="後來的我們",
)
source_chain = Mock()
source_chain.recognize_music.return_value = source_info
chain = MediaChain()
monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain))
monkeypatch.setattr(
"app.runtime.config.settings.MUSIC_METADATA_TO_SIMPLIFIED", True
)
result = chain.recognize_music_from_source(
media_source="musicbrainz",
media_id="recording-1",
)
assert result is not source_info
assert result.title == "后来的我们"
assert result.artists == ["张学友"]
assert result.album == "岁月如歌"
assert result.album_artist == "张学友"
assert result.metadata_category == "华语流行"
assert result.category == ""
assert result.genres == ["华语"]
assert result.names == ["后来的我们", "岁月如歌"]
assert result.lyrics == "後來的我們"
assert source_info.title == "後來的我們"
assert source_info.artists == ["張學友"]
assert source_info.metadata_category == "華語流行"
assert source_info.category == ""
def test_media_chain_preserves_original_music_metadata_when_conversion_disabled(monkeypatch):
"""关闭开关时识别结果应保持来源提供的原始繁简写法。"""
source_info = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="後來的我們",
artists=["張學友"],
)
source_chain = Mock()
source_chain.recognize_music.return_value = source_info
chain = MediaChain()
monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain))
monkeypatch.setattr(
"app.runtime.config.settings.MUSIC_METADATA_TO_SIMPLIFIED", False
)
result = chain.recognize_music_from_source(
media_source="musicbrainz",
media_id="recording-1",
)
assert result is source_info
assert result.title == "後來的我們"
assert result.artists == ["張學友"]
def test_music_path_fallback_converts_local_tag_metadata(monkeypatch):
"""远端未命中时,本地标签生成的音乐信息也应按开关转换为简体。"""
meta = MetaMusic(
title="後來的我們",
artists=["張學友"],
album="歲月如歌",
)
chain = MediaChain()
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(meta, meta, MetaMusic())),
)
monkeypatch.setattr(
AcoustIdChain,
"identify_music_by_fingerprint",
Mock(return_value=None),
)
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=None))
monkeypatch.setattr(
"app.runtime.config.settings.MUSIC_METADATA_TO_SIMPLIFIED", True
)
_, result = chain.recognize_music_by_path("track.flac")
assert result.title == "后来的我们"
assert result.artists == ["张学友"]
assert result.album == "岁月如歌"
assert meta.title == "後來的我們"
def test_media_chain_rejects_replaced_explicit_identity(monkeypatch):
"""显式 ID 识别不得用标题搜索得到的另一 ID 替换请求目标。"""
chain = MediaChain()
replaced = _music_info()
replaced.media_id = "recording-other"
source_chain = Mock()
source_chain.recognize_music.return_value = replaced
monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain))
result = chain.recognize_music_from_source(
media_source="musicbrainz",
media_id="recording-requested",
music_type="recording",
)
assert result is None
def test_chain_music_type_rejects_video_source_before_module_dispatch(monkeypatch):
"""音乐状态即使携带错误影视来源,也不得调用 TMDB 等通用识别模块。"""
chain = MediaChain()
generic_dispatch = Mock()
async_generic_dispatch = AsyncMock()
monkeypatch.setattr(chain, "run_module", generic_dispatch)
monkeypatch.setattr(chain, "async_run_module", async_generic_dispatch)
monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False))
sync_result = chain.recognize_media(
mtype=MediaType.MUSIC,
media_source=MediaSource.TMDB,
media_id="123",
)
async_result = asyncio.run(chain.async_recognize_media(
mtype=MediaType.MUSIC,
media_source=MediaSource.TMDB,
media_id="123",
))
assert sync_result is None
assert async_result is None
generic_dispatch.assert_not_called()
async_generic_dispatch.assert_not_awaited()
def test_themoviedb_module_recognize_media_ignores_music(monkeypatch):
"""音乐模块未响应时 TMDB 的同步和异步入口均不得接管音乐请求。"""
module = TheMovieDbModule()
tmdb = Mock()
monkeypatch.setattr(module, "tmdb", tmdb)
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"))
by_type = module.recognize_media(mtype=MediaType.MUSIC, tmdbid=123)
async_result = asyncio.run(
module.async_recognize_media(meta=MetaMusic(title="晴天"))
)
assert by_meta is None
assert by_type is None
assert async_result is None
assert tmdb.mock_calls == []
def test_bangumi_module_recognize_media_ignores_music(monkeypatch):
"""Bangumi 的同步和异步入口不得把音乐请求识别为动画影视。"""
module = BangumiModule()
api = Mock()
monkeypatch.setattr(module, "bangumiapi", api)
by_meta = module.recognize_media(
meta=MetaMusic(title="晴天"), media_source=MediaSource.Bangumi
)
by_type = module.recognize_media(
mtype=MediaType.MUSIC,
media_source=MediaSource.Bangumi,
media_id="123",
)
async_result = asyncio.run(
module.async_recognize_media(
meta=MetaMusic(title="晴天"), media_source=MediaSource.Bangumi
)
)
assert by_meta is None
assert by_type is None
assert async_result is None
assert api.mock_calls == []
def test_anilist_module_recognize_media_ignores_music(monkeypatch):
"""AniList 的同步和异步入口不得把音乐请求识别为动画影视。"""
module = AniListModule()
api = Mock()
monkeypatch.setattr(module, "anilist_api", api)
by_meta = module.recognize_media(
meta=MetaMusic(title="晴天"), media_source=MediaSource.AniList
)
by_type = module.recognize_media(
mtype=MediaType.MUSIC,
media_source=MediaSource.AniList,
media_id="123",
)
async_result = asyncio.run(
module.async_recognize_media(
meta=MetaMusic(title="晴天"), media_source=MediaSource.AniList
)
)
assert by_meta is None
assert by_type is None
assert async_result is None
assert api.mock_calls == []
def test_chain_obtain_images_skips_music_modules(monkeypatch):
"""音乐封面来自音乐元数据链,同步和异步补图入口均不得调用影视模块。"""
chain = MediaChain()
run_module = Mock()
async_run_module = AsyncMock()
monkeypatch.setattr(chain, "run_module", run_module)
monkeypatch.setattr(chain, "async_run_module", async_run_module)
music = _music_info()
result = chain.obtain_images(music)
async_result = asyncio.run(chain.async_obtain_images(music))
assert result is music
assert async_result is music
run_module.assert_not_called()
async_run_module.assert_not_awaited()
def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(monkeypatch):
"""meta 携带 source+media_id 时应走详情分支,不再触发搜索。"""
module = MusicBrainzModule()
meta = MetaMusic(title="晴天", media_source="musicbrainz", media_id="recording-1")
expected = _music_info()
monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected))
recording_search = Mock(return_value=[])
monkeypatch.setattr(module, "_search_recordings", recording_search)
result = module.recognize_media(meta=meta, media_source="musicbrainz")
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
recording_search.assert_not_called()
assert result is expected
def test_musicbrainz_module_recognize_media_matches_search_candidate(monkeypatch):
"""无身份时应从 Recording 搜索中选择匹配候选。"""
module = MusicBrainzModule()
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidate = _music_info()
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[candidate]))
result = module.recognize_media(meta=meta)
assert result is candidate
def test_musicbrainz_module_recognize_media_falls_back_to_offline_when_no_match(monkeypatch):
"""搜索无候选时应返回元数据兜底,且兜底结果不带远端身份。"""
module = MusicBrainzModule()
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[]))
monkeypatch.setattr(module, "_search_albums", Mock(return_value=[]))
result = module.recognize_media(meta=meta)
assert result is not None
assert result.title == "未知曲目"
assert isinstance(result, MusicInfo)
def test_musicbrainz_module_recognize_media_by_music_type_and_media_id(monkeypatch):
"""mtype 为音乐且指定数据源原生 ID 时应直接按音乐详情识别。"""
module = MusicBrainzModule()
expected = _music_info()
monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected))
result = module.recognize_media(
mtype=MediaType.MUSIC,
media_source=MediaSource.MusicBrainz,
media_id="recording-1",
)
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
assert result is expected
def test_musicbrainz_module_async_recognize_media(monkeypatch):
"""异步 MusicBrainz 识别应直接调用异步检索而不进入同步入口。"""
module = MusicBrainzModule()
expected = _music_info()
async_search = AsyncMock(return_value=[expected])
sync_mock = Mock(side_effect=AssertionError("异步识别不应调用同步入口"))
monkeypatch.setattr(module, "_async_search_recordings", async_search)
monkeypatch.setattr(module, "recognize_media", sync_mock)
result = asyncio.run(module.async_recognize_media(
meta=MetaMusic(title="晴天"), mtype=MediaType.MUSIC
))
async_search.assert_awaited_once()
sync_mock.assert_not_called()
assert result is expected
def test_theaudiodb_module_async_recognize_media(monkeypatch):
"""异步 TheAudioDB 识别应直接调用异步检索而不进入同步入口。"""
module = TheAudioDbModule()
expected = MusicInfo(
media_source="theaudiodb",
media_id="track-1",
title="晴天",
)
async_search = AsyncMock(return_value=[expected])
sync_mock = Mock(side_effect=AssertionError("异步识别不应调用同步入口"))
monkeypatch.setattr(module, "_async_search_tracks", async_search)
monkeypatch.setattr(module, "recognize_media", sync_mock)
result = asyncio.run(module.async_recognize_media(
meta=MetaMusic(title="晴天"),
mtype=MediaType.MUSIC,
media_source="theaudiodb",
))
async_search.assert_awaited_once()
sync_mock.assert_not_called()
assert result is expected
def test_chain_recognize_media_returns_musicinfo_and_reports_share():
"""MusicBrainz 自动识别返回的 MusicInfo 应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
with patch.object(
chain, "recognize_music_from_source", return_value=expected
), patch(
"app.adapters.external.server.MoviePilotServerHelper.report_recognize_share"
) as report_mock:
result = chain.recognize_media(meta=MetaMusic(title="晴天"))
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_and_reports_share():
"""异步 MusicBrainz 自动识别结果应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
with patch.object(
chain,
"async_recognize_music_from_source",
AsyncMock(return_value=expected),
), patch(
"app.adapters.external.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_awaited_once()
assert report_mock.call_args.kwargs["mediainfo"] is expected
assert result is expected