diff --git a/app/application/directory.py b/app/application/directory.py index b2cbca9cc..f6bfe4f67 100644 --- a/app/application/directory.py +++ b/app/application/directory.py @@ -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 暴露的下载目录配置是允许写入的公共合同。 diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index 3fd51a6c3..0df32f4d6 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -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: diff --git a/app/chain/download/subtitle.py b/app/chain/download/subtitle.py index b008d095e..02bfc5fee 100644 --- a/app/chain/download/subtitle.py +++ b/app/chain/download/subtitle.py @@ -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) diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index e6cf6a389..25e456502 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -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 = {} diff --git a/tests/test_download_save_path_alias.py b/tests/test_download_save_path_alias.py index 089da2e8a..26c82e698 100644 --- a/tests/test_download_save_path_alias.py +++ b/tests/test_download_save_path_alias.py @@ -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"), "")