Merge pull request #607 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

Fix: recent change made fetch_info unpicklable
This commit is contained in:
Abdulmohsen 2026-05-24 22:07:41 +03:00 committed by GitHub
commit 8e14258c7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1324 additions and 668 deletions

81
API.md
View file

@ -59,7 +59,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
- [GET /api/thumbnail](#get-apithumbnail)
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
@ -1655,17 +1654,6 @@ Binary TS data (`Content-Type: video/mpegts`).
---
### GET /api/thumbnail
**Purpose**: Proxy/fetch a remote thumbnail image.
**Query Parameter**:
- `?url=<remote-thumbnail-url>`
**Response**:
Binary image data with the appropriate `Content-Type`.
---
### GET /api/file/ffprobe/{file:.*}
**Purpose**: Return the `ffprobe` data for a local file.
@ -2325,12 +2313,36 @@ Binary image data with appropriate headers
{
"logs": [
{
"timestamp": "2023-01-01T12:00:00Z",
"level": "INFO",
"message": "...",
...
},
...
"id": "<uuid>",
"datetime": "2026-05-18T12:00:00.000+00:00",
"level": "error",
"levelno": 40,
"logger": "downloads.queue",
"message": "Download failed",
"exception": {
"type": "ValueError",
"message": "bad",
"file": "/app/library/downloads/queue_manager.py",
"line": 123,
"stack": [
{
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
"module": "queue_manager",
"function": "start",
"line": 123
}
]
},
"source": {
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
"module": "queue_manager",
"function": "start",
"line": 123
},
"fields": {}
}
],
"offset": 0,
"limit": 100,
@ -2352,9 +2364,35 @@ Binary image data with appropriate headers
**Event Payload**:
```json
{
"id": "<sha256>",
"line": "<log line>",
"datetime": "2024-01-01T12:00:00.000000+00:00"
"id": "<uuid>",
"datetime": "2026-05-18T12:00:00.000+00:00",
"level": "error",
"levelno": 40,
"logger": "downloads.queue",
"message": "Download failed",
"exception": {
"type": "ValueError",
"message": "bad",
"file": "/app/library/downloads/queue_manager.py",
"line": 123,
"stack": [
{
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
"module": "queue_manager",
"function": "start",
"line": 123
}
]
},
"source": {
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
"module": "queue_manager",
"function": "start",
"line": 123
},
"fields": {}
}
```
@ -2602,7 +2640,6 @@ or an error:
{
"downloads": {
"paused": false,
"live_bypasses_limits": true,
"global": {
"limit": 20,
"active": 3,

View file

@ -19,18 +19,32 @@ LOG: logging.Logger = logging.getLogger("downloads.extractor")
def _ytdlp_logger(target: logging.Logger):
def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
return _YTDLPLogger(target)
class _YTDLPLogger:
def __init__(self, target: logging.Logger) -> None:
self.target = target
def __call__(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault("stacklevel", 4)
if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "):
target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
self.target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
return
if level <= logging.DEBUG:
target.info(msg, *args, **kwargs)
self.target.info(msg, *args, **kwargs)
return
target.log(level, msg, *args, **kwargs)
self.target.log(level, msg, *args, **kwargs)
return _log
class _LogCapture:
def __init__(self, logs: list[str]) -> None:
self.logs = logs
def __call__(self, _: int, msg: str, *args: Any, **__: Any) -> None:
self.logs.append(msg % args if args else msg)
def _get_process_pool_kwargs() -> dict[str, Any]:
@ -242,7 +256,7 @@ def extract_info_sync(
sanitize_info: bool = False,
capture_logs: int | None = None,
**kwargs,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
) -> tuple[dict[str, Any] | None, list[str]]:
"""
Extract video information from a URL.
@ -257,7 +271,7 @@ def extract_info_sync(
**kwargs: Additional arguments
Returns:
tuple[dict | None, list[dict]]: Extracted information and captured logs.
tuple[dict | None, list[str]]: Extracted information and captured logs.
"""
params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True}
@ -286,7 +300,7 @@ def extract_info_sync(
captured_logs: list[str] = kwargs.get("captured_logs", [])
if capture_logs is not None:
log_wrapper.add_target(
target=lambda _, msg: captured_logs.append(msg),
target=_LogCapture(captured_logs),
level=capture_logs,
name="log-capture",
)
@ -350,7 +364,7 @@ async def fetch_info(
extractor_config: ExtractorConfig | None = None,
budget_sleep: bool = False,
**kwargs,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
) -> tuple[dict[str, Any] | None, list[str]]:
"""
Extract video information from a URL.
@ -370,7 +384,7 @@ async def fetch_info(
**kwargs: Additional arguments
Returns:
tuple[dict | None, list[dict]]: Extracted information and captured logs.
tuple[dict | None, list[str]]: Extracted information and captured logs.
"""
if extractor_config is None:

View file

@ -1,13 +1,16 @@
import logging
import pickle
from unittest.mock import MagicMock, patch
from app.features.ytdlp.extractor import (
ExtractorConfig,
ExtractorPool,
_LogCapture,
_get_process_pool_kwargs,
_ytdlp_logger,
extract_info_sync,
)
from app.features.ytdlp.utils import LogWrapper
class TestProcessPoolConfiguration:
@ -162,11 +165,26 @@ class TestYtdlpLogger:
_ytdlp_logger(logger)(logging.DEBUG, "[debug] hello")
logger.debug.assert_called_once_with("hello")
logger.debug.assert_called_once_with("hello", stacklevel=4)
def test_screen_style_debug_uses_info(self) -> None:
logger = MagicMock()
_ytdlp_logger(logger)(logging.DEBUG, "screen line")
logger.info.assert_called_once_with("screen line")
logger.info.assert_called_once_with("screen line", stacklevel=4)
def test_targets_are_picklable(self) -> None:
logs: list[str] = []
wrapper = LogWrapper()
wrapper.add_target(_ytdlp_logger(logging.getLogger("yt-dlp.test")), level=logging.DEBUG, name="yt-dlp.test")
wrapper.add_target(_LogCapture(logs), level=logging.WARNING, name="log-capture")
pickle.dumps(wrapper)
def test_capture_formats_args(self) -> None:
logs: list[str] = []
_LogCapture(logs)(logging.WARNING, "hello %s", "world")
assert logs == ["hello world"]

View file

@ -90,6 +90,7 @@ class TestLogWrapper:
assert len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X"
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
assert len(calls) == 0
# WARNING hits both

View file

@ -131,7 +131,9 @@ class LogWrapper:
continue
if target.logger:
target.target.log(level, msg, *args, **kwargs)
log_kwargs = {**kwargs}
log_kwargs.setdefault("stacklevel", 3)
target.target.log(level, msg, *args, **log_kwargs)
else:
target.target(level, msg, *args, **kwargs)

View file

@ -2,11 +2,13 @@ import base64
import copy
import glob
import ipaddress
import json
import logging
import os
import re
import socket
import time
import traceback
import uuid
from datetime import UTC, datetime, timedelta
from functools import lru_cache, wraps
@ -40,6 +42,139 @@ class FileLogFormatter(logging.Formatter):
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
LOG_RECORD_ATTRS: set[str] = set(logging.makeLogRecord({}).__dict__) | {"asctime", "message"}
class JsonLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
source = {
"path": record.pathname,
"file": record.filename,
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
data: dict[str, Any] = {
"id": str(uuid.uuid4()),
"datetime": self.formatTime(record),
"level": record.levelname.lower(),
"levelno": record.levelno,
"logger": record.name,
"message": record.getMessage(),
"source": source,
"process": {"id": record.process, "name": record.processName},
"thread": {"id": record.thread, "name": record.threadName},
"fields": self._extra(record),
}
if record.exc_info:
exception = self._exception(record.exc_info)
if exception is not None:
data["exception"] = exception
data["source"].update(self._exception_source(exception))
return json.dumps(data, ensure_ascii=False, default=str)
def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
@staticmethod
def _extra(record: logging.LogRecord) -> dict[str, Any]:
extra: dict[str, Any] = {}
for key, value in record.__dict__.items():
if key in LOG_RECORD_ATTRS or key.startswith("_"):
continue
if isinstance(value, str | int | float | bool) or value is None:
extra[key] = value
return extra
@staticmethod
def _exception(
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
) -> dict[str, Any] | None:
exc = exc_info[1]
if exc is None:
return None
stack = JsonLogFormatter._exception_stack(exc_info)
data: dict[str, Any] = {"type": JsonLogFormatter._exception_type(exc)}
message = str(exc).strip()
if message:
data["message"] = message
if stack:
origin = stack[-1]
data["file"] = origin["path"]
data["line"] = origin["line"]
data["stack"] = stack
return data
@staticmethod
def _exception_type(exc: BaseException) -> str:
module = exc.__class__.__module__
name = exc.__class__.__qualname__
return name if module == "builtins" else f"{module}.{name}"
@staticmethod
def _exception_stack(
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
) -> list[dict[str, Any]]:
exc = exc_info[1]
tb = exc_info[2] if len(exc_info) > 2 else None
if tb is None and exc is not None:
tb = exc.__traceback__
if tb is None:
return []
return [JsonLogFormatter._frame(frame) for frame in traceback.extract_tb(tb)]
@staticmethod
def _frame(frame: traceback.FrameSummary) -> dict[str, Any]:
path = frame.filename
file_path = Path(path)
return {
"path": path,
"file": file_path.name,
"module": file_path.stem,
"function": frame.name,
"line": frame.lineno,
}
@staticmethod
def _exception_source(exception: dict[str, Any]) -> dict[str, Any]:
source: dict[str, Any] = {}
path = exception.get("file")
if isinstance(path, str) and path:
file_path = Path(path)
source["path"] = path
source["file"] = file_path.name
source["module"] = file_path.stem
line = exception.get("line")
if isinstance(line, int):
source["line"] = line
stack = exception.get("stack")
if isinstance(stack, list) and stack:
frame = stack[-1]
if isinstance(frame, dict):
function = frame.get("function")
if isinstance(function, str) and function:
source["function"] = function
module = frame.get("module")
if isinstance(module, str) and module:
source["module"] = module
return source
def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
"""
Decorator that applies an LRU cache with a time-to-live (TTL) to a function.

View file

@ -13,7 +13,7 @@ import coloredlogs
from dotenv import load_dotenv
from .Singleton import Singleton
from .Utils import FileLogFormatter
from .Utils import JsonLogFormatter
from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
if TYPE_CHECKING:
@ -466,14 +466,14 @@ class Config(metaclass=Singleton):
loggingPath.mkdir(parents=True, exist_ok=True)
handler = TimedRotatingFileHandler(
filename=loggingPath / "app.log",
filename=loggingPath / "app.jsonl",
when="midnight",
backupCount=3,
encoding="utf-8",
)
handler.setLevel(log_level_file)
formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s")
formatter = JsonLogFormatter()
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)

View file

@ -450,7 +450,7 @@ class DownloadQueue(metaclass=Singleton):
summary = ", ".join(deleted_titles[:5])
if deleted_count > 5:
summary += ", ..."
LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}")
LOG.info(f"Cleared '{deleted_count}' items. {summary}")
return {"deleted": deleted_count}
@ -469,7 +469,7 @@ class DownloadQueue(metaclass=Singleton):
title="History Cleared",
message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.",
)
LOG.info(f"Bulk cleared {deleted_count} history item(s) by status '{status_filter}'.")
LOG.info(f"Cleared '{deleted_count}' items. Filter '{status_filter}'.")
return {"deleted": deleted_count}
items = await self.done.get_many_by_status(status_filter)

View file

@ -1,10 +1,7 @@
import asyncio
import logging
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
@ -15,76 +12,31 @@ from app.library.cache import Cache
from app.library.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import route
from app.library.Utils import validate_url
LOG: logging.Logger = logging.getLogger(__name__)
IS_REQUESTING_BACKGROUND: bool = False
@route("GET", "api/thumbnail/", "get_thumbnail")
async def get_thumbnail(request: Request, config: Config) -> Response:
"""
Get the thumbnail.
Args:
request (Request): The request object.
config (Config): The configuration object.
Returns:
Response: The response object.
"""
url: str | None = request.query.get("url")
def _safe_url(url: str | None) -> str:
if not url:
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
return ""
try:
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
parsed = urlsplit(url)
except Exception:
return str(url)
try:
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
use_curl = resolve_curl_transport()
request_headers = 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")
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"
client = get_async_client(proxy=proxy, use_curl=use_curl)
LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(
method="GET",
url=url,
follow_redirects=True,
headers=request_headers,
timeout=10.0,
)
if response.status_code != web.HTTPOk.status_code:
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
return web.Response(
body=response.content,
headers={
"Content-Type": response.headers.get("Content-Type"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
),
},
)
except Exception as e:
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
)
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/random/background/", "get_background")
@ -110,7 +62,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 +88,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 +112,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 +125,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 +153,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 +165,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

@ -1,7 +1,7 @@
import asyncio
import json
import logging
import os
import re
from pathlib import Path
from aiohttp import web
@ -13,8 +13,38 @@ from app.library.router import route
LOG: logging.Logger = logging.getLogger(__name__)
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Match ISO8601."
def _parse_jsonl_line(line: bytes | str) -> dict | None:
raw: str = line.decode(errors="replace") if isinstance(line, bytes) else line
raw = raw.rstrip("\r\n")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
required = ("id", "datetime", "level", "logger", "message")
if any(not payload.get(key) for key in required):
return None
result: dict = {
"id": str(payload["id"]),
"datetime": str(payload["datetime"]),
"level": str(payload["level"]),
"logger": str(payload["logger"]),
"message": str(payload["message"]).strip(),
}
for key in ("levelno", "source", "process", "thread", "fields", "exception"):
if key not in payload:
continue
result[key] = payload[key]
return result
async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
@ -33,8 +63,6 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
- end_is_reached: True if there are no older logs.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
@ -68,17 +96,8 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
selected = lines[-(offset + limit) : -offset] if offset else lines[-limit:]
result.extend(log for line in selected if (log := _parse_jsonl_line(line)))
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception:
@ -95,8 +114,6 @@ async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
sleep_time (float): The time to sleep between reads.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
@ -111,16 +128,8 @@ async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
await asyncio.sleep(sleep_time)
continue
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
if log := _parse_jsonl_line(line):
await emitter(log)
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
@ -149,7 +158,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
limit = 50
logs_data = await _read_logfile(
file=Path(config.config_path) / "logs" / "app.log",
file=Path(config.config_path) / "logs" / "app.jsonl",
offset=offset,
limit=limit,
)
@ -175,7 +184,7 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
status=web.HTTPNotFound.status_code,
)
log_file = Path(config.config_path) / "logs" / "app.log"
log_file = Path(config.config_path) / "logs" / "app.jsonl"
if not log_file.exists():
return web.json_response(
data={"error": "Log file is not available."},

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
@ -26,28 +27,21 @@ class _Resp:
@pytest.mark.asyncio
async def test_thumb_thread(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg")
req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"})
config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"]
req = make_mocked_request("GET", "/api/random/background")
seen = {"to_thread": False, "validate": False}
class DummyCache:
def has(self, _key: str) -> bool:
return False
def fake_validate_url(url: str, allow_internal: bool = False) -> bool:
seen["validate"] = True
assert url == "https://example.com/a.jpg"
assert allow_internal is config.allow_internal_urls
return True
async def fake_to_thread(func, *args, **kwargs):
seen["to_thread"] = True
return func(*args, **kwargs)
async def aset(self, **_kwargs) -> None:
return None
client = AsyncMock()
client.request.return_value = _Resp()
client.request.side_effect = RuntimeError("boom")
monkeypatch.setattr(images, "validate_url", fake_validate_url)
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
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: {})
@ -67,31 +61,11 @@ async def test_thumb_thread(monkeypatch: pytest.MonkeyPatch) -> None:
),
)
response = await images.get_thumbnail(req, config)
with caplog.at_level(logging.ERROR):
response = await images.get_background(req, config, DummyCache())
assert response.status == web.HTTPOk.status_code
assert seen["to_thread"] is True
assert seen["validate"] is True
client.request.assert_awaited_once()
@pytest.mark.asyncio
async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
req = make_mocked_request("GET", "/api/thumbnail?url=https://bad.example/a.jpg")
req._rel_url = req._rel_url.with_query({"url": "https://bad.example/a.jpg"})
def fake_validate_url(_url: str, allow_internal: bool = False) -> bool:
assert allow_internal is config.allow_internal_urls
raise ValueError("Invalid hostname.")
async def fake_to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(images, "validate_url", fake_validate_url)
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
response = await images.get_thumbnail(req, config)
assert response.status == web.HTTPForbidden.status_code
assert response.text == '{"error": "Invalid hostname."}'
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,8 +1,11 @@
import asyncio
import copy
import importlib
import json
import logging
import re
import shutil
import sys
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta
@ -14,6 +17,7 @@ import pytest
from app.features.ytdlp.utils import arg_converter
from app.library.Utils import (
FileLogFormatter,
JsonLogFormatter,
calc_download_path,
check_id,
clean_item,
@ -278,6 +282,62 @@ class TestFileLogFormatter:
assert isinstance(formatter, FileLogFormatter)
class TestJsonLogFormatter:
def test_basic(self):
formatter = JsonLogFormatter()
record = logging.LogRecord("test.logger", logging.WARNING, __file__, 123, "hello %s", ("world",), None)
record.download_id = "abc"
record.payload = {"ignored": True}
data = json.loads(formatter.format(record))
assert uuid.UUID(data["id"])
assert data["level"] == "warning"
assert data["levelno"] == logging.WARNING
assert data["logger"] == "test.logger"
assert data["message"] == "hello world"
assert data["fields"] == {"download_id": "abc"}
assert data["source"]["line"] == 123
def test_exception(self):
formatter = JsonLogFormatter()
try:
raise ValueError("bad")
except ValueError:
record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), sys.exc_info())
data = json.loads(formatter.format(record))
assert data["message"] == "failed"
assert data["exception"] == {
"type": "ValueError",
"message": "bad",
"file": __file__,
"line": data["exception"]["line"],
"stack": data["exception"]["stack"],
}
assert data["exception"]["line"] > 0
assert data["exception"]["stack"][-1] == {
"path": __file__,
"file": Path(__file__).name,
"module": Path(__file__).stem,
"function": "test_exception",
"line": data["exception"]["line"],
}
assert data["source"] == data["exception"]["stack"][-1]
assert "exception_message" not in data
def test_no_raw_stack(self):
formatter = JsonLogFormatter()
record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), None)
record.stack_info = 'Stack (most recent call last):\n File "x", line 1, in y'
data = json.loads(formatter.format(record))
assert "stack" not in data
class TestCalcDownloadPath:
"""Test the calc_download_path function."""
@ -1524,12 +1584,84 @@ class TestReadLogfile:
def test_read_logfile_with_content(self):
"""Test reading log file with content."""
self.log_file.write_text("line 1\nline 2\nline 3\n")
lines = [
{
"id": "log-1",
"datetime": "2026-01-01T00:00:01.000+00:00",
"level": "info",
"logger": "test",
"message": "line 1",
},
{
"id": "log-2",
"datetime": "2026-01-01T00:00:02.000+00:00",
"level": "warning",
"logger": "test",
"message": "line 2",
},
{
"id": "log-3",
"datetime": "2026-01-01T00:00:03.000+00:00",
"level": "error",
"logger": "test",
"message": "line 3",
"exception": {
"type": "ValueError",
"message": "bad",
"file": "/tmp/test.py",
"line": 3,
"stack": [
{
"path": "/tmp/test.py",
"file": "test.py",
"module": "test",
"function": "run",
"line": 3,
}
],
},
},
]
self.log_file.write_text("\n".join(json.dumps(line) for line in lines) + "\n")
async def test():
result = await _read_logfile(self.log_file, limit=2)
assert isinstance(result, dict)
assert "logs" in result
assert [line["id"] for line in result["logs"]] == ["log-2", "log-3"]
assert all("line" not in line for line in result["logs"])
assert [line["message"] for line in result["logs"]] == ["line 2", "line 3"]
assert result["logs"][0]["level"] == "warning"
assert result["logs"][1]["exception"]["type"] == "ValueError"
assert result["next_offset"] == 2
assert result["end_is_reached"] is False
asyncio.run(test())
def test_read_logfile_malformed(self):
self.log_file.write_text("not-json\n")
async def test():
result = await _read_logfile(self.log_file, limit=1)
assert result["logs"] == []
asyncio.run(test())
def test_read_logfile_missing_id(self):
self.log_file.write_text(json.dumps({"message": "ignored"}) + "\n")
async def test():
result = await _read_logfile(self.log_file, limit=1)
assert result["logs"] == []
asyncio.run(test())
def test_read_logfile_missing_required(self):
self.log_file.write_text(json.dumps({"id": "ignored", "message": "ignored"}) + "\n")
async def test():
result = await _read_logfile(self.log_file, limit=1)
assert result["logs"] == []
asyncio.run(test())
@ -1564,6 +1696,40 @@ class TestTailLog:
asyncio.run(test())
def test_tail_log_jsonl(self):
self.log_file.write_text("")
emitted_lines = []
async def emitter(line):
emitted_lines.append(line)
raise asyncio.CancelledError
async def test():
task = asyncio.create_task(_tail_log(self.log_file, emitter, sleep_time=0.01))
await asyncio.sleep(0.02)
self.log_file.write_text(
json.dumps(
{
"id": "tail-1",
"datetime": "2026-01-01T00:00:00.000+00:00",
"level": "info",
"logger": "tail",
"message": "live line",
}
)
+ "\n"
)
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
asyncio.run(test())
assert emitted_lines[0]["message"] == "live line"
assert emitted_lines[0]["id"] == "tail-1"
assert "line" not in emitted_lines[0]
assert emitted_lines[0]["level"] == "info"
class TestLoadCookies:
"""Test the load_cookies function."""

View file

@ -1,201 +1,124 @@
<template>
<UModal
v-if="!externalModel"
:open="true"
:title="resolvedTitle"
:dismissible="true"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'p-0 overflow-hidden' }"
@update:open="(open) => !open && emitter('closeModel')"
>
<UModal v-model:open="modalOpen" :title="resolvedTitle" :ui="modalUi">
<template #description>
<span class="sr-only">{{ modalDescription }}</span>
</template>
<template #body>
<div class="flex h-[75vh] min-h-96 min-w-0 flex-col">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
>
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon :name="contentIcon" class="size-4 text-toned" />
<span>{{ contentLabel }}</span>
<UBadge color="neutral" variant="soft" size="sm">
{{ isStructuredData ? 'JSON' : 'Text' }}
</UBadge>
</div>
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
</div>
<div class="flex items-center gap-2">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
:disabled="isLoading || !hasDisplayText"
@click="wrapText = !wrapText"
>
{{ wrapText ? 'No wrap' : 'Wrap text' }}
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText"
@click="copyData"
>
Copy
</UButton>
</div>
<div class="space-y-4">
<div v-if="isLoading" class="flex min-h-80 items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-primary" />
</div>
<div class="flex-1 min-h-0 p-4 sm:p-5">
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
<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" />
<span class="text-sm">Loading information...</span>
</div>
</div>
<UAlert
v-else-if="errorMessage"
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load information"
:description="errorMessage"
/>
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
<div class="w-full max-w-2xl space-y-4">
<UAlert
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load information"
:description="errorMessage"
/>
<div v-else class="space-y-3">
<UInput
v-model="query"
type="search"
placeholder="Filter text"
icon="i-lucide-filter"
size="sm"
class="w-full"
/>
<div class="flex justify-end">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-rotate-ccw"
@click="() => void loadInfo()"
>
Retry
</UButton>
</div>
</div>
</div>
<UAlert
v-if="query && 0 === filteredLineCount"
color="warning"
variant="soft"
icon="i-lucide-filter"
title="No matching lines"
>
<template #description>
<p class="text-sm text-default">
No lines match this filter: <u>{{ query }}</u>
</p>
</template>
</UAlert>
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
<UAlert
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
class="w-full max-w-2xl"
<UAlert
v-else-if="!hasDisplayText"
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
/>
<div v-else class="overflow-hidden rounded-md border border-default bg-elevated/30">
<code
ref="contentView"
class="block max-h-[55vh] overflow-auto p-4 font-mono text-sm leading-6 text-default whitespace-pre-wrap wrap-break-word"
v-text="displayedText"
/>
</div>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
</div>
</div>
</template>
</UModal>
<div v-else class="flex h-[75vh] min-h-96 min-w-0 flex-col">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
>
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon :name="contentIcon" class="size-4 text-toned" />
<span>{{ contentLabel }}</span>
<UBadge color="neutral" variant="soft" size="sm">
{{ isStructuredData ? 'JSON' : 'Text' }}
</UBadge>
</div>
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
</div>
<div class="flex items-center gap-2">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
:disabled="isLoading || !hasDisplayText"
@click="wrapText = !wrapText"
>
{{ wrapText ? 'No wrap' : 'Wrap text' }}
</UButton>
<template #footer>
<div class="flex w-full flex-wrap items-center justify-end gap-2">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText"
:disabled="isLoading || !hasVisibleText"
@click="copyData"
>
Copy
</UButton>
</div>
</div>
<div class="flex-1 min-h-0 p-4 sm:p-5">
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
<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" />
<span class="text-sm">Loading information...</span>
</div>
</div>
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
<div class="w-full max-w-2xl space-y-4">
<UAlert
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load information"
:description="errorMessage"
/>
<div class="flex justify-end">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-rotate-ccw"
@click="() => void loadInfo()"
>
Retry
</UButton>
</div>
</div>
</div>
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
<UAlert
<UButton
type="button"
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
class="w-full max-w-2xl"
/>
</div>
variant="outline"
size="sm"
icon="i-lucide-arrow-down"
:disabled="isLoading || !hasVisibleText"
@click="scrollContent('end')"
>
Go down
</UButton>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
</div>
</div>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:disabled="!link"
:loading="isLoading"
@click="() => void loadInfo()"
>
Reload
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-up"
:disabled="isLoading || !hasVisibleText"
@click="scrollContent('start')"
>
Go up
</UButton>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core';
const toast = useNotification();
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
@ -205,25 +128,37 @@ const props = withDefaults(
preset?: string;
cli?: string;
useUrl?: boolean;
externalModel?: boolean;
code_classes?: string;
}>(),
{
link: '',
preset: '',
cli: '',
useUrl: false,
externalModel: false,
code_classes: '',
},
);
const isLoading = ref(true);
const data = ref<unknown>(null);
const errorMessage = ref('');
const wrapText = useStorage<boolean>('get_info_wrap_text', false);
const query = ref('');
const contentView = ref<HTMLElement | null>(null);
let latestRequestId = 0;
const modalUi = {
content: 'w-full sm:max-w-5xl',
body: 'p-4 sm:p-5',
footer: 'px-4 pb-4 sm:px-5 sm:pb-5',
} as const;
const modalOpen = computed({
get: () => true,
set: (value: boolean) => {
if (!value) {
emitter('closeModel');
}
},
});
const resolvedTitle = computed(() => {
if (props.useUrl && props.link.startsWith('/api/history/')) {
return 'Local Information';
@ -264,44 +199,29 @@ const displayText = computed(() => {
}
});
const lines = computed<Array<string>>(() => {
if (!displayText.value) {
return [];
}
return displayText.value.split('\n');
});
const filteredLines = computed<Array<string>>(() => {
if (!query.value) {
return lines.value;
}
const needle = query.value.toLowerCase();
return lines.value.filter((line) => line.toLowerCase().includes(needle));
});
const filteredLineCount = computed(() => filteredLines.value.length);
const displayedText = computed(() =>
query.value ? filteredLines.value.join('\n') : displayText.value,
);
const hasDisplayText = computed(() => displayText.value.length > 0);
const isStructuredData = computed(() => {
return data.value !== null && data.value !== undefined && typeof data.value !== 'string';
});
const contentIcon = computed(() => {
return isStructuredData.value ? 'i-lucide-braces' : 'i-lucide-file-text';
});
const contentLabel = computed(() => {
return isStructuredData.value ? 'Structured output' : 'Plain text output';
});
const sourceSummary = computed(() => {
if (props.useUrl) {
return props.link || 'Direct response source';
}
const details = [];
if (props.preset) {
details.push(`Preset: ${props.preset}`);
}
if (props.cli) {
details.push('Custom yt-dlp args applied');
}
return details.join(' | ') || 'yt-dlp response payload';
});
const preClasses = computed(() => [
'h-full max-h-full overflow-auto rounded-xl border border-default bg-elevated/40 p-4 text-sm text-default',
'font-mono leading-6',
wrapText.value ? 'whitespace-pre-wrap break-words' : 'whitespace-pre',
props.code_classes,
]);
const hasVisibleText = computed(() => displayedText.value.length > 0);
const buildRequestUrl = (): string => {
if (props.useUrl) {
@ -348,6 +268,7 @@ const loadInfo = async (): Promise<void> => {
latestRequestId += 1;
const requestId = latestRequestId;
query.value = '';
isLoading.value = true;
errorMessage.value = '';
data.value = null;
@ -388,13 +309,32 @@ const loadInfo = async (): Promise<void> => {
};
const copyData = (): void => {
if (!displayText.value) {
if (!displayedText.value) {
return;
}
copyText(displayText.value);
copyText(displayedText.value, false);
};
const scrollContent = (dir: 'start' | 'end'): void => {
if (!contentView.value) {
return;
}
contentView.value.scrollTo({
top: 'start' === dir ? 0 : contentView.value.scrollHeight,
behavior: 'smooth',
});
};
watch(query, () => {
if (!contentView.value) {
return;
}
contentView.value.scrollTo({ top: 0 });
});
watch(
() => [props.link, props.preset, props.cli, props.useUrl],
() => {

View file

@ -8,24 +8,40 @@ img {
</style>
<template>
<div>
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center">
<div class="relative min-h-[50vh]">
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" />
</div>
<div v-else>
<img :src="image" />
<div v-else-if="hasError" class="flex min-h-[50vh] items-center justify-center">
<UAlert
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load image"
description="The preview could not be loaded."
class="w-full max-w-2xl"
/>
</div>
<img
v-if="link"
:src="link"
alt="Image preview"
:class="{ invisible: isLoading || hasError }"
@load="handle_load"
@error="handle_error"
/>
</div>
</template>
<script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils';
const toast = useNotification();
const emitter = defineEmits(['closeModel']);
const isLoading = ref<boolean>(false);
const image = ref<string>('');
const isLoading = ref<boolean>(true);
const hasError = ref<boolean>(false);
const props = defineProps({
link: {
@ -41,36 +57,28 @@ const handle_event = (e: KeyboardEvent) => {
emitter('closeModel');
};
onMounted(async () => {
const handle_load = () => {
isLoading.value = false;
hasError.value = false;
};
const handle_error = () => {
isLoading.value = false;
hasError.value = true;
};
onMounted(() => {
disableOpacity();
document.addEventListener('keydown', handle_event);
const url = props.link.startsWith('/')
? props.link
: '/api/thumbnail?url=' + encodePath(props.link);
try {
isLoading.value = true;
const imgRequest = await request(url);
if (200 !== imgRequest.status) {
return;
}
image.value = URL.createObjectURL(await imgRequest.blob());
} catch (e: any) {
console.error(e);
toast.error(`Error: ${e.message}`);
} finally {
if (!props.link) {
hasError.value = true;
isLoading.value = false;
}
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event);
if (image.value) {
URL.revokeObjectURL(image.value);
}
enableOpacity();
});
</script>

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'"
@ -491,7 +491,7 @@
</div>
<UModal
v-if="model_item"
v-if="model_item && model_item.type !== 'text'"
:open="previewOpen"
:title="previewTitle"
:dismissible="true"
@ -511,14 +511,6 @@
@playback-state-change="(playing: boolean) => (playingNow = playing)"
/>
<GetInfo
v-else-if="model_item?.type === 'text'"
:link="model_item.filename"
:useUrl="true"
:externalModel="true"
@closeModel="closeModel"
/>
<ImageView
v-else-if="model_item?.type === 'image'"
:link="model_item.filename"
@ -526,6 +518,13 @@
/>
</template>
</UModal>
<GetInfo
v-if="model_item?.type === 'text'"
:link="model_item.filename"
:useUrl="true"
@closeModel="closeModel"
/>
</div>
</template>

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
@ -64,6 +64,22 @@
Wrap
</UButton>
<USelect
v-model="selectedLevels"
:items="levelFilterItems"
value-key="value"
label-key="label"
multiple
size="sm"
icon="i-lucide-list-filter"
class="w-44 shrink-0 sm:w-48"
:ui="{ content: 'min-w-48' }"
>
<template #default>
{{ levelFilterLabel }}
</template>
</USelect>
<UInput
v-if="toggleFilter || query"
id="filter"
@ -82,7 +98,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,32 +106,80 @@
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>
<div
v-if="canLoadFilteredHistory"
class="flex justify-center border-b border-default/40 px-4 py-3"
>
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-history"
:loading="loading"
@click="fetchLogs(true)"
>
Load older lines into filter
</UButton>
</div>
<template v-if="filteredLogs.length > 0">
<article
v-for="(entry, index) in filteredLogs"
: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)}`"
/>
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
{{ entry.log.line }}
<p :class="logLineClass()">
<span
class="inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 align-middle"
>
<UTooltip :text="logTimeTitle(entry.log.datetime)">
<span class="inline text-[11px] font-semibold text-toned cursor-pointer">
{{ logTimeLabel(entry.log.datetime) }}
</span>
</UTooltip>
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-panel-right-open"
aria-label="Open log details"
class="inline-flex align-[-0.2em] opacity-70 hover:opacity-100"
@click="openLogDetails(entry.log)"
/>
<span
:class="logLevelBadgeClass(getLogLevel(entry.log.level))"
@click="openLogDetails(entry.log)"
>
<UIcon
:name="LOG_LEVEL_ICON[getLogLevel(entry.log.level)]"
class="size-3"
/>
{{ getLogLevel(entry.log.level) }}
</span>
<span
v-if="entry.log.logger"
:title="entry.log.logger"
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
>[{{ entry.log.logger }}]</span
>
</span>
<span class="ml-2">{{ entry.log.message }}</span>
<span v-if="exceptionSummary(entry.log)" class="ml-1 text-error/90">
: {{ exceptionSummary(entry.log) }}
</span>
</p>
</div>
</article>
@ -126,21 +190,17 @@
class="flex min-h-[55vh] flex-col items-center justify-center gap-3 px-6 py-8 text-center font-sans"
>
<UIcon
:name="query ? 'i-lucide-filter-x' : 'i-lucide-circle-off'"
:name="hasActiveFilter ? 'i-lucide-filter-x' : 'i-lucide-circle-off'"
class="size-6 text-toned"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-default">
{{ query ? 'No logs match this query' : 'No log lines available' }}
{{ emptyStateTitle }}
</p>
<p class="text-sm text-toned">
{{
query
? `No log lines found for the query: ${query}. Please try a different search term.`
: 'No log lines are available yet.'
}}
{{ emptyStateDescription }}
</p>
</div>
</div>
@ -148,6 +208,176 @@
</div>
</template>
</UPageCard>
<UModal v-model:open="detailsOpen" title="Log details" :ui="detailsModalUi">
<template #body>
<div v-if="selectedLog" class="space-y-5">
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<UBadge
:color="LOG_LEVEL_COLOR[getLogLevel(selectedLog.level)]"
variant="soft"
size="sm"
class="uppercase"
>
<UIcon
:name="LOG_LEVEL_ICON[getLogLevel(selectedLog.level)]"
class="mr-1 size-3.5"
/>
{{ getLogLevel(selectedLog.level) }}
</UBadge>
<UBadge
v-if="selectedLog.logger"
color="neutral"
variant="soft"
size="sm"
class="max-w-full min-w-0"
:title="selectedLog.logger"
>
<span class="min-w-0 max-w-full truncate">{{ selectedLog.logger }}</span>
</UBadge>
<span class="text-xs text-toned">{{ logTimeTitle(selectedLog.datetime) }}</span>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-2">
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-copy"
@click="copyLogMessage(selectedLog)"
>
Message
</UButton>
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-braces"
@click="copyLogRaw(selectedLog)"
>
JSON
</UButton>
</div>
<div class="min-w-0 space-y-2 sm:col-span-2">
<p class="wrap-break-word w-full font-mono text-sm text-default">
{{ selectedLog.message }}
</p>
<UAlert
v-if="exceptionSummary(selectedLog)"
color="error"
variant="soft"
icon="i-lucide-badge-alert"
:title="exceptionSummary(selectedLog)"
class="w-full"
/>
</div>
</div>
<section v-if="selectedLog.exception" class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="exceptionOpen = !exceptionOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', exceptionOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-bug" class="size-4 text-error" />
Exception
</button>
<pre
v-if="exceptionOpen"
class="max-h-72 overflow-auto rounded-sm border border-error/30 bg-error/5 p-3 text-xs whitespace-pre-wrap text-error"
>{{ exceptionText(selectedLog) }}</pre
>
</section>
<section v-if="detailRows(selectedLog).length > 0" class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="sourceOpen = !sourceOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', sourceOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-file-code" class="size-4 text-info" />
Source
</button>
<dl v-if="sourceOpen" class="grid gap-2 sm:grid-cols-2">
<div
v-for="row in detailRows(selectedLog)"
:key="row.label"
class="rounded-sm border border-default bg-elevated/40 p-3"
>
<dt
class="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-toned"
>
<UIcon :name="row.icon" class="size-3.5" />
<span>{{ row.label }}</span>
</dt>
<dd class="mt-1 wrap-break-word font-mono text-xs text-default">{{ row.value }}</dd>
</div>
</dl>
</section>
<section v-if="fieldRows(selectedLog).length > 0" class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="fieldsOpen = !fieldsOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', fieldsOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-tags" class="size-4 text-primary" />
Fields
</button>
<dl v-if="fieldsOpen" class="grid gap-2 sm:grid-cols-2">
<div
v-for="row in fieldRows(selectedLog)"
:key="row.label"
class="rounded-sm border border-default bg-elevated/40 p-3"
>
<dt
class="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-toned"
>
<UIcon :name="row.icon" class="size-3.5" />
<span>{{ row.label }}</span>
</dt>
<dd class="mt-1 wrap-break-word font-mono text-xs text-default">{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="rawJsonOpen = !rawJsonOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', rawJsonOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
RAW DATA
</button>
<pre
v-if="rawJsonOpen"
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
>{{ logRaw(selectedLog) }}</pre
>
</section>
</div>
</template>
</UModal>
</main>
</template>
@ -157,18 +387,44 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs';
import { parse_api_error, request, uri } from '~/utils';
import { copyText, parse_api_error, request, uri } from '~/utils';
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';
type LogLevelColor = 'neutral' | 'info' | 'warning' | 'error';
type DetailRow = {
label: string;
value: string;
icon: string;
};
type LevelFilterItem = {
label: string;
value: LogLevel;
};
const FILTER_CONTEXT_REGEX = /context:(\d+)/;
const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warning', 'error'];
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_COLOR: Record<LogLevel, LogLevelColor> = {
debug: 'neutral',
info: 'info',
warning: 'warning',
error: 'error',
};
const LOG_LEVEL_ICON: Record<LogLevel, string> = {
debug: 'i-lucide-terminal',
info: 'i-lucide-info',
warning: 'i-lucide-triangle-alert',
error: 'i-lucide-circle-x',
};
let scrollTimeout: NodeJS.Timeout | null = null;
@ -179,13 +435,20 @@ const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const textWrap = useStorage<boolean>('logs_wrap', true);
const exceptionOpen = useStorage<boolean>('logs_exception_open', false);
const fieldsOpen = useStorage<boolean>('logs_fields_open', true);
const rawJsonOpen = useStorage<boolean>('logs_raw_json_open', false);
const sourceOpen = useStorage<boolean>('logs_source_open', true);
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
const sseController = ref<AbortController | null>(null);
const logs = ref<Array<log_line>>([]);
const selectedLog = ref<log_line | null>(null);
const offset = ref(0);
const loading = ref(false);
const autoScroll = ref(true);
const reachedEnd = ref(false);
const detailsOpen = ref(false);
const pageCardUi = {
root: 'w-full min-w-0 max-w-full bg-transparent',
@ -193,6 +456,10 @@ const pageCardUi = {
wrapper: 'w-full min-w-0 items-stretch',
body: 'w-full min-w-0 max-w-full overflow-hidden',
};
const detailsModalUi = {
content: 'max-w-5xl',
body: 'max-h-[75vh] overflow-y-auto',
};
const query = ref<string>(
(() => {
@ -216,12 +483,75 @@ const query = ref<string>(
const toggleFilter = ref(Boolean(query.value));
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
const selectedLevelSet = computed(
() => new Set(LOG_LEVELS.filter((level) => selectedLevels.value.includes(level))),
);
const hasLevelFilter = computed(() => selectedLevelSet.value.size !== LOG_LEVELS.length);
const filterContext = computed(() => {
const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX);
return match ? parseInt(match[1] ?? '0', 10) : 0;
});
const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim());
const hasActiveFilter = computed(() => Boolean(searchTerm.value));
const hasTextFilter = computed(() => Boolean(searchTerm.value));
const hasActiveFilter = computed(() => hasTextFilter.value || hasLevelFilter.value);
const canLoadFilteredHistory = computed(
() => hasActiveFilter.value && !reachedEnd.value && logs.value.length > 0,
);
const levelCounts = computed<Record<LogLevel, number>>(() => {
const counts: Record<LogLevel, number> = {
debug: 0,
info: 0,
warning: 0,
error: 0,
};
logs.value.forEach((log) => {
const level = getLogLevel(log.level);
counts[level] += 1;
});
return counts;
});
const levelFilterItems = computed<LevelFilterItem[]>(() => [
...LOG_LEVELS.map((level) => ({
label: `${level.charAt(0).toUpperCase()}${level.slice(1)} (${levelCounts.value[level]})`,
value: level,
})),
]);
const levelFilterLabel = computed(() => {
if (selectedLevelSet.value.size === LOG_LEVELS.length) {
return `All levels (${logs.value.length})`;
}
if (selectedLevelSet.value.size === 0) {
return 'No levels selected';
}
return LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)).join(', ');
});
const activeFilterLabel = computed(() => {
const parts: string[] = [];
if (hasTextFilter.value) {
parts.push(`query "${searchTerm.value}"`);
}
if (hasLevelFilter.value) {
const levels = LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level));
parts.push(levels.length ? `levels ${levels.join(', ')}` : 'no selected levels');
}
return parts.join(' and ');
});
const emptyStateTitle = computed(() =>
hasActiveFilter.value ? 'No logs match these filters' : 'No log lines available',
);
const emptyStateDescription = computed(() => {
if (!hasActiveFilter.value) {
return 'No log lines are available yet.';
}
return `No loaded log lines match ${activeFilterLabel.value}. Adjust filters or load older lines.`;
});
watch(toggleFilter, () => {
if (!toggleFilter.value) {
query.value = '';
@ -240,9 +570,20 @@ watch(
},
);
watch(detailsOpen, (open) => {
if (!open) {
selectedLog.value = null;
}
});
const filteredLogs = computed<FilteredLogEntry[]>(() => {
if (!hasActiveFilter.value) {
return logs.value.map((log) => ({ log, isMatch: false, isContext: false }));
return logs.value.map((log) => ({
log,
level: getLogLevel(log.level),
isMatch: false,
isContext: false,
}));
}
const result: Array<FilteredLogEntry> = [];
@ -250,7 +591,16 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
const matchedIndexes = new Set<number>();
logs.value.forEach((log, index) => {
if (log.line.toLowerCase().includes(searchTerm.value)) {
if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
return;
}
if (!hasTextFilter.value) {
visibleIndexes.add(index);
return;
}
if (searchableLog(log).includes(searchTerm.value)) {
matchedIndexes.add(index);
for (
@ -266,8 +616,14 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
Array.from(visibleIndexes)
.sort((a, b) => a - b)
.forEach((index) => {
const log = logs.value[index] as log_line;
if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
return;
}
result.push({
log: logs.value[index] as log_line,
log,
level: getLogLevel(log.level),
isMatch: matchedIndexes.has(index),
isContext: !matchedIndexes.has(index),
});
@ -289,10 +645,10 @@ const scrollLogContainerToBottom = async (behavior: ScrollBehavior = 'auto'): Pr
});
};
const fetchLogs = async (): Promise<void> => {
const fetchLogs = async (force = false): Promise<void> => {
loading.value = true;
if (reachedEnd.value || (hasActiveFilter.value && logs.value.length > 0)) {
if (reachedEnd.value || (!force && hasActiveFilter.value && logs.value.length > 0)) {
loading.value = false;
return;
}
@ -440,49 +796,171 @@ 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();
const logRaw = (log: log_line): string => JSON.stringify(log, null, 2);
if (/error|failed|exception|traceback|fatal/.test(normalized)) {
return 'error';
const exceptionSummary = (log: log_line): string => {
const type = log.exception?.type?.trim() ?? '';
const message = log.exception?.message?.trim() ?? '';
if (type && message) {
return `${type}: ${message}`;
}
if (/warn|deprecated|retry/.test(normalized)) {
return 'warning';
}
if (/info|started|connected|listening|ready/.test(normalized)) {
return 'info';
}
return 'default';
return type || message;
};
const exceptionText = (log: log_line): string =>
log.exception ? JSON.stringify(log.exception, null, 2) : '';
const searchableLog = (log: log_line): string =>
[
log.message,
log.level,
log.logger,
exceptionSummary(log),
log.exception ? JSON.stringify(log.exception) : '',
log.source ? JSON.stringify(log.source) : '',
log.process ? JSON.stringify(log.process) : '',
log.thread ? JSON.stringify(log.thread) : '',
log.fields ? JSON.stringify(log.fields) : '',
]
.filter(Boolean)
.join(' ')
.toLowerCase();
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';
}
};
const logLevelBadgeClass = (level: LogLevel): string[] => [
'inline-flex items-center gap-1.5 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide cursor-pointer',
level === 'debug' ? 'bg-muted/40 text-muted' : '',
level === 'info' ? 'bg-info/10 text-info' : '',
level === 'warning' ? 'bg-warning/10 text-warning' : '',
level === 'error' ? 'bg-error/10 text-error' : '',
];
const logLineClass = (): string[] => [
'flex-1',
textWrap.value
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
: 'min-w-max whitespace-pre',
'text-default',
];
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;
};
const openLogDetails = (log: log_line): void => {
selectedLog.value = log;
detailsOpen.value = true;
};
const formatDetailValue = (value: unknown): string => {
if (value === undefined || value === null || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
};
const compactRows = (rows: Array<{ label: string; value: unknown; icon: string }>): DetailRow[] =>
rows
.map((row) => ({
label: row.label,
value: formatDetailValue(row.value),
icon: row.icon,
}))
.filter((row) => Boolean(row.value));
const formatNameId = (name: unknown, id: unknown): string => {
const nameValue = formatDetailValue(name);
const idValue = formatDetailValue(id);
if (nameValue && idValue) {
return `${nameValue} / ${idValue}`;
}
return nameValue || idValue;
};
const detailRows = (log: log_line): DetailRow[] =>
compactRows([
{ label: 'File', value: log.source?.file, icon: 'i-lucide-file' },
{ label: 'Line', value: log.source?.line, icon: 'i-lucide-hash' },
{ label: 'Function', value: log.source?.function, icon: 'i-lucide-code-2' },
{ label: 'Module', value: log.source?.module, icon: 'i-lucide-box' },
{ label: 'Path', value: log.source?.path, icon: 'i-lucide-folder-tree' },
{
label: 'Process / ID',
value: formatNameId(log.process?.name, log.process?.id),
icon: 'i-lucide-cpu',
},
{
label: 'Thread / ID',
value: formatNameId(log.thread?.name, log.thread?.id),
icon: 'i-lucide-git-branch',
},
]);
const fieldRows = (log: log_line): DetailRow[] =>
compactRows(
Object.entries(log.fields ?? {}).map(([label, value]) => ({
label,
value,
icon: 'i-lucide-tag',
})),
);
const copyLogMessage = (log: log_line): void => {
copyText(log.message);
};
const copyLogRaw = (log: log_line): void => {
copyText(logRaw(log));
};
onMounted(async () => {
if (!config.app.file_logging) {
await navigateTo('/');
@ -502,132 +980,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,49 @@
type log_line = {
id: number;
line: string;
datetime?: string;
type log_source = {
path?: string;
file?: string;
module?: string;
function?: string;
line?: number;
};
export type { log_line };
type log_process = {
id?: number | string;
name?: string;
};
type log_thread = {
id?: number | string;
name?: string;
};
type log_exception_frame = {
path?: string;
file?: string;
module?: string;
function?: string;
line?: number;
};
type log_exception = {
type: string;
message?: string;
file?: string;
line?: number;
stack?: Array<log_exception_frame>;
};
type log_line = {
id: string;
message: string;
datetime: string;
level: string;
levelno?: number;
logger: string;
source?: log_source;
process?: log_process;
thread?: log_thread;
fields?: Record<string, unknown>;
exception?: log_exception | null;
};
export type { log_exception, log_exception_frame, log_line, log_process, log_source, log_thread };

View file

@ -873,7 +873,7 @@ const getPath = (basePath: string, item: StoreItem): string => {
const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => {
if (item?.extras?.thumbnail) {
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail));
return uri(item.extras.thumbnail);
}
return fallback ? uri('/images/placeholder.png') : '';

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

@ -168,7 +168,7 @@ const NavItems: Array<NavDefinition> = [
group: 'tools',
label: 'Logs',
pageLabel: 'Logs',
description: 'Scroll near the top to load older logs.',
description: 'Scroll up to load older logs.',
icon: 'i-lucide-file-text',
to: '/logs',
matchPath: '/logs',

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,