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)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/folders](#get-apisystemfolders)
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
- [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal)
@ -2628,7 +2629,7 @@ or an error:
---
### 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**:
```json
@ -2657,10 +2658,6 @@ or an error:
}
],
"paused": false,
"folders": [
{"name": "folder1", "path": "folder1"},
{"name": "folder2", "path": "folder2"}
],
"history_count": 150,
"queue": [
{
@ -2675,8 +2672,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
- 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_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| 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_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| 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
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:
"""
Extract channel images from a list of thumbnail dictionaries.

View file

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

View file

@ -20,17 +20,14 @@ from app.library.log import get_logger
from app.library.router import route
from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager
from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
LOG = get_logger()
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
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,11 +44,6 @@ 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(),
"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"],
},
@ -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")
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.")
data = diagnostics_error_report(config)
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)

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_diagnostics, system_folders, system_limits
@dataclass
@ -395,3 +395,101 @@ class TestSystemDiagnosticsEndpoint:
)
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,
init_class,
is_private_address,
list_folders,
load_cookies,
merge_dict,
move_file,
@ -1073,43 +1072,6 @@ class TestDeleteDir:
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:
"""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) }}
</span>
<UInput
<FolderInput
id="folder"
v-model="form.folder"
:placeholder="getDefault('folder', '/')"
:disabled="addInProgress"
list="folders"
class="w-full"
size="lg"
:ui="{ root: 'w-full', base: 'bg-default/90' }"
/>
</div>
@ -492,10 +489,6 @@
</UPageCard>
</form>
<datalist v-if="config?.folders" id="folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
<UModal
v-if="showOptions"
v-model:open="showOptions"

View file

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

View file

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

View file

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