refactor: minor ui cleanup

This commit is contained in:
arabcoders 2026-05-17 20:21:58 +03:00
parent 2146e51368
commit 3bd2a7bee7
23 changed files with 225 additions and 230 deletions

1
API.md
View file

@ -2602,7 +2602,6 @@ or an error:
{
"downloads": {
"paused": false,
"live_bypasses_limits": true,
"global": {
"limit": 20,
"active": 3,

View file

@ -4,7 +4,7 @@ import random
import time
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse
from urllib.parse import urlparse, urlsplit, urlunsplit
from aiohttp import web
from aiohttp.web import Request, Response
@ -22,6 +22,27 @@ LOG: logging.Logger = logging.getLogger(__name__)
IS_REQUESTING_BACKGROUND: bool = False
def _safe_url(url: str | None) -> str:
if not url:
return ""
try:
parsed = urlsplit(url)
except Exception:
return str(url)
netloc: str = parsed.netloc
if parsed.username or parsed.password:
host: str = parsed.hostname or ""
if parsed.port:
host: str = f"{host}:{parsed.port}"
netloc: str = f"redacted:redacted@{host}" if host else "redacted:redacted"
query: str = "redacted" if parsed.query else ""
fragment: str = "redacted" if parsed.fragment else ""
return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment))
@route("GET", "api/thumbnail/", "get_thumbnail")
async def get_thumbnail(request: Request, config: Config) -> Response:
"""
@ -52,9 +73,10 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
use_curl=use_curl,
)
proxy = ytdlp_args.get("proxy")
safe_url = _safe_url(url)
client = get_async_client(proxy=proxy, use_curl=use_curl)
LOG.debug(f"Fetching thumbnail from '{url}'.")
LOG.debug(f"Fetching thumbnail from '{safe_url}'.")
response = await client.request(
method="GET",
url=url,
@ -64,7 +86,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
)
if response.status_code != web.HTTPOk.status_code:
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
LOG.error(f"Failed to fetch thumbnail from '{safe_url}'. Status code: {response.status_code}.")
return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
return web.Response(
@ -81,7 +103,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
},
)
except Exception as e:
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
LOG.error(f"Error fetching thumbnail from '{safe_url}'. '{e}'.")
return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
)
@ -110,7 +132,8 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
try:
IS_REQUESTING_BACKGROUND = True
backend = random.choice(config.pictures_backends)
backend: str = random.choice(config.pictures_backends)
safe_backend: str = _safe_url(backend)
CACHE_KEY_BING = "random_background_bing"
CACHE_KEY = "random_background"
@ -135,12 +158,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
)
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
use_curl = resolve_curl_transport()
request_headers = build_request_headers(
use_curl: bool = resolve_curl_transport()
request_headers: dict = build_request_headers(
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
use_curl=use_curl,
)
proxy = ytdlp_args.get("proxy")
proxy: str | None = ytdlp_args.get("proxy")
client = get_async_client(proxy=proxy, use_curl=use_curl)
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
@ -159,10 +182,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
status=web.HTTPInternalServerError.status_code,
)
backend = f"https://www.bing.com{img_url}"
backend: str = f"https://www.bing.com{img_url}"
safe_backend: str = _safe_url(backend)
await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
else:
backend = await cache.aget(CACHE_KEY_BING)
safe_backend = _safe_url(backend if isinstance(backend, str) else None)
if not isinstance(backend, str) or not backend:
return web.json_response(
@ -170,7 +195,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
status=web.HTTPInternalServerError.status_code,
)
LOG.debug(f"Requesting random picture from '{backend!s}'.")
LOG.debug(f"Requesting random picture from '{safe_backend}'.")
response = await client.request(
method="GET",
@ -198,7 +223,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
await cache.aset(key=CACHE_KEY, value=data, ttl=3600)
LOG.debug(f"Random background image from '{backend!s}' cached.")
LOG.debug(f"Random background image from '{safe_backend}' cached.")
return web.Response(
body=data.get("content"),
@ -210,7 +235,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
},
)
except Exception as e:
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
LOG.error(f"Failed to request random background image from '{safe_backend}'. '{e!s}'.")
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,

View file

@ -464,7 +464,6 @@ async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder)
data={
"downloads": {
"paused": queue.is_paused(),
"live_bypasses_limits": True,
"global": {
"limit": config.max_workers,
"active": len(active_non_live),

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from typing import Generator
from unittest.mock import AsyncMock
@ -95,3 +96,48 @@ async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None:
assert response.status == web.HTTPForbidden.status_code
assert response.text == '{"error": "Invalid hostname."}'
@pytest.mark.asyncio
async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"]
req = make_mocked_request("GET", "/api/random/background")
class DummyCache:
def has(self, _key: str) -> bool:
return False
async def aset(self, **_kwargs) -> None:
return None
client = AsyncMock()
client.request.side_effect = RuntimeError("boom")
monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client)
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {})
monkeypatch.setattr(images.Globals, "get_random_agent", staticmethod(lambda: "agent"))
monkeypatch.setattr(
images.YTDLPOpts,
"get_instance",
staticmethod(
lambda: type(
"Opts",
(),
{
"preset": lambda self, name: self,
"get_all": lambda self: {},
},
)()
),
)
with caplog.at_level(logging.ERROR):
response = await images.get_background(req, config, DummyCache())
assert response.status == web.HTTPInternalServerError.status_code
logs = caplog.text
assert "apitoken=secret" not in logs
assert "user:pass@" not in logs
assert "https://redacted:redacted@example.com/bg.jpg?redacted#redacted" in logs

View file

@ -151,7 +151,6 @@ class TestSystemLimitsEndpoint:
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,

View file

@ -1,17 +1,14 @@
<template>
<div class="w-full min-w-0 max-w-full space-y-5 p-4 sm:p-5">
<div class="w-full min-w-0 max-w-full space-y-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>
<span>Queue 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>
Data is based on the current queue state and configured limits.
</p>
</div>
@ -37,7 +34,7 @@
<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>
<p class="text-xs">Fetching current queue state and runtime rules.</p>
</div>
</div>
</div>
@ -166,7 +163,7 @@
<span>Per extractor</span>
</div>
<p class="text-sm text-toned">
<p class="text-sm text-toned" v-if="trackedExtractorCount">
Extractor-specific usage and overrides currently in effect.
</p>
</div>
@ -174,12 +171,19 @@
<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="font-semibold">{{
limits.downloads.per_extractor.default_limit
}}</span
>/slots 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
><span class="font-semibold">{{ trackedExtractorCount }}</span> tracked</span
>
</span>
</div>
</div>
@ -189,8 +193,8 @@
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."
title="No activity"
description="Overrides and extractor usage appear here once activity is detected."
/>
<div
@ -285,14 +289,6 @@ 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 [];
@ -304,23 +300,21 @@ const capacityRows = computed<Array<DetailRow>>(() => {
return [
{
label: 'Regular workers',
description: 'Slots available for non-live downloads.',
description: 'Slots available.',
value: `${global.active}/${global.limit}`,
meta: `${global.available} available`,
},
{
label: 'Waiting queue',
description: 'Downloads waiting for a regular worker slot.',
description: 'Downloads waiting for worker slot.',
value: `${global.queued}`,
meta: global.queued === 1 ? 'item queued' : 'items queued',
meta: '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.',
description: 'Streams bypass the worker slots limits.',
value: `${global.live_active}`,
meta: global.live_active === 1 ? 'live item active' : 'live items active',
meta: 'live items',
},
];
});

View file

@ -572,7 +572,7 @@ const app_shutdown = ref<boolean>(false);
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
const show_settings = ref(false);
const checkingUpdates = ref(false);
const updateCheckMessage = ref('Up to date - Click to check');
const updateCheckMessage = ref('Up to date - Check now');
const showRouteSearch = ref(false);
const showSidebar = ref(false);
const showLimits = ref(false);
@ -920,7 +920,7 @@ const checkForUpdates = async () => {
return;
}
const msg = 'Up to date - Click to check';
const msg = 'Up to date - Check now';
try {
checkingUpdates.value = true;

View file

@ -50,7 +50,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
color="neutral"
:variant="show_filter ? 'soft' : 'outline'"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="logs.length > 0"
color="neutral"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="items.length > 0"
color="neutral"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex flex-wrap gap-2 xl:justify-end">
<div class="flex flex-wrap justify-end gap-2 xl:justify-end">
<UButton
color="neutral"
variant="outline"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="items.length > 0"
color="neutral"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex flex-wrap gap-2 xl:justify-end">
<div class="flex flex-wrap justify-end gap-2 xl:justify-end">
<UButton
v-for="entry in docsEntries"
:key="entry.id"

View file

@ -27,7 +27,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
color="neutral"
:variant="showFilter ? 'soft' : 'outline'"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"

View file

@ -29,7 +29,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="!autoScroll"
color="neutral"
@ -38,7 +38,7 @@
icon="i-lucide-arrow-down"
@click="scrollToBottom(false)"
>
Scroll to bottom
Bottom
</UButton>
<UButton
@ -82,7 +82,7 @@
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
<div
ref="logContainer"
class="logbox overflow-x-auto overflow-y-auto bg-elevated/30 font-mono text-sm text-default overscroll-x-contain"
class="w-full min-w-0 max-w-full min-h-[55vh] max-h-[60vh] overflow-x-auto overflow-y-auto bg-transparent font-mono text-sm text-default overscroll-x-contain"
@scroll.passive="handleScroll"
>
<div
@ -90,8 +90,9 @@
class="flex justify-center border-b border-default/40 px-4 py-3"
>
<div
class="rounded-full border border-default bg-elevated/40 px-3 py-1 text-[11px] text-toned"
class="inline-flex items-center gap-1.5 rounded-full border border-warning/30 bg-warning/10 px-3 py-1 text-[11px] font-medium text-warning"
>
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
No older lines remain in this file.
</div>
</div>
@ -102,19 +103,25 @@
:key="entry.log.id"
:class="logRowClass(entry, index)"
>
<div class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
{{ logTimeLabel(entry.log.datetime) }}
</div>
<div
class="log-content"
:class="textWrap ? 'log-content--wrap' : 'log-content--nowrap'"
:class="[
'flex min-w-0 flex-1 items-start gap-[0.65rem] px-3 py-[0.65rem] leading-[1.6]',
textWrap ? 'w-full' : 'w-max min-w-full',
]"
>
<span
class="log-tone-dot"
:class="`log-tone-dot--${detectLogTone(entry.log.line)}`"
:class="[
'mt-[0.45rem] inline-flex size-2 shrink-0 rounded-full',
logLevelDotClass(entry.level),
]"
/>
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
<p :class="logLineClass(entry.level)">
<span
class="mr-2 inline text-[11px] font-semibold text-toned cursor-pointer"
:title="logTimeTitle(entry.log.datetime)"
>
{{ logTimeLabel(entry.log.datetime) }}
</span>
{{ entry.log.line }}
</p>
</div>
@ -162,13 +169,30 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = {
log: log_line;
level: LogLevel;
isMatch: boolean;
isContext: boolean;
};
type LogTone = 'default' | 'info' | 'warning' | 'error';
type LogLevel = 'debug' | 'info' | 'warning' | 'error';
const FILTER_CONTEXT_REGEX = /context:(\d+)/;
const LOG_LEVEL_PREFIX =
/^\s*(?:\[(debug|info|warning|warn|error|critical|fatal)(?:[.\]])|(debug|info|warning|warn|error|critical|fatal)\b(?::|\s|-))/i;
const LOG_ROW_CLASS =
'flex min-w-0 border-b border-default/40 bg-transparent transition-colors duration-150 last:border-b-0 hover:bg-elevated/70';
const LOG_LEVEL_DOT_CLASS: Record<LogLevel, string> = {
debug: 'bg-muted',
info: 'bg-info',
warning: 'bg-warning',
error: 'bg-error',
};
const LOG_LEVEL_TEXT_CLASS: Record<LogLevel, string> = {
debug: 'text-toned',
info: 'text-info',
warning: 'text-warning',
error: 'text-error',
};
let scrollTimeout: NodeJS.Timeout | null = null;
@ -242,7 +266,12 @@ watch(
const filteredLogs = computed<FilteredLogEntry[]>(() => {
if (!hasActiveFilter.value) {
return logs.value.map((log) => ({ log, isMatch: false, isContext: false }));
return logs.value.map((log) => ({
log,
level: detectLogLevel(log.line),
isMatch: false,
isContext: false,
}));
}
const result: Array<FilteredLogEntry> = [];
@ -266,8 +295,11 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
Array.from(visibleIndexes)
.sort((a, b) => a - b)
.forEach((index) => {
const log = logs.value[index] as log_line;
result.push({
log: logs.value[index] as log_line,
log,
level: detectLogLevel(log.line),
isMatch: matchedIndexes.has(index),
isContext: !matchedIndexes.has(index),
});
@ -440,44 +472,59 @@ const startLogStream = async (): Promise<void> => {
};
const logTimeLabel = (value?: string): string =>
value ? moment(value).format('HH:mm:ss') : '--:--:--';
value ? moment(value).format('HH:mm:ss') : '00:00:00';
const logTimeTitle = (value?: string): string =>
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
const detectLogTone = (line: string): LogTone => {
const normalized = line.toLowerCase();
if (/error|failed|exception|traceback|fatal/.test(normalized)) {
return 'error';
const getLogLevel = (level: string): LogLevel => {
switch (level.toLowerCase()) {
case 'info':
return 'info';
case 'warning':
case 'warn':
return 'warning';
case 'error':
case 'critical':
case 'fatal':
return 'error';
default:
return 'debug';
}
if (/warn|deprecated|retry/.test(normalized)) {
return 'warning';
}
if (/info|started|connected|listening|ready/.test(normalized)) {
return 'info';
}
return 'default';
};
const detectLogLevel = (line: string): LogLevel => {
const match = line.match(LOG_LEVEL_PREFIX);
const level = match?.[1] ?? match?.[2];
return level ? getLogLevel(level) : 'debug';
};
const logLevelDotClass = (level: LogLevel): string => LOG_LEVEL_DOT_CLASS[level];
const logLineClass = (level: LogLevel): string[] => [
'flex-1',
textWrap.value
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
: 'min-w-max whitespace-pre',
LOG_LEVEL_TEXT_CLASS[level],
];
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
const classes = ['log-row', `log-row--${detectLogTone(entry.log.line)}`];
const classes = [LOG_ROW_CLASS];
if (entry.isMatch) {
classes.push('log-row--match');
classes.push('bg-warning/10');
return classes;
}
if (entry.isContext) {
classes.push('log-row--context');
classes.push('bg-muted/30');
return classes;
}
if (index % 2 === 1) {
classes.push('log-row--alt');
classes.push('bg-elevated/40');
}
return classes;
@ -502,132 +549,3 @@ onBeforeUnmount(() => {
}
});
</script>
<style scoped>
.logbox {
width: 100%;
min-width: 0;
max-width: 100%;
min-height: 55vh;
max-height: 60vh;
background: transparent;
}
.log-row {
display: flex;
min-width: 0;
border-bottom: 1px solid color-mix(in oklab, var(--ui-border) 40%, transparent);
box-sizing: border-box;
background: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.log-row:last-child {
border-bottom: 0;
}
.log-row--alt {
background: color-mix(in oklab, var(--ui-bg-elevated) 40%, transparent);
}
.log-row--match {
background: color-mix(in oklab, var(--ui-warning) 10%, var(--ui-bg-elevated) 90%);
}
.log-row--context {
background: color-mix(in oklab, var(--ui-bg-muted) 30%, transparent);
}
.log-row:hover {
background: color-mix(in oklab, var(--ui-bg-elevated) 70%, var(--ui-bg-muted) 30%);
}
.log-timestamp {
display: flex;
width: 5.5rem;
min-width: 5.5rem;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
border-right: 1px solid color-mix(in oklab, var(--ui-border) 40%, transparent);
background: color-mix(in oklab, var(--ui-bg-elevated) 55%, transparent);
padding: 0.65rem 0.75rem;
font-size: 11px;
font-weight: 600;
color: var(--ui-text-toned);
}
.log-content {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
line-height: 1.6;
}
.log-content--nowrap {
width: max-content;
min-width: calc(100% - 5.5rem);
}
.log-tone-dot {
display: inline-flex;
width: 0.5rem;
min-width: 0.5rem;
height: 0.5rem;
margin-top: 0.45rem;
border-radius: 9999px;
}
.log-tone-dot--default {
background: color-mix(in oklab, var(--ui-text-toned) 55%, transparent);
}
.log-tone-dot--info {
background: var(--ui-info);
}
.log-tone-dot--warning {
background: var(--ui-warning);
}
.log-tone-dot--error {
background: var(--ui-error);
}
.log-line {
flex: 1;
min-width: 0;
}
.log-line--wrap {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.log-line--nowrap {
min-width: max-content;
white-space: pre;
}
.log-row--default .log-line {
color: var(--ui-text-highlighted);
}
.log-row--info .log-line {
color: color-mix(in oklab, var(--ui-info) 55%, var(--ui-text-highlighted) 45%);
}
.log-row--warning .log-line {
color: color-mix(in oklab, var(--ui-warning) 60%, var(--ui-text-highlighted) 40%);
}
.log-row--error .log-line {
color: color-mix(in oklab, var(--ui-error) 70%, var(--ui-text-highlighted) 30%);
}
</style>

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="notifications.length > 0"
color="neutral"
@ -53,7 +53,7 @@
:disabled="sendingTest"
@click="() => void sendTest()"
>
<span>Send Test</span>
<span>Test</span>
</UButton>
<UButton

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="presets.length > 0"
color="neutral"

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="definitions.length > 0"
color="neutral"
@ -130,8 +130,6 @@
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
</div>
<div

View file

@ -21,7 +21,7 @@
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
v-if="tasks.length > 0"
color="neutral"

View file

@ -10,7 +10,6 @@ export type SystemLimitsExtractor = {
export type SystemLimitsResponse = {
downloads: {
paused: boolean;
live_bypasses_limits: boolean;
global: {
limit: number;
active: number;

View file

@ -1,7 +1,15 @@
import { useLocalCache } from '~/utils/cache';
const KEY = 'video:';
const cache = useLocalCache();
let cache: ReturnType<typeof useLocalCache> | null = null;
const getCache = (): ReturnType<typeof useLocalCache> => {
if (!cache) {
cache = useLocalCache();
}
return cache;
};
const read = (id: string | null | undefined): number => {
if (!id) {
@ -9,7 +17,7 @@ const read = (id: string | null | undefined): number => {
}
try {
const time = Number(cache.get<number>(`${KEY}${id}`));
const time = Number(getCache().get<number>(`${KEY}${id}`));
return Number.isFinite(time) && time > 0 ? time : 0;
} catch {
return 0;
@ -21,7 +29,7 @@ const save = (id: string | null | undefined, time: number): void => {
return;
}
cache.set(`${KEY}${id}`, time, 3600 * 24);
getCache().set(`${KEY}${id}`, time, 3600 * 24);
};
const clear = (id: string | null | undefined): void => {
@ -29,7 +37,7 @@ const clear = (id: string | null | undefined): void => {
return;
}
cache.remove(`${KEY}${id}`);
getCache().remove(`${KEY}${id}`);
};
const nearEnd = (

View file

@ -12,24 +12,34 @@ const cache = {
}),
};
const useLocalCache = mock(() => cache);
mock.module('~/utils/cache', () => ({
useLocalCache: () => cache,
useLocalCache,
}));
let media: Awaited<typeof import('~/utils/media')>;
let importCacheCalls = 0;
beforeAll(async () => {
media = await import('~/utils/media');
importCacheCalls = useLocalCache.mock.calls.length;
});
beforeEach(() => {
store.clear();
useLocalCache.mockClear();
cache.get.mockClear();
cache.set.mockClear();
cache.remove.mockClear();
});
describe('media utils', () => {
it('defer_cache_init', () => {
expect(importCacheCalls).toBe(0);
expect(media.read('item-missing')).toBe(0);
});
it('clamp_seekable_range', () => {
const seekable = {
length: 1,