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) - [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions) - [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration) - [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/folders](#get-apisystemfolders)
- [GET /api/system/diagnostics](#get-apisystemdiagnostics) - [GET /api/system/diagnostics](#get-apisystemdiagnostics)
- [GET /api/system/limits](#get-apisystemlimits) - [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal) - [POST /api/system/terminal](#post-apisystemterminal)
@ -2628,7 +2629,7 @@ or an error:
--- ---
### GET /api/system/configuration ### GET /api/system/configuration
**Purpose**: Retrieve comprehensive system configuration including app settings, presets, download fields, queue status, and folder structure. **Purpose**: Retrieve system configuration including app settings, presets, download fields, and queue status.
**Response**: **Response**:
```json ```json
@ -2657,10 +2658,6 @@ or an error:
} }
], ],
"paused": false, "paused": false,
"folders": [
{"name": "folder1", "path": "folder1"},
{"name": "folder2", "path": "folder2"}
],
"history_count": 150, "history_count": 150,
"queue": [ "queue": [
{ {
@ -2675,8 +2672,28 @@ or an error:
**Notes**: **Notes**:
- This endpoint combines multiple data sources into a single response for efficient initialization - This endpoint combines multiple data sources into a single response for efficient initialization
- The `folders` array includes available download folders up to the configured depth limit
- The `queue` array contains active download items - The `queue` array contains active download items
- Folder listing is available via the separate `/api/system/folders` endpoint
---
### GET /api/system/folders
**Purpose**: List child directories for a given relative path within the download directory.
**Query Parameters**:
- `path=<relative-path>` (optional, default: root) - Relative path within the download directory.
**Response**:
```json
{
"path": "videos",
"folders": ["archive", "shorts", "2024"]
}
```
**Notes**:
- Results are cached server-side for a short time.
- Non-existent paths return an empty folder list.
--- ---

1
FAQ.md
View file

@ -47,7 +47,6 @@ or the `environment:` section in `compose.yaml` file.
| YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` | | YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` | | YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` | | YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` | | YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` | | YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |

View file

@ -349,6 +349,14 @@ class TestYTDLP:
assert len(result) == 8 assert len(result) == 8
assert result.isalnum() 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: def test_outtmpl_reuses_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}}) ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})

View file

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

View file

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

View file

@ -1477,35 +1477,6 @@ def str_to_dt(time_str: str, now=None) -> datetime:
return dt return dt
def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
"""
List all folders relative to a base path, up to a specified depth limit.
Args:
path (Path): The path to start listing folders from.
base (Path): The base path to which the folders should be relative.
depth_limit (int): The maximum depth to traverse from the base path.
Returns:
list[str]: A list of folder paths relative to the base path, up to the specified
"""
if "/" == str(path):
return []
rel_depth: int = len(path.relative_to(base).parts)
if rel_depth > depth_limit:
return []
folders: list[str] = []
for entry in path.iterdir():
if entry.is_dir():
folders.append(str(entry.relative_to(base)))
folders.extend(list_folders(entry, base, depth_limit))
return folders
def get_channel_images(thumbnails: list[dict]) -> dict: def get_channel_images(thumbnails: list[dict]) -> dict:
""" """
Extract channel images from a list of thumbnail dictionaries. Extract channel images from a list of thumbnail dictionaries.

View file

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

View file

@ -56,6 +56,36 @@ class StatusTracker:
self._terminator_sent: bool = False self._terminator_sent: bool = False
self._candidate_filepath: Path | None = None self._candidate_filepath: Path | None = None
self.update_task: asyncio.Task | 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: async def _finalize_file(self, filepath: Path) -> None:
""" """
@ -184,6 +214,7 @@ class StatusTracker:
self.tmpfilename = status.get("tmpfilename") self.tmpfilename = status.get("tmpfilename")
old_status = self.info.status
self.info.status = status.get("status", self.info.status) self.info.status = status.get("status", self.info.status)
if "download_skipped" in status: if "download_skipped" in status:
self.info.download_skipped = bool(status.get("download_skipped")) self.info.download_skipped = bool(status.get("download_skipped"))
@ -235,7 +266,11 @@ class StatusTracker:
await self._finalize_file(Path(final_name)) await self._finalize_file(Path(final_name))
self.info.status = "finished" 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: 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) self.update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
status = await self.update_task status = await self.update_task
if status is None or isinstance(status, Terminator): if status is None or isinstance(status, Terminator):
self._flush_progress()
return return
await self.process_status_update(status) await self.process_status_update(status)
except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError): except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError):
self._flush_progress()
return return
async def drain_queue(self, max_iterations: int = 50) -> None: async def drain_queue(self, max_iterations: int = 50) -> None:
@ -295,6 +332,8 @@ class StatusTracker:
except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError): except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError):
continue continue
self._flush_progress()
def cancel_update_task(self) -> None: def cancel_update_task(self) -> None:
"""Cancel the progress update task if it's running.""" """Cancel the progress update task if it's running."""
try: try:

View file

@ -20,17 +20,14 @@ from app.library.log import get_logger
from app.library.router import route from app.library.router import route
from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
LOG = get_logger() LOG = get_logger()
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
DIAGNOSTICS_CACHE_TTL = 5.0
@route("GET", "api/system/configuration", "system.configuration") @route("GET", "api/system/configuration", "system.configuration")
async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response: async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
""" """
Pause non-active downloads. Get the system configuration.
Args: Args:
queue (DownloadQueue): The download queue instance. queue (DownloadQueue): The download queue instance.
@ -47,11 +44,6 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder)
"presets": Presets.get_instance().get_all(), "presets": Presets.get_instance().get_all(),
"dl_fields": await DLFields.get_instance().get_all_serialized(), "dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(), "paused": queue.is_paused(),
"folders": list_folders(
path=Path(config.download_path),
base=Path(config.download_path),
depth_limit=config.download_path_depth - 1,
),
"history_count": await queue.done.get_total_count(), "history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"], "queue": (await queue.get("queue"))["queue"],
}, },
@ -60,6 +52,54 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder)
) )
@route("GET", "api/system/folders", "system.folders")
async def system_folders(request: Request, config: Config, encoder: Encoder, cache: Cache) -> Response:
"""
List child directories for a given relative path.
Query params:
path: Relative path within the download directory (default: root).
Returns:
Response: The response object.
"""
raw_path: str = request.query.get("path", "").strip().lstrip("/")
base: Path = Path(config.download_path).resolve()
target: Path = (base / raw_path).resolve()
if not target.is_relative_to(base):
return web.json_response(
data={"path": raw_path, "folders": []},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
cache_key = f"folders:{target!s}"
if (cached := cache.get(cache_key)) is not None:
return web.json_response(data=cached, status=web.HTTPOk.status_code, dumps=encoder.encode)
folders: list[str] = []
if target.is_dir():
try:
folders.extend(
entry.name
for entry in sorted(target.iterdir())
if entry.is_dir() and entry.resolve().is_relative_to(base)
)
except PermissionError:
pass
resolved_rel: str = str(target.relative_to(base)) if target != base else ""
if resolved_rel == ".":
resolved_rel = ""
data: dict[str, str | list[str]] = {"path": resolved_rel, "folders": folders}
cache.set(cache_key, data, ttl=30.0)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/system/pause", "system.pause") @route("POST", "api/system/pause", "system.pause")
async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
""" """
@ -247,7 +287,7 @@ async def system_diagnostics(
LOG.exception("Failed to collect system diagnostics.") LOG.exception("Failed to collect system diagnostics.")
data = diagnostics_error_report(config) data = diagnostics_error_report(config)
else: else:
cache.set(cache_key, data, ttl=60) cache.set(cache_key, data, ttl=60.0)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -1211,6 +1211,92 @@ class TestStatusTracker:
assert 1 == len(queue.items), "Should add terminator to queue" assert 1 == len(queue.items), "Should add terminator to queue"
assert isinstance(queue.items[0], Terminator), "Should add Terminator instance" 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: class TestQueueManager:
@staticmethod @staticmethod

View file

@ -1,7 +1,7 @@
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -9,7 +9,7 @@ from app.library.config import Config
from app.library.cache import Cache from app.library.cache import Cache
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
from app.routes.api.system import check_updates, system_diagnostics, system_limits from app.routes.api.system import check_updates, system_diagnostics, system_folders, system_limits
@dataclass @dataclass
@ -395,3 +395,101 @@ class TestSystemDiagnosticsEndpoint:
) )
assert check.status == "skip" assert check.status == "skip"
class TestSystemFoldersEndpoint:
def setup_method(self):
Config._reset_singleton()
Cache.get_instance().clear()
@pytest.mark.asyncio
async def test_returns_root_children(self, tmp_path: Path) -> None:
(tmp_path / "videos").mkdir()
(tmp_path / "music").mkdir()
(tmp_path / "file.txt").touch()
config = Config.get_instance()
config.download_path = str(tmp_path)
encoder = Encoder()
cache = Cache.get_instance()
req = MagicMock()
req.query = {}
response = await system_folders(req, config, encoder, cache)
assert response.status == 200
data = json.loads(response.body.decode("utf-8"))
assert data["path"] == ""
assert sorted(data["folders"]) == ["music", "videos"]
@pytest.mark.asyncio
async def test_returns_subdir_children(self, tmp_path: Path) -> None:
(tmp_path / "videos" / "archive").mkdir(parents=True)
(tmp_path / "videos" / "shorts").mkdir()
config = Config.get_instance()
config.download_path = str(tmp_path)
encoder = Encoder()
cache = Cache.get_instance()
req = MagicMock()
req.query = {"path": "videos"}
response = await system_folders(req, config, encoder, cache)
assert response.status == 200
data = json.loads(response.body.decode("utf-8"))
assert data["path"] == "videos"
assert sorted(data["folders"]) == ["archive", "shorts"]
@pytest.mark.asyncio
async def test_rejects_path_traversal(self, tmp_path: Path) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
encoder = Encoder()
cache = Cache.get_instance()
req = MagicMock()
req.query = {"path": "../../etc"}
response = await system_folders(req, config, encoder, cache)
assert response.status == 200
data = json.loads(response.body.decode("utf-8"))
assert data["folders"] == []
@pytest.mark.asyncio
async def test_nonexistent_path_returns_empty(self, tmp_path: Path) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
encoder = Encoder()
cache = Cache.get_instance()
req = MagicMock()
req.query = {"path": "no_such_dir"}
response = await system_folders(req, config, encoder, cache)
assert response.status == 200
data = json.loads(response.body.decode("utf-8"))
assert data["folders"] == []
@pytest.mark.asyncio
async def test_caches_result(self, tmp_path: Path) -> None:
(tmp_path / "a").mkdir()
config = Config.get_instance()
config.download_path = str(tmp_path)
encoder = Encoder()
cache = Cache.get_instance()
req = MagicMock()
req.query = {}
await system_folders(req, config, encoder, cache)
(tmp_path / "b").mkdir()
response = await system_folders(req, config, encoder, cache)
data = json.loads(response.body.decode("utf-8"))
assert "b" not in data["folders"], "Should serve cached result"

View file

@ -33,7 +33,6 @@ from app.library.Utils import (
get_possible_images, get_possible_images,
init_class, init_class,
is_private_address, is_private_address,
list_folders,
load_cookies, load_cookies,
merge_dict, merge_dict,
move_file, move_file,
@ -1073,43 +1072,6 @@ class TestDeleteDir:
assert result is False assert result is False
class TestListFolders:
"""Test the list_folders function."""
def setup_method(self):
"""Set up test directory structure."""
self.temp_dir = str(make_test_temp_dir("list-folders"))
self.base = Path(self.temp_dir)
(self.base / "folder1").mkdir()
(self.base / "folder2").mkdir()
(self.base / "folder1" / "subfolder").mkdir()
(self.base / "file.txt").write_text("test")
def teardown_method(self):
"""Clean up after tests."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_list_folders_depth_0(self):
"""Test listing folders with depth 0."""
result = list_folders(self.base, self.base, 0)
expected = ["folder1", "folder2"]
assert sorted(result) == sorted(expected)
def test_list_folders_depth_1(self):
"""Test listing folders with depth 1."""
result = list_folders(self.base, self.base, 1)
expected = ["folder1", "folder2", "folder1/subfolder"]
assert sorted(result) == sorted(expected)
def test_list_folders_depth_2(self):
"""Test listing folders with a depth limit."""
result = list_folders(self.base, self.base, 2)
expected = ["folder1", "folder2", "folder1/subfolder"]
assert sorted(result) == sorted(expected)
class TestEncryptDecrypt: class TestEncryptDecrypt:
"""Test encryption and decryption functions.""" """Test encryption and decryption functions."""

View file

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

View file

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

View file

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

View file

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

View file

@ -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']) => { on('item_moved', (data: WSEP['item_moved']) => {
const queueState = getQueueState(); const queueState = getQueueState();
const to = data.data.to; 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; 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 => { const remove = (key: KeyType): void => {
if (!state.queue[key]) { if (!state.queue[key]) {
return; return;
@ -227,6 +233,7 @@ const queueStateApi = proxyRefs({
...toRefs(state), ...toRefs(state),
add, add,
update, update,
patch,
remove, remove,
get, get,
has, has,

View file

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

View file

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

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 = { export type Event = {
id: string; id: string;
@ -23,6 +34,7 @@ export type WSEP = {
connected: EventPayload<{ sid: string }>; connected: EventPayload<{ sid: string }>;
item_added: EventPayload<StoreItem>; item_added: EventPayload<StoreItem>;
item_updated: EventPayload<StoreItem>; item_updated: EventPayload<StoreItem>;
item_progress: EventPayload<ItemProgress>;
item_cancelled: EventPayload<StoreItem>; item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>; item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>; item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;