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") @route("GET", "api/system/configuration", "system.configuration")
async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response: async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
""" """
Pause non-active downloads. Get the system configuration.
Args: Args:
queue (DownloadQueue): The download queue instance. 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(), "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(),
"queue": (await queue.get("queue"))["queue"],
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,

View file

@ -1,7 +1,7 @@
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -9,7 +9,7 @@ from app.library.config import Config
from app.library.cache import Cache from app.library.cache import Cache
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_diagnostics, system_limits from app.routes.api.system import check_updates, system_config, system_diagnostics, system_folders, system_limits
@dataclass @dataclass
@ -395,3 +395,58 @@ class TestSystemDiagnosticsEndpoint:
) )
assert check.status == "skip" 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" class="w-full"
size="lg" size="lg"
:ui="{ root: 'w-full', base: 'bg-default/90' }" :ui="{ root: 'w-full', base: 'bg-default/90' }"
@focus="config.loadFolders()"
/> />
</div> </div>
</UFormField> </UFormField>

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import { request } from '~/utils';
let last_reload = 0; let last_reload = 0;
const CONFIG_TTL = 10; const CONFIG_TTL = 10;
let isLoadingFolders = false;
const state = reactive<ConfigState>({ const state = reactive<ConfigState>({
showForm: useStorage('showForm', true), 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({ const ytpConfigApi = proxyRefs({
...toRefs(state), ...toRefs(state),
add, add,
@ -215,6 +233,7 @@ const ytpConfigApi = proxyRefs({
isLoaded, isLoaded,
patch, patch,
loadConfig, loadConfig,
loadFolders,
}); });
export const useYtpConfig = () => ytpConfigApi; export const useYtpConfig = () => ytpConfigApi;