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

826 lines
31 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""
TheAudioDB 与豆瓣音乐识别源的标准化和路由测试。
"""
from unittest.mock import AsyncMock, Mock, call
import pytest
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.domain.music import match_music_resource
from app.modules.douban import DoubanModule
from app.modules.theaudiodb import TheAudioDbModule
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType
def test_theaudiodb_module_maps_track_and_album(monkeypatch):
"""
TheAudioDB 原生响应应保留来源 ID,并换算毫秒时长。
"""
module = TheAudioDbModule()
request = Mock(side_effect=[
{
"track": [{
"idTrack": "32793500",
"strTrack": "Yellow",
"strArtist": "Coldplay",
"idArtist": "111239",
"idAlbum": "2109619",
"strAlbum": "Parachutes",
"intTrackNumber": "5",
"intDuration": "269000",
"strTrackThumb": "https://www.theaudiodb.com/images/track.jpg",
}]
},
{"album": []},
{"artists": []},
])
monkeypatch.setattr(module, "_request_json", request)
results = module.search_music(
MetaMusic(title="Yellow", artists=["Coldplay"]),
media_source=MediaSource.TheAudioDB,
)
assert results and len(results) == 1
assert results[0].media_source == "theaudiodb"
assert results[0].media_id == "32793500"
assert results[0].album_id == "2109619"
assert results[0].duration == 269
assert module.get_subtype() == MediaRecognizeType.TheAudioDB
def _selected_music_candidate(source, music_type, meta, candidate):
"""
使用各来源真实确认入口,保留专辑与曲目各自的数据结构。
"""
if source == MediaSource.TheAudioDB:
if music_type == "recording":
return TheAudioDbModule._select_track(meta, [candidate])
album = MusicAlbumInfo.from_dict(candidate.to_dict())
return TheAudioDbModule._select_album(meta, [album])
if music_type == "recording":
return DoubanModule._select_douban_music_track(meta, MusicAlbumInfo(tracks=[candidate]))
candidates = DoubanModule._matching_music_candidates(meta, [candidate])
plan = DoubanModule._music_recognition_plan(meta, source, None, "album")
return DoubanModule._direct_music_candidate(plan, candidates[0]) if candidates else None
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("title,artists,candidate_title,candidate_artists", [
("永遠是朋友", ["周華健"], "永远是朋友", ["周华健"]),
("Example Work", ["AC", "DC"], "Example Work", ["AC/DC"]),
("Example Work", ["Beyonce"], "Example Work", ["Beyoncé"]),
("Fine Day", ["Jay Chou"], "晴天", ["周杰倫"]),
])
def test_alternate_sources_share_music_name_and_artist_rules(source, music_type, title, artists,
candidate_title, candidate_artists):
"""
备选来源确认同样支持繁简、变音符、复合艺名和来自同一实体的可信别名。
"""
meta = MetaMusic(title=title, artists=artists)
candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type,
title=candidate_title, artists=candidate_artists,
title_aliases=["Fine Day"], artist_aliases=["Jay Chou"])
result = _selected_music_candidate(source, music_type, meta, candidate)
assert result and result.media_id == "candidate"
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("candidate_title,matched", [
("Example Work", False), ("Example Work (Live)", True), ("Example Work(Live)", True),
])
def test_alternate_sources_verify_recording_versions(source, music_type, candidate_title, matched):
"""
标题中的版本与独立版本字段可以互认,但现场版不能被确认成普通录音。
"""
meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live")
candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type,
title=candidate_title, artists=["Artist"])
assert bool(_selected_music_candidate(source, music_type, meta, candidate)) is matched
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("requested,candidate_title", [
("Example Work (Deluxe Edition)", "Example Work"),
("Example Work", "Example Work (Deluxe Edition)"),
("Example Work (Live Deluxe Edition)", "Example Work (Live)"),
])
def test_alternate_sources_keep_existing_edition_boundaries(source, music_type, requested, candidate_title):
"""
共用录音版本规则时不能放宽备选来源原有的发行版名称约束。
"""
candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type,
title=candidate_title, artists=["Artist"])
meta = MetaMusic(title=requested, artists=["Artist"])
assert _selected_music_candidate(source, music_type, meta, candidate) is None
def test_theaudiodb_preserves_source_track_alias_for_all_matching():
"""
TheAudioDB 已提供的曲名别名应同时用于元数据确认和资源搜索匹配。
"""
candidate = TheAudioDbModule._track_to_info({
"idTrack": "1", "strTrack": "晴天", "strTrackAlternate": "Fine Day", "strArtist": "Artist",
})
assert candidate.title_aliases == ["Fine Day"]
assert TheAudioDbModule._select_track(MetaMusic(title="Fine Day", artists=["Artist"]), [candidate]) is candidate
assert match_music_resource(candidate, "Artist - Fine Day FLAC").status == "exact"
def test_theaudiodb_album_query_and_matching_use_album_artist():
"""
合辑的专辑艺人用于专辑检索和确认,单曲查询则保持独立表演者署名。
"""
meta = MetaMusic(title="Song", artists=["Performer"], album="Sampler", album_artist="Various Artists")
album = MusicAlbumInfo(title="Sampler", artists=["Various Artists"])
assert TheAudioDbModule._track_search_params(meta) == {"t": "Song", "s": "Performer"}
assert TheAudioDbModule._album_search_params(meta) == {"a": "Sampler", "s": "Various Artists"}
assert TheAudioDbModule._select_album(meta, [album]) is album
def test_theaudiodb_isrc_identity_precedes_title_matches():
"""
明确的 ISRC 录音身份必须优先于首条同名候选。
"""
meta = MetaMusic(title="Song", artists=["Artist"], isrc="USABC2600001")
first = MusicInfo(title="Song", artists=["Artist"], media_source="theaudiodb", media_id="wrong")
exact = MusicInfo(title="Alternate Name", artists=["Other Credit"], media_source="theaudiodb",
media_id="correct", isrc="USABC2600001")
assert TheAudioDbModule._select_track(meta, [first, exact]) is exact
def test_theaudiodb_placeholder_isrc_does_not_override_music_evidence():
"""
外部数据中的相同 ISRC 占位值不能让另一首作品抢占正确候选。
"""
meta = MetaMusic(title="Song", artists=["Artist"], isrc="N/A")
first = MusicInfo(title="Other Song", artists=["Other Artist"], isrc="N/A")
correct = MusicInfo(title="Song", artists=["Artist"])
assert TheAudioDbModule._select_track(meta, [first, correct]) is correct
@pytest.mark.asyncio
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("async_mode", [False, True])
async def test_alternate_source_entrypoints_select_the_matching_version(source, music_type, async_mode):
"""
同步异步入口都跳过首条普通版本,并从真实响应投影中确认后续现场版本。
"""
meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live")
if music_type == "album":
meta.album = meta.title
if source == MediaSource.TheAudioDB:
module = TheAudioDbModule()
payload = {
"track": [{"idTrack": "wrong", "strTrack": "Example Work", "strArtist": "Artist"},
{"idTrack": "right", "strTrack": "Example Work (Live)", "strArtist": "Artist"}],
"album": [{"idAlbum": "wrong", "strAlbum": "Example Work", "strArtist": "Artist"},
{"idAlbum": "right", "strAlbum": "Example Work (Live)", "strArtist": "Artist"}],
}
module._request_json = Mock(return_value=payload)
module._async_request_json = AsyncMock(return_value=payload)
expected_id = "right"
else:
module = DoubanModule()
if music_type == "recording":
meta.album = "Sampler"
search = {"items": [{"id": "parent", "title": "Sampler", "artists": [{"name": "Artist"}]}]}
expected_id = "parent:2"
else:
search = {"items": [
{"id": "wrong", "title": "Example Work", "artists": [{"name": "Artist"}]},
{"id": "right", "title": "Example Work (Live)", "artists": [{"name": "Artist"}]},
]}
expected_id = "right"
detail = {"id": "parent", "title": "Sampler", "artists": [{"name": "Artist"}],
"songs": [{"title": "Example Work"}, {"title": "Example Work (Live)"}]}
module.doubanapi = Mock(
music_search=Mock(return_value=search), music_detail=Mock(return_value=detail),
async_music_search=AsyncMock(return_value=search), async_music_detail=AsyncMock(return_value=detail),
)
if async_mode:
result = await module.async_recognize_media(meta=meta, media_source=source, music_type=music_type)
else:
result = module.recognize_media(meta=meta, media_source=source, music_type=music_type)
assert result and result.media_id == expected_id
assert result.music_type == music_type
if source == MediaSource.TheAudioDB:
assert module._request_json.call_count == (0 if async_mode else 1)
assert module._async_request_json.await_count == (1 if async_mode else 0)
else:
assert module.doubanapi.music_search.call_count == (0 if async_mode else 1)
assert module.doubanapi.async_music_search.await_count == (1 if async_mode else 0)
assert module.doubanapi.music_detail.call_count == (1 if not async_mode and music_type == "recording" else 0)
assert module.doubanapi.async_music_detail.await_count == (1 if async_mode and music_type == "recording" else 0)
def test_theaudiodb_module_ignores_other_sources(monkeypatch):
"""
显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。
"""
module = TheAudioDbModule()
request = Mock()
monkeypatch.setattr(module, "_request_json", request)
searched = module.search_music(
MetaMusic(title="Yellow"), media_source=MediaSource.MusicBrainz
)
recognized = module.recognize_media(
meta=MetaMusic(title="Yellow"),
media_source="doubanmusic",
)
assert searched is None
assert recognized is None
request.assert_not_called()
@pytest.mark.parametrize("module_class", [DoubanModule, TheAudioDbModule])
def test_music_search_modules_accept_entity_filter_from_shared_dispatch(module_class):
"""
共享模块调度器广播实体过滤参数时,各内置音乐来源都必须保持签名兼容。
"""
module = module_class()
assert module.search_music(
MetaMusic(title="Yellow"),
media_source=MediaSource.MusicBrainz,
music_types=("recording", "album"),
) is None
def test_theaudiodb_title_only_search_skips_incomplete_track_and_album_requests(monkeypatch):
"""
缺少艺术家时只搜索艺术家,避免请求会返回空正文的曲目和专辑接口。
"""
module = TheAudioDbModule()
request = Mock(return_value={"artists": []})
monkeypatch.setattr(module, "_request_json", request)
results = module.search_music(
MetaMusic(title="Yellow"),
media_source=MediaSource.TheAudioDB,
)
assert results == []
request.assert_called_once_with("search.php", {"s": "Yellow"})
def test_theaudiodb_search_uses_album_artist_for_required_artist_parameter(monkeypatch):
"""
音轨没有独立艺术家时应使用专辑艺术家补齐 TheAudioDB 必填参数。
"""
module = TheAudioDbModule()
request = Mock(side_effect=[{"track": []}, {"album": []}])
monkeypatch.setattr(module, "_request_json", request)
meta = MetaMusic(
title="Yellow",
album="Parachutes",
album_artist="Coldplay",
)
assert module._search_tracks(meta) == []
assert module._search_albums(meta) == []
assert request.call_args_list == [
call("searchtrack.php", {"t": "Yellow", "s": "Coldplay"}),
call("searchalbum.php", {"a": "Parachutes", "s": "Coldplay"}),
]
def test_theaudiodb_empty_response_is_soft_failure_and_closes_response(monkeypatch):
"""
TheAudioDB 返回 HTTP 200 空正文时应软失败并及时关闭同步连接。
"""
response = Mock(
status_code=200,
content=b"",
text="",
headers={"Content-Type": "text/html; charset=UTF-8"},
)
get_res = Mock(return_value=response)
monkeypatch.setattr("app.modules.theaudiodb.RequestUtils.get_res", get_res)
TheAudioDbModule._request_json.cache_clear()
result = TheAudioDbModule._request_json("searchtrack.php", {"t": "Yellow"})
assert result is None
response.json.assert_not_called()
response.close.assert_called_once_with()
@pytest.mark.asyncio
async def test_theaudiodb_async_empty_response_is_soft_failure_and_closes_response(monkeypatch):
"""
TheAudioDB 返回 HTTP 200 空正文时应软失败并及时关闭异步连接。
"""
response = Mock(
status_code=200,
content=b"",
text="",
headers={"Content-Type": "text/html; charset=UTF-8"},
)
response.aclose = AsyncMock()
get_res = AsyncMock(return_value=response)
monkeypatch.setattr("app.modules.theaudiodb.AsyncRequestUtils.get_res", get_res)
await TheAudioDbModule._async_request_json.cache_clear()
result = await TheAudioDbModule._async_request_json(
"searchalbum.php",
{"a": "Parachutes"},
)
assert result is None
response.json.assert_not_called()
response.aclose.assert_awaited_once_with()
def test_theaudiodb_detail_respects_requested_entity(monkeypatch):
"""
TheAudioDB 数值 ID 必须按显式实体调用对应接口,不能跨表探测。
"""
module = TheAudioDbModule()
request = Mock(return_value={"album": []})
monkeypatch.setattr(module, "_request_json", request)
result = module.recognize_music(
"theaudiodb",
"2109619",
music_type=MUSIC_ENTITY_ALBUM,
)
assert result is None
request.assert_called_once_with("album.php", {"m": "2109619"})
def test_theaudiodb_album_related_excludes_current_album(monkeypatch):
"""
TheAudioDB 关联专辑应按当前专辑艺术家查询并排除自身。
"""
module = TheAudioDbModule()
request = Mock(side_effect=[
{"album": [{"idAlbum": "album-1", "idArtist": "artist-1"}]},
{
"album": [
{"idAlbum": "album-1", "strAlbum": "Current"},
{"idAlbum": "album-2", "strAlbum": "Related"},
]
},
])
monkeypatch.setattr(module, "_request_json", request)
results = module.music_album_related("theaudiodb", "album-1", count=10)
assert results and [item.media_id for item in results] == ["album-2"]
assert request.call_args_list[1].args == ("album.php", {"i": "artist-1"})
def test_douban_detail_rejects_album_id_as_recording(monkeypatch):
"""
豆瓣单曲使用专辑加曲序复合 ID,纯专辑 ID 不能作为 Recording。
"""
module = DoubanModule()
module.doubanapi = Mock()
result = module.recognize_music(
"doubanmusic",
"1401853",
music_type="recording",
)
assert result is None
module.doubanapi.music_detail.assert_not_called()
def test_douban_music_search_and_album_mapping(monkeypatch):
"""
豆瓣模块应把音乐条目映射为专辑,并生成可用于曲目识别的复合 ID。
"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.music_search.return_value = {
"items": [{
"target_type": "music",
"target": {
"id": "1401853",
"title": "范特西",
# 豆瓣真实搜索响应只在卡片副标题提供艺术家和年份。
"card_subtitle": "周杰伦 / 2001",
},
}]
}
module.doubanapi.music_detail.return_value = {
"id": "1401853",
"title": "范特西",
"singer": [{"id": "1050015", "name": "周杰伦"}],
"pubdate": ["2001-09-14"],
"media": ["CD"],
"publisher": ["阿尔发音乐"],
"songs": [
{
"title": "爱在西元前",
"track_number": 1,
"artist_names": ["周杰伦"],
"duration": 221,
},
{
"title": "爸我回来了",
"track_number": 2,
"artist_names": ["周杰伦"],
"cover_url": "https://img.example/track.jpg",
},
],
"rating": {"average": "9.4", "numRaters": "12345"},
}
results = module.search_music(
MetaMusic(title="范特西", artists=["周杰伦"]),
media_source=MediaSource.DoubanMusic,
)
album = module.music_album("doubanmusic", "1401853")
assert results and results[0].media_source == "doubanmusic"
assert results[0].music_type == MUSIC_ENTITY_ALBUM
assert results[0].artists == ["周杰伦"]
assert results[0].artist == "周杰伦"
assert results[0].album_artist == "周杰伦"
assert album and album.media_source == "doubanmusic"
assert album.year == 2001
assert album.artists == ["周杰伦"]
assert album.album_type == "CD"
assert [track.media_id for track in album.tracks] == ["1401853:1", "1401853:2"]
assert album.tracks[0].title == "爱在西元前"
assert album.tracks[0].duration == 221
assert album.tracks[1].cover_url == "https://img.example/track.jpg"
@pytest.mark.parametrize(
("subtitle", "expected"),
[
("Mr Hudson Vic Mensa / 2017", ["Mr Hudson Vic Mensa"]),
("周杰伦 / 2001 / CD / 阿尔发音乐", ["周杰伦"]),
("周杰伦", ["周杰伦"]),
("2007", []),
],
)
def test_douban_music_card_subtitle_artist_fallback(subtitle, expected):
"""
搜索副标题回退应保留完整署名,且不能把纯年份误判为艺术家。
"""
assert DoubanModule._douban_music_search_artists({"card_subtitle": subtitle}) == expected
def test_douban_music_search_prefers_structured_artists_over_card_subtitle():
"""
搜索响应已有结构化艺术家时不能被卡片副标题覆盖。
"""
assert DoubanModule._douban_music_search_artists({
"artists": [{"name": "结构化艺术家"}],
"card_subtitle": "回退艺术家 / 2024",
}) == ["结构化艺术家"]
def test_douban_music_discover_and_related_accept_collection_wrappers(monkeypatch):
"""
豆瓣新碟榜与相关推荐应兼容 subject 包装并保留专辑身份。
"""
module = DoubanModule()
module.doubanapi = Mock()
wrapped_item = {
"type": "subject_collection_item",
"subject": {
"id": "1401853",
"type": "music",
"title": "范特西",
"artists": [{"name": "周杰伦"}],
"cover": {"url": "https://img.example/fantasy.jpg"},
},
}
module.doubanapi.music_chart.return_value = {
"subject_collection_items": [wrapped_item]
}
module.doubanapi.music_recommendations.return_value = [wrapped_item["subject"]]
discovered = module.music_discover("doubanmusic", page=1, count=10)
related = module.music_album_related("doubanmusic", "album-1", count=6)
assert discovered and discovered[0].media_id == "1401853"
assert discovered[0].cover_url == "https://img.example/fantasy.jpg"
assert related and related[0].media_source == "doubanmusic"
module.doubanapi.music_chart.assert_called_once_with()
module.doubanapi.music_recommendations.assert_called_once_with(
subject_id="album-1",
start=0,
count=6,
)
def test_douban_music_tag_discover_intersects_official_tag_results():
"""
豆瓣音乐组合筛选应按原生条目 ID 求交集,并保持主风格排序。
"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.music_tag.side_effect = [
{"items": [
{"id": "1", "type": "music", "title": "流行华语一"},
{"id": "2", "type": "music", "title": "仅流行"},
{"id": "3", "type": "music", "title": "流行华语二"},
]},
{"items": [
{"id": "3", "type": "music", "title": "流行华语二"},
{"id": "1", "type": "music", "title": "流行华语一"},
]},
]
results = module.music_discover(
"doubanmusic", mode="tag", tags="流行,华语", sort="S", count=20
)
assert [item.media_id for item in results] == ["1", "3"]
assert module.doubanapi.music_tag.call_args_list == [
call(tag="流行", start=0, count=100, sort="S"),
call(tag="华语", start=0, count=100, sort="S"),
]
def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
"""
自动文件识别有专辑线索时,豆瓣应返回专辑内音轨而不是专辑实体。
"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.music_search.return_value = {
"items": [{
"target_type": "music",
"target": {
"id": "1401853",
"title": "范特西",
"artists": [{"name": "周杰伦"}],
},
}]
}
module.doubanapi.music_detail.return_value = {
"id": "1401853",
"title": "范特西",
"singer": [{"name": "周杰伦"}],
"songs": [
{"title": "爱在西元前", "track_number": 1},
{"title": "爸我回来了", "track_number": 2},
],
}
result = module.recognize_media(
meta=MetaMusic(
title="爸我回来了",
artists=["周杰伦"],
album="范特西",
track_number=2,
),
media_source="doubanmusic",
)
assert result and result.music_type == "recording"
assert result.media_id == "1401853:2"
assert result.album == "范特西"
@pytest.mark.asyncio
@pytest.mark.parametrize("async_mode", [False, True])
async def test_douban_compilation_lookup_preserves_performer_evidence(async_mode):
"""
先按合辑署名确认所属专辑,再按实际表演者确认曲目,不用合辑署名覆盖单曲。
"""
module = DoubanModule()
search_result = {"items": [{"id": "1", "title": "Sampler", "artists": [{"name": "Various Artists"}]}]}
detail = {"id": "1", "title": "Sampler", "artists": [{"name": "Various Artists"}],
"songs": [{"title": "Song", "artist_names": ["Performer"]}]}
module.doubanapi = Mock(
music_search=Mock(return_value=search_result), music_detail=Mock(return_value=detail),
async_music_search=AsyncMock(return_value=search_result), async_music_detail=AsyncMock(return_value=detail),
)
meta = MetaMusic(title="Song", artists=["Performer"], album="Sampler", album_artist="Various Artists")
if async_mode:
result = await module.async_recognize_media(meta=meta, media_source=MediaSource.DoubanMusic,
music_type="recording")
else:
result = module.recognize_media(meta=meta, media_source=MediaSource.DoubanMusic, music_type="recording")
assert result and result.media_id == "1:1"
assert result.artists == ["Performer"]
assert meta.artists == ["Performer"]
def test_douban_music_mapping_keeps_legacy_attrs_tracks():
"""
豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。
"""
album = DoubanModule._douban_music_to_album({
"id": "1401853",
"title": "范特西",
"attrs": {
"singer": ["周杰伦"],
"tracks": ["01. 爱在西元前", "02. 爸我回来了"],
},
})
assert album and album.artists == ["周杰伦"]
assert [track.title for track in album.tracks] == ["爱在西元前", "爸我回来了"]
@pytest.mark.asyncio
async def test_douban_music_async_recognize_maps_real_songs(monkeypatch):
"""
异步豆瓣自动识别应从真实 songs 字段返回具体音轨。
"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.async_music_search = AsyncMock(return_value={
"items": [{
"target_type": "music",
"target": {"id": "1401853", "title": "范特西"},
}],
})
module.doubanapi.async_music_detail = AsyncMock(return_value={
"id": "1401853",
"title": "范特西",
"singer": [{"name": "周杰伦"}],
"songs": [
{"title": "爱在西元前", "track_number": 1},
{"title": "爸我回来了", "track_number": 2},
],
})
result = await module.async_recognize_media(
meta=MetaMusic(
title="爱在西元前",
artists=["周杰伦"],
album="范特西",
),
media_source="doubanmusic",
)
assert result and result.media_id == "1401853:1"
assert result.title == "爱在西元前"
assert result.artists == ["周杰伦"]
@pytest.mark.asyncio
async def test_douban_recognize_media_routes_only_douban_music(monkeypatch):
"""
豆瓣音乐使用独立数据源,不能与影视豆瓣入口或其它音乐源串线。
"""
module = DoubanModule()
expected = MusicInfo(media_source="doubanmusic", media_id="1401853", title="范特西")
recognize_music = Mock(return_value=expected)
recognize_video = Mock()
async_recognize_music = AsyncMock(return_value=expected)
monkeypatch.setattr(module, "_recognize_music_media", recognize_music)
monkeypatch.setattr(module, "_recognize_media_core", recognize_video)
monkeypatch.setattr(module, "_async_recognize_music_media", async_recognize_music)
recognized = module.recognize_media(
meta=MetaMusic(title="范特西"),
media_source="doubanmusic",
media_id="1401853",
)
ignored = module.recognize_media(
meta=MetaMusic(title="范特西"),
media_source="theaudiodb",
)
async_recognized = await module.async_recognize_media(
mtype=MediaType.MUSIC,
media_source="doubanmusic",
media_id="1401853",
)
assert recognized is expected
assert ignored is None
assert async_recognized is expected
recognize_video.assert_not_called()
@pytest.mark.asyncio
async def test_media_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch):
"""
音乐搜索默认 MusicBrainz,手动选择时原样转发其它音乐源。
"""
chain = MediaChain()
musicbrainz = Mock()
musicbrainz.search_music.return_value = []
douban = Mock()
douban.async_search_music = AsyncMock(return_value=[])
monkeypatch.setattr(
chain,
"_music_source_chain",
Mock(side_effect=[musicbrainz, douban]),
)
chain.search_music("Yellow")
await chain.async_search_music(
"范特西", media_source=MediaSource.DoubanMusic
)
assert chain._music_source_chain.call_args_list[0].args[0] == "musicbrainz"
assert chain._music_source_chain.call_args_list[1].args[0] == "doubanmusic"
assert musicbrainz.search_music.call_args.args[0].title == "Yellow"
assert douban.async_search_music.await_args.args[0].title == "范特西"
@pytest.mark.asyncio
async def test_media_chain_aggregates_music_sources_and_isolates_source_failure(monkeypatch):
"""
多音乐来源应按选择顺序聚合,单一来源失败不能清空其它来源结果。
"""
chain = MediaChain()
musicbrainz = Mock()
musicbrainz.async_search_music = AsyncMock(return_value=[
MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="Yellow",
)
])
theaudiodb = Mock()
theaudiodb.async_search_music = AsyncMock(side_effect=ValueError("empty response"))
douban = Mock()
douban.async_search_music = AsyncMock(return_value=[
MusicInfo(
media_source="doubanmusic",
media_id="album-1",
music_type="album",
title="Yellow",
)
])
source_chains = {
"musicbrainz": musicbrainz,
"acme.music": Mock(
async_search_music=AsyncMock(return_value=[
MusicInfo(
media_source="acme.music",
media_id="plugin-1",
title="Yellow",
)
])
),
"theaudiodb": theaudiodb,
"doubanmusic": douban,
}
select_chain = Mock(side_effect=lambda source: source_chains[str(source)])
monkeypatch.setattr(chain, "_music_source_chain", select_chain)
results = await chain.async_search_music(
"Yellow",
limit=30,
media_source=(
MediaSource.MusicBrainz,
MediaSource("acme.music"),
MediaSource.TheAudioDB,
MediaSource.DoubanMusic,
MediaSource.MusicBrainz,
),
)
assert [(str(item.media_source), item.media_id) for item in results] == [
("musicbrainz", "recording-1"),
("acme.music", "plugin-1"),
("doubanmusic", "album-1"),
]
assert [str(item.args[0]) for item in select_chain.call_args_list] == [
"musicbrainz",
"acme.music",
"theaudiodb",
"doubanmusic",
]
theaudiodb.async_search_music.assert_awaited_once()
def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch):
"""
无显式 ID 的音乐刮削应使用用户选择的来源识别本地音频。
"""
path = tmp_path / "Yellow.flac"
path.write_bytes(b"audio")
expected = MusicInfo(media_source="theaudiodb", media_id="32793500", title="Yellow")
recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected))
media_chain = Mock()
media_chain.recognize_music_by_path = recognize
monkeypatch.setattr("app.chain.scraping.MediaChain", Mock(return_value=media_chain))
result = ScrapingChain._resolve_music_scrape_info(
path,
mediainfo=None,
media_source=MediaSource.TheAudioDB,
)
assert result is expected
recognize.assert_called_once_with(
path, media_source=MediaSource.TheAudioDB
)