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

1337 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
"""
验证音乐整理中的实体身份、命名字段和曲目元数据保留。
"""
from copy import deepcopy
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from jinja2 import Template
from app.application.history import DownloadHistorySnapshot
from app.application.messaging.message import TemplateHelper
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.application.transfer.workflow import JobManager, TransferTask
from app.chain.media import MediaChain
# 此用例覆盖运行时惰性导出,静态检查无法解析该公开入口。
from app.chain.transfer import TransferChain # pylint: disable=no-name-in-module
from app.domain.context import MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.runtime.config import settings
from app.schemas.file import FileItem
from app.schemas.system import TransferDirectoryConf
from app.schemas.transfer import TransferInfo, TransferTorrent
from app.schemas.types import EventType, MediaType
def _music_context() -> tuple[MetaMusic, MusicInfo]:
"""
构造整理测试使用的音乐元数据和媒体信息。
"""
info = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="Get Lucky",
artists=["Daft Punk", "Pharrell Williams"],
album="Random Access Memories",
album_artist="Daft Punk",
year=2013,
track_number=8,
total_tracks=13,
category="Album",
)
return MetaMusic.from_music_info(info), info
def _music_task(path: str, info: MusicInfo) -> TransferTask:
"""
构造带完整音乐上下文的整理作业任务。
"""
file_path = Path(path)
return TransferTask(
fileitem=FileItem(
storage="local",
path=file_path.as_posix(),
name=file_path.name,
basename=file_path.stem,
type="file",
extension=file_path.suffix.lstrip("."),
),
meta=MetaMusic.from_music_info(info),
mediainfo=info,
mtype=MediaType.MUSIC,
)
def test_music_retry_restores_history_entity_namespace(tmp_path, monkeypatch):
"""
重新整理按历史身份恢复音乐时必须保留单曲或专辑实体类型。
"""
history = SimpleNamespace(
type=MediaType.MUSIC.value,
media_source="musicbrainz",
media_id="release-group-1",
music_type="album",
)
media_chain = Mock()
media_chain.recognize_media.return_value = MusicInfo(
media_source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
)
monkeypatch.setattr("app.chain.transfer.queue.MediaChain", lambda: media_chain)
monkeypatch.setattr("app.chain.transfer.filter.MediaChain", lambda: media_chain)
result = TransferChain()._recognize_music_retry_media(
history,
tmp_path / "叶惠美",
)
assert result and result.music_type == "album"
assert media_chain.recognize_media.call_args.kwargs["music_type"] == "album"
def test_music_rename_context_contains_audio_fields():
"""
重命名模板上下文应提供艺术家、专辑、盘号和曲序字段。
"""
meta, info = _music_context()
context = TemplateHelper().builder.build(
meta=meta,
mediainfo=info,
file_extension=".flac",
include_raw_objects=False,
)
assert context["artist"] == "Daft Punk / Pharrell Williams"
assert context["album"] == "Random Access Memories"
assert context["track"] == "08"
assert context["fileExt"] == ".flac"
def test_music_rename_uses_album_identity_and_track_meta():
"""
专辑整理应采用所选专辑身份,同时保留每个文件的曲名和曲序。
"""
meta = MetaMusic(
org_string="10. 明天晴天.m4a",
title="明天晴天",
artists=["孙燕姿"],
album="错误专辑·全精选集",
album_artist="错误艺术家(资源发布者)",
year=1999,
track_number=10,
total_tracks=99,
)
album = MusicInfo(
media_source="musicbrainz",
media_id="album-1",
music_type="album",
title="完美的一天",
artists=["孙燕姿"],
album="完美的一天",
album_artist="孙燕姿",
year=2005,
total_tracks=11,
)
context = TemplateHelper().builder.build(
meta=meta,
mediainfo=album,
file_extension=".m4a",
include_raw_objects=False,
)
rendered = Template(settings.MUSIC_RENAME_FORMAT).render(context)
assert context["title"] == "明天晴天"
assert context["track"] == "10"
assert context["album"] == "完美的一天"
assert context["album_artist"] == "孙燕姿"
assert context["year"] == 2005
assert rendered == "孙燕姿/完美的一天 (2005)/10 - 明天晴天.m4a"
def test_music_rename_format_is_independent_from_movie_format():
"""
音乐应使用独立重命名模板且保持影视模板不变。
"""
assert settings.RENAME_FORMAT(MediaType.MUSIC) == settings.MUSIC_RENAME_FORMAT
assert settings.RENAME_FORMAT(MediaType.MOVIE) == settings.MOVIE_RENAME_FORMAT
def test_audio_is_primary_only_in_music_context():
"""
音频文件只在音乐上下文中作为主要媒体文件。
"""
chain = TransferChain()
audio = FileItem(
storage="local",
path="/music/track.flac",
name="track.flac",
basename="track",
type="file",
extension="flac",
)
assert chain._is_primary_media_file(audio, MusicInfo(title="Track")) is True
assert chain._is_primary_media_file(audio, None) is False
def test_music_scrape_batch_event_preserves_each_track_context():
"""
同一专辑目录的批次刮削事件应保留每个目标音轨自己的识别身份。
"""
chain = object.__new__(TransferChain)
chain._audio_exts = settings.RMT_AUDIOEXT
chain._media_exts = settings.RMT_MEDIAEXT
chain._scrape_batches = {}
chain.eventmanager = Mock()
batch_id = "music-scrape-batch"
target_dir = FileItem(storage="local", path="/library/Album", type="dir")
tasks = []
target_paths = []
for number, title in ((1, "Track One"), (2, "Track Two")):
source = FileItem(
storage="local",
path=f"/downloads/{number:02d}.flac",
type="file",
name=f"{number:02d}.flac",
extension="flac",
)
target_path = f"/library/Album/{number:02d} - {title}.flac"
task = TransferTask(
fileitem=source,
meta=MetaMusic(title=title, track_number=number),
mediainfo=MusicInfo(
media_source="musicbrainz",
media_id=f"recording-{number}",
title=title,
track_number=number,
),
transfer_batch_id=batch_id,
)
tasks.append(task)
target_paths.append(target_path)
chain._register_scrape_batch_task(task)
chain._record_scrape_target(
task,
TransferInfo(
success=True,
target_diritem=target_dir,
file_list_new=[target_path],
need_scrape=True,
),
)
chain._close_scrape_batch(batch_id)
for task in tasks:
chain._finish_scrape_batch_task(task)
scrape_calls = [
call
for call in chain.eventmanager.send_event.call_args_list
if call.args[0] == EventType.MetadataScrape
]
assert len(scrape_calls) == 1
payload = scrape_calls[0].args[1]
assert payload["file_list"] == target_paths
assert [
context["mediainfo"].media_id for context in payload["file_contexts"]
] == ["recording-1", "recording-2"]
def test_restore_music_context_from_download_history():
"""
自动整理应从下载历史备注恢复标准音乐身份。
"""
meta, info = _music_context()
history = SimpleNamespace(
note={
"music": {
"version": 1,
"meta": meta.to_dict(),
"media": info.to_dict(),
}
}
)
restored_meta, restored_info = TransferChain._restore_music_download_context(
history,
Path("/remote/08 - Get Lucky.flac"),
)
assert restored_meta is not None
assert restored_info is not None
assert restored_meta.org_string == "08 - Get Lucky.flac"
assert restored_info.media_source.value == "musicbrainz"
assert restored_info.media_id == "recording-1"
assert restored_info.album == "Random Access Memories"
@pytest.mark.parametrize("file_album", ["Local Album", None])
def test_restore_music_context_discards_shared_recording_identity(tmp_path, monkeypatch, file_album):
"""
多音轨批次不得把下载记录中的单曲身份恢复到每个音频文件。
"""
meta, info = _music_context()
history = DownloadHistorySnapshot(
id=1,
path=tmp_path.as_posix(),
type=MediaType.MUSIC.value,
title="Random Access Memories",
note={
"music": {
"version": 1,
"meta": meta.to_dict(),
"media": info.to_dict(),
}
},
)
audio_file = tmp_path / "01 - Give Life Back to Music.flac"
audio_file.write_bytes(b"fake-flac")
file_meta = MetaMusic(
org_string=audio_file.name,
title="Give Life Back to Music",
artists=["Daft Punk"],
album=file_album,
album_artist="Daft Punk" if file_album else None,
year=2020 if file_album else None,
track_number=1,
)
monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta))
restored_meta, restored_info = TransferChain._restore_music_download_context(
history,
audio_file,
discard_recording_identity=True,
)
assert restored_meta is not None
assert restored_meta.title == "Give Life Back to Music"
assert restored_meta.album == file_meta.album
assert restored_meta.year == file_meta.year
assert restored_meta.media_source is None
assert restored_meta.media_id is None
assert restored_info is None
def test_download_history_music_type_falls_back_to_versioned_note():
"""
旧下载记录缺少独立字段时应从版本化备注恢复实体类型。
"""
history = SimpleNamespace(
music_type=None,
note={
"music": {
"version": 1,
"media": {"music_type": "album"},
}
},
)
assert TransferChain._download_history_music_type(history) == "album"
@pytest.mark.parametrize("resource_meta_only", [False, True])
def test_restore_album_context_keeps_album_identity_and_track_specific_tags(
tmp_path, monkeypatch, resource_meta_only,
):
"""
整专整理应保留选中的专辑身份,同时使用每个文件自己的曲名、艺术家和曲序。
"""
album = MusicInfo(
media_source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
artists=["周杰伦"],
album="叶惠美",
album_artist="周杰伦",
year=2003,
total_tracks=11,
)
meta = (
MetaMusic.parse_resource("周杰伦 - 叶惠美 FLAC")
if resource_meta_only else MetaMusic.from_music_info(album)
)
history = SimpleNamespace(note={
"music": {
"version": 1,
"meta": meta.to_dict(),
"media": album.to_dict(),
}
})
audio_file = tmp_path / "03. 晴天.flac"
audio_file.write_bytes(b"fake-flac")
from app.application.audio import AudioMetadataHelper
monkeypatch.setattr(
AudioMetadataHelper,
"read",
lambda path: MetaMusic(
org_string=path.name,
title="晴天",
artists=["周杰伦"],
album="错误专辑",
album_artist="错误艺术家",
year=1999,
track_number=3,
total_tracks=99,
),
)
restored_meta, restored_info = TransferChain._restore_music_download_context(
history, audio_file, discard_recording_identity=True,
)
assert restored_meta.title == "晴天"
assert restored_meta.track_number == 3
assert restored_meta.album == "叶惠美"
assert restored_meta.album_artist == "周杰伦"
assert restored_meta.year == 2003
assert restored_meta.total_tracks == 11
assert restored_info.music_type == "album"
assert restored_info.media_id == "release-group-1"
def test_manual_batch_can_discard_saved_album_identity(tmp_path, monkeypatch):
"""
手动批次关闭历史复用时应丢弃旧专辑身份,仅保留当前文件标签。
"""
album = MusicInfo(
media_source="musicbrainz",
media_id="stale-release-group",
music_type="album",
title="旧专辑",
artists=["旧艺人"],
album="旧专辑",
album_artist="旧艺人",
year=1999,
category="",
)
history = SimpleNamespace(note={
"music": {
"version": 1,
"meta": MetaMusic.from_music_info(album).to_dict(),
"media": album.to_dict(),
}
})
audio_file = tmp_path / "01 - 我的地盘.flac"
audio_file.write_bytes(b"fake-flac")
file_meta = MetaMusic(
org_string=audio_file.name,
title="我的地盘",
artists=["周杰伦"],
album="七里香",
album_artist="周杰伦",
year=2004,
track_number=1,
total_tracks=10,
)
monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta))
restored_meta, restored_info = TransferChain._restore_music_download_context(
history,
audio_file,
discard_saved_identity=True,
)
assert restored_meta.title == "我的地盘"
assert restored_meta.album == "七里香"
assert restored_meta.media_source is None
assert restored_meta.media_id is None
assert restored_info is None
@pytest.mark.parametrize("field_source", ["selected", "resource", "file"])
def test_restore_music_context_only_fills_missing_selected_fields(monkeypatch, field_source):
"""
已选语义字段仅补缺,不覆盖种子证据、文件标签或注入目标音质。
"""
saved_meta, info = _music_context()
info.disc_number = 1
info.version = "Studio"
info.isrc = "USQX91300108"
info.audio_format = "MP3"
info.bit_depth = 16
if field_source == "selected":
saved_meta = MetaMusic()
else:
saved_meta.album = "Resource Album"
saved_meta.album_artist = "Resource Artist"
saved_meta.year = 2014
saved_meta.total_tracks = 15
saved_meta.disc_number = 2
saved_meta.version = "Live"
saved_meta.isrc = "USQX91400108"
note = {"music": {"version": 1, "meta": saved_meta.to_dict(), "media": info.to_dict()}}
original_note = deepcopy(note)
file_meta = MetaMusic(title="File Title")
if field_source == "file":
file_meta.artists = ["File Artist"]
file_meta.album = "File Album"
file_meta.album_artist = "File Album Artist"
file_meta.year = 2020
file_meta.total_tracks = 20
file_meta.disc_number = 3
file_meta.track_number = 10
file_meta.version = "Remix"
file_meta.isrc = "USQX92000108"
monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta))
restored_meta, restored_info = TransferChain._restore_music_download_context(
SimpleNamespace(note=note), Path("/downloads/03 - File Title.flac"),
)
expected = {"selected": info, "resource": saved_meta, "file": file_meta}[field_source]
for field_name in (
"artists", "album", "album_artist", "year", "total_tracks",
"disc_number", "track_number", "version", "isrc",
):
assert getattr(restored_meta, field_name) == getattr(expected, field_name)
assert getattr(restored_info, field_name) == getattr(expected, field_name)
assert restored_meta.title == "File Title"
assert restored_info.music_type == "recording"
assert restored_info.media_source == info.media_source
assert restored_info.media_id == info.media_id
assert restored_meta.audio_format is None
assert restored_meta.bit_depth is None
assert note == original_note
restored_meta.artists.append("Another Artist")
assert file_meta.artists == (["File Artist"] if field_source == "file" else [])
def test_restore_music_context_uses_file_title_over_subscription_title(tmp_path, monkeypatch):
"""
曲目标题应优先取当前文件自身的标签/文件名,而非沿用订阅时的单曲标题。
"""
meta, info = _music_context()
history = SimpleNamespace(
note={
"music": {
"version": 1,
"meta": meta.to_dict(),
"media": info.to_dict(),
}
}
)
# 订阅标题被识别为单曲"Get Lucky",而实际音轨标签曲目名 不同且无身份字段
audio_file = tmp_path / "07.幸福.flac"
audio_file.write_bytes(b"fake-flac")
from app.application.audio import AudioMetadataHelper
monkeypatch.setattr(
AudioMetadataHelper,
"read",
lambda path: MetaMusic(org_string=path.name, title="流浪地图"),
)
restored_meta, _ = TransferChain._restore_music_download_context(history, audio_file)
assert restored_meta is not None
assert restored_meta.title == "流浪地图"
def test_restore_music_context_uses_filename_when_source_is_not_locally_accessible():
"""
远端音频无法直接读取标签时也应按文件名区分曲目,避免整张专辑重名。
"""
meta, info = _music_context()
meta.artists = ["孙燕姿"]
meta.title = "完美的一天"
meta.album = "完美的一天"
meta.album_artist = "孙燕姿"
meta.year = 2005
meta.track_number = None
meta.total_tracks = None
info.music_type = "album"
info.artists = ["孙燕姿"]
info.title = "完美的一天"
info.album = "完美的一天"
info.album_artist = "孙燕姿"
info.year = 2005
info.track_number = None
info.total_tracks = None
history = SimpleNamespace(
note={
"music": {
"version": 1,
"meta": meta.to_dict(),
"media": info.to_dict(),
}
}
)
restored_meta, restored_info = TransferChain._restore_music_download_context(
history,
Path("/remote/10. 明天晴天.m4a"),
)
assert restored_meta is not None
assert restored_info is not None
assert restored_meta.title == "明天晴天"
assert restored_meta.track_number == 10
assert restored_info.title == "明天晴天"
assert restored_info.track_number == 10
context = TemplateHelper().builder.build(
meta=restored_meta,
mediainfo=restored_info,
file_extension=".m4a",
include_raw_objects=False,
)
rendered = Template(settings.MUSIC_RENAME_FORMAT).render(context)
assert rendered == "孙燕姿/完美的一天 (2005)/10 - 明天晴天.m4a"
def test_job_manager_serializes_music_queue_models():
"""
整理队列应使用音乐专属 Schema 序列化任务。
"""
meta, info = _music_context()
task = TransferTask(
fileitem=FileItem(
storage="local",
path="/music/track.flac",
name="track.flac",
basename="track",
type="file",
extension="flac",
),
meta=meta,
mediainfo=info,
mtype=MediaType.MUSIC,
)
manager = JobManager()
assert manager.add_task(task) is True
job = manager.list_jobs()[0]
assert job.media.type == "音乐"
assert job.media.album == "Random Access Memories"
assert job.tasks[0].meta.type == "音乐"
assert task.mtype == MediaType.MUSIC
def test_job_manager_separates_unidentified_music_recordings():
"""
缺少远端 ID 的不同曲目必须进入不同作业,避免通知和完成状态串组。
"""
manager = JobManager()
first = MusicInfo(title="Intro", artists=["Artist"], album="Album", track_number=1)
second = MusicInfo(title="Finale", artists=["Artist"], album="Album", track_number=2)
assert manager.add_task(_music_task("/music/01 - Intro.flac", first)) is True
assert manager.add_task(_music_task("/music/02 - Finale.flac", second)) is True
assert len(manager.list_jobs()) == 2
def test_job_manager_groups_unidentified_album_tracks_by_album_identity():
"""
无远端 ID 的整专曲目应按专辑身份聚合成一个作业。
"""
manager = JobManager()
first = MusicInfo(
music_type="album",
title="Intro",
artists=["Artist"],
album="Album",
album_artist="Artist",
year=2026,
track_number=1,
)
second = MusicInfo(
music_type="album",
title="Finale",
artists=["Artist"],
album="Album",
album_artist="Artist",
year=2026,
track_number=2,
)
assert manager.add_task(_music_task("/music/01 - Intro.flac", first)) is True
assert manager.add_task(_music_task("/music/02 - Finale.flac", second)) is True
jobs = manager.list_jobs()
assert len(jobs) == 1
assert len(jobs[0].tasks) == 2
def test_job_manager_separates_music_entity_namespaces_for_same_provider_id():
"""
数据源 ID 相同但实体类型不同的单曲和专辑不能共享作业。
"""
manager = JobManager()
recording = MusicInfo(
media_source="musicbrainz",
media_id="shared-id",
music_type="recording",
title="Track",
artists=["Artist"],
)
album = MusicInfo(
media_source="musicbrainz",
media_id="shared-id",
music_type="album",
title="Album",
album="Album",
artists=["Artist"],
)
assert manager.add_task(_music_task("/music/Track.flac", recording)) is True
assert manager.add_task(_music_task("/music/Album/01.flac", album)) is True
assert len(manager.list_jobs()) == 2
def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directory(monkeypatch):
"""
全局关闭聚合时,同目录交错完成的整专与单曲作业仍保留旧文件清单。
"""
chain = object.__new__(TransferChain)
chain.runtime_config = replace(chain.runtime_config, transfer_success_notification_aggregation=False)
chain.jobview = JobManager()
chain._success_target_files = {}
chain._scrape_batches = {}
chain._media_exts = settings.RMT_MEDIAEXT
chain._audio_exts = settings.RMT_AUDIOEXT
chain.eventmanager = Mock()
chain.transfer_completed = Mock()
chain.send_transfer_message = Mock()
def transfer_result(**kwargs):
"""
执行测试历史暂存并返回 task-aware 原子结算回执。
"""
history = kwargs["stage_history"](SimpleNamespace())
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=True,
)
chain.durable_event_writer = Mock()
chain.durable_event_writer.transfer_result.side_effect = transfer_result
album_infos = [
MusicInfo(
music_type="album",
title=title,
artists=["Artist"],
album="Album",
album_artist="Artist",
year=2026,
track_number=number,
)
for number, title in ((1, "Intro"), (2, "Finale"))
]
album_tasks = [
_music_task(f"/downloads/{number:02d}.flac", info)
for number, info in enumerate(album_infos, start=1)
]
recording_task = _music_task(
"/downloads/Single.flac",
MusicInfo(title="Single", artists=["Artist"], album="Album"),
)
tasks = [album_tasks[0], recording_task, album_tasks[1]]
for task in tasks:
task.background = True
assert chain.jobview.add_task(task) is True
target_dir = FileItem(storage="local", path="/library/Artist/Album", type="dir")
def transfer_info(task: TransferTask) -> TransferInfo:
"""
为指定任务构造同目录下的成功整理结果。
"""
target = f"/library/Artist/Album/{task.fileitem.name}"
return TransferInfo(
success=True,
fileitem=task.fileitem,
target_diritem=target_dir,
target_item=FileItem(storage="local", path=target, type="file"),
file_list_new=[target],
transfer_type="copy",
need_notify=True,
)
chain.transfer_history_repository = SimpleNamespace()
monkeypatch.setattr(
"app.chain.transfer.settlement.add_transfer_success",
lambda **kwargs: SimpleNamespace(id=1),
)
for sequence, task in enumerate(tasks):
result = transfer_info(task)
task.bind_admission_task_id(f"music-terminal-{sequence}")
task.bind_execution_lease(
owner_id="music-test-owner",
lease_token=f"music-lease-{sequence}",
)
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": "succeeded",
"transferinfo": result.model_dump(mode="json"),
},
operation_ids=(f"music-operation-{sequence}",),
))
chain._TransferChain__default_callback(task, result)
notified_lists = [
call.kwargs["transferinfo"].file_list_new
for call in chain.send_transfer_message.call_args_list
]
assert notified_lists == [
["/library/Artist/Album/Single.flac"],
[
"/library/Artist/Album/01.flac",
"/library/Artist/Album/02.flac",
],
]
assert chain._success_target_files == {}
def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
"""
无下载身份的音频应先走音乐识别,远端失败后再使用本地标签兜底。
"""
audio_path = tmp_path / "周杰伦 - 晴天.flac"
audio_path.write_bytes(b"fake-flac")
source_item = FileItem(
storage="local",
path=audio_path.as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
size=audio_path.stat().st_size,
)
target_item = FileItem(
storage="local",
path=(tmp_path / "library" / audio_path.name).as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
)
recognized = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
recognize = Mock(return_value=recognized)
chain = TransferChain()
monkeypatch.setattr(MediaChain, "recognize_by_meta", recognize)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
Mock(side_effect=lambda *args, **kwargs: [(source_item, False)]),
)
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=None))
monkeypatch.setattr(
chain,
"_plan_checkpoint_and_execute",
Mock(
return_value=TransferInfo(
success=True,
fileitem=source_item,
target_item=target_item,
)
),
)
state, preview = chain.do_transfer(
fileitem=source_item,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
mtype=MediaType.MUSIC,
force=True,
preview=True,
)
assert state is True
recognize.assert_called_once()
assert isinstance(recognize.call_args.args[0], MetaMusic)
assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC
assert preview["items"][0]["type"] == MediaType.MUSIC.value
recognize.reset_mock()
recognize.return_value = None
state, preview = chain.do_transfer(
fileitem=source_item,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
mtype=MediaType.MUSIC,
force=True,
preview=True,
)
assert state is True
recognize.assert_called_once()
assert isinstance(recognize.call_args.args[0], MetaMusic)
assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC
assert preview["items"][0]["type"] == MediaType.MUSIC.value
def test_automatic_multi_track_recording_context_rematches_album(tmp_path, monkeypatch):
"""
自动整专不得复用下载时误选的单曲身份,应按目录恢复各音轨身份。
"""
source_dir = tmp_path / "徐良 情话"
source_dir.mkdir()
audio_paths = [
source_dir / "01 - 女骑士.flac",
source_dir / "02 - 悲伤的李白.flac",
]
for audio_path in audio_paths:
audio_path.write_bytes(b"fake-flac")
source_items = [
FileItem(
storage="local",
path=audio_path.as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
size=audio_path.stat().st_size,
)
for audio_path in audio_paths
]
source_item = FileItem(
storage="local",
path=source_dir.as_posix(),
name=source_dir.name,
type="dir",
)
saved_recording = MusicInfo(
media_source="musicbrainz",
media_id="wrong-recording",
music_type="recording",
title="情话",
artists=["徐良", "孙羽幽"],
album="北京巷弄",
album_artist="徐良",
year=2013,
cover_url="https://example.com/wrong-cover.jpg",
)
saved_meta = MetaMusic.from_music_info(saved_recording)
history = DownloadHistorySnapshot(
id=1,
path=source_dir.as_posix(),
type=MediaType.MUSIC.value,
title="情话",
note={
"music": {
"version": 1,
"meta": saved_meta.to_dict(),
"media": saved_recording.to_dict(),
}
},
music_type="recording",
downloader="qbittorrent",
download_hash="hash-1",
)
file_metas = {
audio_paths[0]: MetaMusic(
org_string=audio_paths[0].name,
title="女骑士",
artists=["徐良"],
album="情话",
album_artist="徐良",
year=2013,
track_number=1,
total_tracks=12,
),
audio_paths[1]: MetaMusic(
org_string=audio_paths[1].name,
title="悲伤的李白",
artists=["徐良"],
album="情话",
album_artist="徐良",
year=2013,
track_number=2,
total_tracks=12,
),
}
matched_tracks = {
str(path.resolve()): MusicInfo(
media_source="musicbrainz",
media_id=f"recording-{index}",
music_type="recording",
title=file_metas[path].title,
artists=["徐良"],
album="情话",
album_artist="徐良",
album_id="correct-release-group",
year=2013,
track_number=index,
total_tracks=12,
cover_url="https://example.com/correct-cover.jpg",
)
for index, path in enumerate(audio_paths, start=1)
}
chain = TransferChain()
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
Mock(return_value=[(item, False) for item in source_items]),
)
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=history))
monkeypatch.setattr(
MediaChain,
"read_path_meta",
Mock(side_effect=lambda path, **_kwargs: file_metas[Path(path)]),
)
album_match = Mock(return_value=matched_tracks)
monkeypatch.setattr(MediaChain, "recognize_music_album_directory", album_match)
captured_tasks = []
def execute(task, **_kwargs):
captured_tasks.append(task)
target_dir = tmp_path / "library" / "徐良" / "情话 (2013)"
target_item = target_dir / task.fileitem.name
return TransferInfo(
success=True,
fileitem=task.fileitem,
target_item=FileItem(storage="local", path=target_item.as_posix(), type="file"),
target_diritem=FileItem(storage="local", path=target_dir.as_posix(), type="dir"),
)
monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", execute)
state, preview = chain.do_transfer(
fileitem=source_item,
mediainfo=saved_recording,
mtype=MediaType.MUSIC,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
force=True,
preview=True,
)
assert state is True
assert preview["summary"] == {"total": 2, "success": 2, "failed": 0}
assert [task.mediainfo.media_id for task in captured_tasks] == [
"recording-1",
"recording-2",
]
assert {task.mediainfo.album_id for task in captured_tasks} == {"correct-release-group"}
assert {task.mediainfo.cover_url for task in captured_tasks} == {
"https://example.com/correct-cover.jpg"
}
assert album_match.call_count == 2
def test_manual_history_batch_rematches_album_and_groups_preview(tmp_path, monkeypatch):
"""
手动多选历史不复用身份时应重识别整专,并以专辑标题汇总预览。
"""
source_dir = tmp_path / "未分类" / "周杰伦 - 七里香 (2004) [FLAC]"
source_dir.mkdir(parents=True)
audio_paths = [
source_dir / "01 - 我的地盘.flac",
source_dir / "02 - 七里香.flac",
]
for audio_path in audio_paths:
audio_path.write_bytes(b"fake-flac")
source_items = [
FileItem(
storage="local",
path=audio_path.as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
size=audio_path.stat().st_size,
)
for audio_path in audio_paths
]
source_item = source_items[0]
stale_album = MusicInfo(
media_source="musicbrainz",
media_id="stale-release-group",
music_type="album",
title="七里香",
artists=["周杰伦"],
album="七里香",
album_artist="周杰伦",
year=2004,
category="",
)
history = DownloadHistorySnapshot(
id=1,
path=source_dir.as_posix(),
type=MediaType.MUSIC.value,
title="七里香",
note={
"music": {
"version": 1,
"meta": MetaMusic.from_music_info(stale_album).to_dict(),
"media": stale_album.to_dict(),
}
},
music_type="album",
downloader="qbittorrent",
download_hash="hash-1",
)
file_metas = {
path: MetaMusic(
org_string=path.name,
title=title,
artists=["周杰伦"],
album="七里香",
album_artist="周杰伦",
year=2004,
track_number=index,
total_tracks=10,
)
for index, (path, title) in enumerate(
zip(audio_paths, ("我的地盘", "七里香")), start=1
)
}
matched_tracks = {
str(path.resolve()): MusicInfo(
media_source="musicbrainz",
media_id=f"recording-{index}",
music_type="recording",
title=file_metas[path].title,
artists=["周杰伦"],
album="七里香",
album_artist="周杰伦",
album_id="correct-release-group",
album_type="Album",
year=2004,
track_number=index,
total_tracks=10,
library_category="Album",
)
for index, path in enumerate(audio_paths, start=1)
}
chain = TransferChain()
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=history))
monkeypatch.setattr(
MediaChain,
"read_path_meta",
Mock(side_effect=lambda path, **_kwargs: file_metas[Path(path)]),
)
album_match = Mock(return_value=matched_tracks)
monkeypatch.setattr(MediaChain, "recognize_music_album_directory", album_match)
captured_tasks = []
def execute(task, **_kwargs):
captured_tasks.append(task)
target_dir = tmp_path / "library" / "Album" / "周杰伦" / "七里香 (2004)"
target_item = target_dir / task.fileitem.name
return TransferInfo(
success=True,
fileitem=task.fileitem,
target_item=FileItem(storage="local", path=target_item.as_posix(), type="file"),
target_diritem=FileItem(storage="local", path=target_dir.as_posix(), type="dir"),
)
monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", execute)
state, preview = chain._execute_transfer(
fileitem=source_item,
mtype=MediaType.MUSIC,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
library_category_folder=True,
),
selected_fileitems=source_items,
manual=True,
force=True,
preview=True,
)
assert state is True
assert preview["summary"] == {"total": 2, "success": 2, "failed": 0}
assert {item["title"] for item in preview["items"]} == {"七里香 (2004)"}
assert [task.mediainfo.media_id for task in captured_tasks] == [
"recording-1",
"recording-2",
]
assert {task.mediainfo.library_category for task in captured_tasks} == {"Album"}
assert album_match.call_count == 2
def test_explicit_music_batch_excludes_video_from_mixed_directory(tmp_path, monkeypatch):
"""
明确音乐上下文时只规划音频主文件,混合目录中的视频不得套用音乐身份。
"""
audio_path = tmp_path / "08 - Get Lucky.flac"
video_path = tmp_path / "Get Lucky.mkv"
audio_path.write_bytes(b"fake-flac")
video_path.write_bytes(b"fake-video")
audio_item = FileItem(
storage="local",
path=audio_path.as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
size=audio_path.stat().st_size,
)
video_item = FileItem(
storage="local",
path=video_path.as_posix(),
name=video_path.name,
basename=video_path.stem,
type="file",
extension="mkv",
size=video_path.stat().st_size,
)
source_dir = FileItem(
storage="local",
path=tmp_path.as_posix(),
name=tmp_path.name,
type="dir",
)
target_item = FileItem(
storage="local",
path=(tmp_path / "library" / audio_path.name).as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
)
recognized = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
track_number=8,
)
chain = TransferChain()
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
Mock(return_value=[(audio_item, False), (video_item, False)]),
)
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=None))
monkeypatch.setattr(MediaChain, "recognize_by_meta", Mock(return_value=recognized))
monkeypatch.setattr(
chain,
"_plan_checkpoint_and_execute",
Mock(
return_value=TransferInfo(
success=True,
fileitem=audio_item,
target_item=target_item,
)
),
)
state, preview = chain.do_transfer(
fileitem=source_dir,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
mtype=MediaType.MUSIC,
force=True,
preview=True,
)
assert state is True
assert [item["source"] for item in preview["items"]] == [audio_item.path]
assert chain._plan_checkpoint_and_execute.call_count == 1
def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
"""
下载器自动整理应把下载历史中的音乐类型传入文件规划,且不调用影视补图模块。
"""
audio_path = tmp_path / "晴天.flac"
audio_path.write_bytes(b"fake-flac")
recognized = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
history = SimpleNamespace(
type=MediaType.MUSIC.value,
tmdbid=None,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="musicbrainz",
media_id="recording-1",
music_type="recording",
episode_group=None,
media_category=None,
)
chain = TransferChain()
media_chain = Mock()
media_chain.recognize_media.return_value = recognized
run_module = Mock()
monkeypatch.setattr(
"app.chain.transfer.queue.DirectoryHelper.get_download_dirs",
lambda _: [
SimpleNamespace(
monitor_type="downloader",
storage="local",
download_path=tmp_path.as_posix(),
)
],
)
chain.download_history_repository = SimpleNamespace(
get_by_hash=lambda download_hash: history
)
monkeypatch.setattr(
chain,
"list_torrents",
Mock(
return_value=[
TransferTorrent(
downloader="qbittorrent",
hash="hash-1",
path=audio_path,
)
]
),
)
monkeypatch.setattr("app.chain.transfer.queue.MediaChain", lambda: media_chain)
monkeypatch.setattr("app.chain.transfer.filter.MediaChain", lambda: media_chain)
monkeypatch.setattr(chain, "do_transfer", Mock(return_value=(True, "")))
monkeypatch.setattr(chain, "run_module", run_module)
monkeypatch.setattr(
"app.chain.transfer.workflow.StorageChain.get_file_item_strict",
Mock(return_value=FileItem(
storage="local", path=audio_path.as_posix(), name=audio_path.name,
type="file", size=audio_path.stat().st_size, extension=audio_path.suffix.lstrip("."),
)),
)
state = chain.process()
assert state is True
assert media_chain.recognize_media.call_args.kwargs["mtype"] == MediaType.MUSIC
assert media_chain.recognize_media.call_args.kwargs["music_type"] == "recording"
assert chain.do_transfer.call_args.kwargs["mtype"] == MediaType.MUSIC
assert chain.do_transfer.call_args.kwargs["mediainfo"] is recognized
run_module.assert_not_called()