fix(transfer): warn on unmatched custom target directory (#7159)

This commit is contained in:
jxxghp
2026-10-23 18:23:57 +08:00
committed by GitHub
parent 4d32f25fe4
commit 5258e06fd7
3 changed files with 151 additions and 1 deletions

View File

@@ -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(

View File

@@ -37,6 +37,15 @@ Discord 仅指定 `userid` 的定向通知使用私聊;无法私聊时返回
### 文件整理与恢复
自动选择目的目录时,整理请求应将 `target_path` 留空,由源存储、源目录、媒体类型和分类
匹配启用的存储目录配置;未匹配且没有显式目标路径时,整理会以“未找到有效的媒体库目录”拒绝。
`downloader` 为空不代表使用默认目标路径,也不会自动选择存储根目录。
显式传入 `target_path` 时,若未匹配目录配置,则保留自定义目标路径,并按请求的类型、分类
子目录开关拼接路径;例如 `/media` 可得到 `/media/电影/华语电影`,不会自动补入 `links`。
该分支在预览和实际规划时均输出 WARNING,包含源存储与路径、传入的目标根路径及计算后的目标路径。
需要使用已配置的库路径时,请选择自动匹配,并在执行前通过整理预览核对目标位置。
整理成功历史保存 `episode_group`;批量复用历史时仅恢复所有记录共同的剧集组,
不同剧集组应分别提交。目录整理的叶子文件同样遵守冻结的覆盖策略,
`overwrite_skipped` 表示策略拒绝,不能当作文件已导入。

View File

@@ -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 == []