Merge pull request #615 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

fix: breaking change between yt-dlp 2026.03.17 -> 2026.06.09
This commit is contained in:
Abdulmohsen 2026-06-11 18:03:12 +03:00 committed by GitHub
commit a614a48108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 546 additions and 126 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

@ -349,6 +349,14 @@ class TestYTDLP:
assert len(result) == 8
assert result.isalnum()
def test_exec_init(self) -> None:
YTDLP(
params={
"compat_opts": set(),
"postprocessors": [{"key": "Exec", "exec_cmd": "echo %(title)q"}],
}
)
def test_outtmpl_reuses_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})

View file

@ -81,6 +81,9 @@ class YTDLP(yt_dlp.YoutubeDL):
except Exception:
patched_params = params
self._ytptube_outtmpl_info: dict[str, Any] | None = None
self._ytptube_outtmpl_cache: dict[str, Any] = {}
super().__init__(params=patched_params, auto_init=auto_init)
# Restore param and replace upstream archive set with our proxy
@ -90,8 +93,6 @@ class YTDLP(yt_dlp.YoutubeDL):
except Exception:
pass
self._ytptube_outtmpl_info: dict[str, Any] | None = None
self._ytptube_outtmpl_cache: dict[str, Any] = {}
self.archive = _ArchiveProxy(orig_file)
def _delete_downloaded_files(self, *args, **kwargs) -> None:
@ -105,14 +106,14 @@ class YTDLP(yt_dlp.YoutubeDL):
self._ytptube_outtmpl_info = None
self._ytptube_outtmpl_cache = {}
def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False):
def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False, *, _exec=False):
if self._ytptube_outtmpl_info is not info_dict:
self._ytptube_outtmpl_info = info_dict
self._ytptube_outtmpl_cache = {}
outtmpl, enriched = rewrite_outtmpl(outtmpl, info_dict, cache=self._ytptube_outtmpl_cache)
return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize)
return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize, _exec=_exec)
def process_info(self, info_dict):
try:

View file

@ -34,6 +34,7 @@ class Events:
ITEM_ADDED: str = "item_added"
ITEM_UPDATED: str = "item_updated"
ITEM_PROGRESS: str = "item_progress"
ITEM_COMPLETED: str = "item_completed"
ITEM_CANCELLED: str = "item_cancelled"
ITEM_DELETED: str = "item_deleted"
@ -86,6 +87,7 @@ class Events:
Events.LOG_SUCCESS,
Events.ITEM_ADDED,
Events.ITEM_UPDATED,
Events.ITEM_PROGRESS,
Events.ITEM_CANCELLED,
Events.ITEM_DELETED,
Events.ITEM_BULK_DELETED,
@ -103,7 +105,7 @@ class Events:
list: The list of debug events.
"""
return [Events.ITEM_UPDATED]
return [Events.ITEM_UPDATED, Events.ITEM_PROGRESS]
@dataclass(kw_only=True)

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

@ -56,6 +56,36 @@ class StatusTracker:
self._terminator_sent: bool = False
self._candidate_filepath: Path | None = None
self.update_task: asyncio.Task | None = None
self._last_progress_time: float = 0.0
self._pending_progress: bool = False
self._progress_interval: float = 0.5
def _progress_payload(self) -> dict:
return {
"_id": self.info._id,
"status": self.info.status,
"percent": self.info.percent,
"speed": self.info.speed,
"eta": self.info.eta,
"downloaded_bytes": self.info.downloaded_bytes,
"total_bytes": self.info.total_bytes,
"msg": self.info.msg,
}
def _emit_progress(self) -> None:
now = time.monotonic()
if (now - self._last_progress_time) >= self._progress_interval:
self._notify.emit(Events.ITEM_PROGRESS, data=self._progress_payload())
self._last_progress_time = now
self._pending_progress = False
else:
self._pending_progress = True
def _flush_progress(self) -> None:
if self._pending_progress:
self._notify.emit(Events.ITEM_PROGRESS, data=self._progress_payload())
self._pending_progress = False
self._last_progress_time = time.monotonic()
async def _finalize_file(self, filepath: Path) -> None:
"""
@ -184,6 +214,7 @@ class StatusTracker:
self.tmpfilename = status.get("tmpfilename")
old_status = self.info.status
self.info.status = status.get("status", self.info.status)
if "download_skipped" in status:
self.info.download_skipped = bool(status.get("download_skipped"))
@ -235,7 +266,11 @@ class StatusTracker:
await self._finalize_file(Path(final_name))
self.info.status = "finished"
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
if self.info.status != old_status or self.final_update:
self._flush_progress()
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
else:
self._emit_progress()
async def progress_update(self) -> None:
"""
@ -248,9 +283,11 @@ class StatusTracker:
self.update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
status = await self.update_task
if status is None or isinstance(status, Terminator):
self._flush_progress()
return
await self.process_status_update(status)
except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError):
self._flush_progress()
return
async def drain_queue(self, max_iterations: int = 50) -> None:
@ -295,6 +332,8 @@ class StatusTracker:
except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError):
continue
self._flush_progress()
def cancel_update_task(self) -> None:
"""Cancel the progress update task if it's running."""
try:

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

@ -1211,6 +1211,92 @@ class TestStatusTracker:
assert 1 == len(queue.items), "Should add terminator to queue"
assert isinstance(queue.items[0], Terminator), "Should add Terminator instance"
@pytest.mark.asyncio
async def test_progress_emits_item_progress(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config)
st.info.status = "downloading"
calls: list = []
st._notify = Mock()
st._notify.emit = Mock(side_effect=lambda *a, **kw: calls.append((a, kw)))
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 50, "total_bytes": 100}
)
progress_calls = [c for c in calls if c[0][0] == Events.ITEM_PROGRESS]
updated_calls = [c for c in calls if c[0][0] == Events.ITEM_UPDATED]
assert len(progress_calls) == 1
assert len(updated_calls) == 0
payload = progress_calls[0][1]["data"]
assert payload["_id"] == st.info._id
assert payload["percent"] == 50.0
assert "options" not in payload
@pytest.mark.asyncio
async def test_status_change_emits_item_updated(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config)
st.info.status = "started"
calls: list = []
st._notify = Mock()
st._notify.emit = Mock(side_effect=lambda *a, **kw: calls.append((a, kw)))
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 10, "total_bytes": 100}
)
updated_calls = [c for c in calls if c[0][0] == Events.ITEM_UPDATED]
assert len(updated_calls) == 1
assert updated_calls[0][1]["data"] is st.info
@pytest.mark.asyncio
async def test_progress_throttled(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config)
st.info.status = "downloading"
st._progress_interval = 0.5
calls: list = []
st._notify = Mock()
st._notify.emit = Mock(side_effect=lambda *a, **kw: calls.append((a, kw)))
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 10, "total_bytes": 100}
)
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 20, "total_bytes": 100}
)
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 30, "total_bytes": 100}
)
progress_calls = [c for c in calls if c[0][0] == Events.ITEM_PROGRESS]
assert len(progress_calls) == 1, "Rapid ticks should be throttled to one emission"
assert st._pending_progress is True
@pytest.mark.asyncio
async def test_flush_on_status_change(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config)
st.info.status = "downloading"
st._progress_interval = 10.0
calls: list = []
st._notify = Mock()
st._notify.emit = Mock(side_effect=lambda *a, **kw: calls.append((a, kw)))
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 10, "total_bytes": 100}
)
await st.process_status_update(
{"id": "test-id", "status": "downloading", "downloaded_bytes": 50, "total_bytes": 100}
)
assert st._pending_progress is True
await st.process_status_update({"id": "test-id", "status": "error", "error": "fail"})
progress_calls = [c for c in calls if c[0][0] == Events.ITEM_PROGRESS]
updated_calls = [c for c in calls if c[0][0] == Events.ITEM_UPDATED]
assert len(progress_calls) == 2, "Pending progress should be flushed before status change"
assert len(updated_calls) == 1
assert st._pending_progress is False
class TestQueueManager:
@staticmethod

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

@ -343,6 +343,15 @@ on('item_updated', (data: WSEP['item_updated']) => {
}
});
on('item_progress', (data: WSEP['item_progress']) => {
const queueState = getQueueState();
const id = data.data._id;
if (true === queueState.has(id)) {
queueState.patch(id, data.data as Partial<StoreItem>);
}
});
on('item_moved', (data: WSEP['item_moved']) => {
const queueState = getQueueState();
const to = data.data.to;

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

@ -21,6 +21,12 @@ const update = (key: KeyType, value: StoreItem): void => {
state.queue[key] = value;
};
const patch = (key: KeyType, fields: Partial<StoreItem>): void => {
if (state.queue[key]) {
Object.assign(state.queue[key], fields);
}
};
const remove = (key: KeyType): void => {
if (!state.queue[key]) {
return;
@ -227,6 +233,7 @@ const queueStateApi = proxyRefs({
...toRefs(state),
add,
update,
patch,
remove,
get,
has,

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 */

View file

@ -1,4 +1,15 @@
import type { StoreItem } from './store';
import type { ItemStatus, StoreItem } from './store';
export type ItemProgress = {
_id: string;
status: ItemStatus;
percent?: number | null;
speed?: string | null;
eta?: string | null;
downloaded_bytes?: number | null;
total_bytes?: number | null;
msg?: string | null;
};
export type Event = {
id: string;
@ -23,6 +34,7 @@ export type WSEP = {
connected: EventPayload<{ sid: string }>;
item_added: EventPayload<StoreItem>;
item_updated: EventPayload<StoreItem>;
item_progress: EventPayload<ItemProgress>;
item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;