feat: add system limits API endpoint and UI for download capacity overview
This commit is contained in:
parent
29777ff928
commit
3c1567930f
8 changed files with 773 additions and 5 deletions
50
API.md
50
API.md
|
|
@ -96,6 +96,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/limits](#get-apisystemlimits)
|
||||
- [POST /api/system/terminal](#post-apisystemterminal)
|
||||
- [GET /api/system/terminal/active](#get-apisystemterminalactive)
|
||||
- [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id)
|
||||
|
|
@ -2479,6 +2480,55 @@ or an error:
|
|||
|
||||
---
|
||||
|
||||
### GET /api/system/limits
|
||||
**Purpose**: Get the system limits.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"downloads": {
|
||||
"paused": false,
|
||||
"live_bypasses_limits": true,
|
||||
"global": {
|
||||
"limit": 20,
|
||||
"active": 3,
|
||||
"available": 17,
|
||||
"live_active": 1,
|
||||
"queued": 8
|
||||
},
|
||||
"per_extractor": {
|
||||
"default_limit": 2,
|
||||
"items": [
|
||||
{
|
||||
"name": "youtube",
|
||||
"limit": 3,
|
||||
"source": "env_override",
|
||||
"active": 2,
|
||||
"queued": 4,
|
||||
"available": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"extraction": {
|
||||
"concurrency": 4,
|
||||
"timeout_seconds": 70,
|
||||
"info_cache_ttl_seconds": 10800
|
||||
},
|
||||
"live": {
|
||||
"prevent_premiere": true,
|
||||
"premiere_buffer_minutes": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- `downloads.global` counts only non-live downloads against the worker limit.
|
||||
- `downloads.global.live_active` is reported separately because live downloads bypass the global and per-extractor worker limits.
|
||||
- `downloads.per_extractor.items[*].source` is `default` unless an override was provided through `YTP_MAX_WORKERS_FOR_<EXTRACTOR>`.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/system/terminal
|
||||
**Purpose**: Start a yt-dlp terminal session. Requires `YTP_CONSOLE_ENABLED=true`.
|
||||
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ class Config(metaclass=Singleton):
|
|||
"console_enabled",
|
||||
"browser_control_enabled",
|
||||
"ytdlp_auto_update",
|
||||
"prevent_premiere_live",
|
||||
"prevent_live_premiere",
|
||||
"temp_disabled",
|
||||
"allow_internal_urls",
|
||||
"simple_mode",
|
||||
|
|
|
|||
|
|
@ -184,7 +184,8 @@ async def add_video(queue: "DownloadQueue", entry: dict, item: "Item", logs: lis
|
|||
starts_in: datetime = (
|
||||
starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
)
|
||||
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
|
||||
buffer_time = queue.config.live_premiere_buffer if queue.config.live_premiere_buffer >= 0 else 5
|
||||
starts_in = starts_in + timedelta(minutes=buffer_time, seconds=dl.extras.get("duration", 0))
|
||||
dlInfo.info.error += f" Download will start at {starts_in.astimezone().isoformat()}."
|
||||
_requeue = False
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ from app.features.dl_fields.service import DLFields
|
|||
from app.features.presets.service import Presets
|
||||
from app.library.config import Config
|
||||
from app.library.downloads import DownloadQueue
|
||||
from app.library.downloads.core import Download
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
|
@ -363,3 +365,114 @@ async def stream_terminal_session(
|
|||
{"error": str(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/system/limits", "system.limits")
|
||||
async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
|
||||
"""Return user-facing operational limits for downloads and extraction."""
|
||||
active_downloads: dict[str, Download] = queue.pool.get_active_downloads()
|
||||
queue_items: list[tuple[str, Download]] = list(queue.queue.items())
|
||||
|
||||
active_non_live: list[Download] = [download for download in active_downloads.values() if not download.is_live]
|
||||
active_live: list[Download] = [download for download in active_downloads.values() if download.is_live]
|
||||
queued_non_live: list[Download] = [
|
||||
download
|
||||
for _, download in queue_items
|
||||
if not download.started() and not download.is_live and getattr(download.info, "auto_start", True) is not False
|
||||
]
|
||||
|
||||
def _get_extractor_override_limits(config: Config) -> dict[str, dict[str, int | str]]:
|
||||
overrides: dict[str, dict[str, int | str]] = {}
|
||||
prefix = "YTP_MAX_WORKERS_FOR_"
|
||||
|
||||
for env_key, raw_value in os.environ.items():
|
||||
if not env_key.startswith(prefix):
|
||||
continue
|
||||
|
||||
if not (extractor := env_key.removeprefix(prefix).strip().lower()):
|
||||
continue
|
||||
|
||||
if not raw_value.isdigit() or int(raw_value) < 1:
|
||||
continue
|
||||
|
||||
configured = int(raw_value)
|
||||
overrides[extractor] = {
|
||||
"configured": raw_value,
|
||||
"effective": min(configured, config.max_workers),
|
||||
"source": "env_override",
|
||||
}
|
||||
|
||||
return overrides
|
||||
|
||||
overrides: dict[str, dict[str, int | str]] = _get_extractor_override_limits(config)
|
||||
per_extractor: dict[str, dict[str, int | str]] = {}
|
||||
|
||||
for download in [*active_non_live, *queued_non_live]:
|
||||
extractor = (download.info.get_extractor() or "unknown").lower()
|
||||
entry = per_extractor.setdefault(
|
||||
extractor,
|
||||
{
|
||||
"name": extractor,
|
||||
"limit": config.max_workers_per_extractor,
|
||||
"source": "default",
|
||||
"active": 0,
|
||||
"queued": 0,
|
||||
"available": config.max_workers_per_extractor,
|
||||
},
|
||||
)
|
||||
|
||||
if extractor in overrides:
|
||||
entry["limit"] = overrides[extractor]["effective"]
|
||||
entry["source"] = overrides[extractor]["source"]
|
||||
|
||||
if download in active_non_live:
|
||||
entry["active"] += 1
|
||||
else:
|
||||
entry["queued"] += 1
|
||||
|
||||
for extractor, override in overrides.items():
|
||||
per_extractor.setdefault(
|
||||
extractor,
|
||||
{
|
||||
"name": extractor,
|
||||
"limit": override["effective"],
|
||||
"source": override["source"],
|
||||
"active": 0,
|
||||
"queued": 0,
|
||||
"available": override["effective"],
|
||||
},
|
||||
)
|
||||
|
||||
for entry in per_extractor.values():
|
||||
entry["available"] = max(int(entry["limit"]) - int(entry["active"]), 0)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"downloads": {
|
||||
"paused": queue.is_paused(),
|
||||
"live_bypasses_limits": True,
|
||||
"global": {
|
||||
"limit": config.max_workers,
|
||||
"active": len(active_non_live),
|
||||
"available": max(config.max_workers - len(active_non_live), 0),
|
||||
"live_active": len(active_live),
|
||||
"queued": len(queued_non_live),
|
||||
},
|
||||
"per_extractor": {
|
||||
"default_limit": config.max_workers_per_extractor,
|
||||
"items": sorted(per_extractor.values(), key=lambda item: str(item["name"])),
|
||||
},
|
||||
},
|
||||
"extraction": {
|
||||
"concurrency": config.extract_info_concurrency,
|
||||
"timeout_seconds": config.extract_info_timeout,
|
||||
"info_cache_ttl_seconds": config.download_info_expires,
|
||||
},
|
||||
"live": {
|
||||
"prevent_premiere": config.prevent_live_premiere,
|
||||
"premiere_buffer_minutes": config.live_premiere_buffer,
|
||||
},
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,60 @@
|
|||
import pytest
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
from app.routes.api.system import check_updates
|
||||
from app.routes.api.system import check_updates, system_limits
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyInfo:
|
||||
extractor: str | None
|
||||
|
||||
def get_extractor(self) -> str | None:
|
||||
return self.extractor
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyDownload:
|
||||
info: DummyInfo
|
||||
is_live: bool = False
|
||||
_started: bool = False
|
||||
|
||||
def started(self) -> bool:
|
||||
return self._started
|
||||
|
||||
|
||||
class DummyPool:
|
||||
def __init__(self, active: dict[str, DummyDownload] | None = None, paused: bool = False):
|
||||
self._active = active or {}
|
||||
self._paused = paused
|
||||
|
||||
def get_active_downloads(self) -> dict[str, DummyDownload]:
|
||||
return self._active.copy()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return self._paused
|
||||
|
||||
|
||||
class DummyStore:
|
||||
def __init__(self, items: dict[str, DummyDownload] | None = None):
|
||||
self._items = items or {}
|
||||
|
||||
def items(self):
|
||||
return self._items.items()
|
||||
|
||||
|
||||
class DummyQueue:
|
||||
def __init__(self, active: dict[str, DummyDownload] | None = None, queued: dict[str, DummyDownload] | None = None):
|
||||
self.pool = DummyPool(active=active)
|
||||
self.queue = DummyStore(items=queued)
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return self.pool.is_paused()
|
||||
|
||||
|
||||
class TestCheckUpdatesEndpoint:
|
||||
|
|
@ -64,3 +114,83 @@ class TestCheckUpdatesEndpoint:
|
|||
body = response.body.decode("utf-8")
|
||||
assert "v1.0.5" in body, "Response should include new version"
|
||||
assert "update_available" in body, "Response should include update_available status"
|
||||
|
||||
|
||||
class TestSystemLimitsEndpoint:
|
||||
def setup_method(self):
|
||||
Config._reset_singleton()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_limits_returns_user_facing_limits(self):
|
||||
config = Config.get_instance()
|
||||
config.max_workers = 10
|
||||
config.max_workers_per_extractor = 3
|
||||
config.extract_info_concurrency = 4
|
||||
config.extract_info_timeout = 70
|
||||
config.download_info_expires = 10800
|
||||
config.prevent_live_premiere = True
|
||||
config.live_premiere_buffer = 7
|
||||
config.default_pagination = 50
|
||||
|
||||
active = {
|
||||
"a": DummyDownload(info=DummyInfo("youtube"), is_live=False, _started=True),
|
||||
"b": DummyDownload(info=DummyInfo("youtube"), is_live=False, _started=True),
|
||||
"c": DummyDownload(info=DummyInfo("twitch"), is_live=True, _started=True),
|
||||
}
|
||||
queued = {
|
||||
"q1": DummyDownload(info=DummyInfo("youtube"), is_live=False, _started=False),
|
||||
"q2": DummyDownload(info=DummyInfo("vimeo"), is_live=False, _started=False),
|
||||
}
|
||||
queue = DummyQueue(active=active, queued=queued)
|
||||
encoder = Encoder()
|
||||
|
||||
with patch.dict("os.environ", {"YTP_MAX_WORKERS_FOR_YOUTUBE": "2"}, clear=False):
|
||||
response = await system_limits(queue, config, encoder)
|
||||
|
||||
assert 200 == response.status
|
||||
|
||||
body = json.loads(response.body.decode("utf-8"))
|
||||
assert body["downloads"]["paused"] is False
|
||||
assert body["downloads"]["live_bypasses_limits"] is True
|
||||
assert body["downloads"]["global"] == {
|
||||
"limit": 10,
|
||||
"active": 2,
|
||||
"available": 8,
|
||||
"live_active": 1,
|
||||
"queued": 2,
|
||||
}
|
||||
assert body["extraction"] == {
|
||||
"concurrency": 4,
|
||||
"timeout_seconds": 70,
|
||||
"info_cache_ttl_seconds": 10800,
|
||||
}
|
||||
assert body["live"] == {
|
||||
"prevent_premiere": True,
|
||||
"premiere_buffer_minutes": 7,
|
||||
}
|
||||
|
||||
per_extractor = {item["name"]: item for item in body["downloads"]["per_extractor"]["items"]}
|
||||
assert body["downloads"]["per_extractor"]["default_limit"] == 3
|
||||
assert per_extractor["youtube"] == {
|
||||
"name": "youtube",
|
||||
"limit": 2,
|
||||
"source": "env_override",
|
||||
"active": 2,
|
||||
"queued": 1,
|
||||
"available": 0,
|
||||
}
|
||||
assert per_extractor["vimeo"] == {
|
||||
"name": "vimeo",
|
||||
"limit": 3,
|
||||
"source": "default",
|
||||
"active": 0,
|
||||
"queued": 1,
|
||||
"available": 3,
|
||||
}
|
||||
|
||||
def test_config_reads_prevent_live_premiere_boolean_env(self):
|
||||
with patch.dict("os.environ", {"YTP_PREVENT_LIVE_PREMIERE": "false"}, clear=False):
|
||||
Config._reset_singleton()
|
||||
config = Config.get_instance()
|
||||
|
||||
assert config.prevent_live_premiere is False
|
||||
|
|
|
|||
415
ui/app/components/LimitsPage.vue
Normal file
415
ui/app/components/LimitsPage.vue
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
<template>
|
||||
<div class="w-full min-w-0 max-w-full space-y-5 p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
|
||||
<span>Queue capacity overview</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">
|
||||
Snapshot is based on the current queue state and configured backend limits.
|
||||
<template v-if="limits">
|
||||
{{ ' ' + queueLimitDescription }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-refresh-cw"
|
||||
:loading="limitsLoading"
|
||||
:disabled="limitsLoading"
|
||||
@click="void loadLimits(true)"
|
||||
>
|
||||
Refresh
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!limits && limitsLoading"
|
||||
class="flex min-h-72 items-center justify-center rounded-md border border-default bg-default/90"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
||||
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-default">Loading limits</p>
|
||||
<p class="text-xs">Fetching current queue capacity and runtime rules.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-else-if="!limits && limitsError"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
title="Failed to load limits"
|
||||
:description="limitsError"
|
||||
/>
|
||||
|
||||
<div v-else-if="limits" class="space-y-5">
|
||||
<UAlert
|
||||
v-if="limitsError"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
title="Showing last successful snapshot"
|
||||
:description="limitsError"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="limits.downloads.paused"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-pause"
|
||||
title="Download queue is paused"
|
||||
/>
|
||||
|
||||
<div class="rounded-md border border-default bg-default shadow-sm">
|
||||
<div class="grid gap-0 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<section
|
||||
class="space-y-4 border-b border-default px-4 py-4 sm:px-5 lg:border-r lg:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-download" class="size-4 text-toned" />
|
||||
<span>Download capacity</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="row in capacityRows"
|
||||
:key="row.label"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-medium text-default">{{ row.label }}</p>
|
||||
<p class="text-xs text-toned">{{ row.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
|
||||
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 px-4 py-4 sm:px-5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-settings-2" class="size-4 text-toned" />
|
||||
<span>Extraction rules</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="row in extractionRows"
|
||||
:key="row.label"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-medium text-default">{{ row.label }}</p>
|
||||
<p class="text-xs text-toned">{{ row.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
|
||||
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 border-t border-default pt-4">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-clock-3" class="size-4 text-toned" />
|
||||
<span>Premiere handling</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="row in premiereRows"
|
||||
:key="row.label"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-medium text-default">{{ row.label }}</p>
|
||||
<p class="text-xs text-toned">{{ row.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
|
||||
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-list" class="size-4 text-toned" />
|
||||
<span>Per extractor</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Extractor-specific usage and overrides currently in effect.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
||||
<UIcon name="i-lucide-gauge" class="size-3.5 shrink-0" />
|
||||
<span>{{ limits.downloads.per_extractor.default_limit }} default per extractor</span>
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
||||
<UIcon name="i-lucide-list" class="size-3.5 shrink-0" />
|
||||
<span>{{ trackedExtractorCount }} tracked</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="trackedExtractorCount === 0"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
title="No extractor-specific activity"
|
||||
description="Overrides and extractor usage appear here once downloads have active or queued work."
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-md border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-180 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr
|
||||
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-full min-w-56">Extractor</th>
|
||||
<th class="w-36 whitespace-nowrap">Source</th>
|
||||
<th class="w-24 whitespace-nowrap">Active</th>
|
||||
<th class="w-24 whitespace-nowrap">Limit</th>
|
||||
<th class="w-28 whitespace-nowrap">Available</th>
|
||||
<th class="w-24 whitespace-nowrap">Queued</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr
|
||||
v-for="item in extractorItems"
|
||||
:key="item.name"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="font-medium text-default">{{ item.name }}</div>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon :name="extractorSourceIcon(item.source)" class="size-3.5 shrink-0" />
|
||||
<span>{{ extractorSourceLabel(item.source) }}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle font-medium whitespace-nowrap text-default">
|
||||
{{ item.active }}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle font-medium whitespace-nowrap text-default">
|
||||
{{ item.limit }}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
|
||||
{{ item.available }}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
|
||||
{{ item.queued }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import moment from 'moment';
|
||||
import { parse_api_error, parse_api_response, request } from '~/utils';
|
||||
import type { SystemLimitsExtractor, SystemLimitsResponse } from '~/types/limits';
|
||||
|
||||
type DetailRow = {
|
||||
label: string;
|
||||
description: string;
|
||||
value: string;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
const limits = ref<SystemLimitsResponse | null>(null);
|
||||
const limitsLoading = ref(false);
|
||||
const limitsError = ref('');
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
return moment.duration(seconds, 'seconds').humanize();
|
||||
};
|
||||
|
||||
const extractorSourceLabel = (source: string): string => {
|
||||
return source === 'env_override' ? 'Override' : 'Default';
|
||||
};
|
||||
|
||||
const extractorSourceIcon = (source: string): string => {
|
||||
return source === 'env_override' ? 'i-lucide-settings-2' : 'i-lucide-circle-check-big';
|
||||
};
|
||||
|
||||
const queueLimitDescription = computed(() => {
|
||||
if (limits.value?.downloads.live_bypasses_limits === false) {
|
||||
return 'Regular worker slots apply to all downloads, including live downloads.';
|
||||
}
|
||||
|
||||
return 'Regular worker slots apply only to non-live downloads.';
|
||||
});
|
||||
|
||||
const capacityRows = computed<Array<DetailRow>>(() => {
|
||||
if (!limits.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { downloads } = limits.value;
|
||||
const { global } = downloads;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Regular workers',
|
||||
description: 'Slots available for non-live downloads.',
|
||||
value: `${global.active}/${global.limit}`,
|
||||
meta: `${global.available} available`,
|
||||
},
|
||||
{
|
||||
label: 'Waiting queue',
|
||||
description: 'Downloads waiting for a regular worker slot.',
|
||||
value: `${global.queued}`,
|
||||
meta: global.queued === 1 ? 'item queued' : 'items queued',
|
||||
},
|
||||
{
|
||||
label: 'Live downloads',
|
||||
description: downloads.live_bypasses_limits
|
||||
? 'Live downloads bypass the regular worker limit.'
|
||||
: 'Live downloads count toward the regular worker limit.',
|
||||
value: `${global.live_active}`,
|
||||
meta: global.live_active === 1 ? 'live item active' : 'live items active',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const extractionRows = computed<Array<DetailRow>>(() => {
|
||||
if (!limits.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Concurrent requests',
|
||||
description: 'Maximum info extraction jobs running at once.',
|
||||
value: `${limits.value.extraction.concurrency}`,
|
||||
},
|
||||
{
|
||||
label: 'Request timeout',
|
||||
description: 'Per extraction attempt before the backend gives up.',
|
||||
value: `${limits.value.extraction.timeout_seconds}s`,
|
||||
},
|
||||
{
|
||||
label: 'Cached info TTL',
|
||||
description: 'How long extracted info can be reused before refresh.',
|
||||
value: formatDuration(limits.value.extraction.info_cache_ttl_seconds),
|
||||
meta: `${limits.value.extraction.info_cache_ttl_seconds} seconds`,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const premiereRows = computed<Array<DetailRow>>(() => {
|
||||
if (!limits.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Initial premiere capture',
|
||||
description: 'Whether the first premiere capture is delayed until the buffer window passes.',
|
||||
value: limits.value.live.prevent_premiere ? 'Delayed' : 'Immediate',
|
||||
},
|
||||
{
|
||||
label: 'Premiere buffer',
|
||||
description: 'Extra time added after the scheduled start before download begins.',
|
||||
value: `${limits.value.live.premiere_buffer_minutes}m`,
|
||||
meta: `${limits.value.live.premiere_buffer_minutes} minute buffer`,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const extractorItems = computed<Array<SystemLimitsExtractor>>(() => {
|
||||
return limits.value?.downloads.per_extractor.items ?? [];
|
||||
});
|
||||
|
||||
const trackedExtractorCount = computed(() => extractorItems.value.length);
|
||||
|
||||
const loadLimits = async (force: boolean = false): Promise<void> => {
|
||||
if (limitsLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (limits.value && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
limitsError.value = '';
|
||||
|
||||
try {
|
||||
limitsLoading.value = true;
|
||||
|
||||
const response = await request('/api/system/limits');
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
limitsError.value = await parse_api_error(response.clone().json());
|
||||
} catch {
|
||||
limitsError.value = response.statusText || 'Failed to load limits.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
limits.value = await parse_api_response<SystemLimitsResponse>(response.json());
|
||||
} catch (e) {
|
||||
limitsError.value = e instanceof Error ? e.message : 'Failed to load limits.';
|
||||
} finally {
|
||||
limitsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadLimits(true);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -167,6 +167,16 @@
|
|||
|
||||
<template #right>
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-gauge"
|
||||
@click="showLimits = true"
|
||||
>
|
||||
<span class="hidden xl:inline">Limits</span>
|
||||
</UButton>
|
||||
|
||||
<NotifyDropdown />
|
||||
|
||||
<UButton
|
||||
|
|
@ -475,6 +485,18 @@
|
|||
placeholder="Search routes and actions"
|
||||
:ui="{ modal: 'sm:max-w-3xl h-full sm:h-[28rem]' }"
|
||||
/>
|
||||
|
||||
<UModal
|
||||
v-if="showLimits"
|
||||
:open="showLimits"
|
||||
title="Download Limits"
|
||||
:ui="{ content: 'sm:max-w-4xl', body: 'p-0' }"
|
||||
@update:open="(open) => !open && (showLimits = false)"
|
||||
>
|
||||
<template #body>
|
||||
<LimitsPage />
|
||||
</template>
|
||||
</UModal>
|
||||
</UDashboardGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -497,7 +519,8 @@ import { useStorage } from '@vueuse/core';
|
|||
import moment from 'moment';
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||
import type { toastPosition } from '~/composables/useNotification';
|
||||
import { formatPageTitle } from '~/utils';
|
||||
import LimitsPage from '~/components/LimitsPage.vue';
|
||||
import { formatPageTitle, parse_api_response, request, syncOpacity, uri } from '~/utils';
|
||||
import type { YTDLPOption } from '~/types/ytdlp';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import Dialog from '~/components/Dialog.vue';
|
||||
|
|
@ -539,6 +562,7 @@ const checkingUpdates = ref(false);
|
|||
const updateCheckMessage = ref('Up to date - Click to check');
|
||||
const showRouteSearch = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
const showLimits = ref(false);
|
||||
const { alertDialog, confirmDialog } = useDialog();
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 1023px)' });
|
||||
|
||||
|
|
|
|||
35
ui/app/types/limits.ts
Normal file
35
ui/app/types/limits.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export type SystemLimitsExtractor = {
|
||||
name: string;
|
||||
limit: number;
|
||||
source: string;
|
||||
active: number;
|
||||
queued: number;
|
||||
available: number;
|
||||
};
|
||||
|
||||
export type SystemLimitsResponse = {
|
||||
downloads: {
|
||||
paused: boolean;
|
||||
live_bypasses_limits: boolean;
|
||||
global: {
|
||||
limit: number;
|
||||
active: number;
|
||||
available: number;
|
||||
live_active: number;
|
||||
queued: number;
|
||||
};
|
||||
per_extractor: {
|
||||
default_limit: number;
|
||||
items: SystemLimitsExtractor[];
|
||||
};
|
||||
};
|
||||
extraction: {
|
||||
concurrency: number;
|
||||
timeout_seconds: number;
|
||||
info_cache_ttl_seconds: number;
|
||||
};
|
||||
live: {
|
||||
prevent_premiere: boolean;
|
||||
premiere_buffer_minutes: number;
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue