Merge pull request #5 from jbatesy/copilot/remove-folders-response

Remove folders from configuration endpoint, lazy load on demand
This commit is contained in:
Jesse Bate 2026-03-09 16:02:08 +10:30 committed by GitHub
commit 0bad7259f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 103 additions and 9 deletions

23
API.md
View file

@ -2446,10 +2446,6 @@ or an error:
} }
], ],
"paused": false, "paused": false,
"folders": [
{"name": "folder1", "path": "folder1"},
{"name": "folder2", "path": "folder2"}
],
"history_count": 150, "history_count": 150,
"queue": [ "queue": [
{ {
@ -2464,11 +2460,28 @@ or an error:
**Notes**: **Notes**:
- This endpoint combines multiple data sources into a single response for efficient initialization - 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 - 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 ### POST /api/system/terminal
**Purpose**: Stream yt-dlp CLI output via Server-Sent Events (SSE). Requires `YTP_CONSOLE_ENABLED=true`. **Purpose**: Stream yt-dlp CLI output via Server-Sent Events (SSE). Requires `YTP_CONSOLE_ENABLED=true`.

View file

@ -49,12 +49,33 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder)
"presets": Presets.get_instance().get_all(), "presets": Presets.get_instance().get_all(),
"dl_fields": await DLFields.get_instance().get_all_serialized(), "dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(), "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( "folders": list_folders(
path=Path(config.download_path), path=Path(config.download_path),
base=Path(config.download_path), base=Path(config.download_path),
depth_limit=config.download_path_depth - 1, depth_limit=config.download_path_depth - 1,
), ),
"history_count": await queue.done.get_total_count(),
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,

View file

@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker 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: class TestSystemConfigEndpoint:
@ -31,7 +31,6 @@ class TestSystemConfigEndpoint:
with ( with (
patch("app.routes.api.system.Presets") as mock_presets_cls, 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.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_presets_cls.get_instance.return_value.get_all.return_value = []
mock_dl_fields_cls.get_instance.return_value.get_all_serialized = AsyncMock(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 "history_count" in body, "Configuration response should include history_count"
assert "presets" in body, "Configuration response should include presets" assert "presets" in body, "Configuration response should include presets"
assert "dl_fields" in body, "Configuration response should include dl_fields" 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: class TestCheckUpdatesEndpoint:

View file

@ -128,6 +128,7 @@
:placeholder="getDefault('folder', '/')" :placeholder="getDefault('folder', '/')"
:disabled="addInProgress" :disabled="addInProgress"
list="folders" list="folders"
@focus="config.loadFolders()"
/> />
</div> </div>
</div> </div>

View file

@ -189,6 +189,7 @@
v-model="form.folder" v-model="form.folder"
:disabled="addInProgress" :disabled="addInProgress"
list="folders" list="folders"
@focus="config.loadFolders()"
/> />
</div> </div>
</div> </div>

View file

@ -319,6 +319,7 @@
v-model="form.folder" v-model="form.folder"
:disabled="addInProgress" :disabled="addInProgress"
list="folders" list="folders"
@focus="config.loadFolders()"
/> />
</div> </div>
</div> </div>

View file

@ -6,6 +6,7 @@ import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
import { request } from '~/utils'; import { request } from '~/utils';
let last_reload = 0; let last_reload = 0;
let folders_loaded = false;
const CONFIG_TTL = 10; const CONFIG_TTL = 10;
export const useConfigStore = defineStore('config', () => { export const useConfigStore = defineStore('config', () => {
@ -95,6 +96,25 @@ 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) {
return;
}
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}`);
}
};
const add = (key: string, value: any) => { const add = (key: string, value: any) => {
if (key.includes('.')) { if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]; const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
@ -209,6 +229,7 @@ export const useConfigStore = defineStore('config', () => {
isLoaded, isLoaded,
patch, patch,
loadConfig, loadConfig,
loadFolders,
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & { } as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
add: typeof add; add: typeof add;
get: typeof get; get: typeof get;
@ -218,5 +239,6 @@ export const useConfigStore = defineStore('config', () => {
patch: typeof patch; patch: typeof patch;
isLoaded: typeof isLoaded; isLoaded: typeof isLoaded;
loadConfig: typeof loadConfig; loadConfig: typeof loadConfig;
loadFolders: typeof loadFolders;
}; };
}); });