From 20257a88cbcbaed5cd9e2a24a1edf67180a35240 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Sat, 5 Sep 2026 14:06:20 +0800 Subject: [PATCH] fix(music): rematch multi-track recording downloads as albums --- app/chain/transfer/filter.py | 20 +++- app/chain/transfer/workflow.py | 43 +++++++- tests/test_music_transfer.py | 196 ++++++++++++++++++++++++++++++++- 3 files changed, 255 insertions(+), 4 deletions(-) diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index 433a19a07..a1d148a08 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -26,6 +26,7 @@ from app.runtime.log import logger from app.schemas.transfer import TransferInfo from app.schemas.types import ( MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, MediaType, ) from app.schemas.workflow import FileItem @@ -251,8 +252,13 @@ class FileFilterMixin(_TransferOwnerBase): cls, download_history: Optional[DownloadHistorySnapshot], file_path: Path, + discard_recording_identity: bool = False, ) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]: - """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。""" + """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。 + + 多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording + 身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。 + """ note = getattr(download_history, "note", None) music_note = note.get("music") if isinstance(note, dict) else None if not isinstance(music_note, dict) or music_note.get("version") != 1: @@ -264,6 +270,18 @@ class FileFilterMixin(_TransferOwnerBase): return None, None file_tags = MediaChain.read_path_meta(file_path) + should_discard_identity = ( + discard_recording_identity + and saved_info.music_type == MUSIC_ENTITY_RECORDING + ) + if should_discard_identity: + # 共享 recording 上下文可能包含错误的专辑、年份等字段;整张丢弃, + # 只保留当前文件实际标签,目录级匹配失败时也不会回落到错误身份。 + file_meta = deepcopy(file_tags) + file_meta.org_string = file_path.name + file_meta.title = file_meta.title or file_path.stem + return file_meta, None + file_meta = deepcopy(saved_meta) file_meta.org_string = file_path.name # 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名), diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index 47c47a081..4e03574e9 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -36,6 +36,7 @@ from app.schemas.media import resolve_media_identity from app.schemas.system import TransferDirectoryConf from app.schemas.transfer import EpisodeFormat, TransferInfo from app.schemas.types import ( + MUSIC_ENTITY_RECORDING, MediaSource, MediaType, ProgressKey, @@ -46,6 +47,25 @@ from app.schemas.workflow import FileItem from .request import _TransferCandidatePlanner +def _should_discard_batch_recording_identity( + *, + multi_track_music_batch: bool, + manual: bool, + media_source: Optional[MediaSource], + media_id: Optional[str], + mediainfo: Optional[MediaInfo | MusicInfo], + history_music_type: Optional[str], +) -> bool: + """判断自动整专是否误带了共享单曲身份。""" + if not multi_track_music_batch or (manual and media_source and media_id): + return False + batch_music_type = getattr(mediainfo, "music_type", None) + return ( + batch_music_type == MUSIC_ENTITY_RECORDING + or (not batch_music_type and history_music_type == MUSIC_ENTITY_RECORDING) + ) + + class TransferWorkflowOwner(_TransferOwnerBase): """协调请求级候选构建并委托规划、执行与结算 owner。""" @@ -654,6 +674,10 @@ class TransferWorkflowOwner(_TransferOwnerBase): skipped_history_count = 0 skipped_torrents = set() cleanup_intent_assigned = False + multi_track_music_batch = ( + batch_mtype == MediaType.MUSIC + and sum(self._is_audio_file(item) for item, _ in file_items) > 1 + ) try: for file_item, bluray_dir in file_items: if runtime_stop_state.is_system_stopped: @@ -749,9 +773,18 @@ class TransferWorkflowOwner(_TransferOwnerBase): download_hash=download_hash, ) + discard_recording_identity = _should_discard_batch_recording_identity( + multi_track_music_batch=multi_track_music_batch, + manual=manual, + media_source=media_source, + media_id=media_id, + mediainfo=mediainfo, + history_music_type=self._download_history_music_type(download_history), + ) history_music_meta, history_music_info = self._restore_music_download_context( download_history=download_history, file_path=file_path, + discard_recording_identity=discard_recording_identity, ) if not meta: @@ -784,10 +817,16 @@ class TransferWorkflowOwner(_TransferOwnerBase): _download_hash = download_hash # 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。 - task_mediainfo = mediainfo or history_music_info + task_mediainfo = ( + None + if discard_recording_identity + else mediainfo or history_music_info + ) if not task_mediainfo and isinstance(file_meta, MetaMusic): - # 无标签音频按目录级专辑匹配补齐曲目身份,命中结果带缓存不会逐文件重复请求 + # 无标签音频或误带单曲身份的整包按目录级专辑匹配;命中结果带缓存不会逐文件重复请求 file_meta, task_mediainfo = self._match_music_album_context(file_item, file_path, file_meta) + if not task_mediainfo and discard_recording_identity: + task_mediainfo = self._music_info_from_meta(file_meta) if not manual and task_mediainfo and self._is_movie_year_conflict(file_meta, task_mediainfo): task_mediainfo = None diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index 056a857de..bc9813030 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -4,6 +4,7 @@ from unittest.mock import Mock from jinja2 import Template +from app.application.history import DownloadHistorySnapshot from app.application.messaging.message import TemplateHelper from app.application.transfer.execution import ( TransferExecutionCheckpoint, @@ -11,7 +12,7 @@ from app.application.transfer.execution import ( ) from app.application.transfer.workflow import JobManager, TransferTask from app.chain.media import MediaChain -from app.chain.transfer import TransferChain +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 @@ -247,6 +248,50 @@ def test_restore_music_context_from_download_history(): assert restored_info.album == "Random Access Memories" +def test_restore_music_context_discards_shared_recording_identity(tmp_path, monkeypatch): + """多音轨批次不得把下载记录中的单曲身份恢复到每个音频文件。""" + 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="Local Album", + album_artist="Daft Punk", + year=2020, + 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 == "Local Album" + assert restored_meta.year == 2020 + 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( @@ -673,6 +718,155 @@ def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch): 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: 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_explicit_music_batch_excludes_video_from_mixed_directory(tmp_path, monkeypatch): """明确音乐上下文时只规划音频主文件,混合目录中的视频不得套用音乐身份。""" audio_path = tmp_path / "08 - Get Lucky.flac"