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/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile) - [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile) - [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/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath) - [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:.*} ### GET /api/file/ffprobe/{file:.*}
**Purpose**: Return the `ffprobe` data for a local file. **Purpose**: Return the `ffprobe` data for a local file.
@ -2325,12 +2313,36 @@ Binary image data with appropriate headers
{ {
"logs": [ "logs": [
{ {
"timestamp": "2023-01-01T12:00:00Z", "id": "<uuid>",
"level": "INFO", "datetime": "2026-05-18T12:00:00.000+00:00",
"message": "...", "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, "offset": 0,
"limit": 100, "limit": 100,
@ -2352,9 +2364,35 @@ Binary image data with appropriate headers
**Event Payload**: **Event Payload**:
```json ```json
{ {
"id": "<sha256>", "id": "<uuid>",
"line": "<log line>", "datetime": "2026-05-18T12:00:00.000+00:00",
"datetime": "2024-01-01T12:00:00.000000+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": { "downloads": {
"paused": false, "paused": false,
"live_bypasses_limits": true,
"global": { "global": {
"limit": 20, "limit": 20,
"active": 3, "active": 3,

View file

@ -19,18 +19,32 @@ LOG: logging.Logger = logging.getLogger("downloads.extractor")
def _ytdlp_logger(target: logging.Logger): 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] "): 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 return
if level <= logging.DEBUG: if level <= logging.DEBUG:
target.info(msg, *args, **kwargs) self.target.info(msg, *args, **kwargs)
return 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]: def _get_process_pool_kwargs() -> dict[str, Any]:
@ -242,7 +256,7 @@ def extract_info_sync(
sanitize_info: bool = False, sanitize_info: bool = False,
capture_logs: int | None = None, capture_logs: int | None = None,
**kwargs, **kwargs,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: ) -> tuple[dict[str, Any] | None, list[str]]:
""" """
Extract video information from a URL. Extract video information from a URL.
@ -257,7 +271,7 @@ def extract_info_sync(
**kwargs: Additional arguments **kwargs: Additional arguments
Returns: 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} 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", []) captured_logs: list[str] = kwargs.get("captured_logs", [])
if capture_logs is not None: if capture_logs is not None:
log_wrapper.add_target( log_wrapper.add_target(
target=lambda _, msg: captured_logs.append(msg), target=_LogCapture(captured_logs),
level=capture_logs, level=capture_logs,
name="log-capture", name="log-capture",
) )
@ -350,7 +364,7 @@ async def fetch_info(
extractor_config: ExtractorConfig | None = None, extractor_config: ExtractorConfig | None = None,
budget_sleep: bool = False, budget_sleep: bool = False,
**kwargs, **kwargs,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: ) -> tuple[dict[str, Any] | None, list[str]]:
""" """
Extract video information from a URL. Extract video information from a URL.
@ -370,7 +384,7 @@ async def fetch_info(
**kwargs: Additional arguments **kwargs: Additional arguments
Returns: 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: if extractor_config is None:

View file

@ -1,13 +1,16 @@
import logging import logging
import pickle
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from app.features.ytdlp.extractor import ( from app.features.ytdlp.extractor import (
ExtractorConfig, ExtractorConfig,
ExtractorPool, ExtractorPool,
_LogCapture,
_get_process_pool_kwargs, _get_process_pool_kwargs,
_ytdlp_logger, _ytdlp_logger,
extract_info_sync, extract_info_sync,
) )
from app.features.ytdlp.utils import LogWrapper
class TestProcessPoolConfiguration: class TestProcessPoolConfiguration:
@ -162,11 +165,26 @@ class TestYtdlpLogger:
_ytdlp_logger(logger)(logging.DEBUG, "[debug] hello") _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: def test_screen_style_debug_uses_info(self) -> None:
logger = MagicMock() logger = MagicMock()
_ytdlp_logger(logger)(logging.DEBUG, "screen line") _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 len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X" assert cap.records[0].getMessage() == "hello X"
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
assert len(calls) == 0 assert len(calls) == 0
# WARNING hits both # WARNING hits both

View file

@ -131,7 +131,9 @@ class LogWrapper:
continue continue
if target.logger: 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: else:
target.target(level, msg, *args, **kwargs) target.target(level, msg, *args, **kwargs)

View file

@ -2,11 +2,13 @@ import base64
import copy import copy
import glob import glob
import ipaddress import ipaddress
import json
import logging import logging
import os import os
import re import re
import socket import socket
import time import time
import traceback
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from functools import lru_cache, wraps from functools import lru_cache, wraps
@ -40,6 +42,139 @@ class FileLogFormatter(logging.Formatter):
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") 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): 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. 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 dotenv import load_dotenv
from .Singleton import Singleton 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 from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
if TYPE_CHECKING: if TYPE_CHECKING:
@ -466,14 +466,14 @@ class Config(metaclass=Singleton):
loggingPath.mkdir(parents=True, exist_ok=True) loggingPath.mkdir(parents=True, exist_ok=True)
handler = TimedRotatingFileHandler( handler = TimedRotatingFileHandler(
filename=loggingPath / "app.log", filename=loggingPath / "app.jsonl",
when="midnight", when="midnight",
backupCount=3, backupCount=3,
encoding="utf-8", encoding="utf-8",
) )
handler.setLevel(log_level_file) handler.setLevel(log_level_file)
formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s") formatter = JsonLogFormatter()
handler.setFormatter(formatter) handler.setFormatter(formatter)
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)

View file

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

View file

@ -1,10 +1,7 @@
import asyncio
import logging import logging
import random import random
import time
from datetime import UTC, datetime
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse, urlsplit, urlunsplit
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response 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.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport 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.router import route
from app.library.Utils import validate_url
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
IS_REQUESTING_BACKGROUND: bool = False IS_REQUESTING_BACKGROUND: bool = False
@route("GET", "api/thumbnail/", "get_thumbnail") def _safe_url(url: str | None) -> str:
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")
if not url: if not url:
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code) return ""
try: try:
await asyncio.to_thread(validate_url, url, config.allow_internal_urls) parsed = urlsplit(url)
except ValueError as e: except Exception:
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code) return str(url)
try: netloc: str = parsed.netloc
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() if parsed.username or parsed.password:
use_curl = resolve_curl_transport() host: str = parsed.hostname or ""
request_headers = build_request_headers( if parsed.port:
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())), host: str = f"{host}:{parsed.port}"
use_curl=use_curl, netloc: str = f"redacted:redacted@{host}" if host else "redacted:redacted"
)
proxy = ytdlp_args.get("proxy")
client = get_async_client(proxy=proxy, use_curl=use_curl) query: str = "redacted" if parsed.query else ""
LOG.debug(f"Fetching thumbnail from '{url}'.") fragment: str = "redacted" if parsed.fragment else ""
response = await client.request( return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment))
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
)
@route("GET", "api/random/background/", "get_background") @route("GET", "api/random/background/", "get_background")
@ -110,7 +62,8 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
try: try:
IS_REQUESTING_BACKGROUND = True 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_BING = "random_background_bing"
CACHE_KEY = "random_background" 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() ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
use_curl = resolve_curl_transport() use_curl: bool = resolve_curl_transport()
request_headers = build_request_headers( request_headers: dict = build_request_headers(
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())), user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
use_curl=use_curl, 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) client = get_async_client(proxy=proxy, use_curl=use_curl)
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"): 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, 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) await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
else: else:
backend = await cache.aget(CACHE_KEY_BING) 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: if not isinstance(backend, str) or not backend:
return web.json_response( return web.json_response(
@ -170,7 +125,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
status=web.HTTPInternalServerError.status_code, 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( response = await client.request(
method="GET", 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) 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( return web.Response(
body=data.get("content"), body=data.get("content"),
@ -210,7 +165,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
}, },
) )
except Exception as e: 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( return web.json_response(
data={"error": "failed to retrieve the random background image."}, data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,

View file

@ -1,7 +1,7 @@
import asyncio import asyncio
import json
import logging import logging
import os import os
import re
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
@ -13,8 +13,38 @@ from app.library.router import route
LOG: logging.Logger = logging.getLogger(__name__) 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: 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. - end_is_reached: True if there are no older logs.
""" """
from hashlib import sha256
from anyio import open_file from anyio import open_file
if not file.exists(): if not file.exists():
@ -68,17 +96,8 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
next_offset = None next_offset = None
end_is_reached = True end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]: selected = lines[-(offset + limit) : -offset] if offset else lines[-limit:]
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode() result.extend(log for line in selected if (log := _parse_jsonl_line(line)))
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,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached} return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception: 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. sleep_time (float): The time to sleep between reads.
""" """
from hashlib import sha256
from anyio import open_file from anyio import open_file
if not file.exists(): 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) await asyncio.sleep(sleep_time)
continue continue
msg: str = line.decode(errors="replace") if log := _parse_jsonl_line(line):
dt_match: re.Match[str] | None = DT_PATTERN.match(msg) await emitter(log)
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,
}
)
except Exception as e: except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}") LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return return
@ -149,7 +158,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
limit = 50 limit = 50
logs_data = await _read_logfile( logs_data = await _read_logfile(
file=Path(config.config_path) / "logs" / "app.log", file=Path(config.config_path) / "logs" / "app.jsonl",
offset=offset, offset=offset,
limit=limit, limit=limit,
) )
@ -175,7 +184,7 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
status=web.HTTPNotFound.status_code, 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(): if not log_file.exists():
return web.json_response( return web.json_response(
data={"error": "Log file is not available."}, data={"error": "Log file is not available."},

View file

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

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Generator from typing import Generator
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@ -26,28 +27,21 @@ class _Resp:
@pytest.mark.asyncio @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() config = Config.get_instance()
req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg") config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"]
req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"}) 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: async def aset(self, **_kwargs) -> None:
seen["validate"] = True return None
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)
client = AsyncMock() 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, "get_async_client", lambda **_kwargs: client)
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False) monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {}) 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 response.status == web.HTTPInternalServerError.status_code
assert seen["to_thread"] is True logs = caplog.text
assert seen["validate"] is True assert "apitoken=secret" not in logs
client.request.assert_awaited_once() assert "user:pass@" not in logs
assert "https://redacted:redacted@example.com/bg.jpg?redacted#redacted" in logs
@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."}'

View file

@ -151,7 +151,6 @@ class TestSystemLimitsEndpoint:
body = json.loads(response.body.decode("utf-8")) body = json.loads(response.body.decode("utf-8"))
assert body["downloads"]["paused"] is False assert body["downloads"]["paused"] is False
assert body["downloads"]["live_bypasses_limits"] is True
assert body["downloads"]["global"] == { assert body["downloads"]["global"] == {
"limit": 10, "limit": 10,
"active": 2, "active": 2,

View file

@ -1,8 +1,11 @@
import asyncio import asyncio
import copy import copy
import importlib import importlib
import json
import logging
import re import re
import shutil import shutil
import sys
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -14,6 +17,7 @@ import pytest
from app.features.ytdlp.utils import arg_converter from app.features.ytdlp.utils import arg_converter
from app.library.Utils import ( from app.library.Utils import (
FileLogFormatter, FileLogFormatter,
JsonLogFormatter,
calc_download_path, calc_download_path,
check_id, check_id,
clean_item, clean_item,
@ -278,6 +282,62 @@ class TestFileLogFormatter:
assert isinstance(formatter, FileLogFormatter) 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: class TestCalcDownloadPath:
"""Test the calc_download_path function.""" """Test the calc_download_path function."""
@ -1524,12 +1584,84 @@ class TestReadLogfile:
def test_read_logfile_with_content(self): def test_read_logfile_with_content(self):
"""Test reading log file with content.""" """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(): async def test():
result = await _read_logfile(self.log_file, limit=2) result = await _read_logfile(self.log_file, limit=2)
assert isinstance(result, dict) assert isinstance(result, dict)
assert "logs" in result 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()) asyncio.run(test())
@ -1564,6 +1696,40 @@ class TestTailLog:
asyncio.run(test()) 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: class TestLoadCookies:
"""Test the load_cookies function.""" """Test the load_cookies function."""

View file

@ -1,201 +1,124 @@
<template> <template>
<UModal <UModal v-model:open="modalOpen" :title="resolvedTitle" :ui="modalUi">
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')"
>
<template #description> <template #description>
<span class="sr-only">{{ modalDescription }}</span> <span class="sr-only">{{ modalDescription }}</span>
</template> </template>
<template #body> <template #body>
<div class="flex h-[75vh] min-h-96 min-w-0 flex-col"> <div class="space-y-4">
<div <div v-if="isLoading" class="flex min-h-80 items-center justify-center">
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3" <UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-primary" />
>
<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> </div>
<div class="flex-1 min-h-0 p-4 sm:p-5"> <UAlert
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center"> v-else-if="errorMessage"
<div class="flex flex-col items-center gap-3 text-center text-toned"> color="error"
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" /> variant="soft"
<span class="text-sm">Loading information...</span> icon="i-lucide-triangle-alert"
</div> title="Unable to load information"
</div> :description="errorMessage"
/>
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center"> <div v-else class="space-y-3">
<div class="w-full max-w-2xl space-y-4"> <UInput
<UAlert v-model="query"
color="error" type="search"
variant="soft" placeholder="Filter text"
icon="i-lucide-triangle-alert" icon="i-lucide-filter"
title="Unable to load information" size="sm"
:description="errorMessage" class="w-full"
/> />
<div class="flex justify-end"> <UAlert
<UButton v-if="query && 0 === filteredLineCount"
type="button" color="warning"
color="neutral" variant="soft"
variant="outline" icon="i-lucide-filter"
size="sm" title="No matching lines"
icon="i-lucide-rotate-ccw" >
@click="() => void loadInfo()" <template #description>
> <p class="text-sm text-default">
Retry No lines match this filter: <u>{{ query }}</u>
</UButton> </p>
</div> </template>
</div> </UAlert>
</div>
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center"> <UAlert
<UAlert v-else-if="!hasDisplayText"
color="neutral" color="neutral"
variant="soft" variant="soft"
icon="i-lucide-info" icon="i-lucide-info"
title="No content returned" title="No content returned"
description="The request completed successfully but did not return any visible content." description="The request completed successfully but did not return any visible content."
class="w-full max-w-2xl" />
<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> </div>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
</div> </div>
</div> </div>
</template> </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 <UButton
type="button" type="button"
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-copy" icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText" :disabled="isLoading || !hasVisibleText"
@click="copyData" @click="copyData"
> >
Copy Copy
</UButton> </UButton>
</div>
</div>
<div class="flex-1 min-h-0 p-4 sm:p-5"> <UButton
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center"> type="button"
<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
color="neutral" color="neutral"
variant="soft" variant="outline"
icon="i-lucide-info" size="sm"
title="No content returned" icon="i-lucide-arrow-down"
description="The request completed successfully but did not return any visible content." :disabled="isLoading || !hasVisibleText"
class="w-full max-w-2xl" @click="scrollContent('end')"
/> >
</div> Go down
</UButton>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre> <UButton
</div> type="button"
</div> 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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core';
const toast = useNotification(); const toast = useNotification();
const emitter = defineEmits<{ (e: 'closeModel'): void }>(); const emitter = defineEmits<{ (e: 'closeModel'): void }>();
@ -205,25 +128,37 @@ const props = withDefaults(
preset?: string; preset?: string;
cli?: string; cli?: string;
useUrl?: boolean; useUrl?: boolean;
externalModel?: boolean;
code_classes?: string;
}>(), }>(),
{ {
link: '', link: '',
preset: '', preset: '',
cli: '', cli: '',
useUrl: false, useUrl: false,
externalModel: false,
code_classes: '',
}, },
); );
const isLoading = ref(true); const isLoading = ref(true);
const data = ref<unknown>(null); const data = ref<unknown>(null);
const errorMessage = ref(''); const errorMessage = ref('');
const wrapText = useStorage<boolean>('get_info_wrap_text', false); const query = ref('');
const contentView = ref<HTMLElement | null>(null);
let latestRequestId = 0; 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(() => { const resolvedTitle = computed(() => {
if (props.useUrl && props.link.startsWith('/api/history/')) { if (props.useUrl && props.link.startsWith('/api/history/')) {
return 'Local Information'; 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 hasDisplayText = computed(() => displayText.value.length > 0);
const hasVisibleText = computed(() => displayedText.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 buildRequestUrl = (): string => { const buildRequestUrl = (): string => {
if (props.useUrl) { if (props.useUrl) {
@ -348,6 +268,7 @@ const loadInfo = async (): Promise<void> => {
latestRequestId += 1; latestRequestId += 1;
const requestId = latestRequestId; const requestId = latestRequestId;
query.value = '';
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
data.value = null; data.value = null;
@ -388,13 +309,32 @@ const loadInfo = async (): Promise<void> => {
}; };
const copyData = (): void => { const copyData = (): void => {
if (!displayText.value) { if (!displayedText.value) {
return; 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( watch(
() => [props.link, props.preset, props.cli, props.useUrl], () => [props.link, props.preset, props.cli, props.useUrl],
() => { () => {

View file

@ -8,24 +8,40 @@ img {
</style> </style>
<template> <template>
<div> <div class="relative min-h-[50vh]">
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center"> <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" /> <UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" />
</div> </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> </div>
<img
v-if="link"
:src="link"
alt="Image preview"
:class="{ invisible: isLoading || hasError }"
@load="handle_load"
@error="handle_error"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity } from '~/utils';
const toast = useNotification();
const emitter = defineEmits(['closeModel']); const emitter = defineEmits(['closeModel']);
const isLoading = ref<boolean>(false); const isLoading = ref<boolean>(true);
const image = ref<string>(''); const hasError = ref<boolean>(false);
const props = defineProps({ const props = defineProps({
link: { link: {
@ -41,36 +57,28 @@ const handle_event = (e: KeyboardEvent) => {
emitter('closeModel'); emitter('closeModel');
}; };
onMounted(async () => { const handle_load = () => {
isLoading.value = false;
hasError.value = false;
};
const handle_error = () => {
isLoading.value = false;
hasError.value = true;
};
onMounted(() => {
disableOpacity(); disableOpacity();
document.addEventListener('keydown', handle_event); document.addEventListener('keydown', handle_event);
const url = props.link.startsWith('/') if (!props.link) {
? props.link hasError.value = true;
: '/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 {
isLoading.value = false; isLoading.value = false;
} }
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event); document.removeEventListener('keydown', handle_event);
if (image.value) {
URL.revokeObjectURL(image.value);
}
enableOpacity(); enableOpacity();
}); });
</script> </script>

View file

@ -1,17 +1,14 @@
<template> <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="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted"> <div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-gauge" class="size-4 text-toned" /> <UIcon name="i-lucide-gauge" class="size-4 text-toned" />
<span>Queue capacity overview</span> <span>Queue overview</span>
</div> </div>
<p class="max-w-3xl text-sm text-toned"> <p class="max-w-3xl text-sm text-toned">
Snapshot is based on the current queue state and configured backend limits. Data is based on the current queue state and configured limits.
<template v-if="limits">
{{ ' ' + queueLimitDescription }}
</template>
</p> </p>
</div> </div>
@ -37,7 +34,7 @@
<div class="space-y-1"> <div class="space-y-1">
<p class="text-sm font-medium text-default">Loading limits</p> <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> </div>
</div> </div>
@ -166,7 +163,7 @@
<span>Per extractor</span> <span>Per extractor</span>
</div> </div>
<p class="text-sm text-toned"> <p class="text-sm text-toned" v-if="trackedExtractorCount">
Extractor-specific usage and overrides currently in effect. Extractor-specific usage and overrides currently in effect.
</p> </p>
</div> </div>
@ -174,12 +171,19 @@
<div class="flex flex-wrap items-center gap-2 text-xs text-toned"> <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"> <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" /> <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>
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1"> <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" /> <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> </span>
</div> </div>
</div> </div>
@ -189,8 +193,8 @@
color="info" color="info"
variant="soft" variant="soft"
icon="i-lucide-info" icon="i-lucide-info"
title="No extractor-specific activity" title="No activity"
description="Overrides and extractor usage appear here once downloads have active or queued work." description="Overrides and extractor usage appear here once activity is detected."
/> />
<div <div
@ -285,14 +289,6 @@ const extractorSourceIcon = (source: string): string => {
return source === 'env_override' ? 'i-lucide-settings-2' : 'i-lucide-circle-check-big'; 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>>(() => { const capacityRows = computed<Array<DetailRow>>(() => {
if (!limits.value) { if (!limits.value) {
return []; return [];
@ -304,23 +300,21 @@ const capacityRows = computed<Array<DetailRow>>(() => {
return [ return [
{ {
label: 'Regular workers', label: 'Regular workers',
description: 'Slots available for non-live downloads.', description: 'Slots available.',
value: `${global.active}/${global.limit}`, value: `${global.active}/${global.limit}`,
meta: `${global.available} available`, meta: `${global.available} available`,
}, },
{ {
label: 'Waiting queue', label: 'Waiting queue',
description: 'Downloads waiting for a regular worker slot.', description: 'Downloads waiting for worker slot.',
value: `${global.queued}`, value: `${global.queued}`,
meta: global.queued === 1 ? 'item queued' : 'items queued', meta: 'items queued',
}, },
{ {
label: 'Live downloads', label: 'Live downloads',
description: downloads.live_bypasses_limits description: 'Streams bypass the worker slots limits.',
? 'Live downloads bypass the regular worker limit.'
: 'Live downloads count toward the regular worker limit.',
value: `${global.live_active}`, 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 simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
const show_settings = ref(false); const show_settings = ref(false);
const checkingUpdates = 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 showRouteSearch = ref(false);
const showSidebar = ref(false); const showSidebar = ref(false);
const showLimits = ref(false); const showLimits = ref(false);
@ -920,7 +920,7 @@ const checkForUpdates = async () => {
return; return;
} }
const msg = 'Up to date - Click to check'; const msg = 'Up to date - Check now';
try { try {
checkingUpdates.value = true; checkingUpdates.value = true;

View file

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

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-if="logs.length > 0" v-if="logs.length > 0"
color="neutral" color="neutral"

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-if="items.length > 0" v-if="items.length > 0"
color="neutral" color="neutral"

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-if="items.length > 0" v-if="items.length > 0"
color="neutral" color="neutral"

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-for="entry in docsEntries" v-for="entry in docsEntries"
:key="entry.id" :key="entry.id"

View file

@ -27,7 +27,7 @@
</div> </div>
</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 <UButton
color="neutral" color="neutral"
:variant="showFilter ? 'soft' : 'outline'" :variant="showFilter ? 'soft' : 'outline'"

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
color="neutral" color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'" :variant="toggleFilter ? 'soft' : 'outline'"

View file

@ -29,7 +29,7 @@
</div> </div>
</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 <UButton
v-if="!autoScroll" v-if="!autoScroll"
color="neutral" color="neutral"
@ -38,7 +38,7 @@
icon="i-lucide-arrow-down" icon="i-lucide-arrow-down"
@click="scrollToBottom(false)" @click="scrollToBottom(false)"
> >
Scroll to bottom Bottom
</UButton> </UButton>
<UButton <UButton
@ -64,6 +64,22 @@
Wrap Wrap
</UButton> </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 <UInput
v-if="toggleFilter || query" v-if="toggleFilter || query"
id="filter" id="filter"
@ -82,7 +98,7 @@
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm"> <div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
<div <div
ref="logContainer" 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" @scroll.passive="handleScroll"
> >
<div <div
@ -90,32 +106,80 @@
class="flex justify-center border-b border-default/40 px-4 py-3" class="flex justify-center border-b border-default/40 px-4 py-3"
> >
<div <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. No older lines remain in this file.
</div> </div>
</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"> <template v-if="filteredLogs.length > 0">
<article <article
v-for="(entry, index) in filteredLogs" v-for="(entry, index) in filteredLogs"
:key="entry.log.id" :key="entry.log.id"
:class="logRowClass(entry, index)" :class="logRowClass(entry, index)"
> >
<div class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
{{ logTimeLabel(entry.log.datetime) }}
</div>
<div <div
class="log-content" :class="[
:class="textWrap ? 'log-content--wrap' : 'log-content--nowrap'" '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 <p :class="logLineClass()">
class="log-tone-dot" <span
:class="`log-tone-dot--${detectLogTone(entry.log.line)}`" class="inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 align-middle"
/> >
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'"> <UTooltip :text="logTimeTitle(entry.log.datetime)">
{{ entry.log.line }} <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> </p>
</div> </div>
</article> </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" class="flex min-h-[55vh] flex-col items-center justify-center gap-3 px-6 py-8 text-center font-sans"
> >
<UIcon <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" class="size-6 text-toned"
/> />
<div class="space-y-1"> <div class="space-y-1">
<p class="text-sm font-medium text-default"> <p class="text-sm font-medium text-default">
{{ query ? 'No logs match this query' : 'No log lines available' }} {{ emptyStateTitle }}
</p> </p>
<p class="text-sm text-toned"> <p class="text-sm text-toned">
{{ {{ emptyStateDescription }}
query
? `No log lines found for the query: ${query}. Please try a different search term.`
: 'No log lines are available yet.'
}}
</p> </p>
</div> </div>
</div> </div>
@ -148,6 +208,176 @@
</div> </div>
</template> </template>
</UPageCard> </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> </main>
</template> </template>
@ -157,18 +387,44 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import moment from 'moment'; import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs'; 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'; import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = { type FilteredLogEntry = {
log: log_line; log: log_line;
level: LogLevel;
isMatch: boolean; isMatch: boolean;
isContext: 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 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; let scrollTimeout: NodeJS.Timeout | null = null;
@ -179,13 +435,20 @@ const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer'); const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const textWrap = useStorage<boolean>('logs_wrap', true); 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 sseController = ref<AbortController | null>(null);
const logs = ref<Array<log_line>>([]); const logs = ref<Array<log_line>>([]);
const selectedLog = ref<log_line | null>(null);
const offset = ref(0); const offset = ref(0);
const loading = ref(false); const loading = ref(false);
const autoScroll = ref(true); const autoScroll = ref(true);
const reachedEnd = ref(false); const reachedEnd = ref(false);
const detailsOpen = ref(false);
const pageCardUi = { const pageCardUi = {
root: 'w-full min-w-0 max-w-full bg-transparent', 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', wrapper: 'w-full min-w-0 items-stretch',
body: 'w-full min-w-0 max-w-full overflow-hidden', 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>( const query = ref<string>(
(() => { (() => {
@ -216,12 +483,75 @@ const query = ref<string>(
const toggleFilter = ref(Boolean(query.value)); const toggleFilter = ref(Boolean(query.value));
const normalizedQuery = computed(() => query.value.trim().toLowerCase()); 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 filterContext = computed(() => {
const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX); const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX);
return match ? parseInt(match[1] ?? '0', 10) : 0; return match ? parseInt(match[1] ?? '0', 10) : 0;
}); });
const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim()); 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, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
query.value = ''; query.value = '';
@ -240,9 +570,20 @@ watch(
}, },
); );
watch(detailsOpen, (open) => {
if (!open) {
selectedLog.value = null;
}
});
const filteredLogs = computed<FilteredLogEntry[]>(() => { const filteredLogs = computed<FilteredLogEntry[]>(() => {
if (!hasActiveFilter.value) { 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> = []; const result: Array<FilteredLogEntry> = [];
@ -250,7 +591,16 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
const matchedIndexes = new Set<number>(); const matchedIndexes = new Set<number>();
logs.value.forEach((log, index) => { 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); matchedIndexes.add(index);
for ( for (
@ -266,8 +616,14 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
Array.from(visibleIndexes) Array.from(visibleIndexes)
.sort((a, b) => a - b) .sort((a, b) => a - b)
.forEach((index) => { .forEach((index) => {
const log = logs.value[index] as log_line;
if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
return;
}
result.push({ result.push({
log: logs.value[index] as log_line, log,
level: getLogLevel(log.level),
isMatch: matchedIndexes.has(index), isMatch: matchedIndexes.has(index),
isContext: !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; 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; loading.value = false;
return; return;
} }
@ -440,49 +796,171 @@ const startLogStream = async (): Promise<void> => {
}; };
const logTimeLabel = (value?: string): string => 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 => const logTimeTitle = (value?: string): string =>
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp'; value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
const detectLogTone = (line: string): LogTone => { const logRaw = (log: log_line): string => JSON.stringify(log, null, 2);
const normalized = line.toLowerCase();
if (/error|failed|exception|traceback|fatal/.test(normalized)) { const exceptionSummary = (log: log_line): string => {
return 'error'; 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 type || message;
return 'warning';
}
if (/info|started|connected|listening|ready/.test(normalized)) {
return 'info';
}
return 'default';
}; };
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 logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
const classes = ['log-row', `log-row--${detectLogTone(entry.log.line)}`]; const classes = [LOG_ROW_CLASS];
if (entry.isMatch) { if (entry.isMatch) {
classes.push('log-row--match'); classes.push('bg-warning/10');
return classes; return classes;
} }
if (entry.isContext) { if (entry.isContext) {
classes.push('log-row--context'); classes.push('bg-muted/30');
return classes; return classes;
} }
if (index % 2 === 1) { if (index % 2 === 1) {
classes.push('log-row--alt'); classes.push('bg-elevated/40');
} }
return classes; 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 () => { onMounted(async () => {
if (!config.app.file_logging) { if (!config.app.file_logging) {
await navigateTo('/'); await navigateTo('/');
@ -502,132 +980,3 @@ onBeforeUnmount(() => {
} }
}); });
</script> </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> </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 <UButton
v-if="notifications.length > 0" v-if="notifications.length > 0"
color="neutral" color="neutral"
@ -53,7 +53,7 @@
:disabled="sendingTest" :disabled="sendingTest"
@click="() => void sendTest()" @click="() => void sendTest()"
> >
<span>Send Test</span> <span>Test</span>
</UButton> </UButton>
<UButton <UButton

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-if="presets.length > 0" v-if="presets.length > 0"
color="neutral" color="neutral"

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-if="definitions.length > 0" v-if="definitions.length > 0"
color="neutral" color="neutral"
@ -130,8 +130,6 @@
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
</div> </div>
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
</div> </div>
<div <div

View file

@ -21,7 +21,7 @@
</div> </div>
</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 <UButton
v-if="tasks.length > 0" v-if="tasks.length > 0"
color="neutral" color="neutral"

View file

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

View file

@ -1,7 +1,49 @@
type log_line = { type log_source = {
id: number; path?: string;
line: string; file?: string;
datetime?: 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 => { const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => {
if (item?.extras?.thumbnail) { 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') : ''; return fallback ? uri('/images/placeholder.png') : '';

View file

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

View file

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

View file

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