From 5fabd40a47966b7cbaffc32e46ad5c6cc5648ec3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:20:41 +0000 Subject: [PATCH 1/3] Initial plan From 66bfc4cf99d992ee423947c1bce90a6171bd194f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:27:45 +0000 Subject: [PATCH 2/3] Remove folders from config endpoint, add lazy-loaded /api/system/folders endpoint Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com> --- API.md | 23 +++++++++++++---- app/routes/api/system.py | 23 ++++++++++++++++- app/tests/test_system_routes.py | 41 ++++++++++++++++++++++++++++--- ui/app/components/NewDownload.vue | 1 + ui/app/components/PresetForm.vue | 1 + ui/app/components/TaskForm.vue | 1 + ui/app/stores/ConfigStore.ts | 17 +++++++++++++ 7 files changed, 98 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 5513c5b1..1bbb1f76 100644 --- a/API.md +++ b/API.md @@ -2446,10 +2446,6 @@ or an error: } ], "paused": false, - "folders": [ - {"name": "folder1", "path": "folder1"}, - {"name": "folder2", "path": "folder2"} - ], "history_count": 150, "queue": [ { @@ -2464,11 +2460,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 --- +### GET /api/system/folders +**Purpose**: Retrieve available download folders. This endpoint is designed to be called lazily when the user interacts with the download path input. + +**Response**: +```json +{ + "folders": [ + "folder1", + "folder2" + ] +} +``` + +**Notes**: +- The `folders` array includes available download folders up to the configured depth limit + +--- + ### POST /api/system/terminal **Purpose**: Stream yt-dlp CLI output via Server-Sent Events (SSE). Requires `YTP_CONSOLE_ENABLED=true`. diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 67b25b2b..e5323e6e 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -49,12 +49,33 @@ 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(), + "history_count": await queue.done.get_total_count(), + }, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + +@route("GET", "api/system/folders", "system.folders") +async def system_folders(config: Config, encoder: Encoder) -> Response: + """ + Get the list of folders available for downloads. + + Args: + config (Config): The config instance. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + return web.json_response( + data={ "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(), }, 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 b185e62f..1cb93871 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from app.library.config import Config from app.library.encoder import Encoder from app.library.UpdateChecker import UpdateChecker -from app.routes.api.system import check_updates, system_config +from app.routes.api.system import check_updates, system_config, system_folders class TestSystemConfigEndpoint: @@ -31,7 +31,6 @@ class TestSystemConfigEndpoint: with ( patch("app.routes.api.system.Presets") as mock_presets_cls, patch("app.routes.api.system.DLFields") as mock_dl_fields_cls, - patch("app.routes.api.system.list_folders", return_value=[]), ): mock_presets_cls.get_instance.return_value.get_all.return_value = [] mock_dl_fields_cls.get_instance.return_value.get_all_serialized = AsyncMock(return_value=[]) @@ -46,7 +45,43 @@ class TestSystemConfigEndpoint: assert "history_count" in body, "Configuration response should include history_count" assert "presets" in body, "Configuration response should include presets" assert "dl_fields" in body, "Configuration response should include dl_fields" - assert "folders" in body, "Configuration response should include folders" + assert "folders" not in body, "Configuration response should not include folders" + + +class TestSystemFoldersEndpoint: + """Tests for the system folders endpoint.""" + + def setup_method(self): + """Reset singletons before each test.""" + Config._reset_singleton() + + @pytest.mark.asyncio + async def test_system_folders_returns_folders(self): + """Test that the folders endpoint returns the folders list.""" + config = Config.get_instance() + encoder = Encoder() + + with patch("app.routes.api.system.list_folders", return_value=["folder1", "subfolder/nested"]): + response = await system_folders(config, encoder) + + assert 200 == response.status + body = json.loads(response.body.decode("utf-8")) + assert "folders" in body, "Folders response should include folders" + assert ["folder1", "subfolder/nested"] == body["folders"] + + @pytest.mark.asyncio + async def test_system_folders_returns_empty_list(self): + """Test that the folders endpoint returns an empty list when no folders exist.""" + config = Config.get_instance() + encoder = Encoder() + + with patch("app.routes.api.system.list_folders", return_value=[]): + response = await system_folders(config, encoder) + + assert 200 == response.status + body = json.loads(response.body.decode("utf-8")) + assert "folders" in body, "Folders response should include folders" + assert [] == body["folders"] class TestCheckUpdatesEndpoint: diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index 773215cb..f13bab7f 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -128,6 +128,7 @@ :placeholder="getDefault('folder', '/')" :disabled="addInProgress" list="folders" + @focus="config.loadFolders()" /> diff --git a/ui/app/components/PresetForm.vue b/ui/app/components/PresetForm.vue index cdfb6ecd..d05711ac 100644 --- a/ui/app/components/PresetForm.vue +++ b/ui/app/components/PresetForm.vue @@ -189,6 +189,7 @@ v-model="form.folder" :disabled="addInProgress" list="folders" + @focus="config.loadFolders()" /> diff --git a/ui/app/components/TaskForm.vue b/ui/app/components/TaskForm.vue index 11d0e18d..ec04db82 100644 --- a/ui/app/components/TaskForm.vue +++ b/ui/app/components/TaskForm.vue @@ -319,6 +319,7 @@ v-model="form.folder" :disabled="addInProgress" list="folders" + @focus="config.loadFolders()" /> diff --git a/ui/app/stores/ConfigStore.ts b/ui/app/stores/ConfigStore.ts index 142d88cb..dc8f4072 100644 --- a/ui/app/stores/ConfigStore.ts +++ b/ui/app/stores/ConfigStore.ts @@ -95,6 +95,21 @@ export const useConfigStore = defineStore('config', () => { } }; + const loadFolders = async () => { + try { + const resp = await request('/api/system/folders', { timeout: 10 }); + if (!resp.ok) { + return; + } + const data = await resp.json(); + if (Array.isArray(data.folders)) { + state.folders = data.folders; + } + } catch (e: any) { + console.error(`Failed to load folders: ${e}`); + } + }; + const add = (key: string, value: any) => { if (key.includes('.')) { const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]; @@ -209,6 +224,7 @@ export const useConfigStore = defineStore('config', () => { isLoaded, patch, loadConfig, + loadFolders, } as { [K in keyof ConfigState]: Ref } & { add: typeof add; get: typeof get; @@ -218,5 +234,6 @@ export const useConfigStore = defineStore('config', () => { patch: typeof patch; isLoaded: typeof isLoaded; loadConfig: typeof loadConfig; + loadFolders: typeof loadFolders; }; }); From 1f99666f7ea2abd7bc621337729eef02d0edc926 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:28:43 +0000 Subject: [PATCH 3/3] Add guard against redundant folder API calls on repeated focus events Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com> --- ui/app/stores/ConfigStore.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/app/stores/ConfigStore.ts b/ui/app/stores/ConfigStore.ts index dc8f4072..a918ca02 100644 --- a/ui/app/stores/ConfigStore.ts +++ b/ui/app/stores/ConfigStore.ts @@ -6,6 +6,7 @@ import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets'; import { request } from '~/utils'; let last_reload = 0; +let folders_loaded = false; const CONFIG_TTL = 10; export const useConfigStore = defineStore('config', () => { @@ -96,6 +97,9 @@ export const useConfigStore = defineStore('config', () => { }; const loadFolders = async () => { + if (folders_loaded) { + return; + } try { const resp = await request('/api/system/folders', { timeout: 10 }); if (!resp.ok) { @@ -104,6 +108,7 @@ export const useConfigStore = defineStore('config', () => { const data = await resp.json(); if (Array.isArray(data.folders)) { state.folders = data.folders; + folders_loaded = true; } } catch (e: any) { console.error(`Failed to load folders: ${e}`);