feat: add system limits API endpoint and UI for download capacity overview

This commit is contained in:
arabcoders 2026-04-22 22:02:40 +03:00
parent 29777ff928
commit 3c1567930f
8 changed files with 773 additions and 5 deletions

50
API.md
View file

@ -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`.

View file

@ -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",

View file

@ -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:

View file

@ -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,
)

View file

@ -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

View 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>

View file

@ -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
View 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;
};
};