mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
505 lines
17 KiB
Python
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"
|