diff --git a/app/modules/filemanager/module.py b/app/modules/filemanager/module.py index 1ec146845..a3c66abed 100644 --- a/app/modules/filemanager/module.py +++ b/app/modules/filemanager/module.py @@ -539,12 +539,15 @@ class FileManagerModule(_ModuleBase): """ 解析整理策略并生成零写副作用的冻结计划。 + 目录配置优先于显式目标路径;未匹配配置时保留自定义路径能力并告警, + 未提供显式路径时业务拒绝,不自动使用存储根目录。 + :param fileitem: 文件信息 :param meta: 预识别的元数据 :param mediainfo: 识别的媒体信息 :param target_directory: 目标目录配置 :param target_storage: 目标存储 - :param target_path: 目标路径 + :param target_path: 调用方显式指定的目标根路径;未匹配目录配置时使用 :param transfer_type: 转移模式 :param scrape: 是否刮削元数据 :param library_type_folder: 是否按媒体类型创建目录 @@ -582,6 +585,7 @@ class FileManagerModule(_ModuleBase): need_type_folder=library_type_folder, need_category_folder=library_category_folder) elif target_path: + explicit_target_path = target_path need_scrape = scrape or False need_rename = True need_notify = False @@ -589,6 +593,13 @@ class FileManagerModule(_ModuleBase): target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path, need_type_folder=library_type_folder, need_category_folder=library_category_folder) + logger.warning( + "未匹配到媒体库目录配置,使用调用方显式指定的目标路径:" + f"【{target_storage or fileitem.storage or 'local'}】{explicit_target_path}," + f"计算后的目标路径:{target_path},源路径:" + f"【{fileitem.storage or 'local'}】{fileitem.path};" + "如需使用存储目录配置的媒体库路径,请将目标路径设为自动匹配" + ) else: # 目录匹配失败按业务拒绝结算,避免进入异常重试和堆栈日志路径。 logger.error( diff --git a/docs/mcp-api.md b/docs/mcp-api.md index adbaac5e2..64dcb13d4 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -37,6 +37,15 @@ Discord 仅指定 `userid` 的定向通知使用私聊;无法私聊时返回 ### 文件整理与恢复 +自动选择目的目录时,整理请求应将 `target_path` 留空,由源存储、源目录、媒体类型和分类 +匹配启用的存储目录配置;未匹配且没有显式目标路径时,整理会以“未找到有效的媒体库目录”拒绝。 +`downloader` 为空不代表使用默认目标路径,也不会自动选择存储根目录。 + +显式传入 `target_path` 时,若未匹配目录配置,则保留自定义目标路径,并按请求的类型、分类 +子目录开关拼接路径;例如 `/media` 可得到 `/media/电影/华语电影`,不会自动补入 `links`。 +该分支在预览和实际规划时均输出 WARNING,包含源存储与路径、传入的目标根路径及计算后的目标路径。 +需要使用已配置的库路径时,请选择自动匹配,并在执行前通过整理预览核对目标位置。 + 整理成功历史保存 `episode_group`;批量复用历史时仅恢复所有记录共同的剧集组, 不同剧集组应分别提交。目录整理的叶子文件同样遵守冻结的覆盖策略, `overwrite_skipped` 表示策略拒绝,不能当作文件已导入。 diff --git a/tests/test_transfer_target_directory.py b/tests/test_transfer_target_directory.py new file mode 100644 index 000000000..71a433d8e --- /dev/null +++ b/tests/test_transfer_target_directory.py @@ -0,0 +1,130 @@ +""" +整理目录未匹配时的自定义路径告警与自动匹配拒绝边界。 +""" + +from pathlib import Path +from typing import Optional +from unittest.mock import Mock + +import pytest + +from app.application.transfer.execution import TransferPlanningRejectedError +from app.domain.context import MediaInfo +from app.domain.meta.metabase import MetaBase +from app.domain.metainfo import MetaInfo +from app.modules.filemanager.module import FileManagerModule +from app.schemas.category import ClassificationResult, ClassificationSelection +from app.schemas.file import FileItem +from app.schemas.system import TransferDirectoryConf +from app.schemas.types import MediaType + +# 源文件、识别信息和可观察边界共同组成一次隔离规划上下文。 +TransferContext = tuple[FileItem, MetaBase, MediaInfo, Mock, Mock] + + +@pytest.fixture +def transfer_context(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TransferContext: + """ + 提供隔离源文件与分类快照,记录告警并禁止规划产生存储写入。 + """ + source = tmp_path / "downloads" / "Movie.2021.mkv" + source.parent.mkdir() + source.write_bytes(b"video") + fileitem = FileItem( + storage="local", path=source.as_posix(), type="file", + name=source.name, basename=source.stem, extension="mkv", size=5, + ) + media = MediaInfo( + type=MediaType.MOVIE, title="乌海", year="2021", + classification=ClassificationResult(effective=ClassificationSelection( + category_path=["华语电影"], source="automatic", + )), + ) + warning = Mock() + monkeypatch.setattr("app.modules.filemanager.module.logger.warning", warning) + return fileitem, MetaInfo(source.name), media, Mock(), warning + + +@pytest.mark.parametrize("target_storage", [None, "smb"]) +@pytest.mark.parametrize("preview", [False, True]) +@pytest.mark.parametrize( + ("type_folder", "category_folder", "relative_path"), + [(True, True, "电影/华语电影"), (False, True, "华语电影"), (False, False, "")], +) +def test_unmatched_explicit_target_warns_and_preserves_requested_path( + transfer_context: TransferContext, tmp_path: Path, target_storage: Optional[str], + preview: bool, type_folder: bool, category_folder: bool, relative_path: str, +) -> None: + """ + 未匹配配置时保留自定义路径合同,并在预览和实际规划中明确告警。 + """ + fileitem, meta, media, source_oper, warning = transfer_context + explicit_root = tmp_path / "media" + expected_root = explicit_root / relative_path + module = FileManagerModule() + + checkpoint = module.plan_transfer( + fileitem=fileitem, meta=meta, mediainfo=media, + target_storage=target_storage, target_path=explicit_root, + transfer_type="copy", library_type_folder=type_folder, + library_category_folder=category_folder, source_oper=source_oper, + preview=preview, + ) + + assert checkpoint.root_target_path == expected_root.as_posix() + assert Path(checkpoint.final_target_path).is_relative_to(expected_root) + assert checkpoint.target_storage == (target_storage or "local") + warning.assert_called_once() + message = warning.call_args.args[0] + assert "未匹配到媒体库目录配置" in message + assert f"【{target_storage or 'local'}】{explicit_root}" in message + assert f"计算后的目标路径:{expected_root}" in message + assert f"源路径:【local】{fileitem.path}" in message + assert "自动匹配" in message + assert source_oper.mock_calls == [] + assert not explicit_root.exists() + + +def test_matched_directory_keeps_library_path_without_custom_target_warning( + transfer_context: TransferContext, tmp_path: Path, +) -> None: + """ + 命中目录配置时应保留 links 层级,并且不能误报自定义路径告警。 + """ + fileitem, meta, media, source_oper, warning = transfer_context + library_root = tmp_path / "media" / "links" / "电影" + directory = TransferDirectoryConf( + name="电影", library_path=library_root.as_posix(), + library_storage="local", transfer_type="copy", media_type="电影", + library_type_folder=False, library_category_folder=True, + ) + + checkpoint = FileManagerModule().plan_transfer( + fileitem=fileitem, meta=meta, mediainfo=media, + target_directory=directory, target_path=tmp_path / "media", + source_oper=source_oper, + ) + + assert checkpoint.root_target_path == (library_root / "华语电影").as_posix() + warning.assert_not_called() + assert source_oper.mock_calls == [] + assert not library_root.exists() + + +def test_unmatched_automatic_target_rejects_without_storage_root_fallback( + transfer_context: TransferContext, +) -> None: + """ + 没有显式目标路径时必须业务拒绝,不能自行生成存储根下的整理计划。 + """ + fileitem, meta, media, source_oper, warning = transfer_context + + with pytest.raises(TransferPlanningRejectedError, match="未找到有效的媒体库目录"): + FileManagerModule().plan_transfer( + fileitem=fileitem, meta=meta, mediainfo=media, + transfer_type="copy", library_type_folder=True, + library_category_folder=True, source_oper=source_oper, + ) + + warning.assert_not_called() + assert source_oper.mock_calls == []