diff --git a/API.md b/API.md index c1a5d5a5..b360a8ff 100644 --- a/API.md +++ b/API.md @@ -59,7 +59,6 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile) - [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile) - - [GET /api/thumbnail](#get-apithumbnail) - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath) @@ -1655,17 +1654,6 @@ Binary TS data (`Content-Type: video/mpegts`). --- -### GET /api/thumbnail -**Purpose**: Proxy/fetch a remote thumbnail image. - -**Query Parameter**: -- `?url=` - -**Response**: -Binary image data with the appropriate `Content-Type`. - ---- - ### GET /api/file/ffprobe/{file:.*} **Purpose**: Return the `ffprobe` data for a local file. @@ -2325,12 +2313,36 @@ Binary image data with appropriate headers { "logs": [ { - "timestamp": "2023-01-01T12:00:00Z", - "level": "INFO", - "message": "...", - ... - }, - ... + "id": "", + "datetime": "2026-05-18T12:00:00.000+00:00", + "level": "error", + "levelno": 40, + "logger": "downloads.queue", + "message": "Download failed", + "exception": { + "type": "ValueError", + "message": "bad", + "file": "/app/library/downloads/queue_manager.py", + "line": 123, + "stack": [ + { + "path": "/app/library/downloads/queue_manager.py", + "file": "queue_manager.py", + "module": "queue_manager", + "function": "start", + "line": 123 + } + ] + }, + "source": { + "path": "/app/library/downloads/queue_manager.py", + "file": "queue_manager.py", + "module": "queue_manager", + "function": "start", + "line": 123 + }, + "fields": {} + } ], "offset": 0, "limit": 100, @@ -2352,9 +2364,35 @@ Binary image data with appropriate headers **Event Payload**: ```json { - "id": "", - "line": "", - "datetime": "2024-01-01T12:00:00.000000+00:00" + "id": "", + "datetime": "2026-05-18T12:00:00.000+00:00", + "level": "error", + "levelno": 40, + "logger": "downloads.queue", + "message": "Download failed", + "exception": { + "type": "ValueError", + "message": "bad", + "file": "/app/library/downloads/queue_manager.py", + "line": 123, + "stack": [ + { + "path": "/app/library/downloads/queue_manager.py", + "file": "queue_manager.py", + "module": "queue_manager", + "function": "start", + "line": 123 + } + ] + }, + "source": { + "path": "/app/library/downloads/queue_manager.py", + "file": "queue_manager.py", + "module": "queue_manager", + "function": "start", + "line": 123 + }, + "fields": {} } ``` @@ -2602,7 +2640,6 @@ or an error: { "downloads": { "paused": false, - "live_bypasses_limits": true, "global": { "limit": 20, "active": 3, diff --git a/app/features/ytdlp/extractor.py b/app/features/ytdlp/extractor.py index dea1d6d2..a9e9e674 100644 --- a/app/features/ytdlp/extractor.py +++ b/app/features/ytdlp/extractor.py @@ -19,18 +19,32 @@ def _ytdlp_logger(target: logging.Logger): - def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None: + return _YTDLPLogger(target) + + +class _YTDLPLogger: + def __init__(self, target: logging.Logger) -> None: + self.target = target + + def __call__(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("stacklevel", 4) if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "): - target.debug(msg.removeprefix("[debug] "), *args, **kwargs) + self.target.debug(msg.removeprefix("[debug] "), *args, **kwargs) return if level <= logging.DEBUG: - target.info(msg, *args, **kwargs) + self.target.info(msg, *args, **kwargs) return - target.log(level, msg, *args, **kwargs) + self.target.log(level, msg, *args, **kwargs) + + +class _LogCapture: + def __init__(self, logs: list[str]) -> None: + self.logs = logs - return _log + def __call__(self, _: int, msg: str, *args: Any, **__: Any) -> None: + self.logs.append(msg % args if args else msg) def _get_process_pool_kwargs() -> dict[str, Any]: @@ -242,7 +256,7 @@ def extract_info_sync( sanitize_info: bool = False, capture_logs: int | None = None, **kwargs, -) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: +) -> tuple[dict[str, Any] | None, list[str]]: """ Extract video information from a URL. @@ -257,7 +271,7 @@ def extract_info_sync( **kwargs: Additional arguments Returns: - tuple[dict | None, list[dict]]: Extracted information and captured logs. + tuple[dict | None, list[str]]: Extracted information and captured logs. """ params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True} @@ -286,7 +300,7 @@ def extract_info_sync( captured_logs: list[str] = kwargs.get("captured_logs", []) if capture_logs is not None: log_wrapper.add_target( - target=lambda _, msg: captured_logs.append(msg), + target=_LogCapture(captured_logs), level=capture_logs, name="log-capture", ) @@ -350,7 +364,7 @@ async def fetch_info( extractor_config: ExtractorConfig | None = None, budget_sleep: bool = False, **kwargs, -) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: +) -> tuple[dict[str, Any] | None, list[str]]: """ Extract video information from a URL. @@ -370,7 +384,7 @@ async def fetch_info( **kwargs: Additional arguments Returns: - tuple[dict | None, list[dict]]: Extracted information and captured logs. + tuple[dict | None, list[str]]: Extracted information and captured logs. """ if extractor_config is None: diff --git a/app/features/ytdlp/tests/test_ytdlp_extractor.py b/app/features/ytdlp/tests/test_ytdlp_extractor.py index 7956ffb5..33972198 100644 --- a/app/features/ytdlp/tests/test_ytdlp_extractor.py +++ b/app/features/ytdlp/tests/test_ytdlp_extractor.py @@ -1,13 +1,16 @@ import logging +import pickle from unittest.mock import MagicMock, patch from app.features.ytdlp.extractor import ( ExtractorConfig, ExtractorPool, + _LogCapture, _get_process_pool_kwargs, _ytdlp_logger, extract_info_sync, ) +from app.features.ytdlp.utils import LogWrapper class TestProcessPoolConfiguration: @@ -162,11 +165,26 @@ def test_debug_prefix_uses_debug(self) -> None: _ytdlp_logger(logger)(logging.DEBUG, "[debug] hello") - logger.debug.assert_called_once_with("hello") + logger.debug.assert_called_once_with("hello", stacklevel=4) def test_screen_style_debug_uses_info(self) -> None: logger = MagicMock() _ytdlp_logger(logger)(logging.DEBUG, "screen line") - logger.info.assert_called_once_with("screen line") + logger.info.assert_called_once_with("screen line", stacklevel=4) + + def test_targets_are_picklable(self) -> None: + logs: list[str] = [] + wrapper = LogWrapper() + wrapper.add_target(_ytdlp_logger(logging.getLogger("yt-dlp.test")), level=logging.DEBUG, name="yt-dlp.test") + wrapper.add_target(_LogCapture(logs), level=logging.WARNING, name="log-capture") + + pickle.dumps(wrapper) + + def test_capture_formats_args(self) -> None: + logs: list[str] = [] + + _LogCapture(logs)(logging.WARNING, "hello %s", "world") + + assert logs == ["hello world"] diff --git a/app/features/ytdlp/tests/test_ytdlp_utils.py b/app/features/ytdlp/tests/test_ytdlp_utils.py index cf84e5fc..f47c617c 100644 --- a/app/features/ytdlp/tests/test_ytdlp_utils.py +++ b/app/features/ytdlp/tests/test_ytdlp_utils.py @@ -90,6 +90,7 @@ def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: assert len(cap.records) == 1 assert cap.records[0].levelno == logging.INFO assert cap.records[0].getMessage() == "hello X" + assert cap.records[0].funcName == "test_level_filtering_and_dispatch" assert len(calls) == 0 # WARNING hits both diff --git a/app/features/ytdlp/utils.py b/app/features/ytdlp/utils.py index a1ded5db..c3932c65 100644 --- a/app/features/ytdlp/utils.py +++ b/app/features/ytdlp/utils.py @@ -131,7 +131,9 @@ def _log(self, level, msg, *args, **kwargs): continue if target.logger: - target.target.log(level, msg, *args, **kwargs) + log_kwargs = {**kwargs} + log_kwargs.setdefault("stacklevel", 3) + target.target.log(level, msg, *args, **log_kwargs) else: target.target(level, msg, *args, **kwargs) diff --git a/app/library/Utils.py b/app/library/Utils.py index 9db08df6..4c383524 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -2,11 +2,13 @@ import copy import glob import ipaddress +import json import logging import os import re import socket import time +import traceback import uuid from datetime import UTC, datetime, timedelta from functools import lru_cache, wraps @@ -40,6 +42,139 @@ def formatTime(self, record, datefmt=None): # noqa: ARG002, N802 return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") +LOG_RECORD_ATTRS: set[str] = set(logging.makeLogRecord({}).__dict__) | {"asctime", "message"} + + +class JsonLogFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + source = { + "path": record.pathname, + "file": record.filename, + "module": record.module, + "function": record.funcName, + "line": record.lineno, + } + data: dict[str, Any] = { + "id": str(uuid.uuid4()), + "datetime": self.formatTime(record), + "level": record.levelname.lower(), + "levelno": record.levelno, + "logger": record.name, + "message": record.getMessage(), + "source": source, + "process": {"id": record.process, "name": record.processName}, + "thread": {"id": record.thread, "name": record.threadName}, + "fields": self._extra(record), + } + + if record.exc_info: + exception = self._exception(record.exc_info) + if exception is not None: + data["exception"] = exception + data["source"].update(self._exception_source(exception)) + + return json.dumps(data, ensure_ascii=False, default=str) + + def formatTime(self, record, datefmt=None): # noqa: ARG002, N802 + return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") + + @staticmethod + def _extra(record: logging.LogRecord) -> dict[str, Any]: + extra: dict[str, Any] = {} + for key, value in record.__dict__.items(): + if key in LOG_RECORD_ATTRS or key.startswith("_"): + continue + + if isinstance(value, str | int | float | bool) or value is None: + extra[key] = value + + return extra + + @staticmethod + def _exception( + exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None], + ) -> dict[str, Any] | None: + exc = exc_info[1] + if exc is None: + return None + + stack = JsonLogFormatter._exception_stack(exc_info) + data: dict[str, Any] = {"type": JsonLogFormatter._exception_type(exc)} + + message = str(exc).strip() + if message: + data["message"] = message + + if stack: + origin = stack[-1] + data["file"] = origin["path"] + data["line"] = origin["line"] + data["stack"] = stack + + return data + + @staticmethod + def _exception_type(exc: BaseException) -> str: + module = exc.__class__.__module__ + name = exc.__class__.__qualname__ + return name if module == "builtins" else f"{module}.{name}" + + @staticmethod + def _exception_stack( + exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None], + ) -> list[dict[str, Any]]: + exc = exc_info[1] + tb = exc_info[2] if len(exc_info) > 2 else None + if tb is None and exc is not None: + tb = exc.__traceback__ + + if tb is None: + return [] + + return [JsonLogFormatter._frame(frame) for frame in traceback.extract_tb(tb)] + + @staticmethod + def _frame(frame: traceback.FrameSummary) -> dict[str, Any]: + path = frame.filename + file_path = Path(path) + return { + "path": path, + "file": file_path.name, + "module": file_path.stem, + "function": frame.name, + "line": frame.lineno, + } + + @staticmethod + def _exception_source(exception: dict[str, Any]) -> dict[str, Any]: + source: dict[str, Any] = {} + + path = exception.get("file") + if isinstance(path, str) and path: + file_path = Path(path) + source["path"] = path + source["file"] = file_path.name + source["module"] = file_path.stem + + line = exception.get("line") + if isinstance(line, int): + source["line"] = line + + stack = exception.get("stack") + if isinstance(stack, list) and stack: + frame = stack[-1] + if isinstance(frame, dict): + function = frame.get("function") + if isinstance(function, str) and function: + source["function"] = function + + module = frame.get("module") + if isinstance(module, str) and module: + source["module"] = module + + return source + + def timed_lru_cache(ttl_seconds: int, max_size: int = 128): """ Decorator that applies an LRU cache with a time-to-live (TTL) to a function. diff --git a/app/library/config.py b/app/library/config.py index b53e70fd..f3d8b4f4 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from .Singleton import Singleton -from .Utils import FileLogFormatter +from .Utils import JsonLogFormatter from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION if TYPE_CHECKING: @@ -466,14 +466,14 @@ def __init__(self, is_native: bool = False): loggingPath.mkdir(parents=True, exist_ok=True) handler = TimedRotatingFileHandler( - filename=loggingPath / "app.log", + filename=loggingPath / "app.jsonl", when="midnight", backupCount=3, encoding="utf-8", ) handler.setLevel(log_level_file) - formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s") + formatter = JsonLogFormatter() handler.setFormatter(formatter) logging.getLogger().addHandler(handler) diff --git a/app/library/downloads/queue_manager.py b/app/library/downloads/queue_manager.py index d5c700d5..185b0248 100644 --- a/app/library/downloads/queue_manager.py +++ b/app/library/downloads/queue_manager.py @@ -450,7 +450,7 @@ async def clear_bulk(self, ids: list[str], remove_file: bool = False) -> dict[st summary = ", ".join(deleted_titles[:5]) if deleted_count > 5: summary += ", ..." - LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}") + LOG.info(f"Cleared '{deleted_count}' items. {summary}") return {"deleted": deleted_count} @@ -469,7 +469,7 @@ async def clear_by_status(self, status_filter: str, remove_file: bool = False) - title="History Cleared", message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.", ) - LOG.info(f"Bulk cleared {deleted_count} history item(s) by status '{status_filter}'.") + LOG.info(f"Cleared '{deleted_count}' items. Filter '{status_filter}'.") return {"deleted": deleted_count} items = await self.done.get_many_by_status(status_filter) diff --git a/app/routes/api/images.py b/app/routes/api/images.py index 164e9479..ebb55af8 100644 --- a/app/routes/api/images.py +++ b/app/routes/api/images.py @@ -1,10 +1,7 @@ -import asyncio import logging import random -import time -from datetime import UTC, datetime from typing import Any -from urllib.parse import urlparse +from urllib.parse import urlparse, urlsplit, urlunsplit from aiohttp import web from aiohttp.web import Request, Response @@ -15,76 +12,31 @@ from app.library.config import Config from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport from app.library.router import route -from app.library.Utils import validate_url LOG: logging.Logger = logging.getLogger(__name__) IS_REQUESTING_BACKGROUND: bool = False -@route("GET", "api/thumbnail/", "get_thumbnail") -async def get_thumbnail(request: Request, config: Config) -> Response: - """ - Get the thumbnail. - - Args: - request (Request): The request object. - config (Config): The configuration object. - - Returns: - Response: The response object. - - """ - url: str | None = request.query.get("url") +def _safe_url(url: str | None) -> str: if not url: - return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code) + return "" try: - await asyncio.to_thread(validate_url, url, config.allow_internal_urls) - except ValueError as e: - return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code) + parsed = urlsplit(url) + except Exception: + return str(url) - try: - ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() - use_curl = resolve_curl_transport() - request_headers = build_request_headers( - user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())), - use_curl=use_curl, - ) - proxy = ytdlp_args.get("proxy") + netloc: str = parsed.netloc + if parsed.username or parsed.password: + host: str = parsed.hostname or "" + if parsed.port: + host: str = f"{host}:{parsed.port}" + netloc: str = f"redacted:redacted@{host}" if host else "redacted:redacted" - client = get_async_client(proxy=proxy, use_curl=use_curl) - LOG.debug(f"Fetching thumbnail from '{url}'.") - response = await client.request( - method="GET", - url=url, - follow_redirects=True, - headers=request_headers, - timeout=10.0, - ) - - if response.status_code != web.HTTPOk.status_code: - LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.") - return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code) - - return web.Response( - body=response.content, - headers={ - "Content-Type": response.headers.get("Content-Type"), - "Pragma": "public", - "Access-Control-Allow-Origin": "*", - "Cache-Control": f"public, max-age={time.time() + 31536000}", - "Expires": time.strftime( - "%a, %d %b %Y %H:%M:%S GMT", - datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(), - ), - }, - ) - except Exception as e: - LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.") - return web.json_response( - data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code - ) + query: str = "redacted" if parsed.query else "" + fragment: str = "redacted" if parsed.fragment else "" + return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment)) @route("GET", "api/random/background/", "get_background") @@ -110,7 +62,8 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp try: IS_REQUESTING_BACKGROUND = True - backend = random.choice(config.pictures_backends) + backend: str = random.choice(config.pictures_backends) + safe_backend: str = _safe_url(backend) CACHE_KEY_BING = "random_background_bing" CACHE_KEY = "random_background" @@ -135,12 +88,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp ) ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() - use_curl = resolve_curl_transport() - request_headers = build_request_headers( + use_curl: bool = resolve_curl_transport() + request_headers: dict = build_request_headers( user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())), use_curl=use_curl, ) - proxy = ytdlp_args.get("proxy") + proxy: str | None = ytdlp_args.get("proxy") client = get_async_client(proxy=proxy, use_curl=use_curl) if backend.startswith("https://www.bing.com/HPImageArchive.aspx"): @@ -159,10 +112,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp status=web.HTTPInternalServerError.status_code, ) - backend = f"https://www.bing.com{img_url}" + backend: str = f"https://www.bing.com{img_url}" + safe_backend: str = _safe_url(backend) await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24) else: backend = await cache.aget(CACHE_KEY_BING) + safe_backend = _safe_url(backend if isinstance(backend, str) else None) if not isinstance(backend, str) or not backend: return web.json_response( @@ -170,7 +125,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp status=web.HTTPInternalServerError.status_code, ) - LOG.debug(f"Requesting random picture from '{backend!s}'.") + LOG.debug(f"Requesting random picture from '{safe_backend}'.") response = await client.request( method="GET", @@ -198,7 +153,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp await cache.aset(key=CACHE_KEY, value=data, ttl=3600) - LOG.debug(f"Random background image from '{backend!s}' cached.") + LOG.debug(f"Random background image from '{safe_backend}' cached.") return web.Response( body=data.get("content"), @@ -210,7 +165,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp }, ) except Exception as e: - LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.") + LOG.error(f"Failed to request random background image from '{safe_backend}'. '{e!s}'.") return web.json_response( data={"error": "failed to retrieve the random background image."}, status=web.HTTPInternalServerError.status_code, diff --git a/app/routes/api/logs.py b/app/routes/api/logs.py index d36ed350..07c7aec4 100644 --- a/app/routes/api/logs.py +++ b/app/routes/api/logs.py @@ -1,7 +1,7 @@ import asyncio +import json import logging import os -import re from pathlib import Path from aiohttp import web @@ -13,8 +13,38 @@ LOG: logging.Logger = logging.getLogger(__name__) -DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?") -"Match ISO8601." + +def _parse_jsonl_line(line: bytes | str) -> dict | None: + raw: str = line.decode(errors="replace") if isinstance(line, bytes) else line + raw = raw.rstrip("\r\n") + + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return None + + if not isinstance(payload, dict): + return None + + required = ("id", "datetime", "level", "logger", "message") + if any(not payload.get(key) for key in required): + return None + + result: dict = { + "id": str(payload["id"]), + "datetime": str(payload["datetime"]), + "level": str(payload["level"]), + "logger": str(payload["logger"]), + "message": str(payload["message"]).strip(), + } + + for key in ("levelno", "source", "process", "thread", "fields", "exception"): + if key not in payload: + continue + + result[key] = payload[key] + + return result async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict: @@ -33,8 +63,6 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict: - end_is_reached: True if there are no older logs. """ - from hashlib import sha256 - from anyio import open_file if not file.exists(): @@ -68,17 +96,8 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict: next_offset = None end_is_reached = True - for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]: - line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode() - msg: str = line.decode(errors="replace") - dt_match: re.Match[str] | None = DT_PATTERN.match(msg) - result.append( - { - "id": sha256(line_bytes).hexdigest(), - "line": msg[dt_match.end() :] if dt_match else msg, - "datetime": dt_match.group(1) if dt_match else None, - } - ) + selected = lines[-(offset + limit) : -offset] if offset else lines[-limit:] + result.extend(log for line in selected if (log := _parse_jsonl_line(line))) return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached} except Exception: @@ -95,8 +114,6 @@ async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5): sleep_time (float): The time to sleep between reads. """ - from hashlib import sha256 - from anyio import open_file if not file.exists(): @@ -111,16 +128,8 @@ async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5): await asyncio.sleep(sleep_time) continue - msg: str = line.decode(errors="replace") - dt_match: re.Match[str] | None = DT_PATTERN.match(msg) - - await emitter( - { - "id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(), - "line": msg[dt_match.end() :] if dt_match else msg, - "datetime": dt_match.group(1) if dt_match else None, - } - ) + if log := _parse_jsonl_line(line): + await emitter(log) except Exception as e: LOG.error(f"Error while tailing log file '{file!s}': {e!s}") return @@ -149,7 +158,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response: limit = 50 logs_data = await _read_logfile( - file=Path(config.config_path) / "logs" / "app.log", + file=Path(config.config_path) / "logs" / "app.jsonl", offset=offset, limit=limit, ) @@ -175,7 +184,7 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res status=web.HTTPNotFound.status_code, ) - log_file = Path(config.config_path) / "logs" / "app.log" + log_file = Path(config.config_path) / "logs" / "app.jsonl" if not log_file.exists(): return web.json_response( data={"error": "Log file is not available."}, diff --git a/app/routes/api/system.py b/app/routes/api/system.py index f45f5f3f..d96506db 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -464,7 +464,6 @@ def _get_extractor_override_limits(config: Config) -> dict[str, dict[str, int | data={ "downloads": { "paused": queue.is_paused(), - "live_bypasses_limits": True, "global": { "limit": config.max_workers, "active": len(active_non_live), diff --git a/app/tests/test_images_routes.py b/app/tests/test_images_routes.py index 97bce698..c8d4ea1d 100644 --- a/app/tests/test_images_routes.py +++ b/app/tests/test_images_routes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Generator from unittest.mock import AsyncMock @@ -26,28 +27,21 @@ def __init__(self, *, status_code: int = 200, content: bytes = b"img", content_t @pytest.mark.asyncio -async def test_thumb_thread(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: config = Config.get_instance() - req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg") - req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"}) + config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"] + req = make_mocked_request("GET", "/api/random/background") - seen = {"to_thread": False, "validate": False} + class DummyCache: + def has(self, _key: str) -> bool: + return False - def fake_validate_url(url: str, allow_internal: bool = False) -> bool: - seen["validate"] = True - assert url == "https://example.com/a.jpg" - assert allow_internal is config.allow_internal_urls - return True - - async def fake_to_thread(func, *args, **kwargs): - seen["to_thread"] = True - return func(*args, **kwargs) + async def aset(self, **_kwargs) -> None: + return None client = AsyncMock() - client.request.return_value = _Resp() + client.request.side_effect = RuntimeError("boom") - monkeypatch.setattr(images, "validate_url", fake_validate_url) - monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread) monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client) monkeypatch.setattr(images, "resolve_curl_transport", lambda: False) monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {}) @@ -67,31 +61,11 @@ async def fake_to_thread(func, *args, **kwargs): ), ) - response = await images.get_thumbnail(req, config) - - assert response.status == web.HTTPOk.status_code - assert seen["to_thread"] is True - assert seen["validate"] is True - client.request.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None: - config = Config.get_instance() - req = make_mocked_request("GET", "/api/thumbnail?url=https://bad.example/a.jpg") - req._rel_url = req._rel_url.with_query({"url": "https://bad.example/a.jpg"}) - - def fake_validate_url(_url: str, allow_internal: bool = False) -> bool: - assert allow_internal is config.allow_internal_urls - raise ValueError("Invalid hostname.") - - async def fake_to_thread(func, *args, **kwargs): - return func(*args, **kwargs) - - monkeypatch.setattr(images, "validate_url", fake_validate_url) - monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread) - - response = await images.get_thumbnail(req, config) + with caplog.at_level(logging.ERROR): + response = await images.get_background(req, config, DummyCache()) - assert response.status == web.HTTPForbidden.status_code - assert response.text == '{"error": "Invalid hostname."}' + assert response.status == web.HTTPInternalServerError.status_code + logs = caplog.text + assert "apitoken=secret" not in logs + assert "user:pass@" not in logs + assert "https://redacted:redacted@example.com/bg.jpg?redacted#redacted" in logs diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py index eefbfda5..ce616045 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -151,7 +151,6 @@ async def test_system_limits_public(self): body = json.loads(response.body.decode("utf-8")) assert body["downloads"]["paused"] is False - assert body["downloads"]["live_bypasses_limits"] is True assert body["downloads"]["global"] == { "limit": 10, "active": 2, diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index ff65a372..96493605 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -1,8 +1,11 @@ import asyncio import copy import importlib +import json +import logging import re import shutil +import sys import uuid from dataclasses import dataclass from datetime import datetime, timedelta @@ -14,6 +17,7 @@ from app.features.ytdlp.utils import arg_converter from app.library.Utils import ( FileLogFormatter, + JsonLogFormatter, calc_download_path, check_id, clean_item, @@ -278,6 +282,62 @@ def test_file_log_formatter_creation(self): assert isinstance(formatter, FileLogFormatter) +class TestJsonLogFormatter: + def test_basic(self): + formatter = JsonLogFormatter() + record = logging.LogRecord("test.logger", logging.WARNING, __file__, 123, "hello %s", ("world",), None) + record.download_id = "abc" + record.payload = {"ignored": True} + + data = json.loads(formatter.format(record)) + + assert uuid.UUID(data["id"]) + assert data["level"] == "warning" + assert data["levelno"] == logging.WARNING + assert data["logger"] == "test.logger" + assert data["message"] == "hello world" + assert data["fields"] == {"download_id": "abc"} + assert data["source"]["line"] == 123 + + def test_exception(self): + formatter = JsonLogFormatter() + + try: + raise ValueError("bad") + except ValueError: + record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), sys.exc_info()) + + data = json.loads(formatter.format(record)) + + assert data["message"] == "failed" + assert data["exception"] == { + "type": "ValueError", + "message": "bad", + "file": __file__, + "line": data["exception"]["line"], + "stack": data["exception"]["stack"], + } + assert data["exception"]["line"] > 0 + assert data["exception"]["stack"][-1] == { + "path": __file__, + "file": Path(__file__).name, + "module": Path(__file__).stem, + "function": "test_exception", + "line": data["exception"]["line"], + } + assert data["source"] == data["exception"]["stack"][-1] + assert "exception_message" not in data + + def test_no_raw_stack(self): + formatter = JsonLogFormatter() + record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), None) + record.stack_info = 'Stack (most recent call last):\n File "x", line 1, in y' + + data = json.loads(formatter.format(record)) + + assert "stack" not in data + + class TestCalcDownloadPath: """Test the calc_download_path function.""" @@ -1524,12 +1584,84 @@ async def test(): def test_read_logfile_with_content(self): """Test reading log file with content.""" - self.log_file.write_text("line 1\nline 2\nline 3\n") + lines = [ + { + "id": "log-1", + "datetime": "2026-01-01T00:00:01.000+00:00", + "level": "info", + "logger": "test", + "message": "line 1", + }, + { + "id": "log-2", + "datetime": "2026-01-01T00:00:02.000+00:00", + "level": "warning", + "logger": "test", + "message": "line 2", + }, + { + "id": "log-3", + "datetime": "2026-01-01T00:00:03.000+00:00", + "level": "error", + "logger": "test", + "message": "line 3", + "exception": { + "type": "ValueError", + "message": "bad", + "file": "/tmp/test.py", + "line": 3, + "stack": [ + { + "path": "/tmp/test.py", + "file": "test.py", + "module": "test", + "function": "run", + "line": 3, + } + ], + }, + }, + ] + self.log_file.write_text("\n".join(json.dumps(line) for line in lines) + "\n") async def test(): result = await _read_logfile(self.log_file, limit=2) assert isinstance(result, dict) assert "logs" in result + assert [line["id"] for line in result["logs"]] == ["log-2", "log-3"] + assert all("line" not in line for line in result["logs"]) + assert [line["message"] for line in result["logs"]] == ["line 2", "line 3"] + assert result["logs"][0]["level"] == "warning" + assert result["logs"][1]["exception"]["type"] == "ValueError" + assert result["next_offset"] == 2 + assert result["end_is_reached"] is False + + asyncio.run(test()) + + def test_read_logfile_malformed(self): + self.log_file.write_text("not-json\n") + + async def test(): + result = await _read_logfile(self.log_file, limit=1) + assert result["logs"] == [] + + asyncio.run(test()) + + def test_read_logfile_missing_id(self): + self.log_file.write_text(json.dumps({"message": "ignored"}) + "\n") + + async def test(): + result = await _read_logfile(self.log_file, limit=1) + assert result["logs"] == [] + + asyncio.run(test()) + + def test_read_logfile_missing_required(self): + self.log_file.write_text(json.dumps({"id": "ignored", "message": "ignored"}) + "\n") + + async def test(): + result = await _read_logfile(self.log_file, limit=1) + assert result["logs"] == [] asyncio.run(test()) @@ -1564,6 +1696,40 @@ async def 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: """Test the load_cookies function.""" diff --git a/ui/app/components/GetInfo.vue b/ui/app/components/GetInfo.vue index 8b2b6898..e0f1238e 100644 --- a/ui/app/components/GetInfo.vue +++ b/ui/app/components/GetInfo.vue @@ -1,133 +1,80 @@ diff --git a/ui/app/components/LimitsPage.vue b/ui/app/components/LimitsPage.vue index a424342d..e186e476 100644 --- a/ui/app/components/LimitsPage.vue +++ b/ui/app/components/LimitsPage.vue @@ -1,17 +1,14 @@ + + diff --git a/ui/app/pages/changelog.vue b/ui/app/pages/changelog.vue index d282ca84..693b01c7 100644 --- a/ui/app/pages/changelog.vue +++ b/ui/app/pages/changelog.vue @@ -21,7 +21,7 @@ -
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- Scroll to bottom + Bottom + + + +
+ No older lines remain in this file.
+
+ + Load older lines into filter + +
+ + + + + @@ -157,18 +387,44 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source'; import moment from 'moment'; import { useStorage } from '@vueuse/core'; import type { log_line } from '~/types/logs'; -import { parse_api_error, request, uri } from '~/utils'; +import { copyText, parse_api_error, request, uri } from '~/utils'; import { requirePageShell } from '~/utils/topLevelNavigation'; type FilteredLogEntry = { log: log_line; + level: LogLevel; isMatch: boolean; isContext: boolean; }; -type LogTone = 'default' | 'info' | 'warning' | 'error'; +type LogLevel = 'debug' | 'info' | 'warning' | 'error'; +type LogLevelColor = 'neutral' | 'info' | 'warning' | 'error'; +type DetailRow = { + label: string; + value: string; + icon: string; +}; +type LevelFilterItem = { + label: string; + value: LogLevel; +}; const FILTER_CONTEXT_REGEX = /context:(\d+)/; +const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warning', 'error']; +const LOG_ROW_CLASS = + 'flex min-w-0 border-b border-default/40 bg-transparent transition-colors duration-150 last:border-b-0 hover:bg-elevated/70'; +const LOG_LEVEL_COLOR: Record = { + debug: 'neutral', + info: 'info', + warning: 'warning', + error: 'error', +}; +const LOG_LEVEL_ICON: Record = { + debug: 'i-lucide-terminal', + info: 'i-lucide-info', + warning: 'i-lucide-triangle-alert', + error: 'i-lucide-circle-x', +}; let scrollTimeout: NodeJS.Timeout | null = null; @@ -179,13 +435,20 @@ const pageShell = requirePageShell('logs'); const logContainer = useTemplateRef('logContainer'); const textWrap = useStorage('logs_wrap', true); +const exceptionOpen = useStorage('logs_exception_open', false); +const fieldsOpen = useStorage('logs_fields_open', true); +const rawJsonOpen = useStorage('logs_raw_json_open', false); +const sourceOpen = useStorage('logs_source_open', true); +const selectedLevels = useStorage('logs_level_filter', [...LOG_LEVELS]); const sseController = ref(null); const logs = ref>([]); +const selectedLog = ref(null); const offset = ref(0); const loading = ref(false); const autoScroll = ref(true); const reachedEnd = ref(false); +const detailsOpen = ref(false); const pageCardUi = { root: 'w-full min-w-0 max-w-full bg-transparent', @@ -193,6 +456,10 @@ const pageCardUi = { wrapper: 'w-full min-w-0 items-stretch', body: 'w-full min-w-0 max-w-full overflow-hidden', }; +const detailsModalUi = { + content: 'max-w-5xl', + body: 'max-h-[75vh] overflow-y-auto', +}; const query = ref( (() => { @@ -216,12 +483,75 @@ const query = ref( const toggleFilter = ref(Boolean(query.value)); const normalizedQuery = computed(() => query.value.trim().toLowerCase()); +const selectedLevelSet = computed( + () => new Set(LOG_LEVELS.filter((level) => selectedLevels.value.includes(level))), +); +const hasLevelFilter = computed(() => selectedLevelSet.value.size !== LOG_LEVELS.length); const filterContext = computed(() => { const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX); return match ? parseInt(match[1] ?? '0', 10) : 0; }); const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim()); -const hasActiveFilter = computed(() => Boolean(searchTerm.value)); +const hasTextFilter = computed(() => Boolean(searchTerm.value)); +const hasActiveFilter = computed(() => hasTextFilter.value || hasLevelFilter.value); +const canLoadFilteredHistory = computed( + () => hasActiveFilter.value && !reachedEnd.value && logs.value.length > 0, +); +const levelCounts = computed>(() => { + const counts: Record = { + 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(() => [ + ...LOG_LEVELS.map((level) => ({ + label: `${level.charAt(0).toUpperCase()}${level.slice(1)} (${levelCounts.value[level]})`, + value: level, + })), +]); +const levelFilterLabel = computed(() => { + if (selectedLevelSet.value.size === LOG_LEVELS.length) { + return `All levels (${logs.value.length})`; + } + + if (selectedLevelSet.value.size === 0) { + return 'No levels selected'; + } + + return LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)).join(', '); +}); +const activeFilterLabel = computed(() => { + const parts: string[] = []; + if (hasTextFilter.value) { + parts.push(`query "${searchTerm.value}"`); + } + + if (hasLevelFilter.value) { + const levels = LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)); + parts.push(levels.length ? `levels ${levels.join(', ')}` : 'no selected levels'); + } + + return parts.join(' and '); +}); +const emptyStateTitle = computed(() => + hasActiveFilter.value ? 'No logs match these filters' : 'No log lines available', +); +const emptyStateDescription = computed(() => { + if (!hasActiveFilter.value) { + return 'No log lines are available yet.'; + } + + return `No loaded log lines match ${activeFilterLabel.value}. Adjust filters or load older lines.`; +}); watch(toggleFilter, () => { if (!toggleFilter.value) { query.value = ''; @@ -240,9 +570,20 @@ watch( }, ); +watch(detailsOpen, (open) => { + if (!open) { + selectedLog.value = null; + } +}); + const filteredLogs = computed(() => { 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 = []; @@ -250,7 +591,16 @@ const filteredLogs = computed(() => { const matchedIndexes = new Set(); logs.value.forEach((log, index) => { - if (log.line.toLowerCase().includes(searchTerm.value)) { + if (!selectedLevelSet.value.has(getLogLevel(log.level))) { + return; + } + + if (!hasTextFilter.value) { + visibleIndexes.add(index); + return; + } + + if (searchableLog(log).includes(searchTerm.value)) { matchedIndexes.add(index); for ( @@ -266,8 +616,14 @@ const filteredLogs = computed(() => { Array.from(visibleIndexes) .sort((a, b) => a - b) .forEach((index) => { + const log = logs.value[index] as log_line; + if (!selectedLevelSet.value.has(getLogLevel(log.level))) { + return; + } + result.push({ - log: logs.value[index] as log_line, + log, + level: getLogLevel(log.level), isMatch: matchedIndexes.has(index), isContext: !matchedIndexes.has(index), }); @@ -289,10 +645,10 @@ const scrollLogContainerToBottom = async (behavior: ScrollBehavior = 'auto'): Pr }); }; -const fetchLogs = async (): Promise => { +const fetchLogs = async (force = false): Promise => { loading.value = true; - if (reachedEnd.value || (hasActiveFilter.value && logs.value.length > 0)) { + if (reachedEnd.value || (!force && hasActiveFilter.value && logs.value.length > 0)) { loading.value = false; return; } @@ -440,49 +796,171 @@ const startLogStream = async (): Promise => { }; const logTimeLabel = (value?: string): string => - value ? moment(value).format('HH:mm:ss') : '--:--:--'; + value ? moment(value).format('HH:mm:ss') : '00:00:00'; const logTimeTitle = (value?: string): string => value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp'; -const detectLogTone = (line: string): LogTone => { - const normalized = line.toLowerCase(); +const logRaw = (log: log_line): string => JSON.stringify(log, null, 2); - if (/error|failed|exception|traceback|fatal/.test(normalized)) { - return 'error'; - } +const exceptionSummary = (log: log_line): string => { + const type = log.exception?.type?.trim() ?? ''; + const message = log.exception?.message?.trim() ?? ''; - if (/warn|deprecated|retry/.test(normalized)) { - return 'warning'; + if (type && message) { + return `${type}: ${message}`; } - if (/info|started|connected|listening|ready/.test(normalized)) { - return 'info'; - } + return type || message; +}; - 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 classes = ['log-row', `log-row--${detectLogTone(entry.log.line)}`]; + const classes = [LOG_ROW_CLASS]; if (entry.isMatch) { - classes.push('log-row--match'); + classes.push('bg-warning/10'); return classes; } if (entry.isContext) { - classes.push('log-row--context'); + classes.push('bg-muted/30'); return classes; } if (index % 2 === 1) { - classes.push('log-row--alt'); + classes.push('bg-elevated/40'); } return classes; }; +const openLogDetails = (log: log_line): void => { + selectedLog.value = log; + detailsOpen.value = true; +}; + +const formatDetailValue = (value: unknown): string => { + if (value === undefined || value === null || value === '') { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +}; + +const compactRows = (rows: Array<{ label: string; value: unknown; icon: string }>): DetailRow[] => + rows + .map((row) => ({ + label: row.label, + value: formatDetailValue(row.value), + icon: row.icon, + })) + .filter((row) => Boolean(row.value)); + +const formatNameId = (name: unknown, id: unknown): string => { + const nameValue = formatDetailValue(name); + const idValue = formatDetailValue(id); + if (nameValue && idValue) { + return `${nameValue} / ${idValue}`; + } + + return nameValue || idValue; +}; + +const detailRows = (log: log_line): DetailRow[] => + compactRows([ + { label: 'File', value: log.source?.file, icon: 'i-lucide-file' }, + { label: 'Line', value: log.source?.line, icon: 'i-lucide-hash' }, + { label: 'Function', value: log.source?.function, icon: 'i-lucide-code-2' }, + { label: 'Module', value: log.source?.module, icon: 'i-lucide-box' }, + { label: 'Path', value: log.source?.path, icon: 'i-lucide-folder-tree' }, + { + label: 'Process / ID', + value: formatNameId(log.process?.name, log.process?.id), + icon: 'i-lucide-cpu', + }, + { + label: 'Thread / ID', + value: formatNameId(log.thread?.name, log.thread?.id), + icon: 'i-lucide-git-branch', + }, + ]); + +const fieldRows = (log: log_line): DetailRow[] => + compactRows( + Object.entries(log.fields ?? {}).map(([label, value]) => ({ + label, + value, + icon: 'i-lucide-tag', + })), + ); + +const copyLogMessage = (log: log_line): void => { + copyText(log.message); +}; + +const copyLogRaw = (log: log_line): void => { + copyText(logRaw(log)); +}; + onMounted(async () => { if (!config.app.file_logging) { await navigateTo('/'); @@ -502,132 +980,3 @@ onBeforeUnmount(() => { } }); - - diff --git a/ui/app/pages/notifications.vue b/ui/app/pages/notifications.vue index 291d8ad5..17418781 100644 --- a/ui/app/pages/notifications.vue +++ b/ui/app/pages/notifications.vue @@ -21,7 +21,7 @@
-
+
- Send Test + Test
-
+
-
+
- -
{{ filteredDefinitions.length }} displayed
-
+
; +}; + type log_line = { - id: number; - line: string; - datetime?: string; + id: string; + message: string; + datetime: string; + level: string; + levelno?: number; + logger: string; + source?: log_source; + process?: log_process; + thread?: log_thread; + fields?: Record; + exception?: log_exception | null; }; -export type { log_line }; +export type { log_exception, log_exception_frame, log_line, log_process, log_source, log_thread }; diff --git a/ui/app/utils/index.ts b/ui/app/utils/index.ts index 8b846f47..49655989 100644 --- a/ui/app/utils/index.ts +++ b/ui/app/utils/index.ts @@ -873,7 +873,7 @@ const getPath = (basePath: string, item: StoreItem): string => { const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => { if (item?.extras?.thumbnail) { - return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail)); + return uri(item.extras.thumbnail); } return fallback ? uri('/images/placeholder.png') : ''; diff --git a/ui/app/utils/media.ts b/ui/app/utils/media.ts index e3006db1..984b574b 100644 --- a/ui/app/utils/media.ts +++ b/ui/app/utils/media.ts @@ -1,7 +1,15 @@ import { useLocalCache } from '~/utils/cache'; const KEY = 'video:'; -const cache = useLocalCache(); +let cache: ReturnType | null = null; + +const getCache = (): ReturnType => { + if (!cache) { + cache = useLocalCache(); + } + + return cache; +}; const read = (id: string | null | undefined): number => { if (!id) { @@ -9,7 +17,7 @@ const read = (id: string | null | undefined): number => { } try { - const time = Number(cache.get(`${KEY}${id}`)); + const time = Number(getCache().get(`${KEY}${id}`)); return Number.isFinite(time) && time > 0 ? time : 0; } catch { return 0; @@ -21,7 +29,7 @@ const save = (id: string | null | undefined, time: number): void => { return; } - cache.set(`${KEY}${id}`, time, 3600 * 24); + getCache().set(`${KEY}${id}`, time, 3600 * 24); }; const clear = (id: string | null | undefined): void => { @@ -29,7 +37,7 @@ const clear = (id: string | null | undefined): void => { return; } - cache.remove(`${KEY}${id}`); + getCache().remove(`${KEY}${id}`); }; const nearEnd = ( diff --git a/ui/app/utils/topLevelNavigation.ts b/ui/app/utils/topLevelNavigation.ts index 03c1b212..21074d4b 100644 --- a/ui/app/utils/topLevelNavigation.ts +++ b/ui/app/utils/topLevelNavigation.ts @@ -168,7 +168,7 @@ const NavItems: Array = [ group: 'tools', label: 'Logs', pageLabel: 'Logs', - description: 'Scroll near the top to load older logs.', + description: 'Scroll up to load older logs.', icon: 'i-lucide-file-text', to: '/logs', matchPath: '/logs', diff --git a/ui/tests/utils/media.test.ts b/ui/tests/utils/media.test.ts index a597565e..f667760b 100644 --- a/ui/tests/utils/media.test.ts +++ b/ui/tests/utils/media.test.ts @@ -12,24 +12,34 @@ const cache = { }), }; +const useLocalCache = mock(() => cache); + mock.module('~/utils/cache', () => ({ - useLocalCache: () => cache, + useLocalCache, })); let media: Awaited; +let importCacheCalls = 0; beforeAll(async () => { media = await import('~/utils/media'); + importCacheCalls = useLocalCache.mock.calls.length; }); beforeEach(() => { store.clear(); + useLocalCache.mockClear(); cache.get.mockClear(); cache.set.mockClear(); cache.remove.mockClear(); }); describe('media utils', () => { + it('defer_cache_init', () => { + expect(importCacheCalls).toBe(0); + expect(media.read('item-missing')).toBe(0); + }); + it('clamp_seekable_range', () => { const seekable = { length: 1,