diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index 30832ffdb..087ab7875 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -166,6 +166,9 @@ class _MusicFileFilterBase(_TransferOwnerBase): :param task: 整理任务 :return: 是否必须具备自动分类结果 """ + if (isinstance(task.mediainfo, MusicInfo) and task.planning_input + and task.planning_input.options.get("music_target_path")): + return False target_directory = task.target_directory if target_directory and DirectoryHelper.has_fixed_category(target_directory): return False diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index b69ee8a48..142d0a9c5 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -333,7 +333,6 @@ class TransferHistoryOwner(_TransferOwnerBase): transfer_kwargs["music_track_match"] = music_track_match if music_target_path is not None: transfer_kwargs["music_target_path"] = music_target_path - transfer_kwargs["target_path"] = target_path or music_target_path if media_source and media_id: # 有输入媒体ID时预先识别,音乐与影视统一走 recognize_media 按类型分发 mediainfo = _recognize_manual_media( diff --git a/app/modules/filemanager/module.py b/app/modules/filemanager/module.py index fdc732e68..b3a250953 100644 --- a/app/modules/filemanager/module.py +++ b/app/modules/filemanager/module.py @@ -559,6 +559,8 @@ class FileManagerModule(_ModuleBase): 目录配置优先于显式目标路径;未匹配配置时保留自定义路径能力并告警, 未提供显式路径时业务拒绝,不自动使用存储根目录。 + 本次音乐最终目录沿用匹配目录的整理策略,未匹配目录且方式为自动时使用复制。 + :param fileitem: 文件信息 :param meta: 预识别的元数据 :param mediainfo: 识别的媒体信息 @@ -611,6 +613,7 @@ class FileManagerModule(_ModuleBase): need_category_folder=library_category_folder) elif target_path: explicit_target_path = target_path + transfer_type = transfer_type or ("copy" if music_target_path else None) need_scrape = scrape or False need_rename = True need_notify = False diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 4bd657599..43872532d 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -620,6 +620,7 @@ Emby、Jellyfin、Plex、Navidrome 的目录、条目、搜索、季集和活动 专辑与音质目录层级统一落在该目录;留空沿用现有目录规则。歌词同步重命名,封面与整轨 CUE 保持配套文件命名规则。最终目录与已选曲目身份会冻结到任务输入,任务恢复沿用原选择。 音乐没有线上条目时,可留空媒体 ID,使用完整的本地标签整理。 +填写最终目录时,自动整理方式继承匹配目录配置,未匹配目录配置时采用复制。 ### 分页与截断 diff --git a/skills/moviepilot-api/api/transfer.md b/skills/moviepilot-api/api/transfer.md index abf8232d9..2f4149010 100644 --- a/skills/moviepilot-api/api/transfer.md +++ b/skills/moviepilot-api/api/transfer.md @@ -34,6 +34,7 @@ Preview and execute using the same source scope and options, and inspect sequent `music_target_path` is an optional absolute final music directory. It applies only the filename template and places tracks and companions in that directory. The task freezes this choice for recovery. With no online album, omit the media ID to use complete local tags. +Automatic transfer inherits the matched directory policy and defaults to copying if no directory matches. ## Durable AI recognition correction diff --git a/tests/test_filemanager_planning.py b/tests/test_filemanager_planning.py index ebab9561f..7b2c98f79 100644 --- a/tests/test_filemanager_planning.py +++ b/tests/test_filemanager_planning.py @@ -408,18 +408,19 @@ def test_manual_music_final_directory_preserves_filename_and_frozen_input(monkey monkeypatch.setattr(transhandler_module, "get_runtime_setting", setting) directory = TransferDirectoryConf( - library_path="/configured-library", library_storage="alist", transfer_type="copy", renaming=renaming, + library_path="/configured-library", library_storage="alist", transfer_type="reflink", renaming=renaming, library_type_folder=True, library_category_folder=True, scraping=True, overwrite_mode="never", ) if configured else None checkpoint = FileManagerModule().plan_transfer( fileitem=fileitem, meta=meta, mediainfo=info, target_directory=directory, target_path=Path("/default-root"), - target_storage="alist", transfer_type="copy", library_type_folder=True, library_category_folder=True, + target_storage="alist", transfer_type=None, library_type_folder=True, library_category_folder=True, source_oper=ReadOnlyTreeStorage({}), planning_input=planning, ) target = Path(checkpoint.final_target_path) assert target.parent == Path("/chosen/OST/Album") assert target.name == ("song.jpg" if extension == "jpg" else f"01 - Selected Song.{extension}") assert checkpoint.root_target_path == "/chosen/OST/Album" + assert checkpoint.resolved_transfer_type == ("reflink" if configured else "copy") assert checkpoint.planning_input.fingerprint == fingerprint assert checkpoint.resolved_meta["sample_rate"] == 48000 assert checkpoint.resolved_meta["duration"] == 30 diff --git a/tests/test_music_release_selection.py b/tests/test_music_release_selection.py index 87c01bfc5..31a0ae781 100644 --- a/tests/test_music_release_selection.py +++ b/tests/test_music_release_selection.py @@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, Mock import httpx import pytest from fastapi import FastAPI +from mutagen.flac import FLAC from pydantic import ValidationError from app.adapters.web.security.access import verify_token @@ -22,9 +23,13 @@ from app.chain.musicbrainz import MusicBrainzChain from app.chain.theaudiodb import TheAudioDbChain from app.chain.transfer.facade import TransferChain from app.domain.context import MusicAlbumInfo, MusicInfo +from app.modules.filemanager.module import FileManagerModule from app.modules.musicbrainz import MusicBrainzModule +from app.schemas.file import FileItem +from app.schemas.system import TransferDirectoryConf from app.schemas.transfer import ManualTransferItem, TransferInfo from app.schemas.types import MediaSource, MediaType +from tests.test_filemanager_planning import ReadOnlyTreeStorage from tests.test_music_resource_context import _audio_files from tests.test_transfer_music_album_selection import _directory_item, _prepare_chain from tests.test_transfer_sync_extra_files import make_fileitem, make_transfer_chain @@ -254,6 +259,69 @@ def test_manual_music_options_reject_invalid_namespace_or_directory(changes): ManualTransferItem(**({"music_type": "album"} | changes)) +@pytest.mark.parametrize("configured", [False, True]) +@pytest.mark.parametrize("selected_files", [False, True]) +def test_music_final_directory_auto_defaults_reach_real_planner(tmp_path, monkeypatch, configured, selected_files): + """ + 真实标签从手动 API 经目录选择与分类校验到命名规划,自动方式继承源目录或使用复制。 + """ + paths = _audio_files(tmp_path / "Artist - Album") + for index, path in enumerate(paths, 1): + tags = FLAC(path) + tags.update(title=[f"Song {index}"], artist=["Artist"], albumartist=["Artist"], album=["Album"], + date=["2004"], tracknumber=[str(index)]) + tags.save() + before = [path.read_bytes() for path in paths] + items = [make_fileitem(str(path)) for path in paths] + chain = _prepare_chain(monkeypatch, items) + monkeypatch.setattr("app.chain.transfer.workflow.StorageChain.get_item", lambda _self, item: item) + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", lambda: chain) + monkeypatch.setattr(chain, "_finalize_recognition_result", lambda info, **_kwargs: info) + lookup = Mock(return_value=TransferDirectoryConf( + library_path=str(tmp_path / "configured"), library_storage="local", transfer_type="reflink", + library_category_folder=True, renaming=True, + ) if configured else None) + monkeypatch.setattr("app.chain.transfer.execution.DirectoryHelper.get_dir", lookup) + network = Mock(side_effect=AssertionError("完整本地标签不应请求在线识别")) + monkeypatch.setattr(chain, "_match_music_album_context", network) + monkeypatch.setattr(chain, "_match_music_recording_context", network) + monkeypatch.setattr(chain, "_TransferChain__select_storage_oper", lambda _storage: None) + planned = [] + + def plan(task, **_kwargs): + """ + 在文件执行边界使用真实文件管理规划器,记录目录和方式后返回零写副作用的预览。 + """ + assert task.target_path is None + checkpoint = FileManagerModule().plan_transfer( + fileitem=task.fileitem, meta=task.meta, mediainfo=task.mediainfo, target_directory=task.target_directory, + target_storage=task.target_storage, target_path=task.target_path, transfer_type=task.transfer_type, + library_category_folder=task.library_category_folder, planning_input=task.planning_input, + source_oper=ReadOnlyTreeStorage({}), preview=True, + ) + planned.append(checkpoint) + target = Path(checkpoint.final_target_path) + return TransferInfo(success=True, fileitem=task.fileitem, target_item=FileItem( + storage=checkpoint.target_storage, path=target.as_posix(), name=target.name, type="file", + )) + + monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", plan) + target = tmp_path / "chosen" + request = ManualTransferItem(fileitem=_directory_item(paths[0].parent), fileitems=items if selected_files else None, + type_name="音乐", music_type="album", music_target_path=str(target), preview=True) + response = manual_transfer(request, background=False, history_query=SimpleNamespace(), _="test") + assert response.success, response.message + assert response.model_dump()["data"]["summary"]["failed"] == 0 + assert len(planned) == 2 + assert all(Path(item.final_target_path).parent == target for item in planned) + assert all(item.resolved_transfer_type == ("reflink" if configured else "copy") for item in planned) + assert lookup.call_count == 2 + assert all("src_path" in call.kwargs and "dest_path" not in call.kwargs for call in lookup.call_args_list) + assert not target.exists() + assert [path.read_bytes() for path in paths] == before + network.assert_not_called() + + def test_invalid_manual_release_cannot_enqueue_or_fall_back(monkeypatch): """ 旧调用绕过HTTP时同样拒绝不完整发行身份,查不到发行也不进入文件整理。