Files
MoviePilot/tests/test_music_source_fallback.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

602 lines
29 KiB
Python
Raw Permalink 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.
"""
自动音乐来源顺序、隔离诊断和不可重置的请求预算。
"""
import asyncio
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.music.observation import (
capture_music_recognition,
capture_music_source,
music_request_timeout,
report_music_recognition,
)
from app.application.music.recognition import async_recognize_music_sources, recognize_music_sources
from app.chain.media import MediaChain
from app.chain.media.cache import AlbumDirectoryCache
from app.chain.media.path import _music_path_plan
from app.domain.context import MusicAlbumInfo, MusicInfo
from app.domain.media import music_recognition_sources
from app.domain.meta.metamusic import MetaMusic
from app.modules.douban.apiv2 import DoubanApi
from app.modules.theaudiodb import TheAudioDbModule
from app.schemas.types import MediaSource, MediaType
def _accepts(info, source):
"""
测试入口采用实际的来源与主身份判据,不凭对象非空判定远端命中。
"""
return bool(info and info.media_source == source and info.media_id)
@pytest.mark.parametrize("configured,expected", [
("themoviedb", (MediaSource.MusicBrainz,)),
("theaudiodb,musicbrainz,theaudiodb,doubanmusic", (MediaSource.TheAudioDB, MediaSource.MusicBrainz, MediaSource.DoubanMusic)),
("", (MediaSource.MusicBrainz,)),
("invalid,musicbrainz,anilist", (MediaSource.MusicBrainz,)),
])
def test_configured_music_sources_keep_order_and_ignore_video_sources(configured, expected):
"""
混合来源设置不会把影视来源派发为音乐服务,默认设置仍保持MusicBrainz。
"""
assert music_recognition_sources(configured) == expected
@pytest.mark.parametrize("asynchronous", [False, True])
def test_source_failure_does_not_poison_following_source(asynchronous):
"""
首源故障不阻断后源的实际HTTP额度,两次请求仍计入同一操作总量。
"""
calls = []
def recognize(source):
"""
第一来源记录服务故障,第二来源以自己的身份成功命中。
"""
calls.append(source)
assert music_request_timeout() is not None
if source == MediaSource.MusicBrainz:
report_music_recognition("service_error", "服务暂不可用")
return None
return MusicInfo(media_source=source, media_id="audio-db-track", title="Song")
sources = (MediaSource.MusicBrainz, MediaSource.TheAudioDB, MediaSource.DoubanMusic)
result, diagnostic = asyncio.run(async_recognize_music_sources(sources, AsyncMock(side_effect=recognize), _accepts)) if asynchronous else (
recognize_music_sources(sources, recognize, _accepts))
assert calls == list(sources[:2])
assert result.media_source == MediaSource.TheAudioDB
assert diagnostic["status"] == "matched" and diagnostic["requests"] == 2
assert [row["status"] for row in diagnostic["sources"]] == ["service_error", "matched"]
assert [row["requests"] for row in diagnostic["sources"]] == [1, 1]
@pytest.mark.parametrize("state", ["ambiguous", "conflict"])
def test_ambiguous_or_conflicting_source_stops_weaker_fallback(state):
"""
已知冲突不能因为另一个来源愿意返回首个同名结果而被掩盖。
"""
pending = MusicInfo(title="Song", raw_data={"recognition": {"status": state, "message": "需要确认"}})
recognize = Mock(side_effect=[pending, MusicInfo(media_source="theaudiodb", media_id="wrong", title="Song")])
result, diagnostic = recognize_music_sources((MediaSource.MusicBrainz, MediaSource.TheAudioDB), recognize, _accepts)
assert result is None and diagnostic["status"] == state
assert recognize.call_count == 1
def test_outer_budget_cannot_be_reset_by_changing_source():
"""
来源范围独立失败,但不能通过新建子范围恢复已用完的外层请求额度。
"""
with capture_music_recognition(seconds=5, request_limit=1) as total:
with capture_music_source("first") as first:
assert music_request_timeout() is not None
report_music_recognition("service_error", "失败")
with capture_music_source("second") as second:
assert music_request_timeout() is None
assert total.requests == first.requests == 1
assert second.requests == 0 and second.status == "budget_exhausted"
@pytest.mark.asyncio
async def test_concurrent_source_scopes_claim_shared_budget_atomically():
"""
并行继承同一观察范围时也只能消耗总额度,不能分别领取一份完整预算。
"""
async def claim(source):
"""
让两个来源交错执行后领取共享HTTP名额。
"""
with capture_music_source(source):
await asyncio.sleep(0)
return music_request_timeout()
with capture_music_recognition(seconds=5, request_limit=1) as total:
allowed = await asyncio.gather(claim("one"), claim("two"))
assert sum(value is not None for value in allowed) == total.requests == 1
def test_all_empty_sources_keep_service_error_and_fresh_operation_recovers():
"""
没有成功回退时保留故障语义,下一次独立操作不继承上次状态。
"""
def failed(source):
"""
只有第一个来源出现故障,后面的来源正常无匹配。
"""
if source == MediaSource.MusicBrainz:
raise OSError("offline")
return None
sources = (MediaSource.MusicBrainz, MediaSource.TheAudioDB)
result, diagnostic = recognize_music_sources(sources, failed, _accepts)
assert result is None and diagnostic["status"] == "service_error"
expected = MusicInfo(media_source="musicbrainz", media_id="recording", title="Song")
result, diagnostic = recognize_music_sources(sources, lambda _source: expected, _accepts)
assert result is expected and diagnostic["status"] == "matched"
@pytest.mark.parametrize("asynchronous", [False, True])
def test_native_recognition_uses_configured_fallback_without_mutating_provider_result(monkeypatch, asynchronous):
"""
自动识别使用配置顺序;成功保留来源ID,附加诊断不会污染来源缓存对象。
"""
chain = object.__new__(MediaChain)
monkeypatch.setattr("app.chain.media.recognition.get_chain_runtime_config_snapshot",
lambda: SimpleNamespace(search_source="musicbrainz,theaudiodb,doubanmusic"))
expected = MusicInfo(media_source="theaudiodb", media_id="track-42", title="Song")
callback = AsyncMock(side_effect=[None, expected]) if asynchronous else Mock(side_effect=[None, expected])
monkeypatch.setattr(chain, "async_recognize_music_from_source" if asynchronous else "recognize_music_from_source", callback)
kwargs = {"meta": MetaMusic(title="Song", artists=["Artist"]), "mtype": MediaType.MUSIC}
result = asyncio.run(chain._async_run_native_media_recognize(kwargs, True)) if asynchronous else chain._run_native_media_recognize(kwargs, True)
assert result.media_source == MediaSource.TheAudioDB and result.media_id == "track-42"
assert [call.kwargs["media_source"] for call in callback.call_args_list] == [MediaSource.MusicBrainz, MediaSource.TheAudioDB]
assert all(call.kwargs.get("media_id") is None for call in callback.call_args_list)
assert result.raw_data["recognition"]["status"] == "matched"
assert expected.raw_data == {} and result is not expected
@pytest.mark.parametrize("explicit", [False, True])
def test_bound_or_explicit_identity_does_not_cross_sources(monkeypatch, explicit):
"""
已绑定身份经过真实请求规划后仍固定来源,不用其它来源解释同一个ID。
"""
chain = object.__new__(MediaChain)
monkeypatch.setattr("app.chain.media.recognition.get_chain_runtime_config_snapshot",
lambda: SimpleNamespace(search_source="theaudiodb,musicbrainz,doubanmusic"))
meta = MetaMusic(title="Song", media_source=None if explicit else "musicbrainz", media_id=None if explicit else "recording")
plan = chain._build_recognition_plan(meta=meta, mtype=MediaType.MUSIC,
media_source=MediaSource.MusicBrainz if explicit else None,
media_id="recording" if explicit else None, episode_group=None,
cache=True, share_meta=None, music_type="recording")
expected = MusicInfo(media_source="musicbrainz", media_id="recording", title="Song")
callback = Mock(return_value=expected)
monkeypatch.setattr(chain, "recognize_music_from_source", callback)
assert chain._run_native_media_recognize(plan.module_kwargs(), True) is expected
callback.assert_called_once()
assert callback.call_args.kwargs["media_source"] == MediaSource.MusicBrainz
assert callback.call_args.kwargs["media_id"] == "recording"
def test_unbound_track_with_explicit_release_evidence_stays_in_musicbrainz(monkeypatch):
"""
只有发行MBID时也不能将其映射到其它来源的同名专辑。
"""
chain = object.__new__(MediaChain)
monkeypatch.setattr("app.chain.media.recognition.get_chain_runtime_config_snapshot",
lambda: SimpleNamespace(search_source="theaudiodb,musicbrainz"))
callback = Mock(return_value=None)
monkeypatch.setattr(chain, "recognize_music_from_source", callback)
chain._run_native_media_recognize({"meta": MetaMusic(title="Song", musicbrainz_release_id="known-release")}, True)
callback.assert_called_once()
assert callback.call_args.kwargs["media_source"] == MediaSource.MusicBrainz
@pytest.mark.parametrize("state", ["ambiguous", "conflict", "service_error", "budget_exhausted"])
def test_path_tiers_preserve_unresolved_diagnostics(monkeypatch, state):
"""
无远端ID的诊断不能在版本校验中丢失,再用文件名启动一轮弱查询。
"""
chain = object.__new__(MediaChain)
meta = MetaMusic(title="Song")
pending = MusicInfo(title="Song", raw_data={"recognition": {"status": state}})
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=pending))
assert chain._recognize_music_meta_tier(meta, None, "标签") is pending
plan = _music_path_plan(meta, MetaMusic(title="filename"), None)
next(plan)
plan.send(None)
with pytest.raises(StopIteration) as finished:
plan.send(pending)
assert finished.value.value is pending
def test_ambiguity_cannot_be_overwritten_by_plugin_or_shared_identity():
"""
原生已判定歧义时,真实插件规划和共享状态机都必须保留待确认结果。
"""
chain = object.__new__(MediaChain)
chain.eventmanager = Mock()
meta = MetaMusic(title="Song")
pending = MusicInfo(title="Song", raw_data={"recognition": {"status": "ambiguous"}})
assert chain._supplement_media_recognize(meta, MediaType.MUSIC, None, None, pending) is pending
chain.eventmanager.check.assert_not_called()
plan = chain._build_recognition_plan(meta=meta, mtype=MediaType.MUSIC, media_source=None, media_id=None,
episode_group=None, cache=True, share_meta=None, music_type="recording")
steps = chain._recognition_steps(plan, True)
next(steps)
steps.send(pending)
with pytest.raises(StopIteration) as finished:
steps.send(pending)
assert finished.value.value is pending
class _Response:
"""
模拟HTTP响应及明确的资源释放,不允许测试访问真实服务。
"""
def __init__(self, payload, status=200):
"""
保存响应内容及关闭状态。
"""
self.payload, self.status_code, self.closed = payload, status, False
self.content, self.headers, self.text = b"json", {}, ""
def json(self):
"""
返回已设定的JSON结构。
"""
return self.payload
def close(self):
"""
标记同步响应释放。
"""
self.closed = True
async def aclose(self):
"""
标记异步响应释放。
"""
self.closed = True
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("source", ["theaudiodb", "doubanmusic"])
async def test_secondary_provider_counts_requests_and_recovers_uncached_failure(monkeypatch, source, asynchronous):
"""
次级来源真实HTTP边界也服从预算,故障后同一请求可恢复而不会命中长负缓存。
"""
failed, recovered = _Response({}, 503), _Response({"album": [{"id": "ok"}]})
network = (AsyncMock if asynchronous else Mock)(side_effect=[failed, recovered])
client = SimpleNamespace(get_res=network)
if source == "theaudiodb":
getattr(TheAudioDbModule._request_json, "cache_clear")()
monkeypatch.setattr("app.modules.theaudiodb.AsyncRequestUtils" if asynchronous else "app.modules.theaudiodb.RequestUtils",
lambda **_kwargs: client)
async def request(query):
"""
通过实际同步或异步请求入口验证共享缓存。
"""
return await TheAudioDbModule._async_request_json("searchalbum.php", {"a": query}) if asynchronous else (
TheAudioDbModule._request_json("searchalbum.php", {"a": query}))
else:
api = object.__new__(DoubanApi)
api._request = client
api._user_agents = ["test"]
monkeypatch.setattr(api, "_prepare_get_request", lambda url, **kwargs: (url, kwargs))
getattr(api._music_get, "cache_clear")()
monkeypatch.setattr("app.modules.douban.apiv2.AsyncRequestUtils", lambda **_kwargs: client)
async def request(query):
"""
音乐API请求仍经过实际解析和关闭逻辑。
"""
return await api.async_music_search(query) if asynchronous else api.music_search(query)
try:
with capture_music_recognition(seconds=5, request_limit=1) as first:
assert await request("same") is None
assert first.requests == 1 and first.status == "service_error" and failed.closed
with capture_music_recognition(seconds=5, request_limit=1) as second:
assert await request("same") == recovered.payload
assert await request("other") is None
assert await request("same") == recovered.payload
assert second.requests == 1 and second.status == "budget_exhausted"
assert network.call_count == 2 and recovered.closed
finally:
if source == "theaudiodb":
getattr(TheAudioDbModule._request_json, "cache_clear")()
else:
getattr(api._music_get, "cache_clear")()
def _directory_setup(tmp_path, monkeypatch, sources, *, known_album=True):
"""
使用真实无标签FLAC和独立媒体链测试整专来源选择,不注册全局单例。
"""
directory = tmp_path / ("Artist - Album" if known_album else "Music")
directory.mkdir()
for index, title in enumerate(("First", "Second"), 1):
name = f"{index:02} - {title}.flac" if known_album else f"{index:02} - Artist - {title}.flac"
shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", directory / name)
config = {"sources": sources}
monkeypatch.setattr("app.chain.media.album.get_chain_runtime_config_snapshot", lambda: SimpleNamespace(
search_source=config["sources"], audio_extensions=(".flac",), music_cue_enable=True,
music_release_region_priority=("CN",), music_release_script_priority=("Hans",)))
chain = object.__new__(MediaChain)
chain._album_dir_cache = AlbumDirectoryCache(8)
monkeypatch.setattr(chain, "_finalize_recognition_result", lambda info, **_kwargs: info)
monkeypatch.setattr(chain, "_async_finalize_recognition_result", AsyncMock(side_effect=lambda info, **_kwargs: info))
primary = SimpleNamespace(match_music_album=Mock(return_value=None), async_match_music_album=AsyncMock(return_value=None))
monkeypatch.setattr("app.chain.media.album.MusicBrainzChain", lambda: primary)
return directory, chain, config, primary
def _album(source, identity="album-one", *, tracks=True):
"""
构造同一来源命名空间内的完整专辑和原生曲目身份。
"""
album = MusicAlbumInfo(media_source=source, media_id=identity, title="Album", artists=["Artist"])
if tracks:
album.tracks = [MusicInfo(media_source=source, media_id=f"{identity}-track-{index}", title=title,
artists=["Artist"], album="Album", album_id=identity,
track_number=index, disc_number=1, duration=3)
for index, title in enumerate(("First", "Second"), 1)]
return album
def _backend(albums):
"""
模拟来源链的读边界,真正的搜索计划、候选核验和文件对位继续运行。
"""
by_id = {album.media_id: album for album in albums}
cards = [album.to_music_info() for album in albums]
return SimpleNamespace(search_music=Mock(return_value=cards), async_search_music=AsyncMock(return_value=cards),
get_music_album=Mock(side_effect=by_id.get), async_get_music_album=AsyncMock(side_effect=by_id.get))
@pytest.mark.parametrize("asynchronous", [False, True])
def test_directory_fallback_requires_complete_tracks_and_keeps_source_identity(tmp_path, monkeypatch, asynchronous):
"""
首源故障时可按完整次级曲目表整理;匹配身份始终属于该次级来源。
"""
directory, chain, _config, primary = _directory_setup(tmp_path, monkeypatch, "musicbrainz,theaudiodb")
def fail_primary(*_args, **_kwargs):
"""
复现主元数据源短暂不可用。
"""
report_music_recognition("service_error", "临时不可用")
return None
primary.match_music_album.side_effect = fail_primary
primary.async_match_music_album.side_effect = fail_primary
backend = _backend([_album(MediaSource.TheAudioDB)])
monkeypatch.setattr(chain, "_music_source_chain", lambda _source: backend)
result = asyncio.run(chain.async_recognize_music_album_directory(directory)) if asynchronous else chain.recognize_music_album_directory(directory)
assert len(result) == 2 and result.recognition["status"] == "matched"
assert [row["status"] for row in result.recognition["sources"]] == ["service_error", "matched"]
assert {info.media_source for info in result.values()} == {MediaSource.TheAudioDB}
assert {info.media_id for info in result.values()} == {"album-one-track-1", "album-one-track-2"}
assert all(info.musicbrainz_release_id is None and info.raw_data["recognition"]["release_verified"] is False for info in result.values())
@pytest.mark.parametrize("case", ["no_tracks", "ambiguous", "wrong_source", "wrong_duration"])
def test_directory_does_not_invent_alignment_or_pick_first_album(tmp_path, monkeypatch, case):
"""
没有曲目、等价多候选、混源ID及明显时长冲突都不能产生自动文件映射。
"""
directory, chain, _config, _primary = _directory_setup(tmp_path, monkeypatch, "theaudiodb")
album = _album(MediaSource.TheAudioDB, tracks=case != "no_tracks")
albums = [album, _album(MediaSource.TheAudioDB, "other")] if case == "ambiguous" else [album]
backend = _backend(albums)
if case == "wrong_source":
backend.get_music_album.side_effect = lambda _id: _album(MediaSource.DoubanMusic)
if case == "wrong_duration":
album.tracks[0].duration = 200
monkeypatch.setattr(chain, "_music_source_chain", lambda _source: backend)
result = chain.recognize_music_album_directory(directory)
assert not result
assert result.recognition["status"] == ("ambiguous" if case == "ambiguous" else "not_found")
def test_source_configuration_change_invalidates_directory_negative_cache(tmp_path, monkeypatch):
"""
切换来源后必须重新匹配,不能复用同一目录在旧来源中的无匹配结果。
"""
directory, chain, config, primary = _directory_setup(tmp_path, monkeypatch, "musicbrainz")
backend = _backend([_album(MediaSource.TheAudioDB)])
monkeypatch.setattr(chain, "_music_source_chain", lambda _source: backend)
assert not chain.recognize_music_album_directory(directory)
config["sources"] = "theaudiodb"
result = chain.recognize_music_album_directory(directory)
assert len(result) == 2
primary.match_music_album.assert_called_once()
backend.search_music.assert_called_once()
def test_directory_lookup_freezes_source_choice_with_cache_key(tmp_path, monkeypatch):
"""
配置在缓存键生成后改变,也不能将新来源的结果存入旧来源键。
"""
directory, chain, config, _primary = _directory_setup(tmp_path, monkeypatch, "theaudiodb")
backends = {source: _backend([_album(source)]) for source in (MediaSource.TheAudioDB, MediaSource.DoubanMusic)}
monkeypatch.setattr(chain, "_music_source_chain", backends.get)
original = chain._album_dir_cache.resolve
def change_config(key, signature, resolver):
"""
在真实缓存准备加载时切换下一次请求的来源。
"""
config["sources"] = "doubanmusic"
return original(key, signature, resolver)
monkeypatch.setattr(chain._album_dir_cache, "resolve", change_config)
first = chain.recognize_music_album_directory(directory)
second = chain.recognize_music_album_directory(directory)
assert {info.media_source for info in first.values()} == {MediaSource.TheAudioDB}
assert {info.media_source for info in second.values()} == {MediaSource.DoubanMusic}
def test_unknown_album_can_be_discovered_from_two_track_names(tmp_path, monkeypatch):
"""
专辑目录名无意义时,使用真实曲名返回的同来源album_id汇总候选再完整核验。
"""
directory, chain, _config, _primary = _directory_setup(tmp_path, monkeypatch, "theaudiodb", known_album=False)
album = _album(MediaSource.TheAudioDB)
backend = _backend([album])
backend.search_music.side_effect = lambda meta, **_kwargs: [track for track in album.tracks if track.title == meta.title]
monkeypatch.setattr(chain, "_music_source_chain", lambda _source: backend)
result = chain.recognize_music_album_directory(directory)
assert len(result) == 2
assert backend.search_music.call_count == 2
backend.get_music_album.assert_called_once_with("album-one")
def test_secondary_recording_candidates_are_not_selected_by_response_order():
"""
同名同艺人的不同原生ID没有额外证据时必须保持歧义。
"""
candidates = [MusicInfo(media_source="theaudiodb", media_id=identity, title="Song", artists=["Artist"])
for identity in ("one", "two")]
with capture_music_recognition() as observed:
assert TheAudioDbModule._select_track(MetaMusic(title="Song", artists=["Artist"]), candidates) is None
assert observed.status == "ambiguous" and len(observed.candidates) == 2
@pytest.mark.parametrize("asynchronous", [False, True])
def test_secondary_catalog_preserves_local_multidisc_edition(tmp_path, monkeypatch, asynchronous):
"""
真实标签的再版年及双碟曲序不能被次级目录的单碟首版布局覆盖。
"""
from mutagen.flac import FLAC
directory, chain, _config, _primary = _directory_setup(tmp_path, monkeypatch, "theaudiodb")
files = sorted(directory.glob("*.flac"))
for disc, path in enumerate(files, 1):
tags = FLAC(path)
tags.update(title=["First" if disc == 1 else "Second"], album=["Album"], artist=["Unknown Artist"],
albumartist=["Artist"], discnumber=[str(disc)], disctotal=["2"], tracknumber=["1"],
tracktotal=["1"], date=["2020-09-01"], originaldate=["2001"])
tags.save()
album = _album(MediaSource.TheAudioDB)
album.release_date = "2001"
for track in album.tracks:
track.year, track.release_year, track.original_year = 2001, None, 2001
track.total_tracks, track.total_discs = 2, 1
backend = _backend([album])
monkeypatch.setattr(chain, "_music_source_chain", lambda _source: backend)
result = asyncio.run(chain.async_recognize_music_album_directory(directory)) if asynchronous else chain.recognize_music_album_directory(directory)
assert len(result) == 2
for disc, path in enumerate(files, 1):
info = result[str(path.resolve())]
assert (info.disc_number, info.track_number, info.total_tracks, info.total_discs) == (disc, 1, 1, 2)
assert (info.year, info.release_year, info.original_year) == (2020, 2020, 2001)
assert info.field_sources["release_year"] == "tag"
assert info.media_id == f"album-one-track-{disc}"
assert info.musicbrainz_release_id is None
assert [track.track_number for track in album.tracks] == [1, 2]
assert [track.year for track in album.tracks] == [2001, 2001]
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("music_type", ["album", "recording"])
def test_douban_distinct_matching_identities_require_confirmation(asynchronous, music_type):
"""
同步和异步豆瓣搜索遇到同名同署名的不同身份时都不能取第一个。
"""
from app.modules.douban import DoubanModule
module = object.__new__(DoubanModule)
cards = [{"id": identity, "title": "Album", "artists": [{"name": "Artist"}]} for identity in ("100", "200")]
def detail(subject_id):
"""
提供同证据的两个专辑曲目表。
"""
return {"id": subject_id, "title": "Album", "singer": [{"name": "Artist"}],
"songs": [{"title": "First", "track_number": 1}]}
module.doubanapi = SimpleNamespace(music_search=Mock(return_value={"items": cards}),
async_music_search=AsyncMock(return_value={"items": cards}),
music_detail=Mock(side_effect=detail), async_music_detail=AsyncMock(side_effect=detail))
meta = MetaMusic(title="First", album="Album", artists=["Artist"], track_number=1, music_type=music_type)
with capture_music_recognition() as observation:
result = asyncio.run(module._async_recognize_music_media(meta, MediaSource.DoubanMusic, None, music_type)) if asynchronous else (
module._recognize_music_media(meta, MediaSource.DoubanMusic, None, music_type))
assert result is None and observation.status == "ambiguous"
assert len(observation.candidates) == 2
if music_type == "album":
module.doubanapi.music_detail.assert_not_called()
module.doubanapi.async_music_detail.assert_not_called()
@pytest.mark.parametrize("state", ["budget_exhausted", "service_error", "ambiguous", "conflict"])
def test_source_cache_cannot_turn_failed_parent_into_success(state):
"""
外层已中断的识别不能因后续来源命中缓存而返回成功身份。
"""
with capture_music_recognition() as observation:
report_music_recognition(state, "外层已中断")
result, diagnostic = recognize_music_sources((MediaSource.TheAudioDB,), lambda source: MusicInfo(
media_source=source, media_id="cached", title="First"), _accepts)
assert result is None
assert diagnostic["status"] == observation.status == state
@pytest.mark.parametrize("asynchronous", [False, True])
def test_directory_does_not_confirm_truncated_candidates(tmp_path, monkeypatch, asynchronous):
"""
超过有界详情核验范围时保留歧义,不能仅凭前五个候选宣布唯一。
"""
directory, chain, _config, _primary = _directory_setup(tmp_path, monkeypatch, "theaudiodb")
backend = _backend([_album(MediaSource.TheAudioDB, str(index + 10)) for index in range(6)])
monkeypatch.setattr(chain, "_music_source_chain", lambda _source: backend)
result = asyncio.run(chain.async_recognize_music_album_directory(directory)) if asynchronous else chain.recognize_music_album_directory(directory)
assert not result and result.recognition["status"] == "ambiguous"
backend.get_music_album.assert_not_called()
backend.async_get_music_album.assert_not_called()
@pytest.mark.parametrize("asynchronous", [False, True])
def test_empty_directory_diagnostics_survive_single_file_fallback(tmp_path, monkeypatch, asynchronous):
"""
空目录映射仍须把待确认原因送到单文件识别,并保留本地展示信息。
"""
from app.chain.media.path import _finalize_music_path_info
from app.domain.music import MusicDirectoryMatch
path = tmp_path / "First.flac"
path.write_bytes(b"test")
chain = object.__new__(MediaChain)
pending = MusicDirectoryMatch(recognition={"status": "ambiguous", "message": "多个专辑"})
monkeypatch.setattr(chain, "recognize_music_album_directory", Mock(return_value=pending))
monkeypatch.setattr(chain, "async_recognize_music_album_directory", AsyncMock(return_value=pending))
result = asyncio.run(chain._async_music_album_dir_fallback(path)) if asynchronous else chain._music_album_dir_fallback(path)
result = _finalize_music_path_info(MetaMusic(title="First", artists=["Artist"]), result)
assert result.title == "First" and result.artists == ["Artist"]
assert result.media_id is None and result.raw_data["recognition"] == pending.recognition