From 8c54ed6c0a92549c11223677ecf0854682d59b02 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Wed, 10 Jun 2026 21:46:41 +0300 Subject: [PATCH] refactor: remove folders from configuration endpoint --- API.md | 29 +++- FAQ.md | 1 - app/library/Utils.py | 29 ---- app/library/config.py | 4 - app/routes/api/system.py | 60 ++++++-- app/tests/test_system_routes.py | 102 ++++++++++++- app/tests/test_utils.py | 38 ----- ui/app/components/FolderInput.vue | 169 +++++++++++++++++++++ ui/app/components/NewDownload.vue | 9 +- ui/app/components/PresetForm.vue | 10 +- ui/app/components/TaskForm.vue | 10 +- ui/app/composables/useFolderSuggestions.ts | 30 ++++ ui/app/composables/useYtpConfig.ts | 1 - ui/app/types/config.d.ts | 2 - 14 files changed, 375 insertions(+), 119 deletions(-) create mode 100644 ui/app/components/FolderInput.vue create mode 100644 ui/app/composables/useFolderSuggestions.ts diff --git a/API.md b/API.md index 5c1c9642..fc09f356 100644 --- a/API.md +++ b/API.md @@ -101,6 +101,7 @@ This document describes the available endpoints and their usage. All endpoints r - [POST /api/notifications/test](#post-apinotificationstest) - [GET /api/yt-dlp/options](#get-apiyt-dlpoptions) - [GET /api/system/configuration](#get-apisystemconfiguration) + - [GET /api/system/folders](#get-apisystemfolders) - [GET /api/system/diagnostics](#get-apisystemdiagnostics) - [GET /api/system/limits](#get-apisystemlimits) - [POST /api/system/terminal](#post-apisystemterminal) @@ -2628,7 +2629,7 @@ or an error: --- ### GET /api/system/configuration -**Purpose**: Retrieve comprehensive system configuration including app settings, presets, download fields, queue status, and folder structure. +**Purpose**: Retrieve system configuration including app settings, presets, download fields, and queue status. **Response**: ```json @@ -2657,10 +2658,6 @@ or an error: } ], "paused": false, - "folders": [ - {"name": "folder1", "path": "folder1"}, - {"name": "folder2", "path": "folder2"} - ], "history_count": 150, "queue": [ { @@ -2675,8 +2672,28 @@ or an error: **Notes**: - This endpoint combines multiple data sources into a single response for efficient initialization -- The `folders` array includes available download folders up to the configured depth limit - The `queue` array contains active download items +- Folder listing is available via the separate `/api/system/folders` endpoint + +--- + +### GET /api/system/folders +**Purpose**: List child directories for a given relative path within the download directory. + +**Query Parameters**: +- `path=` (optional, default: root) - Relative path within the download directory. + +**Response**: +```json +{ + "path": "videos", + "folders": ["archive", "shorts", "2024"] +} +``` + +**Notes**: +- Results are cached server-side for a short time. +- Non-existent paths return an empty folder list. --- diff --git a/FAQ.md b/FAQ.md index 6316ee9e..339bd87d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -47,7 +47,6 @@ or the `environment:` section in `compose.yaml` file. | YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` | | YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` | | YTP_TEMP_DISABLED | Disable temp files handling. | `false` | -| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` | | YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` | | YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` | | YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` | diff --git a/app/library/Utils.py b/app/library/Utils.py index 0360a96c..1a753445 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -1477,35 +1477,6 @@ def str_to_dt(time_str: str, now=None) -> datetime: return dt -def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]: - """ - List all folders relative to a base path, up to a specified depth limit. - - Args: - path (Path): The path to start listing folders from. - base (Path): The base path to which the folders should be relative. - depth_limit (int): The maximum depth to traverse from the base path. - - Returns: - list[str]: A list of folder paths relative to the base path, up to the specified - - """ - if "/" == str(path): - return [] - - rel_depth: int = len(path.relative_to(base).parts) - if rel_depth > depth_limit: - return [] - - folders: list[str] = [] - for entry in path.iterdir(): - if entry.is_dir(): - folders.append(str(entry.relative_to(base))) - folders.extend(list_folders(entry, base, depth_limit)) - - return folders - - def get_channel_images(thumbnails: list[dict]) -> dict: """ Extract channel images from a list of thumbnail dictionaries. diff --git a/app/library/config.py b/app/library/config.py index 3baff0b5..7cf49008 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -47,9 +47,6 @@ class Config(metaclass=Singleton): download_path: str = "." """The path to the download directory.""" - download_path_depth: int = 2 - """How many subdirectories to show in auto complete.""" - download_info_expires: int = 10800 """How long (in seconds) the download info is valid before it needs to be re-extracted.""" @@ -282,7 +279,6 @@ class Config(metaclass=Singleton): "max_workers_per_extractor", "extract_info_timeout", "debugpy_port", - "download_path_depth", "download_info_expires", "auto_clear_history_days", "default_pagination", diff --git a/app/routes/api/system.py b/app/routes/api/system.py index a9b604ad..3580cad8 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -20,17 +20,14 @@ from app.library.log import get_logger from app.library.router import route from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager from app.library.UpdateChecker import UpdateChecker -from app.library.Utils import list_folders LOG = get_logger() -DIAGNOSTICS_CACHE_KEY = "system:diagnostics" -DIAGNOSTICS_CACHE_TTL = 5.0 @route("GET", "api/system/configuration", "system.configuration") async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response: """ - Pause non-active downloads. + Get the system configuration. Args: queue (DownloadQueue): The download queue instance. @@ -47,11 +44,6 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) "presets": Presets.get_instance().get_all(), "dl_fields": await DLFields.get_instance().get_all_serialized(), "paused": queue.is_paused(), - "folders": list_folders( - path=Path(config.download_path), - base=Path(config.download_path), - depth_limit=config.download_path_depth - 1, - ), "history_count": await queue.done.get_total_count(), "queue": (await queue.get("queue"))["queue"], }, @@ -60,6 +52,54 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) ) +@route("GET", "api/system/folders", "system.folders") +async def system_folders(request: Request, config: Config, encoder: Encoder, cache: Cache) -> Response: + """ + List child directories for a given relative path. + + Query params: + path: Relative path within the download directory (default: root). + + Returns: + Response: The response object. + + """ + raw_path: str = request.query.get("path", "").strip().lstrip("/") + base: Path = Path(config.download_path).resolve() + target: Path = (base / raw_path).resolve() + + if not target.is_relative_to(base): + return web.json_response( + data={"path": raw_path, "folders": []}, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + cache_key = f"folders:{target!s}" + if (cached := cache.get(cache_key)) is not None: + return web.json_response(data=cached, status=web.HTTPOk.status_code, dumps=encoder.encode) + + folders: list[str] = [] + if target.is_dir(): + try: + folders.extend( + entry.name + for entry in sorted(target.iterdir()) + if entry.is_dir() and entry.resolve().is_relative_to(base) + ) + except PermissionError: + pass + + resolved_rel: str = str(target.relative_to(base)) if target != base else "" + if resolved_rel == ".": + resolved_rel = "" + + data: dict[str, str | list[str]] = {"path": resolved_rel, "folders": folders} + cache.set(cache_key, data, ttl=30.0) + + return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode) + + @route("POST", "api/system/pause", "system.pause") async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: """ @@ -247,7 +287,7 @@ async def system_diagnostics( LOG.exception("Failed to collect system diagnostics.") data = diagnostics_error_report(config) else: - cache.set(cache_key, data, ttl=60) + cache.set(cache_key, data, ttl=60.0) return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode) diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py index cde68baf..680d158b 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -1,7 +1,7 @@ import json from dataclasses import dataclass from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -9,7 +9,7 @@ from app.library.config import Config from app.library.cache import Cache from app.library.encoder import Encoder from app.library.UpdateChecker import UpdateChecker -from app.routes.api.system import check_updates, system_diagnostics, system_limits +from app.routes.api.system import check_updates, system_diagnostics, system_folders, system_limits @dataclass @@ -395,3 +395,101 @@ class TestSystemDiagnosticsEndpoint: ) assert check.status == "skip" + + +class TestSystemFoldersEndpoint: + def setup_method(self): + Config._reset_singleton() + Cache.get_instance().clear() + + @pytest.mark.asyncio + async def test_returns_root_children(self, tmp_path: Path) -> None: + (tmp_path / "videos").mkdir() + (tmp_path / "music").mkdir() + (tmp_path / "file.txt").touch() + + config = Config.get_instance() + config.download_path = str(tmp_path) + encoder = Encoder() + cache = Cache.get_instance() + + req = MagicMock() + req.query = {} + + response = await system_folders(req, config, encoder, cache) + + assert response.status == 200 + data = json.loads(response.body.decode("utf-8")) + assert data["path"] == "" + assert sorted(data["folders"]) == ["music", "videos"] + + @pytest.mark.asyncio + async def test_returns_subdir_children(self, tmp_path: Path) -> None: + (tmp_path / "videos" / "archive").mkdir(parents=True) + (tmp_path / "videos" / "shorts").mkdir() + + config = Config.get_instance() + config.download_path = str(tmp_path) + encoder = Encoder() + cache = Cache.get_instance() + + req = MagicMock() + req.query = {"path": "videos"} + + response = await system_folders(req, config, encoder, cache) + + assert response.status == 200 + data = json.loads(response.body.decode("utf-8")) + assert data["path"] == "videos" + assert sorted(data["folders"]) == ["archive", "shorts"] + + @pytest.mark.asyncio + async def test_rejects_path_traversal(self, tmp_path: Path) -> None: + config = Config.get_instance() + config.download_path = str(tmp_path) + encoder = Encoder() + cache = Cache.get_instance() + + req = MagicMock() + req.query = {"path": "../../etc"} + + response = await system_folders(req, config, encoder, cache) + + assert response.status == 200 + data = json.loads(response.body.decode("utf-8")) + assert data["folders"] == [] + + @pytest.mark.asyncio + async def test_nonexistent_path_returns_empty(self, tmp_path: Path) -> None: + config = Config.get_instance() + config.download_path = str(tmp_path) + encoder = Encoder() + cache = Cache.get_instance() + + req = MagicMock() + req.query = {"path": "no_such_dir"} + + response = await system_folders(req, config, encoder, cache) + + assert response.status == 200 + data = json.loads(response.body.decode("utf-8")) + assert data["folders"] == [] + + @pytest.mark.asyncio + async def test_caches_result(self, tmp_path: Path) -> None: + (tmp_path / "a").mkdir() + + config = Config.get_instance() + config.download_path = str(tmp_path) + encoder = Encoder() + cache = Cache.get_instance() + + req = MagicMock() + req.query = {} + + await system_folders(req, config, encoder, cache) + (tmp_path / "b").mkdir() + response = await system_folders(req, config, encoder, cache) + + data = json.loads(response.body.decode("utf-8")) + assert "b" not in data["folders"], "Should serve cached result" diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 7f7579e1..01052374 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -33,7 +33,6 @@ from app.library.Utils import ( get_possible_images, init_class, is_private_address, - list_folders, load_cookies, merge_dict, move_file, @@ -1073,43 +1072,6 @@ class TestDeleteDir: assert result is False -class TestListFolders: - """Test the list_folders function.""" - - def setup_method(self): - """Set up test directory structure.""" - self.temp_dir = str(make_test_temp_dir("list-folders")) - self.base = Path(self.temp_dir) - (self.base / "folder1").mkdir() - (self.base / "folder2").mkdir() - (self.base / "folder1" / "subfolder").mkdir() - (self.base / "file.txt").write_text("test") - - def teardown_method(self): - """Clean up after tests.""" - import shutil - - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_list_folders_depth_0(self): - """Test listing folders with depth 0.""" - result = list_folders(self.base, self.base, 0) - expected = ["folder1", "folder2"] - assert sorted(result) == sorted(expected) - - def test_list_folders_depth_1(self): - """Test listing folders with depth 1.""" - result = list_folders(self.base, self.base, 1) - expected = ["folder1", "folder2", "folder1/subfolder"] - assert sorted(result) == sorted(expected) - - def test_list_folders_depth_2(self): - """Test listing folders with a depth limit.""" - result = list_folders(self.base, self.base, 2) - expected = ["folder1", "folder2", "folder1/subfolder"] - assert sorted(result) == sorted(expected) - - class TestEncryptDecrypt: """Test encryption and decryption functions.""" diff --git a/ui/app/components/FolderInput.vue b/ui/app/components/FolderInput.vue new file mode 100644 index 00000000..63ee90ce --- /dev/null +++ b/ui/app/components/FolderInput.vue @@ -0,0 +1,169 @@ + + + diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index 6c9253f1..88817875 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -144,14 +144,11 @@ {{ shortPath(config.app.download_path) }} - @@ -492,10 +489,6 @@ - - - - @@ -328,10 +324,6 @@ - - - - @@ -484,10 +480,6 @@ - - - (); + +const fetchFolders = async (parentPath: string): Promise => { + const key = parentPath; + const cached = cache.get(key); + if (cached && cached.expires > Date.now()) { + return cached.folders; + } + + try { + const resp = await request(`/api/system/folders?path=${encodePath(parentPath)}`, { + timeout: 5, + }); + if (!resp.ok) { + return []; + } + const data = await resp.json(); + const folders: string[] = data.folders ?? []; + cache.set(key, { folders, expires: Date.now() + CACHE_TTL }); + return folders; + } catch { + return []; + } +}; + +export const useFolderSuggestions = () => ({ fetchFolders }); diff --git a/ui/app/composables/useYtpConfig.ts b/ui/app/composables/useYtpConfig.ts index 522475e2..8dc65b9c 100644 --- a/ui/app/composables/useYtpConfig.ts +++ b/ui/app/composables/useYtpConfig.ts @@ -52,7 +52,6 @@ const state = reactive({ }, ], dl_fields: [], - folders: [], ytdlp_options: [], paused: false, is_loaded: false, diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index 58b14913..09fce8a8 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -64,8 +64,6 @@ type ConfigState = { presets: Array; /** List of custom download fields */ dl_fields: Array; - /** List of folders where files can be saved */ - folders: Array; /** List of yt-dlp options */ ytdlp_options: Array; /** Indicates if downloads are currently paused */