fix(download): treat blank save path as default directory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-10-10 23:44:15 +08:00
committed by InfinityPacer
parent dd90883c5d
commit 338687bee8
5 changed files with 109 additions and 3 deletions

View File

@@ -839,6 +839,21 @@ def _validate_download_save_path_value(
raise ValueError("保存路径不在允许的下载目录范围内")
def specified_download_save_path(save_path: Optional[str]) -> Optional[str]:
"""
返回调用方真正指定的下载保存目录,空白值视为未指定。
旧版订阅编辑界面会把清空的保存目录持久化为空字符串,它表示使用默认下载目录,
不能当作非法路径交给 validate_download_save_path 拒绝。
:param save_path: 订阅、事件或接口传入的保存目录
:return: 原样保留的非空保存目录;空值或纯空白返回 None
"""
if save_path is None or not str(save_path).strip():
return None
return save_path
def validate_download_save_path(save_path: str) -> str:
"""
校验用户传入的下载保存目录,/download/paths 暴露的下载目录配置是允许写入的公共合同。

View File

@@ -9,7 +9,10 @@ from typing import Any, Callable, Dict, Optional, Set, Tuple, Union, cast
from urllib.parse import urlencode, urljoin, urlparse
from app.application.configuration import get_chain_runtime_config_snapshot
from app.application.directory import validate_download_save_path
from app.application.directory import (
specified_download_save_path,
validate_download_save_path,
)
from app.application.download.admission import SubscriptionDownloadGovernance
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.application.torrent.download import TorrentHelper
@@ -293,6 +296,8 @@ class _DownloadResourceOwner(_DownloadOwnerBase):
username: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""应用资源下载事件覆盖,并校验事件返回的下载目录。"""
# 空白保存目录表示使用默认下载目录,事件订阅方也只会看到 None。
save_path = specified_download_save_path(save_path)
meta = context.meta_info
media = context.media_info
event_data = ResourceDownloadEventData(
@@ -319,7 +324,9 @@ class _DownloadResourceOwner(_DownloadOwnerBase):
)
return save_path, "下载被事件取消"
if event_data.options and "save_path" in event_data.options:
save_path = cast(Optional[str], event_data.options.get("save_path"))
save_path = specified_download_save_path(
cast(Optional[str], event_data.options.get("save_path"))
)
if save_path is None:
return None, None
try:

View File

@@ -12,6 +12,7 @@ from app.application.configuration import get_chain_runtime_config_snapshot
from app.application.directory import (
DirectoryHelper,
build_media_download_path,
specified_download_save_path,
validate_download_save_path,
)
from app.application.torrent.download import TorrentHelper
@@ -321,6 +322,7 @@ class DownloadSubtitleOwner(_DownloadOwnerBase):
根据媒体信息解析下载目录。
"""
storage = 'local'
save_path = specified_download_save_path(save_path)
if save_path is not None:
try:
validated_save_path = validate_download_save_path(save_path)

View File

@@ -577,6 +577,69 @@ def test_download_single_supplements_category_before_download_event(monkeypatch)
assert context.media_info.tmdb_id == 12345
@pytest.mark.parametrize("save_path", ["", " "])
def test_resource_download_event_treats_blank_save_path_as_default(monkeypatch, save_path):
"""旧订阅持久化的空白保存目录表示使用默认目录,不能被路径校验拒绝。"""
captured = {}
def keep_event(_event_type, event_data):
"""记录事件订阅方看到的保存目录。"""
captured["save_path"] = event_data.options["save_path"]
return SimpleNamespace(event_data=event_data)
monkeypatch.setattr(eventmanager, "send_event", keep_event)
context = Context(
meta_info=MetaInfo("测试剧集 S01E01"),
media_info=MediaInfo(type=MediaType.TV, title="测试剧集"),
torrent_info=TorrentInfo(title="测试剧集 S01E01"),
)
result = DownloadChain._apply_resource_download_event(
context, None, None, "Subscribe|{}", None, save_path, None, None,
)
assert result == (None, None)
assert captured["save_path"] is None
def test_resource_download_event_treats_blank_override_as_default(monkeypatch):
"""事件把保存目录改成空白时同样回到默认目录。"""
def blank_override(_event_type, event_data):
"""模拟插件清空保存目录。"""
event_data.options["save_path"] = " "
return SimpleNamespace(event_data=event_data)
monkeypatch.setattr(eventmanager, "send_event", blank_override)
context = Context(
meta_info=MetaInfo("测试剧集 S01E01"),
media_info=MediaInfo(type=MediaType.TV, title="测试剧集"),
torrent_info=TorrentInfo(title="测试剧集 S01E01"),
)
result = DownloadChain._apply_resource_download_event(
context, None, None, None, None, "/downloads/tv", None, None,
)
assert result == (None, None)
def test_resource_download_event_still_rejects_invalid_save_path(monkeypatch):
"""非空白的非法保存目录仍按白名单拒绝。"""
monkeypatch.setattr(eventmanager, "send_event", lambda *_args: None)
context = Context(
meta_info=MetaInfo("测试剧集 S01E01"),
media_info=MediaInfo(type=MediaType.TV, title="测试剧集"),
torrent_info=TorrentInfo(title="测试剧集 S01E01"),
)
result = DownloadChain._apply_resource_download_event(
context, None, None, None, None, "relative/path", None, None,
)
assert result == ("relative/path", "保存路径必须是绝对路径")
def test_download_single_persists_custom_words_snapshot(monkeypatch):
"""下载成功登记历史时,应把传入的订阅识别词原样存入快照,供整理时原样复现识别。"""
captured = {}

View File

@@ -4,7 +4,11 @@ from unittest.mock import patch
import pytest
from app.application.directory import DirectoryHelper, validate_download_save_path
from app.application.directory import (
DirectoryHelper,
specified_download_save_path,
validate_download_save_path,
)
from app.chain.download.subtitle import DownloadSubtitleOwner
from app.domain.context import MediaInfo
from app.schemas.system import TransferDirectoryConf
@@ -139,3 +143,18 @@ def test_resolve_media_download_dir_surfaces_unknown_alias_error():
with patch.object(DirectoryHelper, "get_download_dirs", return_value=dirs):
result = DownloadSubtitleOwner._resolve_media_download_dir(media, save_path="不存在的库")
assert result == (None, None, "未找到名为「不存在的库」的下载目录")
@pytest.mark.parametrize(("value", "expected"), [(None, None), ("", None), (" ", None), ("剧集库", "剧集库")])
def test_specified_download_save_path_treats_blank_as_unspecified(value, expected):
"""空白保存目录视为未指定,非空值原样交给后续校验。"""
assert specified_download_save_path(value) == expected
def test_resolve_media_download_dir_uses_default_dir_for_blank_save_path():
"""空字符串保存目录走媒体默认下载目录,而不是报“保存路径不能为空”。"""
default_dir = TransferDirectoryConf(name="剧集库", priority=1, storage="local", download_path="/downloads/tv")
media = MediaInfo(type=MediaType.TV, title="测试剧集")
with patch.object(DirectoryHelper, "get_dir", return_value=default_dir):
result = DownloadSubtitleOwner._resolve_media_download_dir(media, save_path="")
assert result == ("local", Path("/downloads/tv"), "")