diff --git a/app/chain/download.py b/app/chain/download.py index 2c6e29c18..5cfd74568 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -137,9 +137,23 @@ class DownloadChain(ChainBase): logger.warn(str(err)) return None, None, str(err) if re.match(r"^[A-Za-z]:/", validated_save_path): - return storage, Path(validated_save_path), "" - file_uri = FileURI.from_uri(validated_save_path) - return file_uri.storage or storage, Path(file_uri.path), "" + target_dir = Path(validated_save_path) + else: + file_uri = FileURI.from_uri(validated_save_path) + storage = file_uri.storage or storage + target_dir = Path(file_uri.path) + + dir_info = DirectoryHelper().get_download_dir_by_save_path( + media=media_info, + save_path=validated_save_path, + ) + if dir_info: + target_dir = DownloadChain._append_download_classification( + root_path=target_dir, + dir_info=dir_info, + media_info=media_info, + ) + return storage, target_dir, "" dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True) storage = dir_info.storage if dir_info else storage @@ -147,15 +161,33 @@ class DownloadChain(ChainBase): logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}") return None, None, "未找到下载目录" - if not dir_info.media_type and dir_info.download_type_folder: - download_dir = Path(dir_info.download_path) / media_info.type.value - else: - download_dir = Path(dir_info.download_path) + download_dir = DownloadChain._append_download_classification( + root_path=Path(dir_info.download_path), + dir_info=dir_info, + media_info=media_info, + ) + return storage, download_dir, "" + @staticmethod + def _append_download_classification( + root_path: Path, + dir_info: schemas.TransferDirectoryConf, + media_info: MediaInfo, + ) -> Path: + """ + 按下载目录配置拼装媒体类型和类别子目录。 + + :param root_path: 下载根目录 + :param dir_info: 下载目录配置 + :param media_info: 媒体信息 + :return: 应传给存储或下载器的媒体下载目录 + """ + download_dir = root_path + if not dir_info.media_type and dir_info.download_type_folder: + download_dir = download_dir / media_info.type.value if not dir_info.media_category and dir_info.download_category_folder and media_info.category: download_dir = download_dir / media_info.category - - return storage, download_dir, "" + return download_dir @staticmethod def _upload_subtitle_file( @@ -785,36 +817,17 @@ class DownloadChain(ChainBase): # 获取种子文件的文件夹名和文件清单 _folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content) - storage = 'local' - # 下载目录 - if save_path is not None: - download_dir = Path(save_path) - else: - # 根据媒体信息查询下载目录配置 - dir_info = DirectoryHelper().get_dir(_media, include_unsorted=True) - storage = dir_info.storage if dir_info else storage - # 拼装子目录 - if dir_info: - # 一级目录 - if not dir_info.media_type and dir_info.download_type_folder: - # 一级自动分类 - download_dir = Path(dir_info.download_path) / _media.type.value - else: - # 一级不分类 - download_dir = Path(dir_info.download_path) - - # 二级目录 - if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category: - # 二级自动分类 - download_dir = download_dir / _media.category - else: - # 未找到下载目录,且没有自定义下载目录 - logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}") + storage, download_dir, error_msg = self._resolve_media_download_dir( + media_info=_media, + save_path=save_path, + ) + if not download_dir: + if error_msg == "未找到下载目录": self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!", title="下载失败", role="system") - return (None, "未找到下载目录") if return_detail else None - fileURI = FileURI(storage=storage, path=download_dir.as_posix()) - download_dir = Path(fileURI.uri) + return (None, error_msg or "未找到下载目录") if return_detail else None + file_uri = FileURI(storage=storage, path=download_dir.as_posix()) + download_dir = Path(file_uri.uri) # 添加下载 result: Optional[tuple] = self.download(content=torrent_content, diff --git a/app/helper/directory.py b/app/helper/directory.py index 5caaa775a..d76b0f9fc 100644 --- a/app/helper/directory.py +++ b/app/helper/directory.py @@ -41,6 +41,43 @@ class DirectoryHelper: """ return [d for d in self.get_download_dirs() if d.storage == "local"] + def get_download_dir_by_save_path( + self, + media: Optional[MediaInfo], + save_path: str, + ) -> Optional[schemas.TransferDirectoryConf]: + """ + 按媒体信息和精确保存根路径匹配下载目录配置。 + + 仅配置根目录本身继承自动分类规则;根目录下的自定义子目录保持调用方指定的完整路径。 + + :param media: 媒体信息 + :param save_path: 已选择的下载保存目录,支持本地路径或远端 FileURI + :return: 匹配的下载目录配置 + """ + value = str(save_path or "").strip() + try: + storage, raw_path = _split_file_uri(value) + target_style, target_path = _normalize_download_path(raw_path, storage) + except ValueError: + return None + + media_type = media.type.value if media else None + for dir_info in self.get_download_dirs(): + root = _normalize_download_root(dir_info) + if not root: + continue + root_storage, root_style, root_path = root + if storage != root_storage or target_style != root_style or target_path != root_path: + continue + if not media_type or not dir_info.media_type: + return dir_info + if dir_info.media_type == media_type and not dir_info.media_category: + return dir_info + if dir_info.media_type == media_type and dir_info.media_category == media.category: + return dir_info + return None + def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]: """ 获取所有媒体库目录 diff --git a/tests/test_download_save_path_allowlist.py b/tests/test_download_save_path_allowlist.py index db05a09b7..227be4c7a 100644 --- a/tests/test_download_save_path_allowlist.py +++ b/tests/test_download_save_path_allowlist.py @@ -1,4 +1,5 @@ import asyncio +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock @@ -45,6 +46,75 @@ def _windows_download_dirs(): ] +def _classified_download_dirs(): + return [ + TransferDirectoryConf( + name="分类下载", + priority=1, + storage="local", + download_path="/downloads", + download_type_folder=True, + download_category_folder=True, + ), + TransferDirectoryConf( + name="远程分类下载", + priority=2, + storage="rclone", + download_path="/media", + download_type_folder=True, + download_category_folder=True, + ), + ] + + +def _media_specific_download_dirs(): + return [ + TransferDirectoryConf( + name="电影下载", + priority=1, + storage="local", + download_path="/downloads", + media_type=MediaType.MOVIE.value, + download_category_folder=True, + ), + TransferDirectoryConf( + name="电视剧下载", + priority=2, + storage="local", + download_path="/downloads", + media_type=MediaType.TV.value, + download_category_folder=True, + ), + ] + + +def _nested_download_dirs(): + return [ + TransferDirectoryConf( + name="A", + priority=1, + storage="local", + download_path="/downloads", + download_type_folder=True, + ), + TransferDirectoryConf( + name="B", + priority=2, + storage="local", + download_path="/downloads/tv", + download_category_folder=True, + ), + TransferDirectoryConf( + name="C", + priority=3, + storage="local", + download_path="/downloads/tv/collection", + download_type_folder=True, + download_category_folder=True, + ), + ] + + @pytest.fixture(autouse=True) def patch_download_dirs(monkeypatch): monkeypatch.setattr( @@ -129,6 +199,109 @@ def test_validate_download_save_path_rejects_paths_outside_configured_roots(save validate_download_save_path(save_path) +def _build_tv_media() -> MediaInfo: + return MediaInfo( + type=MediaType.TV, + title="Demo Show", + year="2026", + tmdb_id=2, + genre_ids=[16], + category="动漫", + ) + + +def test_resolve_media_download_dir_applies_configured_root_classification(monkeypatch): + monkeypatch.setattr( + "app.helper.directory.DirectoryHelper.get_download_dirs", + lambda _self: _classified_download_dirs(), + ) + + storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir( + media_info=_build_tv_media(), + save_path="/downloads", + ) + + assert storage == "local" + assert target_dir == Path("/downloads/电视剧/动漫") + assert error_msg == "" + + +def test_resolve_media_download_dir_keeps_configured_child_path_exact(monkeypatch): + monkeypatch.setattr( + "app.helper.directory.DirectoryHelper.get_download_dirs", + lambda _self: _classified_download_dirs(), + ) + + storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir( + media_info=_build_tv_media(), + save_path="/downloads/收藏区", + ) + + assert storage == "local" + assert target_dir == Path("/downloads/收藏区") + assert error_msg == "" + + +def test_resolve_media_download_dir_applies_remote_root_classification(monkeypatch): + monkeypatch.setattr( + "app.helper.directory.DirectoryHelper.get_download_dirs", + lambda _self: _classified_download_dirs(), + ) + + storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir( + media_info=_build_tv_media(), + save_path="rclone:/media", + ) + + assert storage == "rclone" + assert target_dir == Path("/media/电视剧/动漫") + assert error_msg == "" + + +def test_resolve_media_download_dir_uses_matching_media_specific_root(monkeypatch): + monkeypatch.setattr( + "app.helper.directory.DirectoryHelper.get_download_dirs", + lambda _self: _media_specific_download_dirs(), + ) + + storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir( + media_info=_build_tv_media(), + save_path="/downloads", + ) + + assert storage == "local" + assert target_dir == Path("/downloads/动漫") + assert error_msg == "" + + +@pytest.mark.parametrize( + ("save_path", "expected"), + [ + ("/downloads", "/downloads/电视剧"), + ("/downloads/tv", "/downloads/tv/动漫"), + ("/downloads/tv/collection", "/downloads/tv/collection/电视剧/动漫"), + ], +) +def test_resolve_media_download_dir_uses_exact_nested_root_configuration( + monkeypatch, + save_path, + expected, +): + monkeypatch.setattr( + "app.helper.directory.DirectoryHelper.get_download_dirs", + lambda _self: _nested_download_dirs(), + ) + + storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir( + media_info=_build_tv_media(), + save_path=save_path, + ) + + assert storage == "local" + assert target_dir == Path(expected) + assert error_msg == "" + + def _build_context() -> Context: return Context( meta_info=MetaInfo("Demo Movie 2026"), @@ -193,6 +366,26 @@ def test_download_single_rejects_event_overridden_bad_save_path_before_downloade chain.download.assert_not_called() +def test_download_single_applies_configured_root_classification(monkeypatch): + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + monkeypatch.setattr( + "app.helper.directory.DirectoryHelper.get_download_dirs", + lambda _self: _classified_download_dirs(), + ) + chain = _build_download_chain() + chain.download.return_value = ("qb", None, "Original", "test stop") + context = _build_context() + context.media_info = _build_tv_media() + + chain.download_single( + context=context, + torrent_content=b"torrent-content", + save_path="/downloads", + ) + + assert chain.download.call_args.kwargs["download_dir"] == Path("/downloads/电视剧/动漫") + + @pytest.mark.parametrize("save_path", ["", " "]) def test_download_single_rejects_explicit_empty_save_path_before_default_fallback(monkeypatch, save_path): monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)