feat(media): refine primary-source recognition

This commit is contained in:
jxxghp
2026-08-12 08:33:10 +08:00
parent 97dba2a8ae
commit a1dd259143
20 changed files with 1899 additions and 862 deletions
+188 -11
View File
@@ -15,6 +15,7 @@ from app.core.meta 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.schemas.types import MediaType
@@ -103,10 +104,6 @@ def test_media_chain_recognize_by_path_routes_musicbrainz_source_to_music_chain(
def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
"""本地音频识别应使用内嵌标签补全艺术家、专辑并保留音频质量参数。"""
from unittest.mock import AsyncMock
from app.helper.audio import AudioMetadataHelper
audio_path = tmp_path / "02. 眼泪成诗.m4a"
audio_path.write_bytes(b"audio")
meta = MetaMusic(
@@ -119,7 +116,17 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa
info = _music_info()
chain = MediaChain()
recognize = AsyncMock(return_value=info)
monkeypatch.setattr(AudioMetadataHelper, "read", lambda path: meta)
filename_meta = MetaMusic(title="02. 眼泪成诗")
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(meta, meta, filename_meta)),
)
monkeypatch.setattr(
MusicChain,
"async_identify_by_fingerprint",
AsyncMock(return_value=None),
)
monkeypatch.setattr(chain, "async_recognize_media", recognize)
recognized_meta, recognized_info = asyncio.run(
@@ -128,7 +135,150 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa
assert recognized_meta is meta
assert recognized_info is info
recognize.assert_awaited_once_with(meta=meta, source=None)
recognize.assert_awaited_once_with(
meta=meta,
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="Tagged Title")
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
source="musicbrainz",
media_id=recording_id,
title="Get Lucky",
)
chain = MediaChain()
direct = Mock(return_value=expected)
later_tier = Mock()
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(merged, tag_meta, filename_meta)),
)
monkeypatch.setattr(
MusicChain,
"identify_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(
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(
MusicChain,
"read_path_evidence",
Mock(return_value=(tag_meta, tag_meta, filename_meta)),
)
monkeypatch.setattr(MusicChain, "identify_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")
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
source="musicbrainz",
media_id="recording-from-filename",
title="Filename Title",
)
chain = MediaChain()
recognize = Mock(side_effect=[MusicInfo(title="Offline Tag"), expected])
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(tag_meta, tag_meta, filename_meta)),
)
monkeypatch.setattr(MusicChain, "identify_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
assert [call.kwargs["meta"] for call in recognize.call_args_list] == [
tag_meta,
filename_meta,
]
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")
expected = MusicInfo(
source="musicbrainz",
media_id=recording_id,
title="Get Lucky",
)
chain = MediaChain()
direct = AsyncMock(return_value=expected)
later_tier = AsyncMock()
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(merged, MetaMusic(), MetaMusic())),
)
monkeypatch.setattr(
MusicChain,
"async_identify_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_musicbrainz_module_recognize_media_ignores_non_music():
@@ -355,22 +505,49 @@ def test_musicbrainz_module_recognize_media_by_music_type_and_media_id(monkeypat
def test_musicbrainz_module_async_recognize_media(monkeypatch):
"""异步模块入口应在线程池中调用同步实现"""
"""异步 MusicBrainz 识别应直接调用异步检索而不进入同步入口"""
module = MusicBrainzModule()
expected = _music_info()
sync_mock = Mock(return_value=expected)
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
))
sync_mock.assert_called_once()
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(
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,
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():
"""自动多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
"""MusicBrainz 自动识别返回的 MusicInfo 应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
with patch.object(MusicChain, "recognize_best", return_value=expected), patch(
@@ -384,7 +561,7 @@ def test_chain_recognize_media_returns_musicinfo_and_reports_share():
def test_chain_async_recognize_media_returns_musicinfo_and_reports_share():
"""异步多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
"""异步 MusicBrainz 自动识别结果应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
with patch.object(