feat(music): 支持多源识别与元数据刮削

This commit is contained in:
jxxghp
2026-08-11 23:34:00 +08:00
parent 1197ece12b
commit 3f1b0cdb05
38 changed files with 3764 additions and 284 deletions
+278
View File
@@ -0,0 +1,278 @@
"""TheAudioDB 与豆瓣音乐识别源的标准化和路由测试。"""
from unittest.mock import AsyncMock, Mock
import pytest
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.core.meta import MetaMusic
from app.modules.douban import DoubanModule
from app.modules.theaudiodb import TheAudioDbModule
from app.schemas.types import MediaRecognizeType, 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"]),
source="theaudiodb",
)
assert results and len(results) == 1
assert results[0].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 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"), source="musicbrainz")
recognized = module.recognize_media(
meta=MetaMusic(title="Yellow"),
source="doubanmusic",
)
assert searched is None
assert recognized is None
request.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": "范特西",
"artists": [{"name": "周杰伦"}],
"year": "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=["周杰伦"]),
source="doubanmusic",
)
album = module.music_album("doubanmusic", "1401853")
assert results and results[0].source == "doubanmusic"
assert results[0].music_type == MUSIC_ENTITY_ALBUM
assert album and album.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"
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,
),
source="doubanmusic",
)
assert result and result.music_type == "recording"
assert result.media_id == "1401853:2"
assert result.album == "范特西"
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="范特西",
),
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):
"""豆瓣音乐使用独立 source,不能与影视豆瓣入口或其它音乐源串线。"""
module = DoubanModule()
expected = MusicInfo(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="范特西"),
source="doubanmusic",
mediaid="1401853",
)
ignored = module.recognize_media(
meta=MetaMusic(title="范特西"),
source="theaudiodb",
)
async_recognized = await module.async_recognize_media(
mtype=MediaType.MUSIC,
source="doubanmusic",
mediaid="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_music_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch):
"""音乐搜索默认 MusicBrainz,手动选择时原样转发其它音乐源。"""
chain = MusicChain()
run_module = Mock(return_value=[])
async_run_module = AsyncMock(return_value=[])
monkeypatch.setattr(chain, "run_module", run_module)
monkeypatch.setattr(chain, "async_run_module", async_run_module)
chain.search("Yellow")
await chain.async_search("范特西", source="doubanmusic")
assert run_module.call_args.kwargs["source"] == "musicbrainz"
assert async_run_module.await_args.kwargs["source"] == "doubanmusic"
def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch):
"""无显式 ID 的音乐刮削应使用用户选择的来源识别本地音频。"""
path = tmp_path / "Yellow.flac"
path.write_bytes(b"audio")
expected = MusicInfo(source="theaudiodb", media_id="32793500", title="Yellow")
recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected))
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize)
result = MediaChain._resolve_music_scrape_info(
path,
mediainfo=None,
source="theaudiodb",
)
assert result is expected
recognize.assert_called_once_with(path, source="theaudiodb")