Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2657,10 +2658,6 @@ or an error:
}
],
"paused": false,
"folders": [
{"name": "folder1", "path": "folder1"},
{"name": "folder2", "path": "folder2"}
],
"history_count": 150,
"queue": [
{
Expand All @@ -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.

---

Expand Down
1 change: 0 additions & 1 deletion FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down
8 changes: 8 additions & 0 deletions app/features/ytdlp/tests/test_ytdlp_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}})

Expand Down
9 changes: 5 additions & 4 deletions app/features/ytdlp/ytdlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion app/library/Events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
29 changes: 0 additions & 29 deletions app/library/Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 0 additions & 4 deletions app/library/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
Expand Down
41 changes: 40 additions & 1 deletion app/library/downloads/status_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
60 changes: 50 additions & 10 deletions app/routes/api/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"],
},
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading