From 4d7bd96c6bcc2e6b1ffc0a2a314b201adec4b974 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 15:06:36 +0800 Subject: [PATCH 1/4] fix(music): preserve album context for manual batches --- app/api/endpoints/transfer.py | 67 ++++++++++- app/chain/media/album.py | 32 ++++-- app/chain/media/contract.py | 10 ++ app/chain/media/facade.py | 3 + app/chain/transfer/filter.py | 1 + app/chain/transfer/history.py | 4 + app/chain/transfer/workflow.py | 17 ++- tests/test_music_album_match.py | 20 ++++ tests/test_transfer_history_retransfer.py | 114 +++++++++++++++++++ tests/test_transfer_music_album_selection.py | 110 ++++++++++++++++++ 10 files changed, 365 insertions(+), 13 deletions(-) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 4f9f37e43..ea2662858 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -1,3 +1,4 @@ +import re from pathlib import Path from typing import Annotated, Any, List, Literal, Optional, cast @@ -115,6 +116,58 @@ def _merge_transfer_messages(messages: List[str]) -> str: ) +def _is_music_file_batch(fileitems: List[FileItem]) -> bool: + """判断显式选中项是否为可按专辑识别的多音轨批次。""" + if len(fileitems) < 2 or any(fileitem.type != "file" for fileitem in fileitems): + return False + audio_extensions = set(get_api_runtime_config_snapshot().audio_extensions) + return all( + Path(fileitem.path or fileitem.name or "").suffix.lower() in audio_extensions + for fileitem in fileitems + ) + + +def _selected_music_fileitems( + fileitems: List[FileItem], + *, + explicitly_selected: bool, + media_type: Optional[MediaType], +) -> Optional[List[FileItem]]: + """返回需要共享专辑上下文的显式多选音轨。""" + if not explicitly_selected or media_type not in (None, MediaType.MUSIC): + return None + if not _is_music_file_batch(fileitems): + return None + paths = [Path(fileitem.path) for fileitem in fileitems if fileitem.path] + parent_counts: dict[Path, int] = {} + for path in paths: + parent_counts[path.parent] = parent_counts.get(path.parent, 0) + 1 + album_roots = [ + parent + for parent in parent_counts + if all(path.parent == parent or parent in path.parents for path in paths) + ] + if not album_roots: + return fileitems + album_root = max(album_roots, key=lambda path: len(path.parts)) + filtered = [] + for fileitem, path in zip(fileitems, paths): + if path.parent == album_root: + filtered.append(fileitem) + continue + relative_parent = path.parent.relative_to(album_root) + first_directory = relative_parent.parts[0] if relative_parent.parts else "" + if re.fullmatch(r"(?:cd|disc|disk)\s*\d{1,2}", first_directory.strip(), re.IGNORECASE): + filtered.append(fileitem) + if filtered and len(filtered) < len(fileitems): + logger.info( + "音乐专辑批次忽略 %s 个非碟片子目录中的重复或附加音频", + len(fileitems) - len(filtered), + ) + return filtered + return fileitems + + def _manual_review_actor(current_user: object) -> str: """按名称、用户名和用户 ID 的稳定顺序提取人工复核操作者。""" for attribute in ("name", "username", "id"): @@ -674,6 +727,11 @@ def _execute_manual_transfer( offset=transer_item.episode_offset, ) explicit_selected_files = bool(transer_item.fileitems) + selected_music_fileitems = _selected_music_fileitems( + src_fileitems, explicitly_selected=explicit_selected_files, media_type=mtype + ) + mtype = MediaType.MUSIC if selected_music_fileitems is not None else mtype + explicit_selected_files = explicit_selected_files and selected_music_fileitems is None # 前端显式传入文件列表时,按选中的文件逐个处理,避免将目录整体展开。 if explicit_selected_files: @@ -784,7 +842,11 @@ def _execute_manual_transfer( target_path=target_path, media_source=transer_item.media_source, media_id=transer_item.media_id, - music_type=_resolve_music_type(src_fileitem), + music_type=( + MUSIC_ENTITY_ALBUM + if selected_music_fileitems is not None + else _resolve_music_type(src_fileitem) + ), music_release_regions=transer_item.music_release_regions, music_release_scripts=transer_item.music_release_scripts, mtype=mtype, @@ -802,8 +864,9 @@ def _execute_manual_transfer( download_hash=download_hash, preview=transer_item.preview, reorganize=transer_item.reorganize, - sync_extra_files=True, + sync_extra_files=selected_music_fileitems is None, cleanup_dest_fileitem=cleanup_dest_fileitem, + selected_fileitems=selected_music_fileitems, ) # 失败 if not state: diff --git a/app/chain/media/album.py b/app/chain/media/album.py index ca922f7cf..2e509f442 100644 --- a/app/chain/media/album.py +++ b/app/chain/media/album.py @@ -1,6 +1,7 @@ """专辑目录扫描、曲目对齐与缓存编排 owner。""" import os +from copy import deepcopy from pathlib import Path from typing import Optional, Union, cast @@ -62,7 +63,8 @@ class MediaAlbumOwner(_MediaOwnerBase): except OSError: subdirectories = [] for subdirectory in subdirectories: - collect(subdirectory) + if MetaMusic.parse_disc_dir(subdirectory.name) is not None: + collect(subdirectory) return files @staticmethod @@ -177,6 +179,24 @@ class MediaAlbumOwner(_MediaOwnerBase): ).items() } + @classmethod + def _album_track_map( + cls, + files: list[Path], + metas: list[MetaMusic], + album: MusicAlbumInfo, + ) -> dict[str, MusicInfo]: + """将专辑分类与识别事实传递给每条已对位曲目。""" + aligned = cls._align_music_album_tracks(files, metas, album.tracks) + for info in aligned.values(): + info.set_library_category(album.library_category) + info.classification = deepcopy(album.classification) + info.classification_facts = dict(album.classification_facts) + return { + str(file.resolve()): info + for file, info in aligned.items() + } + def _match_music_album_directory( self, directory: Path, @@ -199,10 +219,7 @@ class MediaAlbumOwner(_MediaOwnerBase): ) if not album or not album.tracks: return {} - return { - str(file.resolve()): info - for file, info in self._align_music_album_tracks(files, metas, album.tracks).items() - } + return self._album_track_map(files, metas, album) async def _async_match_music_album_directory( self, @@ -226,10 +243,7 @@ class MediaAlbumOwner(_MediaOwnerBase): ) if not album or not album.tracks: return {} - return { - str(file.resolve()): info - for file, info in self._align_music_album_tracks(files, metas, album.tracks).items() - } + return self._album_track_map(files, metas, album) def recognize_music_album_directory( self, diff --git a/app/chain/media/contract.py b/app/chain/media/contract.py index d4aadcf41..d0c98ab08 100644 --- a/app/chain/media/contract.py +++ b/app/chain/media/contract.py @@ -173,6 +173,16 @@ if TYPE_CHECKING: """同步识别音乐专辑目录。""" ... + @classmethod + def _album_track_map( + cls, + files: list[Path], + metas: list[MetaMusic], + album: MusicAlbumInfo, + ) -> dict[str, MusicInfo]: + """将专辑级分类上下文传递给对位曲目。""" + ... + async def async_recognize_music_album_directory( self, path: Union[str, Path], diff --git a/app/chain/media/facade.py b/app/chain/media/facade.py index 91ca726ca..a2fdb58b7 100644 --- a/app/chain/media/facade.py +++ b/app/chain/media/facade.py @@ -126,6 +126,9 @@ class MediaChain(ChainBase, metaclass=Singleton): _align_selected_music_album = classmethod( # type: ignore[var-annotated] MediaAlbumOwner._align_selected_music_album.__func__ # type: ignore[attr-defined] ) + _album_track_map = classmethod( # type: ignore[var-annotated] + MediaAlbumOwner._album_track_map.__func__ # type: ignore[attr-defined] + ) _match_music_album_directory = MediaAlbumOwner._match_music_album_directory _async_match_music_album_directory = MediaAlbumOwner._async_match_music_album_directory recognize_music_album_directory = cast( diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index 8ac6d8749..216322a4f 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -235,6 +235,7 @@ class FileFilterMixin(_TransferOwnerBase): merged_info.set_library_category(info.library_category) merged_info.metadata_category = info.metadata_category merged_info.classification = deepcopy(info.classification) + merged_info.classification_facts = dict(info.classification_facts) merged_info.genres = list(info.genres) merged_info.tags = list(info.tags) merged_info.artist_country = info.artist_country diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index 96691ac0d..af14c0383 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -171,6 +171,7 @@ class TransferHistoryOwner(_TransferOwnerBase): music_type: Optional[str] = None, music_release_regions: Optional[list[str]] = None, music_release_scripts: Optional[list[str]] = None, + selected_fileitems: Optional[list[FileItem]] = None, ) -> Tuple[bool, Union[str, dict[str, Any]]]: """ 手动整理,支持复杂条件,带进度显示 @@ -199,6 +200,7 @@ class TransferHistoryOwner(_TransferOwnerBase): :param music_type: 音乐实体类型;为保持位置参数兼容,必须追加在签名末尾 :param music_release_regions: 本次音乐整理的发行地区优先级,空值继承系统设置 :param music_release_scripts: 本次音乐整理的文字字形优先级,空值继承系统设置 + :param selected_fileitems: 前端显式选中的批量文件 """ logger.info(f"手动整理:{fileitem.path} ...") explicit_identity = media_source is not None or media_id is not None @@ -252,6 +254,7 @@ class TransferHistoryOwner(_TransferOwnerBase): cleanup_dest_fileitem=cleanup_dest_fileitem, music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, + selected_fileitems=selected_fileitems, ) if not state: return False, errmsg @@ -284,6 +287,7 @@ class TransferHistoryOwner(_TransferOwnerBase): cleanup_dest_fileitem=cleanup_dest_fileitem, music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, + selected_fileitems=selected_fileitems, ) return state, errmsg diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index 950ed4e55..9f09c8bfe 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -276,6 +276,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): epformat: Optional[EpisodeFormat], season: Optional[int], continue_callback: Optional[Callable], + selected_fileitems: Optional[list[FileItem]] = None, ) -> Tuple[List[Tuple[FileItem, bool]], bool]: """ 收集并过滤本次整理的候选文件。 @@ -329,7 +330,15 @@ class TransferWorkflowOwner(_TransferOwnerBase): return False return not self._is_blocked_by_exclude_words(item.path, exclude_words) - candidates = self._TransferChain__get_trans_fileitems(fileitem, predicate=keep_candidate) + if selected_fileitems is None: + candidates = self._TransferChain__get_trans_fileitems(fileitem, predicate=keep_candidate) + else: + storage_chain = StorageChain() + candidates = [ + (latest_fileitem, False) + for selected_fileitem in selected_fileitems + if (latest_fileitem := storage_chain.get_item(selected_fileitem)) + ] return [ (item, is_bluray_dir) for item, is_bluray_dir in candidates if is_allowed(item, is_bluray_dir) ], matched_template @@ -364,6 +373,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): reorganize: Optional[bool] = False, music_release_regions: Optional[list[str]] = None, music_release_scripts: Optional[list[str]] = None, + selected_fileitems: Optional[list[FileItem]] = None, ) -> Tuple[bool, Union[str, dict]]: """ 兼容公开整理入口,委托给内部批次执行阶段。 @@ -400,6 +410,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): reorganize=reorganize, music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, + selected_fileitems=selected_fileitems, ) def _execute_transfer(self, *args: Any, **kwargs: Any) -> Tuple[bool, Union[str, dict]]: @@ -437,6 +448,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): recovery_admission: Optional[TransferAdmission] = None, music_release_regions: Optional[list[str]] = None, music_release_scripts: Optional[list[str]] = None, + selected_fileitems: Optional[list[FileItem]] = None, ) -> Tuple[bool, Union[str, dict]]: """ 执行一个复杂目录的整理操作 @@ -469,6 +481,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): :param recovery_admission: 内部恢复调用绑定的既有 durable 记录 :param music_release_regions: 本次音乐整理的发行地区优先级 :param music_release_scripts: 本次音乐整理的文字字形优先级 + :param selected_fileitems: 前端显式选中的文件,按同一批次规划 返回:成功标识,错误信息 """ selected_music_album: Optional[MusicAlbumInfo] @@ -487,7 +500,6 @@ class TransferWorkflowOwner(_TransferOwnerBase): if identity_error: return False, identity_error - # 是否全部成功 all_success = True transfer_batch_id = str(uuid.uuid4()) batch_mtype = getattr(mediainfo, "type", None) @@ -536,6 +548,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): epformat=epformat, season=season, continue_callback=continue_callback, + selected_fileitems=selected_fileitems, ) except OperationInterrupted: return False, f"{fileitem.name} 已取消" diff --git a/tests/test_music_album_match.py b/tests/test_music_album_match.py index ccdb427cc..100f817cd 100644 --- a/tests/test_music_album_match.py +++ b/tests/test_music_album_match.py @@ -203,6 +203,7 @@ def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch media_id="rg-1", title="七里香", artists=["周杰伦"], + library_category="Album", tracks=[ MusicInfo( media_source="musicbrainz", @@ -230,11 +231,30 @@ def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch info = matched[str(file.resolve())] assert info.media_id == f"rec-{index + 1}" assert info.title == ALBUM_TRACKS[index][0] + assert info.library_category == "Album" # 同一目录再次识别直接命中缓存,不重复请求模块 assert media_chain.recognize_music_album_directory(album_dir) == matched source_chain.match_music_album.assert_called_once() +def test_directory_audio_files_only_includes_disc_subdirectories(tmp_path): + """专辑根目录只应合并 CD/Disc 子目录,不得把附加版本当成额外碟。""" + album_dir = tmp_path / "周杰伦 - 七里香 (2004)" + disc_dir = album_dir / "CD2" + alternate_dir = album_dir / "附加原版" + disc_dir.mkdir(parents=True) + alternate_dir.mkdir() + root_track = album_dir / "01.flac" + disc_track = disc_dir / "01.flac" + alternate_track = alternate_dir / "01.flac" + for path in (root_track, disc_track, alternate_track): + path.write_bytes(b"audio") + + files = MediaChain._directory_audio_files(album_dir) + + assert files == [root_track, disc_track] + + def test_align_album_tracks_prefers_exact_titles_over_conflicting_positions(): """本地曲序与发行版本冲突时,精确曲名必须优先,避免整张专辑错位。""" files = [ diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index 897a3c4ca..cdb2747b4 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -429,6 +429,120 @@ def test_manual_transfer_preview_multi_select_collects_failures(monkeypatch): assert resp.data["items"][1]["success"] is False +def test_manual_transfer_music_files_share_one_album_batch(monkeypatch): + """多选音轨应一次进入整理链,避免按单曲丢失专辑分类上下文。""" + selected_fileitems = [ + { + "storage": "local", + "path": f"/downloads/七里香/{index:02d}.flac", + "name": f"{index:02d}.flac", + "extension": "flac", + "type": "file", + } + for index in (1, 2) + ] + captured = [] + + class FakeTransferChain: + def manual_transfer(self, **kwargs): + captured.append(kwargs) + return True, { + "summary": {"total": 2, "success": 2, "failed": 0}, + "items": [ + { + "source": item.path, + "target": f"/library/Album/周杰伦/七里香/{item.name}", + "target_dir": "/library/Album/周杰伦/七里香", + "success": True, + "message": "", + "type": "音乐", + "title": "七里香 (2004)", + } + for item in kwargs["selected_fileitems"] + ], + "message": "", + } + + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + monkeypatch.setattr( + "app.api.endpoints.transfer.get_api_runtime_config_snapshot", + lambda: SimpleNamespace(audio_extensions=(".flac",)), + ) + + resp = manual_transfer( + transer_item=ManualTransferItem( + fileitems=selected_fileitems, + preview=True, + type_name="自动", + ), + background=False, + history_query=SimpleNamespace(get=lambda _history_id: None), + _="token", + ) + + assert resp.success is True + assert resp.data["summary"] == {"total": 2, "success": 2, "failed": 0} + assert len(captured) == 1 + assert captured[0]["mtype"].value == "音乐" + assert captured[0]["music_type"] == "album" + assert [item.path for item in captured[0]["selected_fileitems"]] == [ + item["path"] for item in selected_fileitems + ] + + +def test_manual_transfer_music_batch_ignores_non_disc_alternate_directory(monkeypatch): + """根目录已有完整专辑时,不得把“附加原版”等任意子目录当成第二张碟。""" + selected_fileitems = [ + { + "storage": "local", + "path": path, + "name": path.rsplit("/", 1)[-1], + "extension": "flac", + "type": "file", + } + for path in ( + "/downloads/七里香/01.flac", + "/downloads/七里香/02.flac", + "/downloads/七里香/附加原版/01.flac", + "/downloads/七里香/附加原版/02.flac", + ) + ] + captured = [] + + class FakeTransferChain: + def manual_transfer(self, **kwargs): + captured.append(kwargs) + return True, { + "summary": {"total": 2, "success": 2, "failed": 0}, + "items": [], + "message": "", + } + + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + monkeypatch.setattr( + "app.api.endpoints.transfer.get_api_runtime_config_snapshot", + lambda: SimpleNamespace(audio_extensions=(".flac",)), + ) + + response = manual_transfer( + transer_item=ManualTransferItem( + fileitems=selected_fileitems, + preview=True, + type_name="自动", + ), + background=False, + history_query=SimpleNamespace(get=lambda _history_id: None), + _="token", + ) + + assert response.success is True + assert len(captured) == 1 + assert [item.path for item in captured[0]["selected_fileitems"]] == [ + "/downloads/七里香/01.flac", + "/downloads/七里香/02.flac", + ] + + def test_match_manual_transfer_target_path_returns_directory_match(monkeypatch): captured = {} diff --git a/tests/test_transfer_music_album_selection.py b/tests/test_transfer_music_album_selection.py index b573e6b21..b90189b1f 100644 --- a/tests/test_transfer_music_album_selection.py +++ b/tests/test_transfer_music_album_selection.py @@ -149,6 +149,116 @@ def test_selected_album_tracks_override_source_tag_names(tmp_path, monkeypatch): ] +def test_selected_music_fileitems_keep_album_batch_context(tmp_path, monkeypatch): + """显式多选音轨应在单个批次中应用专辑曲目和分类。""" + album_dir = tmp_path / "周杰伦 - 七里香 (2004)" + album_dir.mkdir() + paths = [album_dir / "01.flac", album_dir / "02.flac"] + for path in paths: + path.write_bytes(b"audio") + fileitems = [make_fileitem(path.as_posix()) for path in paths] + local_metas = { + paths[0]: MetaMusic(title="我的地盤", artists=["周杰倫"], track_number=1), + paths[1]: MetaMusic(title="藉口", artists=["周杰倫"], track_number=2), + } + chain = _prepare_chain(monkeypatch, fileitems) + monkeypatch.setattr( + "app.chain.transfer.workflow.StorageChain.get_item", + lambda _self, item: item, + ) + monkeypatch.setattr( + "app.chain.media.album.AudioMetadataHelper.read_many", + lambda requested: [deepcopy(local_metas[path]) for path in requested], + ) + monkeypatch.setattr( + MediaChain, + "read_path_meta", + staticmethod(lambda path: deepcopy(local_metas[path])), + ) + planned = [] + + def handle_transfer(task, callback=None): + del callback + planned.append((task.meta.title, task.mediainfo.library_category)) + return True, "" + + monkeypatch.setattr(chain, "_TransferChain__handle_transfer", handle_transfer) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitems[0], + selected_fileitems=fileitems, + mediainfo=_album(), + mtype=MediaType.MUSIC, + media_source=MediaSource.MusicBrainz, + media_id="release-group-1", + background=False, + ) + + assert state is True + assert message == "" + assert planned == [("我的地盘", "Album"), ("借口", "Album")] + + +def test_automatic_music_fileitems_receive_album_identity_and_category(tmp_path, monkeypatch): + """自动多选音轨应用目录级专辑识别结果覆盖本地繁体标签。""" + album_dir = tmp_path / "周杰伦 - 七里香 (2004)" + album_dir.mkdir() + paths = [album_dir / "01.flac", album_dir / "02.flac"] + for path in paths: + path.write_bytes(b"audio") + fileitems = [make_fileitem(path.as_posix()) for path in paths] + local_metas = { + paths[0]: MetaMusic(title="我的地盤", artists=["周杰倫"], track_number=1), + paths[1]: MetaMusic(title="藉口", artists=["周杰倫"], track_number=2), + } + album = _album() + matched = {} + for path, track in zip(paths, album.tracks): + matched_track = deepcopy(track) + matched_track.set_library_category(album.library_category) + matched_track.classification = deepcopy(album.classification) + matched[str(path.resolve())] = matched_track + chain = _prepare_chain(monkeypatch, fileitems) + monkeypatch.setattr( + "app.chain.transfer.workflow.StorageChain.get_item", + lambda _self, item: item, + ) + monkeypatch.setattr( + MediaChain, + "read_path_meta", + staticmethod(lambda path: deepcopy(local_metas[path])), + ) + monkeypatch.setattr( + MediaChain, + "recognize_music_album_directory", + lambda _self, _path, **_kwargs: matched, + ) + planned = [] + + def handle_transfer(task, callback=None): + del callback + planned.append((task.meta.title, task.mediainfo.album, task.mediainfo.library_category)) + return True, "" + + monkeypatch.setattr(chain, "_TransferChain__handle_transfer", handle_transfer) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitems[0], + selected_fileitems=fileitems, + mtype=MediaType.MUSIC, + background=False, + ) + + assert state is True + assert message == "" + assert planned == [ + ("我的地盘", "七里香", "Album"), + ("借口", "七里香", "Album"), + ] + + def test_manual_album_identity_forwards_full_selected_album(monkeypatch): """手动指定专辑 ID 时不应在进入整理链前丢失曲目表。""" chain = make_transfer_chain() From a5285238810a225550bb4f39cddff77b6484a77a Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 15:15:14 +0800 Subject: [PATCH 2/4] fix(transfer): keep selected batches internal --- app/chain/transfer/history.py | 20 ++++++++++++++++---- app/chain/transfer/workflow.py | 2 -- tests/test_transfer_music_album_selection.py | 4 ++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index af14c0383..64daeaef5 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -228,7 +228,7 @@ class TransferHistoryOwner(_TransferOwnerBase): self.obtain_images(mediainfo=mediainfo) # 开始整理 - state, errmsg = self.do_transfer( + transfer_kwargs = dict( fileitem=fileitem, target_storage=target_storage, target_path=target_path, @@ -254,8 +254,14 @@ class TransferHistoryOwner(_TransferOwnerBase): cleanup_dest_fileitem=cleanup_dest_fileitem, music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, - selected_fileitems=selected_fileitems, ) + if selected_fileitems is not None: + state, errmsg = self._execute_transfer( + **transfer_kwargs, + selected_fileitems=selected_fileitems, + ) + else: + state, errmsg = self.do_transfer(**transfer_kwargs) if not state: return False, errmsg @@ -263,7 +269,7 @@ class TransferHistoryOwner(_TransferOwnerBase): return True, errmsg if preview else "" else: # 没有输入媒体ID时,按文件识别 - state, errmsg = self.do_transfer( + transfer_kwargs = dict( fileitem=fileitem, target_storage=target_storage, target_path=target_path, @@ -287,8 +293,14 @@ class TransferHistoryOwner(_TransferOwnerBase): cleanup_dest_fileitem=cleanup_dest_fileitem, music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, - selected_fileitems=selected_fileitems, ) + if selected_fileitems is not None: + state, errmsg = self._execute_transfer( + **transfer_kwargs, + selected_fileitems=selected_fileitems, + ) + else: + state, errmsg = self.do_transfer(**transfer_kwargs) return state, errmsg def send_transfer_message( diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index 9f09c8bfe..6815715e3 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -373,7 +373,6 @@ class TransferWorkflowOwner(_TransferOwnerBase): reorganize: Optional[bool] = False, music_release_regions: Optional[list[str]] = None, music_release_scripts: Optional[list[str]] = None, - selected_fileitems: Optional[list[FileItem]] = None, ) -> Tuple[bool, Union[str, dict]]: """ 兼容公开整理入口,委托给内部批次执行阶段。 @@ -410,7 +409,6 @@ class TransferWorkflowOwner(_TransferOwnerBase): reorganize=reorganize, music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, - selected_fileitems=selected_fileitems, ) def _execute_transfer(self, *args: Any, **kwargs: Any) -> Tuple[bool, Union[str, dict]]: diff --git a/tests/test_transfer_music_album_selection.py b/tests/test_transfer_music_album_selection.py index b90189b1f..c4779bf61 100644 --- a/tests/test_transfer_music_album_selection.py +++ b/tests/test_transfer_music_album_selection.py @@ -184,7 +184,7 @@ def test_selected_music_fileitems_keep_album_batch_context(tmp_path, monkeypatch monkeypatch.setattr(chain, "_TransferChain__handle_transfer", handle_transfer) - state, message = TransferChain.do_transfer( + state, message = TransferChain._execute_transfer( chain, fileitem=fileitems[0], selected_fileitems=fileitems, @@ -243,7 +243,7 @@ def test_automatic_music_fileitems_receive_album_identity_and_category(tmp_path, monkeypatch.setattr(chain, "_TransferChain__handle_transfer", handle_transfer) - state, message = TransferChain.do_transfer( + state, message = TransferChain._execute_transfer( chain, fileitem=fileitems[0], selected_fileitems=fileitems, From dbf6d5d89024b0e2f15bc16a155bc0a1f6c58d78 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 15:46:24 +0800 Subject: [PATCH 3/4] refactor(transfer): preserve public transfer entrypoint --- app/chain/transfer/history.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index 64daeaef5..0c2779265 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -54,6 +54,19 @@ def _recognize_manual_media( class TransferHistoryOwner(_TransferOwnerBase): """唯一持有手动历史、重整命令和通知公开入口。""" + def _run_manual_transfer_request( + self, + transfer_kwargs: dict[str, Any], + selected_fileitems: Optional[list[FileItem]], + ) -> Tuple[bool, Union[str, dict]]: + """显式文件批次走内部入口,普通请求继续保持公开签名兼容。""" + if selected_fileitems is not None: + return self._execute_transfer( + **transfer_kwargs, + selected_fileitems=selected_fileitems, + ) + return self.do_transfer(**transfer_kwargs) + def remote_transfer( self, arg_str: str, @@ -255,13 +268,7 @@ class TransferHistoryOwner(_TransferOwnerBase): music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, ) - if selected_fileitems is not None: - state, errmsg = self._execute_transfer( - **transfer_kwargs, - selected_fileitems=selected_fileitems, - ) - else: - state, errmsg = self.do_transfer(**transfer_kwargs) + state, errmsg = self._run_manual_transfer_request(transfer_kwargs, selected_fileitems) if not state: return False, errmsg @@ -294,13 +301,7 @@ class TransferHistoryOwner(_TransferOwnerBase): music_release_regions=music_release_regions, music_release_scripts=music_release_scripts, ) - if selected_fileitems is not None: - state, errmsg = self._execute_transfer( - **transfer_kwargs, - selected_fileitems=selected_fileitems, - ) - else: - state, errmsg = self.do_transfer(**transfer_kwargs) + state, errmsg = self._run_manual_transfer_request(transfer_kwargs, selected_fileitems) return state, errmsg def send_transfer_message( From 13f0ac4faabdc97f93921ced04b57edc01744382 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 22:17:45 +0800 Subject: [PATCH 4/4] fix(transfer): reidentify selected music history batches --- app/api/endpoints/transfer.py | 51 ++++- app/api/endpoints/transferhistory.py | 66 +++++++ app/chain/transfer/filter.py | 5 +- app/chain/transfer/history.py | 18 +- app/chain/transfer/request.py | 35 ++++ app/chain/transfer/workflow.py | 17 +- docs/architecture-overview.md | 4 +- docs/architecture/optimization-checklist.md | 4 +- docs/mcp-api.md | 2 +- .../architecture/dependency-baseline.json | 16 +- .../fixtures/architecture/ruff-baseline.json | 3 - tests/test_music_transfer.py | 179 ++++++++++++++++++ tests/test_transfer_history_retransfer.py | 64 ++++++- 13 files changed, 432 insertions(+), 32 deletions(-) create mode 100644 app/api/endpoints/transferhistory.py diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index ea2662858..784254c09 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -7,6 +7,7 @@ from fastapi import Depends, HTTPException, Query, status from app.adapters.web.security.access import verify_apitoken, verify_token from app.api.dependencies.auth import get_current_active_manage_user from app.api.dependencies.history import get_transfer_execution_repository, get_transfer_history_lookup_service +from app.api.endpoints.transferhistory import restore_manual_transfer_history_batch from app.api.response import ( CompatibleCountParam, CompatiblePageParam, @@ -588,10 +589,50 @@ def manual_transfer( :param history_query: 整理历史投影服务 :param _: Token校验 """ + return _route_manual_transfer( + transer_item=transer_item, + background=background, + history_query=history_query, + ) + + +def _route_manual_transfer( + transer_item: ManualTransferItem, + background: Optional[bool], + history_query: TransferHistoryLookupService, +) -> Any: + """执行历史恢复、批量预览与 TransferChain 兼容编排。""" + if not transer_item.logids: + return _execute_manual_transfer( + transer_item=transer_item, + background=background, + history_query=history_query, + ) + + ( + src_fileitems, + force, + downloader, + download_hash, + history_error, + ) = restore_manual_transfer_history_batch( + transer_item=transer_item, + history_query=history_query, + ) + if history_error or not src_fileitems: + return _SchemaResponse( + success=False, + message=history_error or "缺少参数", + ) + transer_item.fileitems = src_fileitems + transer_item.logids = None return _execute_manual_transfer( transer_item=transer_item, background=background, history_query=history_query, + force=force, + downloader=downloader, + download_hash=download_hash, ) @@ -599,11 +640,11 @@ def _execute_manual_transfer( transer_item: ManualTransferItem, background: Optional[bool], history_query: TransferHistoryLookupService, + force: bool = False, + downloader: Optional[str] = None, + download_hash: Optional[str] = None, ) -> Any: - """执行历史恢复、批量预览与 TransferChain 兼容编排。""" - force = False - downloader = None - download_hash = None + """执行已还原源文件项的手动整理兼容编排。""" src_fileitems: List[FileItem] = [] cleanup_dest_fileitem: Optional[FileItem] = None target_path = Path(transer_item.target_path) if transer_item.target_path else None @@ -726,7 +767,7 @@ def _execute_manual_transfer( part=transer_item.episode_part, offset=transer_item.episode_offset, ) - explicit_selected_files = bool(transer_item.fileitems) + explicit_selected_files = bool(transer_item.fileitems or transer_item.logids) selected_music_fileitems = _selected_music_fileitems( src_fileitems, explicitly_selected=explicit_selected_files, media_type=mtype ) diff --git a/app/api/endpoints/transferhistory.py b/app/api/endpoints/transferhistory.py new file mode 100644 index 000000000..5da52cb42 --- /dev/null +++ b/app/api/endpoints/transferhistory.py @@ -0,0 +1,66 @@ +"""手动整理历史批次的 HTTP 适配辅助函数。""" + +from collections.abc import Sequence +from typing import Any, List, Optional + +from app.application.history import ManualTransferHistory, TransferHistoryLookupService +from app.runtime.log import logger +from app.schemas.file import FileItem +from app.schemas.transfer import ManualTransferItem + + +def _common_history_value( + histories: Sequence[ManualTransferHistory], attribute: str, +) -> Any: + """返回一组整理历史中完全一致的非空字段值。""" + values = [getattr(history, attribute, None) for history in histories] + nonempty_values = [value for value in values if value is not None and value != ""] + if not nonempty_values or len(nonempty_values) != len(values): + return None + first_value = nonempty_values[0] + return first_value if all(value == first_value for value in nonempty_values) else None + + +def restore_manual_transfer_history_batch( + transer_item: ManualTransferItem, + history_query: TransferHistoryLookupService, +) -> tuple[List[FileItem], bool, Optional[str], Optional[str], Optional[str]]: + """还原多选历史的源文件集合与可安全复用的共同下载上下文。""" + histories = [] + src_fileitems = [] + for logid in transer_item.logids or []: + history = history_query.get(logid) + if not history: + return [], False, None, None, f"整理记录不存在,ID:{logid}" + histories.append(history) + source_payload = ( + history.dest_fileitem + if history.status and history.mode and "move" in history.mode + else history.src_fileitem + ) + if not source_payload: + return [], False, None, None, f"整理记录缺少文件信息,ID:{logid}" + src_fileitems.append(FileItem.model_validate(source_payload)) + + force = bool(histories) and all(bool(history.status) for history in histories) + downloader = None + download_hash = None + if transer_item.from_history and histories: + transer_item.type_name = ( + _common_history_value(histories, "type") or transer_item.type_name + ) + transer_item.media_source = ( + _common_history_value(histories, "media_source") + or transer_item.media_source + ) + transer_item.media_id = ( + _common_history_value(histories, "media_id") or transer_item.media_id + ) + transer_item.music_type = ( + _common_history_value(histories, "music_type") + or transer_item.music_type + ) + downloader = _common_history_value(histories, "downloader") + download_hash = _common_history_value(histories, "download_hash") + logger.info("手动整理历史批次还原 %s 个源文件", len(src_fileitems)) + return src_fileitems, force, downloader, download_hash, None diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index 216322a4f..85d9f8254 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -320,6 +320,7 @@ class FileFilterMixin(_TransferOwnerBase): download_history: Optional[DownloadHistorySnapshot], file_path: Path, discard_recording_identity: bool = False, + discard_saved_identity: bool = False, ) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]: """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。 @@ -327,6 +328,8 @@ class FileFilterMixin(_TransferOwnerBase): 沿用已选媒体,不根据专辑名或文件曲名在单曲与专辑之间转换。 多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording 身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。 + 手动选中多条历史且未要求复用历史身份时,专辑身份也必须丢弃,确保 + 目录级识别能够重新补齐发行版、分类和规范名称。 """ note = getattr(download_history, "note", None) music_note = note.get("music") if isinstance(note, dict) else None @@ -339,7 +342,7 @@ class FileFilterMixin(_TransferOwnerBase): return None, None file_tags = MediaChain.read_path_meta(file_path) - should_discard_identity = ( + should_discard_identity = discard_saved_identity or ( discard_recording_identity and saved_info.music_type == MUSIC_ENTITY_RECORDING ) diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index 0c2779265..8bbd43ee2 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from typing import Any, List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union, cast from app.chain.media import MediaChain from app.chain.transfer.contract import _TransferOwnerBase @@ -58,14 +58,20 @@ class TransferHistoryOwner(_TransferOwnerBase): self, transfer_kwargs: dict[str, Any], selected_fileitems: Optional[list[FileItem]], - ) -> Tuple[bool, Union[str, dict]]: + ) -> Tuple[bool, Union[str, dict[str, Any]]]: """显式文件批次走内部入口,普通请求继续保持公开签名兼容。""" if selected_fileitems is not None: - return self._execute_transfer( - **transfer_kwargs, - selected_fileitems=selected_fileitems, + return cast( + Tuple[bool, Union[str, dict[str, Any]]], + self._execute_transfer( + **transfer_kwargs, + selected_fileitems=selected_fileitems, + ), ) - return self.do_transfer(**transfer_kwargs) + return cast( + Tuple[bool, Union[str, dict[str, Any]]], + self.do_transfer(**transfer_kwargs), + ) def remote_transfer( self, diff --git a/app/chain/transfer/request.py b/app/chain/transfer/request.py index f92e21143..b1a213c70 100644 --- a/app/chain/transfer/request.py +++ b/app/chain/transfer/request.py @@ -22,6 +22,19 @@ from app.schemas.types import ( from app.schemas.workflow import FileItem +def preview_media_title( + mediainfo: Optional[Union[MediaInfo, MusicInfo]], +) -> Optional[str]: + """音乐预览以专辑为批次标题,影视继续使用原有标题。""" + if isinstance(mediainfo, MusicInfo) and mediainfo.album: + return ( + f"{mediainfo.album} ({mediainfo.year})" + if mediainfo.year + else mediainfo.album + ) + return mediainfo.title_year if mediainfo else None + + def _should_discard_batch_recording_identity( *, multi_track_music_batch: bool, @@ -41,6 +54,28 @@ def _should_discard_batch_recording_identity( ) +def _should_discard_batch_music_identity( + *, + manual: bool, + multi_track_music_batch: bool, + media_source: Optional[MediaSource], + media_id: Optional[str], + mediainfo: Optional[MediaInfo | MusicInfo], + history_music_type: Optional[str], +) -> bool: + """批次误带单曲身份或未显式指定媒体时重新识别整张专辑。""" + if manual and multi_track_music_batch and not (media_source and media_id): + return True + return _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=history_music_type, + ) + + class _TransferCandidatePlanner: """持有一次整理请求的只读候选规划上下文。""" diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index 6815715e3..324ff7329 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -44,8 +44,9 @@ from app.schemas.types import ( from app.schemas.workflow import FileItem from .request import ( - _should_discard_batch_recording_identity, + _should_discard_batch_music_identity, _TransferCandidatePlanner, + preview_media_title, ) @@ -789,9 +790,8 @@ 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, + discard_music_identity = _should_discard_batch_music_identity( + multi_track_music_batch=multi_track_music_batch, manual=manual, media_source=media_source, media_id=media_id, mediainfo=mediainfo, @@ -800,7 +800,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): 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, + discard_saved_identity=discard_music_identity, ) if not meta: @@ -835,14 +835,15 @@ class TransferWorkflowOwner(_TransferOwnerBase): # 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。 file_meta, task_mediainfo = self._selected_music_task_context( file_item, file_path, file_meta, selected_music_track_map, - None if discard_recording_identity else mediainfo or history_music_info, + None if discard_music_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, music_release_regions, music_release_scripts, ) - if not task_mediainfo and discard_recording_identity: + if not task_mediainfo and discard_music_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 @@ -956,7 +957,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): "success": transferinfo.success, "message": transferinfo.message, "type": item_media.type.value if item_media and item_media.type else None, - "title": item_media.title_year if item_media else None, + "title": preview_media_title(item_media), "season": item_meta.begin_season if item_meta else None, "episode": item_meta.begin_episode if item_meta else None, "episode_end": item_meta.end_episode if item_meta else None, diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 0172bf711..db9d86791 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -754,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 981 | -| 内部导入边 | 8,315 | +| Python 模块 | 982 | +| 内部导入边 | 8,324 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index ee52c0e3d..bea0c0c07 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 981 / 8,315 | `dependency-baseline.json` 当前快照;分类离线词表新增一个纯领域模块及其受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 982 / 8,324 | `dependency-baseline.json` 当前快照;手动整理历史批次新增一个 HTTP 适配辅助模块及其受控下行依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -103,7 +103,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 全量 mypy 历史债务 | 9,441 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 538 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| Ruff 历史诊断 | 537 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 09459b411..6c8960801 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -280,7 +280,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 | | POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | -| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;可用最多三项的 `music_release_regions`(ISO 3166-1)和 `music_release_scripts`(ISO 15924)仅覆盖本次 MusicBrainz 发行版本排序,省略时继承系统设置;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;`logids` 会先还原为同一个显式文件批次,多首音乐因而共享专辑识别上下文;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;可用最多三项的 `music_release_regions`(ISO 3166-1)和 `music_release_scripts`(ISO 15924)仅覆盖本次 MusicBrainz 发行版本排序,省略时继承系统设置;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | | GET | `/api/v1/transfer/tasks/manual-reviews` | 管理员分页查询 durable 人工复核任务;`state` 仅允许 `manual_review`(默认)或已经人工判定、等待调度恢复的 `retry_wait`,支持 `page` 与 `page_size`。响应只公开任务、源文件、状态、步骤意图/证据/错误和复核修订号,不返回 lease 或 attempt 身份 | | GET | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员查询单个 durable 人工复核任务详情;仅可读取 `manual_review` 或已经人工判定的 `retry_wait` 任务,其余状态按不存在处理 | | POST | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员判定处于 `manual_review` 的 durable 整理步骤;请求包含 `operation_id`、`decision=not_applied|applied`、`reason`,`applied` 还必须提供 `result_payload`。`failed` 不属于公开决策,失败终态只能由持租约的 durable 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 12d9ae2d0..f7d06f71f 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8315, - "edge_sha256": "798e408eb7130ed507124b1b775aee3fa04e592beb8c01f66394a264e8d37242", + "edge_count": 8324, + "edge_sha256": "74533a654108e22c91d7ff8ec8b004cc01945e89ccddfb84982b098d9d7a6d0c", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2857,6 +2857,8 @@ "app.api.endpoints.transfer -> app.api.dependencies", "app.api.endpoints.transfer -> app.api.dependencies.auth", "app.api.endpoints.transfer -> app.api.dependencies.history", + "app.api.endpoints.transfer -> app.api.endpoints", + "app.api.endpoints.transfer -> app.api.endpoints.transferhistory", "app.api.endpoints.transfer -> app.api.response", "app.api.endpoints.transfer -> app.application", "app.api.endpoints.transfer -> app.application.configuration", @@ -2880,6 +2882,13 @@ "app.api.endpoints.transfer -> app.schemas.transfer", "app.api.endpoints.transfer -> app.schemas.types", "app.api.endpoints.transfer -> app.schemas.workflow", + "app.api.endpoints.transferhistory -> app.application", + "app.api.endpoints.transferhistory -> app.application.history", + "app.api.endpoints.transferhistory -> app.runtime", + "app.api.endpoints.transferhistory -> app.runtime.log", + "app.api.endpoints.transferhistory -> app.schemas", + "app.api.endpoints.transferhistory -> app.schemas.file", + "app.api.endpoints.transferhistory -> app.schemas.transfer", "app.api.endpoints.user -> app.api", "app.api.endpoints.user -> app.api.dependencies", "app.api.endpoints.user -> app.api.dependencies.auth", @@ -9393,7 +9402,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 981, + "module_count": 982, "modules": [ "app", "app.adapters", @@ -9578,6 +9587,7 @@ "app.api.endpoints.tmdb", "app.api.endpoints.torrent", "app.api.endpoints.transfer", + "app.api.endpoints.transferhistory", "app.api.endpoints.user", "app.api.endpoints.webhook", "app.api.endpoints.workflow", diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 65732a158..20ba6e4a5 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -964,9 +964,6 @@ "tests/test_transfer_download_history_oper_sessions.py": { "I001": 1 }, - "tests/test_transfer_history_retransfer.py": { - "I001": 1 - }, "tests/test_transfer_rename_build_event.py": { "I001": 1 }, diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index 624330c08..578c320dd 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -374,6 +374,53 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags( 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): """已选语义字段仅补缺,不覆盖种子证据、文件标签或注入目标音质。""" @@ -937,6 +984,138 @@ def test_automatic_multi_track_recording_context_rematches_album(tmp_path, monke 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: 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" diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index cdb2747b4..42f77eb4e 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -1,6 +1,5 @@ from types import SimpleNamespace - from app.api.endpoints.transfer import ( manual_transfer, match_manual_transfer_target_path, @@ -543,6 +542,69 @@ def test_manual_transfer_music_batch_ignores_non_disc_alternate_directory(monkey ] +def test_manual_transfer_history_ids_share_one_music_album_batch(monkeypatch): + """多选整理历史应先还原文件集合,再以一个专辑批次进入整理链。""" + paths = ( + "/downloads/七里香/01.flac", + "/downloads/七里香/02.flac", + "/downloads/七里香/附加原版/01.flac", + "/downloads/七里香/附加原版/02.flac", + ) + histories = { + index: SimpleNamespace( + status=1, + mode="copy", + src_fileitem={ + "storage": "local", + "path": path, + "name": path.rsplit("/", 1)[-1], + "extension": "flac", + "type": "file", + }, + dest_fileitem=None, + ) + for index, path in enumerate(paths, start=41) + } + captured = [] + + class FakeTransferChain: + def manual_transfer(self, **kwargs): + captured.append(kwargs) + selected = kwargs["selected_fileitems"] + return True, { + "summary": {"total": len(selected), "success": len(selected), "failed": 0}, + "items": [], + "message": "", + } + + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + monkeypatch.setattr( + "app.api.endpoints.transfer.get_api_runtime_config_snapshot", + lambda: SimpleNamespace(audio_extensions=(".flac",)), + ) + + response = manual_transfer( + transer_item=ManualTransferItem( + logids=list(histories), + preview=True, + reorganize=True, + type_name="自动", + ), + background=False, + history_query=SimpleNamespace(get=histories.get), + _="token", + ) + + assert response.success is True + assert len(captured) == 1 + assert captured[0]["mtype"].value == "音乐" + assert captured[0]["music_type"] == "album" + assert [item.path for item in captured[0]["selected_fileitems"]] == [ + "/downloads/七里香/01.flac", + "/downloads/七里香/02.flac", + ] + + def test_match_manual_transfer_target_path_returns_directory_match(monkeypatch): captured = {}