diff --git a/API.md b/API.md index 5c1c9642..fc09f356 100644 --- a/API.md +++ b/API.md @@ -101,6 +101,7 @@ This document describes the available endpoints and their usage. All endpoints r - [POST /api/notifications/test](#post-apinotificationstest) - [GET /api/yt-dlp/options](#get-apiyt-dlpoptions) - [GET /api/system/configuration](#get-apisystemconfiguration) + - [GET /api/system/folders](#get-apisystemfolders) - [GET /api/system/diagnostics](#get-apisystemdiagnostics) - [GET /api/system/limits](#get-apisystemlimits) - [POST /api/system/terminal](#post-apisystemterminal) @@ -2628,7 +2629,7 @@ or an error: --- ### GET /api/system/configuration -**Purpose**: Retrieve comprehensive system configuration including app settings, presets, download fields, queue status, and folder structure. +**Purpose**: Retrieve system configuration including app settings, presets, download fields, and queue status. **Response**: ```json @@ -2657,10 +2658,6 @@ or an error: } ], "paused": false, - "folders": [ - {"name": "folder1", "path": "folder1"}, - {"name": "folder2", "path": "folder2"} - ], "history_count": 150, "queue": [ { @@ -2675,8 +2672,28 @@ or an error: **Notes**: - This endpoint combines multiple data sources into a single response for efficient initialization -- The `folders` array includes available download folders up to the configured depth limit - The `queue` array contains active download items +- Folder listing is available via the separate `/api/system/folders` endpoint + +--- + +### GET /api/system/folders +**Purpose**: List child directories for a given relative path within the download directory. + +**Query Parameters**: +- `path=` (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. --- diff --git a/FAQ.md b/FAQ.md index 6316ee9e..339bd87d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -47,7 +47,6 @@ or the `environment:` section in `compose.yaml` file. | YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` | | YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` | | YTP_TEMP_DISABLED | Disable temp files handling. | `false` | -| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` | | YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` | | YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` | | YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` | diff --git a/app/features/ytdlp/tests/test_ytdlp_module.py b/app/features/ytdlp/tests/test_ytdlp_module.py index 535de774..0e84c7df 100644 --- a/app/features/ytdlp/tests/test_ytdlp_module.py +++ b/app/features/ytdlp/tests/test_ytdlp_module.py @@ -349,6 +349,14 @@ def test_outtmpl_callable(self) -> None: 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"}}) diff --git a/app/features/ytdlp/ytdlp.py b/app/features/ytdlp/ytdlp.py index a04b3b08..43192bfb 100644 --- a/app/features/ytdlp/ytdlp.py +++ b/app/features/ytdlp/ytdlp.py @@ -81,6 +81,9 @@ def __init__(self, params=None, auto_init=True): 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 @@ def __init__(self, params=None, auto_init=True): 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 @@ def _reset_outtmpl_cache(self) -> None: 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: diff --git a/app/library/Events.py b/app/library/Events.py index 3a514a84..2b6e3b78 100644 --- a/app/library/Events.py +++ b/app/library/Events.py @@ -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 @@ def frontend() -> list: 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 @@ def only_debug() -> list: list: The list of debug events. """ - return [Events.ITEM_UPDATED] + return [Events.ITEM_UPDATED, Events.ITEM_PROGRESS] @dataclass(kw_only=True) diff --git a/app/library/Utils.py b/app/library/Utils.py index 0360a96c..1a753445 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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. diff --git a/app/library/config.py b/app/library/config.py index 3baff0b5..7cf49008 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -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", diff --git a/app/library/downloads/status_tracker.py b/app/library/downloads/status_tracker.py index 5a29a77c..704a9a17 100644 --- a/app/library/downloads/status_tracker.py +++ b/app/library/downloads/status_tracker.py @@ -56,6 +56,36 @@ def __init__( 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 @@ async def process_status_update(self, status: StatusDict) -> None: 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 @@ async def process_status_update(self, status: StatusDict) -> None: 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 @@ async def progress_update(self) -> None: 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 @@ async def drain_queue(self, max_iterations: int = 50) -> None: 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: diff --git a/app/routes/api/system.py b/app/routes/api/system.py index a9b604ad..3580cad8 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -20,17 +20,14 @@ 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) diff --git a/app/tests/test_download.py b/app/tests/test_download.py index 22228ce1..f5440d0e 100644 --- a/app/tests/test_download.py +++ b/app/tests/test_download.py @@ -1211,6 +1211,92 @@ def test_put_terminator_adds_to_queue(self, mock_config: dict) -> None: 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 diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py index cde68baf..680d158b 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -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.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 @@ async def test_optional_binary_missing_skip(self): ) 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" diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 7f7579e1..01052374 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -33,7 +33,6 @@ get_possible_images, init_class, is_private_address, - list_folders, load_cookies, merge_dict, move_file, @@ -1073,43 +1072,6 @@ def test_delete_dir_nonexistent(self): 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.""" diff --git a/ui/app/components/FolderInput.vue b/ui/app/components/FolderInput.vue new file mode 100644 index 00000000..63ee90ce --- /dev/null +++ b/ui/app/components/FolderInput.vue @@ -0,0 +1,169 @@ + + + diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index 6c9253f1..88817875 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -144,14 +144,11 @@ {{ shortPath(config.app.download_path) }} - @@ -492,10 +489,6 @@ - - - - @@ -328,10 +324,6 @@ - - - - @@ -484,10 +480,6 @@ - - - { } }); +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); + } +}); + on('item_moved', (data: WSEP['item_moved']) => { const queueState = getQueueState(); const to = data.data.to; diff --git a/ui/app/composables/useFolderSuggestions.ts b/ui/app/composables/useFolderSuggestions.ts new file mode 100644 index 00000000..f45271fb --- /dev/null +++ b/ui/app/composables/useFolderSuggestions.ts @@ -0,0 +1,30 @@ +import { encodePath, request } from '~/utils'; + +const CACHE_TTL = 30_000; + +const cache = new Map(); + +const fetchFolders = async (parentPath: string): Promise => { + 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 }); diff --git a/ui/app/composables/useQueueState.ts b/ui/app/composables/useQueueState.ts index 62f76417..27f12736 100644 --- a/ui/app/composables/useQueueState.ts +++ b/ui/app/composables/useQueueState.ts @@ -21,6 +21,12 @@ const update = (key: KeyType, value: StoreItem): void => { state.queue[key] = value; }; +const patch = (key: KeyType, fields: Partial): 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, diff --git a/ui/app/composables/useYtpConfig.ts b/ui/app/composables/useYtpConfig.ts index 522475e2..8dc65b9c 100644 --- a/ui/app/composables/useYtpConfig.ts +++ b/ui/app/composables/useYtpConfig.ts @@ -52,7 +52,6 @@ const state = reactive({ }, ], dl_fields: [], - folders: [], ytdlp_options: [], paused: false, is_loaded: false, diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index 58b14913..09fce8a8 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -64,8 +64,6 @@ type ConfigState = { presets: Array; /** List of custom download fields */ dl_fields: Array; - /** List of folders where files can be saved */ - folders: Array; /** List of yt-dlp options */ ytdlp_options: Array; /** Indicates if downloads are currently paused */ diff --git a/ui/app/types/sockets.d.ts b/ui/app/types/sockets.d.ts index 23851cf6..00c6c715 100644 --- a/ui/app/types/sockets.d.ts +++ b/ui/app/types/sockets.d.ts @@ -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; item_updated: EventPayload; + item_progress: EventPayload; item_cancelled: EventPayload; item_deleted: EventPayload; item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;