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)
|
- [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
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_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)` |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
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) }}
|
{{ 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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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: [],
|
dl_fields: [],
|
||||||
folders: [],
|
|
||||||
ytdlp_options: [],
|
ytdlp_options: [],
|
||||||
paused: false,
|
paused: false,
|
||||||
is_loaded: 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>;
|
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 */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue