refactor: remove folders from configuration endpoint

This commit is contained in:
arabcoders 2026-06-10 21:46:41 +03:00
parent a346ed69d9
commit 8c54ed6c0a
14 changed files with 375 additions and 119 deletions

29
API.md
View file

@ -101,6 +101,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/notifications/test](#post-apinotificationstest) - [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions) - [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration) - [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/folders](#get-apisystemfolders)
- [GET /api/system/diagnostics](#get-apisystemdiagnostics) - [GET /api/system/diagnostics](#get-apisystemdiagnostics)
- [GET /api/system/limits](#get-apisystemlimits) - [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal) - [POST /api/system/terminal](#post-apisystemterminal)
@ -2628,7 +2629,7 @@ or an error:
--- ---
### GET /api/system/configuration ### 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**: **Response**:
```json ```json
@ -2657,10 +2658,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": [
{ {
@ -2675,8 +2672,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
- 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=<relative-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.
--- ---

1
FAQ.md
View file

@ -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_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_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | 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_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` | | YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` | | YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |

View file

@ -1477,35 +1477,6 @@ def str_to_dt(time_str: str, now=None) -> datetime:
return dt 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: def get_channel_images(thumbnails: list[dict]) -> dict:
""" """
Extract channel images from a list of thumbnail dictionaries. Extract channel images from a list of thumbnail dictionaries.

View file

@ -47,9 +47,6 @@ class Config(metaclass=Singleton):
download_path: str = "." download_path: str = "."
"""The path to the download directory.""" """The path to the download directory."""
download_path_depth: int = 2
"""How many subdirectories to show in auto complete."""
download_info_expires: int = 10800 download_info_expires: int = 10800
"""How long (in seconds) the download info is valid before it needs to be re-extracted.""" """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", "max_workers_per_extractor",
"extract_info_timeout", "extract_info_timeout",
"debugpy_port", "debugpy_port",
"download_path_depth",
"download_info_expires", "download_info_expires",
"auto_clear_history_days", "auto_clear_history_days",
"default_pagination", "default_pagination",

View file

@ -20,17 +20,14 @@ from app.library.log import get_logger
from app.library.router import route from app.library.router import route
from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
LOG = get_logger() LOG = get_logger()
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
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,11 +44,6 @@ 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(),
"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(), "history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"], "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") @route("POST", "api/system/pause", "system.pause")
async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: 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.") LOG.exception("Failed to collect system diagnostics.")
data = diagnostics_error_report(config) data = diagnostics_error_report(config)
else: 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) return web.json_response(data=data, status=web.HTTPOk.status_code, 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_diagnostics, system_folders, system_limits
@dataclass @dataclass
@ -395,3 +395,101 @@ class TestSystemDiagnosticsEndpoint:
) )
assert check.status == "skip" 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"

View file

@ -33,7 +33,6 @@ from app.library.Utils import (
get_possible_images, get_possible_images,
init_class, init_class,
is_private_address, is_private_address,
list_folders,
load_cookies, load_cookies,
merge_dict, merge_dict,
move_file, move_file,
@ -1073,43 +1072,6 @@ class TestDeleteDir:
assert result is False 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: class TestEncryptDecrypt:
"""Test encryption and decryption functions.""" """Test encryption and decryption functions."""

View file

@ -0,0 +1,169 @@
<template>
<div class="relative w-full">
<UInput
:id="id"
ref="inputRef"
v-model="model"
type="text"
:placeholder="placeholder"
:disabled="disabled"
:size="size"
autocomplete="off"
class="w-full"
:ui="ui"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
@keydown="onKeydown"
/>
<div
v-if="open && suggestions.length"
ref="dropdownRef"
class="absolute inset-x-0 top-full z-20 mt-1 max-h-40 overflow-y-auto rounded-md border border-default bg-default shadow-lg"
role="listbox"
>
<button
v-for="(item, idx) in suggestions"
:key="item"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="
idx === highlighted ? 'bg-elevated text-highlighted' : 'text-default hover:bg-elevated/60'
"
role="option"
:aria-selected="idx === highlighted"
@mousedown.prevent="select(item)"
>
<UIcon name="i-lucide-folder" class="size-3.5 shrink-0 text-toned" />
<span>{{ item }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { useFolderSuggestions } from '~/composables/useFolderSuggestions';
withDefaults(
defineProps<{
id?: string;
placeholder?: string;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
ui?: Record<string, any>;
}>(),
{
id: undefined,
placeholder: '/',
disabled: false,
size: 'lg',
ui: undefined,
},
);
const model = defineModel<string>({ default: '' });
const { fetchFolders } = useFolderSuggestions();
const open = ref(false);
const highlighted = ref(-1);
const children = ref<string[]>([]);
const dropdownRef = ref<HTMLElement | null>(null);
const inputRef = ref<any>(null);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let selecting = false;
const parentPath = computed(() => {
const val = model.value || '';
const lastSlash = val.lastIndexOf('/');
return lastSlash === -1 ? '' : val.slice(0, lastSlash);
});
const suffix = computed(() => {
const val = model.value || '';
const lastSlash = val.lastIndexOf('/');
return lastSlash === -1 ? val : val.slice(lastSlash + 1);
});
const suggestions = computed(() => {
const s = suffix.value.toLowerCase();
if (!s) return children.value;
return children.value.filter((c) => c.toLowerCase().startsWith(s));
});
const loadChildren = async (path: string) => {
children.value = await fetchFolders(path);
highlighted.value = children.value.length ? 0 : -1;
};
const scheduleLoad = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => loadChildren(parentPath.value), 200);
};
const onFocus = () => {
open.value = true;
loadChildren(parentPath.value);
};
const onBlur = () => {
setTimeout(() => {
if (selecting) return;
open.value = false;
highlighted.value = -1;
}, 120);
};
const onInput = () => {
open.value = true;
scheduleLoad();
};
const select = async (name: string) => {
selecting = true;
const parent = parentPath.value;
const newPath = parent ? `${parent}/${name}` : name;
model.value = newPath + '/';
highlighted.value = -1;
await loadChildren(newPath);
open.value = true;
selecting = false;
nextTick(() => {
const el = inputRef.value?.$el?.querySelector('input') ?? inputRef.value?.inputRef?.value;
el?.focus();
});
};
const onKeydown = (e: KeyboardEvent) => {
if (!open.value || !suggestions.value.length) {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
highlighted.value = Math.min(highlighted.value + 1, suggestions.value.length - 1);
scrollIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlighted.value = Math.max(highlighted.value - 1, 0);
scrollIntoView();
} else if (e.key === 'Enter' || e.key === 'Tab') {
const item = suggestions.value[highlighted.value];
if (highlighted.value >= 0 && item) {
e.preventDefault();
select(item);
}
} else if (e.key === 'Escape') {
open.value = false;
highlighted.value = -1;
}
};
const scrollIntoView = () => {
nextTick(() => {
const el = dropdownRef.value?.children[highlighted.value] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
});
};
</script>

View file

@ -144,14 +144,11 @@
{{ shortPath(config.app.download_path) }} {{ shortPath(config.app.download_path) }}
</span> </span>
<UInput <FolderInput
id="folder" id="folder"
v-model="form.folder" v-model="form.folder"
:placeholder="getDefault('folder', '/')" :placeholder="getDefault('folder', '/')"
:disabled="addInProgress" :disabled="addInProgress"
list="folders"
class="w-full"
size="lg"
:ui="{ root: 'w-full', base: 'bg-default/90' }" :ui="{ root: 'w-full', base: 'bg-default/90' }"
/> />
</div> </div>
@ -492,10 +489,6 @@
</UPageCard> </UPageCard>
</form> </form>
<datalist v-if="config?.folders" id="folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
<UModal <UModal
v-if="showOptions" v-if="showOptions"
v-model:open="showOptions" v-model:open="showOptions"

View file

@ -156,15 +156,11 @@
</div> </div>
</UTooltip> </UTooltip>
<UInput <FolderInput
id="folder" id="folder"
v-model="form.folder" v-model="form.folder"
type="text"
list="folders"
placeholder="Leave empty to use default download path" placeholder="Leave empty to use default download path"
size="lg"
:disabled="addInProgress" :disabled="addInProgress"
class="w-full"
:ui="inputUi" :ui="inputUi"
/> />
</div> </div>
@ -328,10 +324,6 @@
</UButton> </UButton>
</div> </div>
<datalist v-if="config?.folders" id="folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
<UModal <UModal
v-if="showOptions" v-if="showOptions"
v-model:open="showOptions" v-model:open="showOptions"

View file

@ -290,15 +290,11 @@
</div> </div>
</UTooltip> </UTooltip>
<UInput <FolderInput
id="folder" id="folder"
v-model="form.folder" v-model="form.folder"
type="text"
list="folders"
:placeholder="getDefault('folder', '/')" :placeholder="getDefault('folder', '/')"
:disabled="addInProgress" :disabled="addInProgress"
size="lg"
class="w-full"
:ui="inputUi" :ui="inputUi"
/> />
</div> </div>
@ -484,10 +480,6 @@
</UButton> </UButton>
</div> </div>
<datalist v-if="config?.folders" id="folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
<UModal <UModal
v-if="showOptions" v-if="showOptions"
v-model:open="showOptions" v-model:open="showOptions"

View file

@ -0,0 +1,30 @@
import { encodePath, request } from '~/utils';
const CACHE_TTL = 30_000;
const cache = new Map<string, { folders: string[]; expires: number }>();
const fetchFolders = async (parentPath: string): Promise<string[]> => {
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 });

View file

@ -52,7 +52,6 @@ const state = reactive<ConfigState>({
}, },
], ],
dl_fields: [], dl_fields: [],
folders: [],
ytdlp_options: [], ytdlp_options: [],
paused: false, paused: false,
is_loaded: false, is_loaded: false,

View file

@ -64,8 +64,6 @@ type ConfigState = {
presets: Array<Preset>; presets: Array<Preset>;
/** List of custom download fields */ /** List of custom download fields */
dl_fields: Array<DLField>; dl_fields: Array<DLField>;
/** List of folders where files can be saved */
folders: Array<string>;
/** List of yt-dlp options */ /** List of yt-dlp options */
ytdlp_options: Array<YTDLPOption>; ytdlp_options: Array<YTDLPOption>;
/** Indicates if downloads are currently paused */ /** Indicates if downloads are currently paused */