perf: lazy-load folders on demand; remove queue from config endpoint

Two startup cost reductions:

1. Folder enumeration is moved from /api/system/configuration to a new
   GET /api/system/folders endpoint. The frontend fetches it only when
   the user focuses a folder input field, and caches the result for the
   session. On large directory trees this was a noticeable startup delay.

2. The queue array is removed from /api/system/configuration. The queue
   is already loaded separately via /api/history/live on mount; including
   it in the config response was a double-load that inflated the initial
   payload for users with large queues.
This commit is contained in:
Jesse Bate 2026-06-10 19:48:45 +09:30
parent e0dbc2d15d
commit 38c120ce85
6 changed files with 102 additions and 5 deletions

View file

@ -30,7 +30,7 @@ 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,13 +47,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(),
"queue": (await queue.get("queue"))["queue"],
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,

View file

@ -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_config, system_diagnostics, system_folders, system_limits
@dataclass
@ -395,3 +395,58 @@ class TestSystemDiagnosticsEndpoint:
)
assert check.status == "skip"
class TestSystemFoldersEndpoint:
def setup_method(self):
Config._reset_singleton()
@pytest.mark.asyncio
async def test_system_folders_returns_folder_list(self, tmp_path: Path) -> None:
"""GET /api/system/folders returns the folders list."""
config = Config.get_instance()
config.download_path = str(tmp_path)
config.download_path_depth = 1
encoder = Encoder()
response = await system_folders(config, encoder)
assert response.status == 200
data = json.loads(response.body.decode("utf-8"))
assert "folders" in data
assert isinstance(data["folders"], list)
class TestSystemConfigEndpoint:
def setup_method(self):
Config._reset_singleton()
@pytest.mark.asyncio
async def test_system_configuration_excludes_queue_and_folders(self, tmp_path: Path) -> None:
"""GET /api/system/configuration no longer includes queue or folders."""
config = Config.get_instance()
config.download_path = str(tmp_path)
config.download_path_depth = 1
encoder = Encoder()
done_mock = MagicMock()
done_mock.get_total_count = AsyncMock(return_value=0)
queue = DummyQueue()
queue.done = done_mock
with patch("app.features.dl_fields.service.DLFields.get_instance") as mock_dlfields, \
patch("app.features.presets.service.Presets.get_instance") as mock_presets:
mock_dlfields_instance = MagicMock()
mock_dlfields_instance.get_all_serialized = AsyncMock(return_value=[])
mock_dlfields.return_value = mock_dlfields_instance
mock_presets_instance = MagicMock()
mock_presets_instance.get_all.return_value = []
mock_presets.return_value = mock_presets_instance
response = await system_config(queue, config, encoder)
assert response.status == 200
data = json.loads(response.body.decode("utf-8"))
assert "queue" not in data, "queue should be removed from config response"
assert "folders" not in data, "folders should be moved to /api/system/folders"

View file

@ -151,6 +151,7 @@
class="w-full"
size="lg"
:ui="{ root: 'w-full', base: 'bg-default/90' }"
@focus="config.loadFolders()"
/>
</div>
</UFormField>

View file

@ -166,6 +166,7 @@
:disabled="addInProgress"
class="w-full"
:ui="inputUi"
@focus="config.loadFolders()"
/>
</div>
</UFormField>

View file

@ -300,6 +300,7 @@
size="lg"
class="w-full"
:ui="inputUi"
@focus="config.loadFolders()"
/>
</div>
</UFormField>

View file

@ -9,6 +9,7 @@ import { request } from '~/utils';
let last_reload = 0;
const CONFIG_TTL = 10;
let isLoadingFolders = false;
const state = reactive<ConfigState>({
showForm: useStorage('showForm', true),
@ -205,6 +206,23 @@ const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown
}
};
const loadFolders = async (): Promise<void> => {
if (state.folders.length > 0 || isLoadingFolders) return;
isLoadingFolders = true;
try {
const resp = await request('/api/system/folders', { timeout: 10 });
if (!resp.ok) {
return;
}
const data = await resp.json();
state.folders = data.folders ?? [];
} catch (e: any) {
console.error(`Failed to load folders: ${e}`);
} finally {
isLoadingFolders = false;
}
};
const ytpConfigApi = proxyRefs({
...toRefs(state),
add,
@ -215,6 +233,7 @@ const ytpConfigApi = proxyRefs({
isLoaded,
patch,
loadConfig,
loadFolders,
});
export const useYtpConfig = () => ytpConfigApi;