From 1862a7ab4bde671dd6ca8b1c552c5248eeb8c510 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 10 May 2026 12:02:22 +0800 Subject: [PATCH] feat: expose download save paths in API Return configured download directories as API-ready save_path values so external integrations can choose download destinations without guessing local or remote path syntax. Fixes #5737 --- app/api/endpoints/download.py | 24 +++++++ app/schemas/download.py | 14 +++++ tests/test_download_paths_endpoint.py | 91 +++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/test_download_paths_endpoint.py diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 1ed0dc0a..405775f9 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -11,6 +11,7 @@ from app.core.security import verify_token from app.db.models.user import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_user +from app.helper.directory import DirectoryHelper from app.schemas.types import SystemConfigKey router = APIRouter() @@ -140,6 +141,29 @@ async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return [] +@router.get("/paths", summary="查询可用下载路径", response_model=List[schemas.DownloadDirectory]) +def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询可直接用于下载接口 save_path 参数的下载路径 + """ + return [ + schemas.DownloadDirectory( + name=dir_info.name, + storage=dir_info.storage or "local", + download_path=dir_info.download_path, + save_path=schemas.FileURI( + storage=dir_info.storage or "local", + path=dir_info.download_path, + ).uri, + priority=dir_info.priority, + media_type=dir_info.media_type, + media_category=dir_info.media_category, + ) + for dir_info in DirectoryHelper().get_download_dirs() + if dir_info.download_path + ] + + @router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response) def delete(hashString: str, name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: diff --git a/app/schemas/download.py b/app/schemas/download.py index 486d7c82..758e052f 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -11,3 +11,17 @@ class DownloadTask(BaseModel): downloader: Optional[str] = Field(default=None, description="下载器") path: Optional[str] = Field(default=None, description="下载路径") completed: Optional[bool] = Field(default=False, description="是否完成") + + +class DownloadDirectory(BaseModel): + """ + 下载目录 + """ + + name: Optional[str] = Field(default=None, description="目录名称") + storage: Optional[str] = Field(default="local", description="存储类型") + download_path: Optional[str] = Field(default=None, description="配置的下载目录") + save_path: Optional[str] = Field(default=None, description="可直接传给下载接口 save_path 的路径") + priority: Optional[int] = Field(default=0, description="目录优先级") + media_type: Optional[str] = Field(default=None, description="适用媒体类型") + media_category: Optional[str] = Field(default=None, description="适用媒体分类") diff --git a/tests/test_download_paths_endpoint.py b/tests/test_download_paths_endpoint.py new file mode 100644 index 00000000..ec5ee77a --- /dev/null +++ b/tests/test_download_paths_endpoint.py @@ -0,0 +1,91 @@ +import sys +import unittest +from types import ModuleType +from unittest.mock import patch + + +def _stub_module(name: str, **attrs): + module = sys.modules.get(name) + if module is None: + module = ModuleType(name) + sys.modules[name] = module + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +class _Dummy: + def __init__(self, *args, **kwargs): + pass + + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + +for _module_name in ("pillow_avif", "aiofiles", "psutil"): + _stub_module(_module_name) + +_stub_module("app.chain.download", DownloadChain=_Dummy) +_stub_module("app.chain.media", MediaChain=_Dummy) +_stub_module("app.core.context", MediaInfo=_Dummy, Context=_Dummy, TorrentInfo=_Dummy) +_stub_module("app.core.metainfo", MetaInfo=_Dummy) +_stub_module("app.core.security", verify_token=_Dummy) +_stub_module("app.db.models.user", User=_Dummy) +_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy) +_stub_module("app.db.user_oper", get_current_active_user=_Dummy) +_stub_module( + "app.log", + logger=_Dummy(), + log_settings=_Dummy(), + LogConfigModel=type("LogConfigModel", (), {}), +) +_stub_module("version", APP_VERSION="test") + +from app.api.endpoints import download as download_endpoint + + +class DownloadPathsEndpointTest(unittest.TestCase): + def test_paths_returns_api_ready_save_paths(self): + mocked_dirs = [ + download_endpoint.schemas.TransferDirectoryConf( + name="电影目录", + priority=1, + storage="local", + download_path="/downloads/movies", + media_type="movie", + ), + download_endpoint.schemas.TransferDirectoryConf( + name="动漫远程目录", + priority=2, + storage="rclone", + download_path="/media/anime", + media_type="tv", + media_category="动漫", + ), + ] + + with patch.object(download_endpoint.DirectoryHelper, "get_download_dirs", return_value=mocked_dirs): + ret = download_endpoint.paths(_=download_endpoint.schemas.TokenPayload()) + + self.assertEqual(len(ret), 2) + self.assertEqual(ret[0].name, "电影目录") + self.assertEqual(ret[0].storage, "local") + self.assertEqual(ret[0].download_path, "/downloads/movies") + self.assertEqual(ret[0].save_path, "/downloads/movies") + self.assertEqual(ret[0].priority, 1) + self.assertEqual(ret[0].media_type, "movie") + self.assertIsNone(ret[0].media_category) + + self.assertEqual(ret[1].name, "动漫远程目录") + self.assertEqual(ret[1].storage, "rclone") + self.assertEqual(ret[1].download_path, "/media/anime") + self.assertEqual(ret[1].save_path, "rclone:/media/anime") + self.assertEqual(ret[1].priority, 2) + self.assertEqual(ret[1].media_type, "tv") + self.assertEqual(ret[1].media_category, "动漫") + + def test_paths_returns_empty_list_when_unconfigured(self): + with patch.object(download_endpoint.DirectoryHelper, "get_download_dirs", return_value=[]): + ret = download_endpoint.paths(_=download_endpoint.schemas.TokenPayload()) + + self.assertEqual(ret, [])