Files
MoviePilot/tests/test_music_scrape.py
T

505 lines
17 KiB
Python

from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.chain.scraping import ScrapingChain, ScrapingConfig, _MusicScrapeFileResult
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo, MusicLyrics
from app.runtime.events import Event
from app.domain.meta.metamusic import MetaMusic
from app.schemas import FileItem
from app.schemas.types import EventType, ScrapingPolicy
def _media_chain() -> ScrapingChain:
"""构造不注册全局单例的音乐刮削链测试实例。"""
return object.__new__(ScrapingChain)
def _album_info() -> MusicInfo:
"""构造专辑批量刮削使用的标准目标。"""
return MusicInfo(
media_source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
artists=["周杰伦"],
album="叶惠美",
album_artist="周杰伦",
year=2003,
total_tracks=11,
cover_url="https://example.com/album.jpg",
)
def test_album_scrape_merge_preserves_track_fields_and_applies_album_identity() -> None:
"""专辑批量刮削应保留每首歌自己的曲名和曲序,只统一专辑级字段。"""
local = MetaMusic(
title="晴天",
artists=["周杰伦"],
album="错误专辑",
album_artist="错误艺术家",
year=1999,
track_number=3,
total_tracks=99,
)
merged = ScrapingChain._merge_music_album_metadata(local, _album_info())
assert merged.title == "晴天"
assert merged.artists == ["周杰伦"]
assert merged.track_number == 3
assert merged.album == "叶惠美"
assert merged.album_artist == "周杰伦"
assert merged.year == 2003
assert merged.total_tracks == 11
assert merged.media_source == "musicbrainz"
assert merged.media_id == "release-group-1"
def test_album_directory_scrape_processes_each_track_and_reuses_cover() -> None:
"""显式选择专辑刮削目录时应逐曲写标签,并让整批文件共用一次封面下载。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scraping_policies = Mock()
def scraping_option(_target, metadata):
"""保持原测试只验证标签和封面,歌词由独立用例覆盖。"""
return SimpleNamespace(
is_skip=metadata == "lyrics",
is_overwrite=False,
)
chain.scraping_policies.option.side_effect = scraping_option
audio_files = [
FileItem(storage="local", path="/music/叶惠美/01.flac", type="file", name="01.flac"),
FileItem(storage="local", path="/music/叶惠美/02.m4a", type="file", name="02.m4a"),
]
chain.storagechain.list_files.return_value = audio_files
chain._download_music_cover = Mock(return_value=(b"cover", "image/jpeg"))
chain._scrape_music_file = Mock(
return_value=_MusicScrapeFileResult(metadata_success=True)
)
album = _album_info()
with patch(
"app.chain.scraping.MediaChain.get_music_album",
return_value=None,
) as get_music_album:
success, message = chain.scrape_music_metadata(
FileItem(
storage="local",
path="/music/叶惠美",
type="dir",
name="叶惠美",
),
mediainfo=album,
)
assert success is True
assert message == "已刮削 2 个音频文件"
chain._download_music_cover.assert_called_once_with(album.cover_url)
get_music_album.assert_called_once_with(
media_source=album.media_source,
media_id=album.media_id,
)
assert chain._scrape_music_file.call_count == 2
assert all(
call.args[1] is album and call.kwargs["cover"] == (b"cover", "image/jpeg")
for call in chain._scrape_music_file.call_args_list
)
def test_music_cover_download_uses_bounded_external_response_cache() -> None:
"""重复刮削同一封面时应复用缓存内容,避免再次访问外部图片接口。"""
response = SimpleNamespace(
status_code=200,
headers={"Content-Type": "image/webp"},
content=b"cover",
close=Mock(),
)
request = Mock()
request.get_res.return_value = response
ScrapingChain._request_music_cover.cache_clear()
with patch("app.chain.scraping.RequestUtils", return_value=request):
first = ScrapingChain._download_music_cover("https://example.com/album.webp")
second = ScrapingChain._download_music_cover("https://example.com/album.webp")
assert first == second == (b"cover", "image/webp")
request.get_res.assert_called_once_with("https://example.com/album.webp")
response.close.assert_called_once()
ScrapingChain._request_music_cover.cache_clear()
def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
"""单曲身份不得覆盖整目录,否则会把同一首歌的标签写到专辑内所有文件。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.storagechain.list_files.return_value = [
FileItem(storage="local", path="/music/01.flac", type="file"),
FileItem(storage="local", path="/music/02.flac", type="file"),
]
success, message = chain.scrape_music_metadata(
FileItem(storage="local", path="/music", type="dir"),
mediainfo=MusicInfo(title="晴天", music_type="recording"),
)
assert success is False
assert message == "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
def test_generic_scrape_dispatches_music_without_entering_video_handlers() -> None:
"""统一刮削入口应直接分派音乐,不能因音频后缀过滤或落入电视剧分支。"""
chain = _media_chain()
chain.scrape_music_metadata = Mock(return_value=(True, "已刮削 1 个音频文件"))
music = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
fileitem = FileItem(
storage="local",
path="/music/晴天.flac",
type="file",
name="晴天.flac",
extension="flac",
)
result = chain.scrape_metadata(
fileitem=fileitem,
meta=MetaMusic(title="晴天", artists=["周杰伦"]),
mediainfo=music,
overwrite=False,
)
assert result == (True, "已刮削 1 个音频文件")
chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=music,
overwrite=False,
)
def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
"""新安装和未保存过该字段的用户应默认仅在缺失时下载歌词。"""
assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.MISSINGONLY
def test_album_track_match_uses_disc_track_title_and_duration() -> None:
"""整张专辑刮削时应把本地音轨绑定到对应 Recording,不能复用专辑级身份。"""
album = MusicAlbumInfo(
media_source="musicbrainz",
media_id="album-1",
title="叶惠美",
tracks=[
MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="以父之名",
artists=["周杰伦"],
disc_number=1,
track_number=1,
duration=342,
),
MusicInfo(
media_source="musicbrainz",
media_id="recording-3",
title="晴天",
artists=["周杰伦"],
disc_number=1,
track_number=3,
duration=269,
),
],
)
matched = ScrapingChain._match_music_album_track(
MetaMusic(
title="03 - 晴天",
artists=["周杰伦"],
disc_number=1,
track_number=3,
duration=270,
),
album,
)
assert matched is not None
assert matched.media_id == "recording-3"
assert matched.title == "晴天"
def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None:
"""标签和封面关闭时,歌词开关仍应独立驱动逐曲处理。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scraping_policies = Mock()
def scraping_option(_target, metadata):
"""仅开启歌词的缺失刮削策略。"""
return SimpleNamespace(
is_skip=metadata != "lyrics",
is_overwrite=False,
)
chain.scraping_policies.option.side_effect = scraping_option
chain._scrape_music_file = Mock(
return_value=_MusicScrapeFileResult(
metadata_success=True,
lyrics_status="saved",
)
)
music_chain = Mock()
with patch("app.chain.scraping.LrclibChain", return_value=music_chain):
success, message = chain.scrape_music_metadata(
FileItem(
storage="local",
path="/music/晴天.flac",
type="file",
name="晴天.flac",
),
mediainfo=MusicInfo(title="晴天", artists=["周杰伦"]),
overwrite=False,
)
assert success is True
assert message == "已刮削 1 个音频文件,歌词新增 1 首、已存在 0 首、未匹配 0 首"
call = chain._scrape_music_file.call_args
assert call.kwargs["write_tags"] is False
assert call.kwargs["with_cover"] is False
assert call.kwargs["lyrics_chain"] is music_chain
def test_write_music_lyrics_sidecar_creates_same_name_lrc(tmp_path) -> None:
"""同步歌词应以 UTF-8 同名 LRC 文件写入音轨所在目录。"""
chain = _media_chain()
chain.storagechain = Mock()
audio_path = tmp_path / "晴天.flac"
audio_path.write_bytes(b"audio")
success = chain._write_music_lyrics_sidecar(
fileitem=FileItem(
storage="local",
path=audio_path.as_posix(),
type="file",
name=audio_path.name,
),
local_path=audio_path,
lyrics=MusicLyrics(
provider="lrclib",
provider_id="1",
synced_lyrics="[00:01.00]故事的小黄花",
),
overwrite=False,
)
lyric_path = audio_path.with_suffix(".lrc")
assert success is True
assert lyric_path.read_text(encoding="utf-8") == "[00:01.00]故事的小黄花\n"
def test_missing_only_lyrics_skips_existing_sidecar(tmp_path) -> None:
"""仅缺失策略发现同名歌词后不得再次请求歌词源。"""
chain = _media_chain()
existing = FileItem(storage="local", path=(tmp_path / "晴天.lrc").as_posix(), type="file")
chain.storagechain = Mock()
chain.storagechain.get_file_item.return_value = existing
music_chain = Mock()
lyrics_option = SimpleNamespace(is_skip=False)
status = chain._scrape_music_lyrics(
fileitem=FileItem(storage="local", path=(tmp_path / "晴天.flac").as_posix(), type="file"),
local_path=tmp_path / "晴天.flac",
scrape_info=MetaMusic(title="晴天", artists=["周杰伦"]),
lyrics_option=lyrics_option,
overwrite=False,
lyrics_chain=music_chain,
album_info=None,
)
assert status == "existing"
music_chain.lyrics.assert_not_called()
def test_write_music_lyrics_sidecar_uploads_to_remote_audio_directory(tmp_path) -> None:
"""远端存储歌词应使用音轨父目录和同名文件上传。"""
chain = _media_chain()
chain.storagechain = Mock()
parent = FileItem(storage="u115", path="/Music/叶惠美", type="dir")
chain.storagechain.get_parent_item.return_value = parent
chain.storagechain.upload_file.return_value = FileItem(
storage="u115",
path="/Music/叶惠美/晴天.lrc",
type="file",
)
local_path = tmp_path / "晴天.flac"
local_path.write_bytes(b"audio")
fileitem = FileItem(
storage="u115",
path="/Music/叶惠美/晴天.flac",
type="file",
name="晴天.flac",
)
success = chain._write_music_lyrics_sidecar(
fileitem=fileitem,
local_path=local_path,
lyrics=MusicLyrics(provider="lrclib", synced_lyrics="[00:01.00]晴天"),
overwrite=False,
)
assert success is True
upload = chain.storagechain.upload_file.call_args
assert upload.args[0] is parent
assert upload.kwargs["new_name"] == "晴天.lrc"
def test_music_scrape_event_preserves_independent_policy_overwrite() -> None:
"""标签覆盖策略不得升级为全局覆盖并误覆盖缺失模式下的歌词。"""
chain = _media_chain()
chain.scrape_music_metadata = Mock(return_value=(True, "done"))
fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
mediainfo = MusicInfo(
media_source="musicbrainz",
media_id="album-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
)
chain.scrape_metadata_event(
Event(
event_type=EventType.MetadataScrape,
event_data={
"fileitem": fileitem,
"mediainfo": mediainfo,
"overwrite": False,
},
)
)
chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=mediainfo,
overwrite=False,
)
def test_music_scrape_event_uses_only_batch_files_and_per_track_contexts() -> None:
"""自动刮削必须按成功文件清单处理,并保留批次内每首歌各自的身份。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scrape_music_metadata = Mock(return_value=(True, "done"))
root = FileItem(storage="local", path="/music/叶惠美", type="dir")
paths = ["/music/叶惠美/01 - 以父之名.flac", "/music/叶惠美/03 - 晴天.flac"]
audio_files = [
FileItem(
storage="local",
path=path,
type="file",
name=path.rsplit("/", 1)[-1],
extension="flac",
)
for path in paths
]
chain.storagechain.get_file_item.side_effect = audio_files
recordings = [
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="以父之名"),
MusicInfo(media_source="musicbrainz", media_id="recording-3", title="晴天"),
]
chain.scrape_metadata_event(Event(
event_type=EventType.MetadataScrape,
event_data={
"fileitem": root,
"file_list": paths,
"mediainfo": recordings[0],
"file_contexts": [
{"path": path, "mediainfo": recording}
for path, recording in zip(paths, recordings)
],
"overwrite": False,
},
))
chain.scrape_music_metadata.assert_called_once_with(
fileitem=root,
mediainfo=recordings[0],
overwrite=False,
audio_files=audio_files,
media_by_path=dict(zip(paths, recordings)),
)
def test_music_scrape_batch_applies_each_recording_to_its_own_file() -> None:
"""多首单曲批次应逐文件使用对应身份,不触发单曲覆盖整目录保护。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scraping_policies = Mock()
chain.scraping_policies.option.side_effect = lambda _target, metadata: SimpleNamespace(
is_skip=metadata == "lyrics",
is_overwrite=False,
)
chain._scrape_music_file = Mock(
return_value=_MusicScrapeFileResult(metadata_success=True)
)
files = [
FileItem(storage="local", path="/music/01.flac", type="file", name="01.flac"),
FileItem(storage="local", path="/music/02.flac", type="file", name="02.flac"),
]
recordings = [
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="Track 1"),
MusicInfo(media_source="musicbrainz", media_id="recording-2", title="Track 2"),
]
success, message = chain.scrape_music_metadata(
fileitem=FileItem(storage="local", path="/music", type="dir"),
mediainfo=recordings[0],
overwrite=False,
audio_files=files,
media_by_path=dict(zip((item.path for item in files), recordings)),
)
assert success is True
assert message == "已刮削 2 个音频文件"
assert [call.args[1] for call in chain._scrape_music_file.call_args_list] == recordings
def test_music_download_failure_is_attributed_only_to_enabled_outputs() -> None:
"""音频下载失败时只标记实际启用的标签、封面或歌词任务。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.storagechain.download_file.return_value = None
fileitem = FileItem(
storage="local",
path="/music/晴天.flac",
type="file",
name="晴天.flac",
)
lyrics_option = SimpleNamespace(is_skip=False)
lyrics_only = chain._scrape_music_file(
fileitem=fileitem,
mediainfo=MusicInfo(title="晴天"),
write_tags=False,
tag_overwrite=False,
with_cover=False,
lyrics_option=lyrics_option,
lyrics_chain=Mock(),
)
metadata_only = chain._scrape_music_file(
fileitem=fileitem,
mediainfo=MusicInfo(title="晴天"),
write_tags=True,
tag_overwrite=False,
with_cover=False,
lyrics_option=SimpleNamespace(is_skip=True),
lyrics_chain=None,
)
assert lyrics_only.metadata_success is True
assert lyrics_only.lyrics_status == "failed"
assert metadata_only.metadata_success is False
assert metadata_only.lyrics_status == "disabled"