refactor: remove folders from configuration endpoint
This commit is contained in:
parent
a346ed69d9
commit
8c54ed6c0a
14 changed files with 375 additions and 119 deletions
29
API.md
29
API.md
|
|
@ -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
1
FAQ.md
|
|
@ -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)` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
169
ui/app/components/FolderInput.vue
Normal file
169
ui/app/components/FolderInput.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
30
ui/app/composables/useFolderSuggestions.ts
Normal file
30
ui/app/composables/useFolderSuggestions.ts
Normal 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 });
|
||||
|
|
@ -52,7 +52,6 @@ const state = reactive<ConfigState>({
|
|||
},
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
ytdlp_options: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue