Merge pull request #608 from arabcoders/dev

feat: add system diagnostics endpoint.
This commit is contained in:
Abdulmohsen 2026-05-26 19:49:47 +03:00 committed by GitHub
commit ef8642ace7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2627 additions and 501 deletions

49
API.md
View file

@ -99,6 +99,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
- [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal)
- [GET /api/system/terminal](#get-apisystemterminal)
@ -2632,6 +2633,54 @@ or an error:
---
### GET /api/system/diagnostics
**Purpose**: View system information.
**Response**:
```json
{
"status": "error",
"generated_at": 1713000000,
"summary": {
"total": 10,
"pass": 5,
"fail": 2,
"warn": 1,
"skip": 2,
"required_failed": 2
},
"runtime": {
"app_version": "1.0.0",
"app_branch": "main",
"app_commit_sha": "abcdef12",
"app_build_date": "20260526",
"started": 1712999900,
"uptime_seconds": 100,
"platform": "linux",
"platform_release": "6.8.0",
"platform_machine": "x86_64",
"python_version": "3.13.1",
"python_minimum": "3.13",
"is_native": false,
"console_enabled": false
},
"requirements": {
"python": {
"current": "3.13.1",
"required": "3.13",
"supported": true,
"note": ""
}
},
"checks": []
}
```
**Notes**:
- Unexpected collection errors are returned as an `error`.
---
### GET /api/system/limits
**Purpose**: Get the system limits.

View file

@ -17,6 +17,9 @@ from app.library.Singleton import Singleton
LOG: logging.Logger = logging.getLogger("downloads.extractor")
LIVE_REEXTRACT_STATUSES: set[str] = {"is_live", "post_live"}
REEXTRACT_INFO_KEY = "_ytptube_reextract"
def _ytdlp_logger(target: logging.Logger):
return _YTDLPLogger(target)
@ -247,6 +250,23 @@ def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
return sanitized
def needs_reextract(info: dict[str, Any]) -> bool:
return bool(info.get("is_live") or info.get("live_status") in LIVE_REEXTRACT_STATUSES)
def _process_safe_info(info: dict[str, Any]) -> dict[str, Any]:
if needs_reextract(info):
info = dict(info)
info[REEXTRACT_INFO_KEY] = True
for key in ("formats", "requested_formats", "requested_downloads", "fragments"):
info.pop(key, None)
if _is_picklable(info):
return info
return YTDLP.sanitize_info(info, remove_private_keys=False)
def extract_info_sync(
config: dict[str, Any],
url: str,
@ -255,6 +275,7 @@ def extract_info_sync(
follow_redirect: bool = False,
sanitize_info: bool = False,
capture_logs: int | None = None,
process_safe: bool = False,
**kwargs,
) -> tuple[dict[str, Any] | None, list[str]]:
"""
@ -268,6 +289,7 @@ def extract_info_sync(
follow_redirect: Follow URL redirects
sanitize_info: Sanitize the extracted information
capture_logs: If provided (e.g., logging.WARNING), capture logs at this level.
process_safe: Strip non-pickleable data for safe inter-process communication.
**kwargs: Additional arguments
Returns:
@ -330,6 +352,7 @@ def extract_info_sync(
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
capture_logs=capture_logs,
process_safe=process_safe,
captured_logs=captured_logs,
**kwargs,
)
@ -348,6 +371,9 @@ def extract_info_sync(
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
if process_safe and isinstance(result, dict):
result = _process_safe_info(result)
return (result, captured_logs)
finally:
logging.Logger.manager.loggerDict.pop(logger_name, None)
@ -404,6 +430,8 @@ async def fetch_info(
loop = asyncio.get_running_loop()
safe_config = _sanitize_config(config)
safe_kwargs = _sanitize_picklable(kwargs)
safe_kwargs.pop("process_safe", None)
timeout = _sleep_timeout(safe_config, extractor_config.timeout, budget_sleep)
try:
@ -422,7 +450,8 @@ async def fetch_info(
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
capture_logs=capture_logs,
**kwargs,
process_safe=True,
**safe_kwargs,
),
),
timeout=timeout,

View file

@ -5,8 +5,10 @@ from unittest.mock import MagicMock, patch
from app.features.ytdlp.extractor import (
ExtractorConfig,
ExtractorPool,
REEXTRACT_INFO_KEY,
_LogCapture,
_get_process_pool_kwargs,
_process_safe_info,
_ytdlp_logger,
extract_info_sync,
)
@ -158,6 +160,44 @@ class TestExtractInfo:
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
assert (logging.WARNING, "[generic_browser] Browser fallback warning") in seen
def test_process_safe_live(self) -> None:
data = {
"id": "live-id",
"is_live": True,
"formats": [{"format_id": "dash", "fragments": ({"url": "https://example.test/sq/1"} for _ in range(1))}],
"requested_formats": [{"format_id": "dash"}],
}
result = _process_safe_info(data)
assert result[REEXTRACT_INFO_KEY] is True
assert "formats" not in result
assert "requested_formats" not in result
pickle.dumps(result)
def test_process_safe_post_live(self) -> None:
data = {
"id": "post-live-id",
"live_status": "post_live",
"formats": [{"format_id": "dash", "fragments": ({"url": "https://example.test/sq/1"} for _ in range(1))}],
}
result = _process_safe_info(data)
assert result[REEXTRACT_INFO_KEY] is True
assert "formats" not in result
pickle.dumps(result)
def test_process_safe_lazy(self) -> None:
from yt_dlp.utils import LazyList
data = {"id": "video-id", "formats": LazyList({"format_id": str(i)} for i in range(2))}
result = _process_safe_info(data)
assert result["formats"] == [{"format_id": "0"}, {"format_id": "1"}]
pickle.dumps(result)
class TestYtdlpLogger:
def test_debug_prefix_uses_debug(self) -> None:

760
app/library/diagnostics.py Normal file
View file

@ -0,0 +1,760 @@
from __future__ import annotations
import asyncio
import importlib.metadata
import os
import platform
import re
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from app.library.config import Config
from app.library.httpx_client import resolve_curl_transport
CheckStatus = Literal["pass", "fail", "warn", "skip"]
ReportStatus = Literal["ok", "degraded", "error"]
MIN_PYTHON: tuple[int, int] = (3, 13)
@dataclass(kw_only=True)
class DiagnosticCheck:
id: str
label: str
group: str
required: bool
status: CheckStatus
description: str
message: str
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"label": self.label,
"group": self.group,
"required": self.required,
"status": self.status,
"description": self.description,
"message": self.message,
"details": self.details,
}
@dataclass(frozen=True, kw_only=True)
class CheckMeta:
group: str
description: str
BINARY_META: dict[str, CheckMeta] = {
"ffmpeg": CheckMeta(
group="core", description="Used for thumbnails and streaming and various media processing tasks."
),
"ffprobe": CheckMeta(group="core", description="Used for media inspection and metadata extraction."),
"deno": CheckMeta(group="youtube", description="Used for yt-dlp YouTube support."),
"yt_dlp_cli": CheckMeta(group="core", description="Used by the built-in terminal."),
"aria2c": CheckMeta(group="advanced", description="External downloader for yt-dlp."),
"mkvpropedit": CheckMeta(group="advanced", description="Edit Matroska container properties without remux."),
"mkvextract": CheckMeta(group="advanced", description="Extract tracks from Matroska containers."),
"mp4box": CheckMeta(group="advanced", description="MP4 container manipulation tool."),
}
DIRECTORY_DESCRIPTIONS: dict[str, str] = {
"config_path": "Directory for config files and app data.",
"download_path": "Directory where downloads are saved.",
"temp_path": "Directory used for temporary files.",
}
def _first_line(*parts: str) -> str:
for part in parts:
for line in part.splitlines():
if line := line.strip():
return line
return ""
def _bin_version(value: str) -> str:
if not (line := value.strip()):
return ""
if match := re.match(r"^deno\s+([0-9][^\s]*)", line, flags=re.IGNORECASE):
return match.group(1).strip()
if match := re.match(r"^mkv\w+\s+v([0-9][^\s]*)", line, flags=re.IGNORECASE):
return match.group(1).strip()
if match := re.search(r"\bversion\s+(.+?)(?:\s+Copyright\b|$)", line, flags=re.IGNORECASE):
return match.group(1).strip()
return line
def _parse_browser_url(value: str) -> tuple[str, str]:
if value.startswith(("selenium+http://", "selenium+https://")):
return "selenium", value.removeprefix("selenium+")
if value.startswith(("playwright+ws://", "playwright+wss://")):
return "playwright", value.removeprefix("playwright+")
if value.startswith("playwright+cdp://"):
return "playwright", f"http://{value.removeprefix('playwright+cdp://')}"
if value.startswith("playwright+cdp+"):
return "playwright", value.removeprefix("playwright+cdp+")
return "unknown", value
def _safe_url(url: str) -> str:
try:
parsed = urllib.parse.urlsplit(url)
except Exception:
return "***"
if not parsed.scheme or not parsed.netloc:
return "***"
netloc = parsed.netloc
if parsed.username or parsed.password:
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
netloc = f"***:***@{host}" if host else "***:***"
path = "/***" if parsed.path and parsed.path != "/" else parsed.path
query = "***" if parsed.query else ""
fragment = "***" if parsed.fragment else ""
return urllib.parse.urlunsplit((parsed.scheme, netloc, path, query, fragment))
def _package_version(name: str) -> str | None:
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return None
def _package_check(
name: str,
*,
label: str,
group: str,
required: bool,
description: str,
present_message: str = "Installed.",
missing_message: str = "Missing.",
missing_status: CheckStatus = "skip",
check_id: str | None = None,
details: dict[str, Any] | None = None,
) -> DiagnosticCheck:
version = _package_version(name)
installed = version is not None
base_details: dict[str, Any] = {"package": name, "version": version}
if details:
base_details.update(details)
return DiagnosticCheck(
id=check_id or name.replace("-", "_"),
label=label,
group=group,
required=required,
status="pass" if installed else ("fail" if required else missing_status),
description=description,
message=present_message if installed else missing_message,
details=base_details,
)
def _check_ytdlp_package() -> DiagnosticCheck:
version = Config._ytdlp_version()
installed = version != "0.0.0"
return DiagnosticCheck(
id="yt_dlp_package",
label="yt-dlp package",
group="core",
required=True,
status="pass" if installed else "fail",
description="Main downloader library used by the app.",
message="Installed." if installed else "Missing.",
details={"version": version},
)
def _check_apprise_package(config: Config) -> DiagnosticCheck:
configured = Path(config.apprise_config).exists()
return _package_check(
"apprise",
label="Apprise",
group="notifications",
required=False,
description="Sends notification targets.",
present_message="Installed.",
missing_message="Missing." if configured else "Not installed.",
missing_status="warn" if configured else "skip",
check_id="apprise",
details={"config": str(config.apprise_config) if configured else None},
)
def _check_curl_transport() -> DiagnosticCheck:
available = resolve_curl_transport(use_curl=True)
version = _package_version("httpx-curl-cffi")
return DiagnosticCheck(
id="curl_transport",
label="curl-cffi transport",
group="advanced",
required=False,
status="pass" if available else "skip",
description="Transport for impersonation support.",
message="Installed." if available else "Not installed.",
details={"package": "httpx-curl-cffi", "version": version},
)
def _check_pot_provider_package() -> DiagnosticCheck:
return _package_check(
"bgutil-ytdlp-pot-provider",
check_id="pot_provider_plugin",
label="POT provider plugin",
group="youtube",
required=False,
description="Optional plugin for external POT token providers for youtube.",
present_message="Installed.",
missing_message="Not installed.",
)
def _check_configured_pip_packages(config: Config) -> list[DiagnosticCheck]:
checks: list[DiagnosticCheck] = []
for raw_pkg in config.pip_packages.split(" "):
pkg = raw_pkg.strip()
if not pkg:
continue
name = pkg.split("==", 1)[0].split(">=", 1)[0].split("<=", 1)[0].strip()
installed = False
version = None
try:
version = importlib.metadata.version(name)
installed = True
except importlib.metadata.PackageNotFoundError:
installed = False
checks.append(
DiagnosticCheck(
id=f"pip_{name.replace('-', '_')}",
label=name,
group="custom",
required=False,
status="pass" if installed else "warn",
description="Configured extra pip package.",
message="Installed." if installed else "Missing.",
details={"package": name, "requested": pkg, "version": version},
)
)
return checks
def _check_browser_endpoint() -> DiagnosticCheck:
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
if not raw_url:
return DiagnosticCheck(
id="browser_endpoint",
label="Remote browser",
group="advanced",
required=False,
status="skip",
description="Endpoint used by the browser extractor.",
message="Not configured.",
details={},
)
engine, parsed_url = _parse_browser_url(raw_url)
if engine == "unknown":
return DiagnosticCheck(
id="browser_endpoint",
label="Remote browser",
group="advanced",
required=False,
status="warn",
description="Endpoint used by the browser extractor.",
message="Invalid URL.",
details={"endpoint": _safe_url(raw_url)},
)
return DiagnosticCheck(
id="browser_endpoint",
label="Remote browser",
group="advanced",
required=False,
status="pass",
description="Endpoint used by the browser extractor.",
message="Configured.",
details={
"engine": engine,
"endpoint": _safe_url(parsed_url),
},
)
def _check_browser_client() -> DiagnosticCheck:
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
if not raw_url:
return DiagnosticCheck(
id="browser_client",
label="Browser client",
group="advanced",
required=False,
status="skip",
description="Client package required for the configured browser backend.",
message="Not configured.",
details={},
)
engine, _ = _parse_browser_url(raw_url)
if engine == "unknown":
return DiagnosticCheck(
id="browser_client",
label="Browser client",
group="advanced",
required=False,
status="skip",
description="Client package required for the configured browser backend.",
message="Invalid URL.",
details={},
)
package_name = "selenium" if engine == "selenium" else "playwright"
version = _package_version(package_name)
return DiagnosticCheck(
id="browser_client",
label="Browser client",
group="advanced",
required=False,
status="pass" if version else "warn",
description="Client package required for the configured browser backend.",
message="Installed." if version else "Missing.",
details={"package": package_name, "version": version},
)
def _check_flaresolverr(config: Config) -> DiagnosticCheck:
endpoint = (config.flaresolverr_url or "").strip()
if not endpoint:
return DiagnosticCheck(
id="flaresolverr",
label="FlareSolverr",
group="advanced",
required=False,
status="skip",
description="Optional Cloudflare challenge bypass service.",
message="Not configured.",
details={},
)
parsed = urllib.parse.urlsplit(endpoint)
valid = parsed.scheme in {"http", "https"} and bool(parsed.netloc)
return DiagnosticCheck(
id="flaresolverr",
label="FlareSolverr",
group="advanced",
required=False,
status="pass" if valid else "warn",
description="Optional Cloudflare challenge bypass service.",
message="Configured." if valid else "Invalid URL.",
details={"endpoint": _safe_url(endpoint)},
)
def _probe_directory(path: Path) -> tuple[CheckStatus, str]:
if not path.exists():
return "fail", "Directory does not exist."
if not path.is_dir():
return "fail", "Path is not a directory."
if not os.access(path, os.R_OK | os.W_OK | os.X_OK):
return "fail", "Not readable or writable."
return "pass", "Writable."
async def _check_directory(path: Path, *, check_id: str, label: str) -> DiagnosticCheck:
status, message = await asyncio.to_thread(_probe_directory, path)
return DiagnosticCheck(
id=check_id,
label=label,
group="core",
required=True,
status=status,
description=DIRECTORY_DESCRIPTIONS.get(check_id, "Directory check."),
message=message,
details={"path": str(path)},
)
def _probe_db_file(path: Path) -> tuple[CheckStatus, str]:
if not path.exists():
return "fail", "Missing."
if not path.is_file():
return "fail", "Invalid path."
if not os.access(path, os.R_OK):
return "fail", "Not readable."
if not os.access(path, os.W_OK):
return "fail", "Not writable."
try:
uri = f"file:{urllib.parse.quote(str(path))}?mode=ro"
with sqlite3.connect(uri, timeout=3, uri=True) as conn:
conn.execute("SELECT name FROM sqlite_master LIMIT 1")
except sqlite3.Error as exc:
return "fail", f"SQLite error. {exc!s}"
return "pass", "Ready."
async def _check_db_file(path: Path) -> DiagnosticCheck:
status, message = await asyncio.to_thread(_probe_db_file, path)
return DiagnosticCheck(
id="database",
label="SQLite database",
group="core",
required=True,
status=status,
description="Local database used for app state and history.",
message=message,
details={"path": str(path)},
)
async def _run_command(command: str, *args: str, wait_seconds: float = 5.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
command,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=wait_seconds)
except TimeoutError:
proc.kill()
await proc.communicate()
raise
return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
def _binary_meta(check_id: str) -> CheckMeta:
return BINARY_META.get(check_id, CheckMeta(group="core", description="Command line tool."))
def _make_binary_check(
command: str,
*,
check_id: str,
label: str,
required: bool,
status: CheckStatus,
message: str,
path: str | None = None,
version: str | None = None,
) -> DiagnosticCheck:
meta = _binary_meta(check_id)
details: dict[str, Any] = {"command": command}
if path:
details["path"] = path
if version:
details["version"] = version
return DiagnosticCheck(
id=check_id,
label=label,
group=meta.group,
required=required,
status=status,
description=meta.description,
message=message,
details=details,
)
def _resolve_binary(*names: str) -> tuple[str, str | None]:
for name in names:
path = shutil.which(name)
if path:
return name, path
return names[0], None
async def _check_binary(
command: str,
*,
check_id: str,
label: str,
required: bool,
args: tuple[str, ...] = ("--version",),
enabled: bool = True,
disabled_message: str = "Check is not applicable.",
missing_status: CheckStatus | None = None,
aliases: tuple[str, ...] = (),
) -> DiagnosticCheck:
if not enabled:
return _make_binary_check(
command,
check_id=check_id,
label=label,
required=required,
status="skip",
message=disabled_message,
)
resolved_name, command_path = _resolve_binary(command, *aliases)
not_found_status: CheckStatus = missing_status or ("fail" if required else "warn")
if not command_path:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status=not_found_status,
message="Missing." if not_found_status == "fail" else "Not installed.",
)
try:
return_code, stdout, stderr = await _run_command(resolved_name, *args)
except FileNotFoundError:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status=not_found_status,
message="Missing." if not_found_status == "fail" else "Not installed.",
path=command_path,
)
except TimeoutError:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status="fail" if required else "warn",
message="Timed out.",
path=command_path,
)
except Exception as exc:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status="fail" if required else "warn",
message=f"Error. {exc!s}",
path=command_path,
)
version: str = _bin_version(_first_line(stdout, stderr))
status: CheckStatus = "pass" if return_code == 0 else ("fail" if required else "warn")
message: str = "Available." if return_code == 0 else f"Exit code {return_code}."
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status=status,
message=message,
path=command_path,
version=version,
)
def _make_runtime(config: Config) -> dict[str, Any]:
started = int(config.started) if config.started else 0
uptime_seconds = max(int(time.time()) - started, 0) if started else 0
return {
"app_version": config.app_version,
"app_branch": config.app_branch,
"app_commit_sha": config.app_commit_sha,
"app_build_date": config.app_build_date,
"started": started,
"uptime_seconds": uptime_seconds,
"platform": platform.system().lower(),
"platform_release": platform.release(),
"platform_machine": platform.machine(),
"python_version": platform.python_version(),
"python_minimum": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
"is_native": config.is_native,
"console_enabled": config.console_enabled,
}
def _requirements() -> dict[str, Any]:
return {
"python": {
"current": platform.python_version(),
"required": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
"supported": sys.version_info[:2] >= MIN_PYTHON,
"note": "",
}
}
def _summarize(checks: list[DiagnosticCheck]) -> tuple[ReportStatus, dict[str, int]]:
summary = {"total": len(checks), "pass": 0, "fail": 0, "warn": 0, "skip": 0, "required_failed": 0}
for check in checks:
summary[check.status] += 1
if check.required and check.status == "fail":
summary["required_failed"] += 1
if summary["required_failed"] > 0:
status: ReportStatus = "error"
elif summary["warn"] > 0 or any(check.status == "fail" for check in checks):
status = "degraded"
else:
status = "ok"
return status, summary
def _make_report(config: Config, checks: list[DiagnosticCheck]) -> dict[str, Any]:
status, summary = _summarize(checks)
return {
"status": status,
"generated_at": int(time.time()),
"summary": summary,
"runtime": _make_runtime(config),
"requirements": _requirements(),
"checks": [check.to_dict() for check in checks],
}
def diagnostics_error_report(config: Config) -> dict[str, Any]:
return _make_report(
config,
[
DiagnosticCheck(
id="diagnostics",
label="Diagnostics",
group="core",
required=True,
status="fail",
description="Diagnostics collector.",
message="Diagnostics collection failed.",
details={},
)
],
)
async def collect_diagnostics(config: Config) -> dict[str, Any]:
checks: list[DiagnosticCheck] = [
_check_ytdlp_package(),
_check_apprise_package(config),
_check_curl_transport(),
_check_pot_provider_package(),
_check_browser_endpoint(),
_check_browser_client(),
_check_flaresolverr(config),
]
checks.extend(_check_configured_pip_packages(config))
storage_checks, binary_checks = await asyncio.gather(
asyncio.gather(
_check_directory(Path(config.config_path), check_id="config_path", label="Config directory"),
_check_directory(Path(config.download_path), check_id="download_path", label="Download directory"),
_check_directory(Path(config.temp_path), check_id="temp_path", label="Temp directory"),
_check_db_file(Path(config.db_file)),
),
asyncio.gather(
_check_binary(
"ffmpeg",
check_id="ffmpeg",
label="ffmpeg",
required=True,
args=("-version",),
),
_check_binary(
"ffprobe",
check_id="ffprobe",
label="ffprobe",
required=True,
args=("-version",),
),
_check_binary(
"deno",
check_id="deno",
label="deno",
required=True,
),
_check_binary(
"yt-dlp",
check_id="yt_dlp_cli",
label="yt-dlp CLI",
required=True,
enabled=config.console_enabled,
disabled_message="Disabled.",
),
_check_binary(
"aria2c",
check_id="aria2c",
label="aria2c",
required=False,
missing_status="skip",
),
_check_binary(
"mkvpropedit",
check_id="mkvpropedit",
label="mkvpropedit",
required=False,
missing_status="skip",
),
_check_binary(
"mkvextract",
check_id="mkvextract",
label="mkvextract",
required=False,
missing_status="skip",
),
_check_binary(
"MP4Box",
check_id="mp4box",
label="MP4Box",
required=False,
missing_status="skip",
args=("-version",),
aliases=("mp4box",),
),
),
)
checks.extend(storage_checks)
checks.extend(binary_checks)
return _make_report(config, checks)

View file

@ -21,7 +21,7 @@ from app.library.config import Config
from app.library.Events import EventBus, Events
from app.library.Utils import create_cookies_file
from ...features.ytdlp.extractor import extract_info_sync
from ...features.ytdlp.extractor import REEXTRACT_INFO_KEY, extract_info_sync
from .hooks import HookHandlers, NestedLogger
from .process_manager import ProcessManager
from .status_tracker import StatusTracker
@ -147,11 +147,16 @@ class Download:
raise ValueError(err_msg) from e
if self.info_dict and isinstance(self.info_dict, dict):
if self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and self.info.get_preset().default:
if self.info_dict.get(REEXTRACT_INFO_KEY):
self.logger.info(
f"Info dict for '{self.info.url}' marked for re-extraction, ignoring pre-extracted info."
)
self.info_dict = None
elif self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and self.info.get_preset().default:
self.logger.debug(f"Removing 'download_archive' for generic extractor. url={self.info.url}")
params.pop("download_archive", None)
if self.download_info_expires > 0:
if self.info_dict and self.download_info_expires > 0:
_ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None))
_ts_dt = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None
if not _ts_dt or (datetime.now(tz=UTC) - _ts_dt).total_seconds() > self.download_info_expires:

View file

@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta
from email.utils import formatdate
from typing import TYPE_CHECKING
from app.features.ytdlp.extractor import REEXTRACT_INFO_KEY, needs_reextract
from app.features.ytdlp.utils import extract_ytdlp_logs, get_extras
from app.library.downloads import Download
from app.library.Events import Events
@ -125,12 +126,13 @@ async def add_video(queue: "DownloadQueue", entry: dict, item: "Item", logs: lis
)
try:
dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start else None, logs=logs)
_reextract: bool = bool(entry.get(REEXTRACT_INFO_KEY) or needs_reextract(entry))
dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start and not _reextract else None, logs=logs)
nEvent: str | None = None
nTitle: str | None = None
nMessage: str | None = None
nStore: str = "queue"
hasFormats: bool = len(entry.get("formats", [])) > 0 or entry.get("url")
hasFormats: bool = _reextract or bool(entry.get("formats") or entry.get("url"))
text_logs: str = ""
if filtered_logs := extract_ytdlp_logs(logs):

View file

@ -10,7 +10,9 @@ from aiohttp.web_runner import GracefulExit
from app.features.dl_fields.service import DLFields
from app.features.presets.service import Presets
from app.library.cache import Cache
from app.library.config import Config
from app.library.diagnostics import collect_diagnostics, diagnostics_error_report
from app.library.downloads import DownloadQueue
from app.library.downloads.core import Download
from app.library.encoder import Encoder
@ -21,6 +23,8 @@ from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
LOG: logging.Logger = logging.getLogger(__name__)
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
DIAGNOSTICS_CACHE_TTL = 5.0
@route("GET", "api/system/configuration", "system.configuration")
@ -225,6 +229,29 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update
)
@route("GET", "api/system/diagnostics", "system.diagnostics")
async def system_diagnostics(
config: Config, encoder: Encoder, request: Request | None = None, cache: Cache | None = None
) -> Response:
"""Return user-facing runtime diagnostics for standalone installs."""
cache_key = "system:diagnostics"
cache = cache or Cache.get_instance()
use_cache: bool = request is None or request.query.get("refresh") not in {"1", "true", "yes"}
if use_cache and (data := cache.get(cache_key)) is not None:
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
try:
data = await collect_diagnostics(config)
except Exception:
LOG.exception("Diagnostics collection failed.")
data = diagnostics_error_report(config)
else:
cache.set(cache_key, data, ttl=60)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
async def _validate_terminal_command_request(request: Request) -> str | Response:
if not request.can_read_body:
return web.json_response(

View file

@ -1,6 +1,7 @@
import logging
import os
import signal
import time
from multiprocessing.reduction import ForkingPickler
from pathlib import Path
from types import SimpleNamespace
@ -17,6 +18,7 @@ from app.library.downloads.process_manager import ProcessManager
from app.library.downloads.queue_manager import DownloadQueue
from app.library.downloads.status_tracker import StatusTracker
from app.library.downloads.temp_manager import TempManager
from app.library.downloads.video_processor import add_video
from app.library.ItemDTO import ItemDTO
@ -292,7 +294,12 @@ class TestDownloadFlow:
download = Download(
info=make_item(),
info_dict={"id": "test-id", "url": "http://u", "formats": [{"format_id": "18"}]},
info_dict={
"id": "test-id",
"url": "http://u",
"formats": [{"format_id": "18"}],
"epoch": int(time.time()),
},
)
download.status_queue = cast(Any, DummyQueue())
download._hook_handlers = Mock(
@ -326,6 +333,7 @@ class TestDownloadFlow:
queue = cast(DummyQueue, download.status_queue)
assert queue.items[0]["download_skipped"] is True
assert queue.items[1]["status"] == "finished"
assert queue.items[1]["download_skipped"] is True
def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None:
@ -1205,6 +1213,88 @@ class TestStatusTracker:
class TestQueueManager:
@staticmethod
def _video_queue() -> Mock:
async def put(item):
return item
queue_manager = Mock()
queue_manager.config = Mock(
download_path="/tmp",
temp_path="/tmp",
output_template="%(title)s.%(ext)s",
output_template_chapter="%(title)s.%(ext)s",
prevent_live_premiere=False,
)
queue_manager.done.get = AsyncMock(side_effect=KeyError)
queue_manager.queue.get = AsyncMock(side_effect=KeyError)
queue_manager.done.put = AsyncMock(side_effect=put)
queue_manager.queue.put = AsyncMock(side_effect=put)
queue_manager.pool.trigger_download = Mock()
queue_manager._notify.emit = Mock()
return queue_manager
@staticmethod
def _video_item() -> SimpleNamespace:
return SimpleNamespace(
extras={},
folder="",
preset="default",
cookies=None,
template=None,
cli=[],
auto_start=True,
)
@pytest.mark.asyncio
async def test_live_reextracts(self, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[dict | None] = []
def fake_download(*, info, info_dict, logs):
seen.append(info_dict)
return SimpleNamespace(info=info, info_dict=info_dict, logs=logs)
monkeypatch.setattr("app.library.downloads.video_processor.Download", fake_download)
result = await add_video(
queue=self._video_queue(),
item=self._video_item(),
entry={
"id": "live-id",
"title": "Live stream",
"webpage_url": "https://example.test/live",
"is_live": True,
"live_status": "is_live",
"_ytptube_reextract": True,
},
)
assert result == {"status": "ok"}
assert seen == [None]
@pytest.mark.asyncio
async def test_regular_reuses_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[dict | None] = []
def fake_download(*, info, info_dict, logs):
seen.append(info_dict)
return SimpleNamespace(info=info, info_dict=info_dict, logs=logs)
monkeypatch.setattr("app.library.downloads.video_processor.Download", fake_download)
entry = {
"id": "video-id",
"title": "Video",
"webpage_url": "https://example.test/video",
"live_status": "not_live",
"formats": [{"format_id": "18"}],
}
result = await add_video(queue=self._video_queue(), item=self._video_item(), entry=entry)
assert result == {"status": "ok"}
assert seen == [entry]
@pytest.mark.asyncio
async def test_cleanup_thumbnails(self, tmp_path: Path) -> None:
from app.library.downloads.monitors import cleanup_thumbnails

View file

@ -92,3 +92,30 @@ async def test_pool_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
assert loop.calls == [pool.executor, None]
assert seen == [130, 130]
assert not pool.semaphore.locked()
@pytest.mark.asyncio
async def test_pool_process_safe(monkeypatch: pytest.MonkeyPatch) -> None:
pool = _Pool()
loop = _Loop()
expected = ({"id": "ok"}, [])
sync = Mock(return_value=expected)
async def fake_wait_for(*, fut, timeout): # noqa: ARG001
return fut()
monkeypatch.setattr(extractor.ExtractorPool, "get_instance", classmethod(lambda cls: pool))
monkeypatch.setattr(extractor.asyncio, "get_running_loop", lambda: loop)
monkeypatch.setattr(extractor.asyncio, "wait_for", fake_wait_for)
monkeypatch.setattr(extractor, "extract_info_sync", sync)
result = await extractor.fetch_info(
config={},
url="https://example.com",
extractor_config=extractor.ExtractorConfig(concurrency=1, timeout=70),
)
assert result == expected
assert loop.calls == [pool.executor]
assert sync.call_args.kwargs["process_safe"] is True
assert not pool.semaphore.locked()

View file

@ -1,13 +1,15 @@
import json
from dataclasses import dataclass
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from app.library.config import Config
from app.library.cache import Cache
from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker
from app.routes.api.system import check_updates, system_limits
from app.routes.api.system import check_updates, system_diagnostics, system_limits
@dataclass
@ -193,3 +195,203 @@ class TestSystemLimitsEndpoint:
config = Config.get_instance()
assert config.prevent_live_premiere is False
class TestSystemDiagnosticsEndpoint:
def setup_method(self):
Config._reset_singleton()
Cache.get_instance().clear()
@pytest.mark.asyncio
async def test_diagnostics_always_200(self, tmp_path: Path):
config = Config.get_instance()
config.config_path = str(tmp_path / "config")
config.download_path = str(tmp_path / "downloads")
config.temp_path = str(tmp_path / "tmp")
config.db_file = str(tmp_path / "config" / "ytptube.db")
config.console_enabled = False
Path(config.config_path).mkdir(parents=True)
Path(config.download_path).mkdir(parents=True)
Path(config.temp_path).mkdir(parents=True)
Path(config.db_file).touch()
encoder = Encoder()
with (
patch("app.library.diagnostics.Config._ytdlp_version", return_value="2026.01.01"),
patch("app.library.diagnostics.shutil.which") as mock_which,
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}" if cmd in {"ffmpeg", "ffprobe", "deno"} else None
mock_run.side_effect = [
(0, "ffmpeg version 7.0", ""),
(0, "ffprobe version 7.0", ""),
(0, "deno 2.3.0", ""),
]
response = await system_diagnostics(config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["status"] == "ok"
assert body["summary"]["required_failed"] == 0
checks = {item["id"]: item for item in body["checks"]}
assert checks["deno"]["status"] == "pass"
assert checks["deno"]["details"]["version"] == "2.3.0"
assert checks["yt_dlp_cli"]["status"] == "skip"
@pytest.mark.asyncio
async def test_diagnostics_error_payload(self):
config = Config.get_instance()
encoder = Encoder()
with patch("app.routes.api.system.collect_diagnostics", new_callable=AsyncMock) as mock_collect:
mock_collect.side_effect = RuntimeError("boom")
response = await system_diagnostics(config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["status"] == "error"
assert body["summary"]["required_failed"] >= 1
assert len(body["checks"]) == 1
assert body["checks"][0]["status"] == "fail"
def test_distribution_package_detection(self):
from app.library.diagnostics import _check_pot_provider_package
with patch("app.library.diagnostics._package_version", return_value="1.3.1"):
check = _check_pot_provider_package()
assert check.status == "pass"
assert check.details["version"] == "1.3.1"
assert "description" not in check.details
def test_safe_details(self):
from app.library.diagnostics import _safe_url
assert _safe_url("https://user:pass@example.test/token/path?x=1#frag") == (
"https://***:***@example.test/***?***#***"
)
@pytest.mark.asyncio
async def test_binary_timeout(self):
from app.library.diagnostics import _check_binary
with (
patch("app.library.diagnostics.shutil.which", return_value="/usr/local/bin/deno"),
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_run.side_effect = TimeoutError
check = await _check_binary("deno", check_id="deno", label="deno", required=True)
assert check.status == "fail"
assert check.details["command"] == "deno"
@pytest.mark.asyncio
async def test_diagnostics_marks_required_missing(self, tmp_path: Path):
config = Config.get_instance()
config.config_path = str(tmp_path / "config")
config.download_path = str(tmp_path / "downloads")
config.temp_path = str(tmp_path / "tmp")
config.db_file = str(tmp_path / "config" / "ytptube.db")
config.console_enabled = True
Path(config.config_path).mkdir(parents=True)
Path(config.download_path).mkdir(parents=True)
Path(config.temp_path).mkdir(parents=True)
Path(config.db_file).touch()
encoder = Encoder()
with (
patch("app.library.diagnostics.Config._ytdlp_version", return_value="2026.01.01"),
patch("app.library.diagnostics.shutil.which") as mock_which,
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_which.side_effect = lambda cmd: (
"/usr/bin/ffprobe" if cmd == "ffprobe" else "/usr/bin/yt-dlp" if cmd == "yt-dlp" else None
)
mock_run.side_effect = [
(0, "ffprobe version 7.0", ""),
(0, "yt-dlp 2026.01.01", ""),
]
response = await system_diagnostics(config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["status"] == "error"
assert body["summary"]["required_failed"] >= 1
checks = {item["id"]: item for item in body["checks"]}
assert checks["ffmpeg"]["status"] == "fail"
assert checks["deno"]["status"] == "fail"
assert checks["yt_dlp_cli"]["status"] == "pass"
assert checks["aria2c"]["status"] == "skip"
assert checks["mkvpropedit"]["status"] == "skip"
assert checks["mkvextract"]["status"] == "skip"
assert checks["mp4box"]["status"] == "skip"
@pytest.mark.asyncio
async def test_alias_fallback_mp4box(self):
from app.library.diagnostics import _check_binary
with (
patch("app.library.diagnostics.shutil.which") as mock_which,
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_which.side_effect = lambda cmd: "/usr/bin/mp4box" if cmd == "mp4box" else None
mock_run.return_value = (0, "MP4Box 2.2.1", "")
check = await _check_binary(
"MP4Box",
check_id="mp4box",
label="MP4Box",
required=False,
missing_status="skip",
aliases=("mp4box",),
)
assert check.status == "pass"
assert check.details["command"] == "mp4box"
assert check.details["version"] == "MP4Box 2.2.1"
@pytest.mark.asyncio
async def test_optional_binary_present(self):
from app.library.diagnostics import _check_binary
with (
patch("app.library.diagnostics.shutil.which", return_value="/usr/bin/aria2c"),
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_run.return_value = (0, "aria2 version 1.37.0", "")
check = await _check_binary(
"aria2c",
check_id="aria2c",
label="aria2c",
required=False,
missing_status="skip",
)
assert check.status == "pass"
assert check.details["version"] == "1.37.0"
@pytest.mark.asyncio
async def test_optional_binary_missing_skip(self):
from app.library.diagnostics import _check_binary
with patch("app.library.diagnostics.shutil.which", return_value=None):
check = await _check_binary(
"mkvpropedit",
check_id="mkvpropedit",
label="mkvpropedit",
required=False,
missing_status="skip",
)
assert check.status == "skip"

View file

@ -0,0 +1,100 @@
import { computed, readonly, ref } from 'vue';
import { useNotification } from '~/composables/useNotification';
import type { DiagnosticCheck, DiagnosticsResponse } from '~/types/diagnostics';
import { parse_api_error, parse_api_response, request } from '~/utils';
const diagnostics = ref<DiagnosticsResponse | null>(null);
const isLoading = ref(false);
const lastError = ref<string | null>(null);
const throwInstead = ref(false);
const groupedChecks = computed<Record<string, Array<DiagnosticCheck>>>(() => {
return (diagnostics.value?.checks ?? []).reduce<Record<string, Array<DiagnosticCheck>>>(
(acc, check) => {
if (!acc[check.group]) {
acc[check.group] = [];
}
acc[check.group]?.push(check);
return acc;
},
{},
);
});
const groupOrder = computed<Array<string>>(() => Object.keys(groupedChecks.value));
const readJson = async (response: Response): Promise<unknown> => {
try {
return await response.clone().json();
} catch {
return null;
}
};
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return;
}
const payload = await readJson(response);
throw new Error(await parse_api_error(payload));
};
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Failed to load diagnostics.';
lastError.value = message;
useNotification().error(message);
};
const loadDiagnostics = async (force: boolean = false): Promise<DiagnosticsResponse | null> => {
if (isLoading.value) {
return diagnostics.value;
}
if (diagnostics.value && !force) {
return diagnostics.value;
}
isLoading.value = true;
lastError.value = null;
try {
const response = await request(`/api/system/diagnostics${force ? '?refresh=1' : ''}`);
await ensureSuccess(response);
diagnostics.value = await parse_api_response<DiagnosticsResponse>(response.json());
return diagnostics.value;
} catch (error) {
handleError(error);
if (throwInstead.value) {
throw error;
}
return null;
} finally {
isLoading.value = false;
}
};
const clearError = (): void => {
lastError.value = null;
};
const __resetForTesting = (): void => {
diagnostics.value = null;
isLoading.value = false;
lastError.value = null;
throwInstead.value = false;
};
export const useDiagnostics = () => ({
diagnostics: readonly(diagnostics),
isLoading: readonly(isLoading),
lastError: readonly(lastError),
groupedChecks,
groupOrder,
loadDiagnostics,
clearError,
throwInstead,
__resetForTesting,
});

View file

@ -0,0 +1,551 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !report"
@click="copyDiagnostics"
>
Copy
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="void load(true)"
>
Refresh
</UButton>
</div>
</div>
<div
v-if="!report && isLoading"
class="flex min-h-72 items-center justify-center rounded-md border border-default bg-default/90"
>
<div class="flex flex-col items-center gap-3 text-center text-toned">
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
<div class="space-y-1">
<p class="text-sm font-medium text-default">Loading</p>
<p class="text-xs">Reading tools, paths, and configured extras.</p>
</div>
</div>
</div>
<UAlert
v-else-if="!report && lastError"
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Diagnostics failed"
:description="lastError"
/>
<template v-else-if="report">
<UAlert
v-if="showRequiredAlert"
color="error"
variant="soft"
icon="i-lucide-octagon-alert"
title="Required items missing"
:description="requiredAlertDescription"
/>
<section class="rounded-md border border-default bg-default shadow-sm">
<div class="border-b border-default px-4 py-3 sm:px-5">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
<span>Overview</span>
</div>
</div>
<div class="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-4">
<article
v-for="item in summaryCards"
:key="item.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-toned">
{{ item.label }}
</p>
<p class="mt-1 text-xs text-toned">{{ item.description }}</p>
</div>
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-default bg-default"
>
<UIcon :name="item.icon" class="size-4 text-toned" />
</span>
</div>
<p :class="['mt-4 text-2xl font-semibold', item.valueClass]">{{ item.value }}</p>
</article>
</div>
</section>
<section class="rounded-md border border-default bg-default shadow-sm">
<div class="border-b border-default px-4 py-3 sm:px-5">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-server" class="size-4 text-toned" />
<span>Runtime</span>
</div>
</div>
<div class="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-3">
<article
v-for="row in runtimeRows"
:key="row.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start gap-3">
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-default bg-default"
>
<UIcon :name="row.icon" class="size-4 text-toned" />
</span>
<div class="min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-toned">
{{ row.label }}
</p>
<p class="mt-2 text-sm font-semibold text-default">{{ row.value }}</p>
<p class="mt-1 text-xs text-toned">{{ row.description }}</p>
</div>
</div>
</article>
</div>
</section>
<section v-for="section in featureSections" :key="section.id" class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon :name="section.icon" class="size-4 text-toned" />
<span>{{ section.label }}</span>
</div>
<p class="text-sm text-toned">{{ section.description }}</p>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned">
<span class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1">
<span class="font-medium text-default">Checks:</span>
<span>{{ section.items.length }}</span>
</span>
<span class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1">
<span class="font-medium text-default">Required fails:</span>
<span>{{ requiredFails(section.items) }}</span>
</span>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
<article v-for="item in section.items" :key="item.id" :class="cardClass(item.status)">
<div class="min-w-0 space-y-3">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span :class="tagDotClass(item.status)"></span>
<p class="text-base font-semibold text-default">{{ item.label }}</p>
<span :class="badgeClass(item.status)">{{ statusLabel(item.status) }}</span>
<span
class="inline-flex items-center rounded-md border border-default px-2 py-1 text-xs text-toned"
>
{{ item.required ? 'Required' : 'Optional' }}
</span>
</div>
<p v-if="item.description" class="text-sm text-toned">{{ item.description }}</p>
<p v-if="showMessage(item)" class="text-sm leading-6 text-default">
{{ item.message }}
</p>
</div>
<div v-if="Object.keys(item.details || {}).length > 0" class="flex flex-wrap gap-2">
<span
v-for="(value, key) in item.details"
:key="`${item.id}-${key}`"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs text-toned"
>
<span class="font-medium text-default">{{ keyLabel(key) }}:</span>
<span>{{ formatValue(value) }}</span>
</span>
</div>
</div>
</article>
</div>
</section>
</template>
</main>
</template>
<script setup lang="ts">
import moment from 'moment';
import type { DiagnosticCheck, DiagnosticStatus } from '~/types/diagnostics';
import { requirePageShell } from '~/utils/topLevelNavigation';
import { copyText } from '~/utils';
type SummaryCard = {
label: string;
description: string;
value: number;
icon: string;
valueClass: string;
};
type DetailRow = {
label: string;
description: string;
value: string;
icon: string;
};
type FeatureMeta = {
label: string;
description: string;
icon: string;
};
type FeatureSection = FeatureMeta & {
id: string;
items: Array<DiagnosticCheck>;
};
const FEATURE_META: Record<string, FeatureMeta> = {
core: {
label: 'Core setup',
description: 'Main tools and local paths.',
icon: 'i-lucide-wrench',
},
youtube: {
label: 'YouTube',
description: 'Support needed for YouTube downloads.',
icon: 'i-lucide-video',
},
notifications: {
label: 'Notifications',
description: 'Apprise support.',
icon: 'i-lucide-bell',
},
advanced: {
label: 'Advanced',
description: 'Optional tools, transports, browser extractor, and plugins.',
icon: 'i-lucide-plug-zap',
},
custom: {
label: 'Custom pip packages',
description: 'Packages requested through config.',
icon: 'i-lucide-package-plus',
},
};
const FEATURE_ORDER = ['core', 'youtube', 'notifications', 'advanced', 'custom'];
const pageShell = requirePageShell('diagnostics');
const diagnosticsState = useDiagnostics();
const report = diagnosticsState.diagnostics;
const isLoading = diagnosticsState.isLoading;
const lastError = diagnosticsState.lastError;
const groupedChecks = diagnosticsState.groupedChecks;
const showRequiredAlert = computed(() => {
return (report.value?.summary.required_failed ?? 0) > 0;
});
const requiredAlertDescription = computed(() => {
const count = report.value?.summary.required_failed ?? 0;
return `${count} required fail${count === 1 ? '' : 's'}.`;
});
const featureSections = computed<Array<FeatureSection>>(() => {
return FEATURE_ORDER.filter((id) => (groupedChecks.value[id] ?? []).length > 0).map((id) => ({
id,
...FEATURE_META[id]!,
items: groupedChecks.value[id] ?? [],
}));
});
const summaryCards = computed<Array<SummaryCard>>(() => {
const current = report.value;
if (!current) {
return [];
}
return [
{
label: 'Passing',
description: 'Checks that passed.',
value: current.summary.pass,
icon: 'i-lucide-badge-check',
valueClass: current.summary.pass > 0 ? 'text-success' : 'text-default',
},
{
label: 'Required fails',
description: 'Items that block core use.',
value: current.summary.required_failed,
icon: 'i-lucide-octagon-alert',
valueClass: current.summary.required_failed > 0 ? 'text-error' : 'text-default',
},
{
label: 'Warnings',
description: 'Optional or incomplete items.',
value: current.summary.warn,
icon: 'i-lucide-triangle-alert',
valueClass: current.summary.warn > 0 ? 'text-warning' : 'text-default',
},
{
label: 'Skipped',
description: 'Not configured or not needed.',
value: current.summary.skip,
icon: 'i-lucide-minus',
valueClass: current.summary.skip > 0 ? 'text-toned' : 'text-default',
},
];
});
const runtimeRows = computed<Array<DetailRow>>(() => {
const runtime = report.value?.runtime;
const generatedAt = report.value?.generated_at;
const python = report.value?.requirements.python;
if (!runtime || !python) {
return [];
}
return [
{
label: 'App',
description: 'Current build.',
value: runtime.app_version || 'Unknown',
icon: 'i-lucide-package',
},
{
label: 'Host',
description: 'OS and machine.',
value: `${runtime.platform} ${runtime.platform_release} (${runtime.platform_machine})`,
icon: 'i-lucide-server',
},
{
label: 'Python',
description: `${python.note} Minimum ${python.required}+`,
value: python.current,
icon: 'i-lucide-square-terminal',
},
{
label: 'Started',
description: 'Process start time.',
value: runtime.started ? moment.unix(runtime.started).fromNow() : 'Unknown',
icon: 'i-lucide-power',
},
{
label: 'Uptime',
description: 'Current process uptime.',
value: moment.duration(runtime.uptime_seconds, 'seconds').humanize(),
icon: 'i-lucide-timer',
},
{
label: 'Snapshot',
description: 'Diagnostics timestamp.',
value: generatedAt ? moment.unix(generatedAt).fromNow() : 'Unknown',
icon: 'i-lucide-clock-3',
},
];
});
const shareText = computed(() => {
const current = report.value;
if (!current) {
return '';
}
const lines = [
'YTPTube diagnostics',
`Generated: ${formatIsoTimestamp(current.generated_at)}`,
'',
];
lines.push('Overview');
for (const item of summaryCards.value) {
lines.push(`- ${item.label}: ${item.value}`);
}
lines.push('', 'Runtime');
lines.push(`- App: ${current.runtime.app_version || 'Unknown'}`);
lines.push(
`- Host: ${current.runtime.platform} ${current.runtime.platform_release} (${current.runtime.platform_machine})`,
);
lines.push(`- Python: ${current.requirements.python.current}`);
lines.push(`- Started: ${formatIsoTimestamp(current.runtime.started)}`);
lines.push(`- Uptime: ${formatIsoDuration(current.runtime.uptime_seconds)}`);
lines.push(`- Snapshot: ${formatIsoTimestamp(current.generated_at)}`);
for (const section of featureSections.value) {
lines.push('', section.label);
for (const item of section.items) {
const versionSuffix = formatShareVersion(item);
lines.push(
`- ${item.label} (${item.required ? 'Required' : 'Optional'}) (${statusLabel(item.status)})${versionSuffix}`,
);
}
}
return lines.join('\n');
});
const load = async (force: boolean = false): Promise<void> => {
await diagnosticsState.loadDiagnostics(force);
};
const copyDiagnostics = (): void => {
if (!shareText.value) {
return;
}
copyText(shareText.value);
};
const requiredFails = (items: Array<DiagnosticCheck>): number => {
return items.filter((item) => item.required && item.status === 'fail').length;
};
const showMessage = (item: DiagnosticCheck): boolean => {
if (item.status === 'pass') {
return false;
}
return Boolean(item.message?.trim());
};
const statusLabel = (status: DiagnosticStatus): string => {
switch (status) {
case 'pass':
return 'Pass';
case 'fail':
return 'Fail';
case 'warn':
return 'Warn';
case 'skip':
default:
return 'Skip';
}
};
const cardClass = (status: DiagnosticStatus): string => {
const base = 'rounded-md border border-default bg-default px-4 py-4 shadow-sm ring-1 ring-inset';
switch (status) {
case 'pass':
return `${base} ring-success/10`;
case 'fail':
return `${base} ring-error/10`;
case 'warn':
return `${base} ring-warning/10`;
case 'skip':
default:
return `${base} ring-default/40`;
}
};
const tagDotClass = (status: DiagnosticStatus): string => {
const base = 'inline-flex size-2.5 shrink-0 rounded-full';
switch (status) {
case 'pass':
return `${base} bg-success`;
case 'fail':
return `${base} bg-error`;
case 'warn':
return `${base} bg-warning`;
case 'skip':
default:
return `${base} bg-muted`;
}
};
const badgeClass = (status: DiagnosticStatus): string => {
const base = 'inline-flex items-center rounded-md border px-2 py-1 text-xs font-medium';
switch (status) {
case 'pass':
return `${base} border-success/30 bg-success/10 text-success`;
case 'fail':
return `${base} border-error/30 bg-error/10 text-error`;
case 'warn':
return `${base} border-warning/30 bg-warning/10 text-warning`;
case 'skip':
default:
return `${base} border-default bg-muted/40 text-toned`;
}
};
const formatValue = (value: DiagnosticCheck['details'][string]): string => {
if (value === null || value === undefined || value === '') {
return 'n/a';
}
return String(value);
};
const keyLabel = (value: string): string => {
return value.replace(/_/g, ' ');
};
const formatIsoTimestamp = (value: number | undefined): string => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 'Unknown';
}
return moment.unix(value).utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
};
const formatIsoDuration = (value: number | undefined): string => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 'Unknown';
}
return moment.duration(value, 'seconds').toISOString();
};
const formatShareVersion = (item: DiagnosticCheck): string => {
const version = item.details?.version;
if (version === null || version === undefined || version === '') {
return '';
}
return ` [${String(version)}]`;
};
onMounted(() => {
void load(true);
});
</script>

View file

@ -672,6 +672,13 @@
must resolve inside <code>{{ config.app.download_path }}</code
>.
</li>
<li>
It's pretty normal for youtube rss feeds to return 404 from around
<span class="text-error">00:00 UTC</span> to around
<span class="text-error">06:00 UTC</span>. This is because youtube updates the feeds
around that time The tasks will automatically recover once the feeds are available
again.
</li>
</ul>
</template>
</UAlert>

View file

@ -0,0 +1,56 @@
export type DiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
export type DiagnosticReportStatus = 'ok' | 'degraded' | 'error';
export type DiagnosticCheck = {
id: string;
label: string;
group: string;
required: boolean;
status: DiagnosticStatus;
description: string;
message: string;
details: Record<string, string | number | boolean | null | undefined>;
};
export type DiagnosticSummary = {
total: number;
pass: number;
fail: number;
warn: number;
skip: number;
required_failed: number;
};
export type DiagnosticRuntime = {
app_version: string;
app_branch: string;
app_commit_sha: string;
app_build_date: string;
started: number;
uptime_seconds: number;
platform: string;
platform_release: string;
platform_machine: string;
python_version: string;
python_minimum: string;
is_native: boolean;
console_enabled: boolean;
};
export type DiagnosticRequirements = {
python: {
current: string;
required: string;
supported: boolean;
note: string;
};
};
export type DiagnosticsResponse = {
status: DiagnosticReportStatus;
generated_at: number;
summary: DiagnosticSummary;
runtime: DiagnosticRuntime;
requirements: DiagnosticRequirements;
checks: Array<DiagnosticCheck>;
};

View file

@ -186,6 +186,17 @@ const NavItems: Array<NavDefinition> = [
matchPath: '/console',
requires: 'console_enabled',
},
{
id: 'diagnostics',
section: 'tools',
group: 'tools',
label: 'Diagnostics',
pageLabel: 'Diagnostics',
description: 'View system information.',
icon: 'i-lucide-stethoscope',
to: '/diagnostics',
matchPath: '/diagnostics',
},
...DOCS_ENTRIES.map<NavDefinition>((entry) => ({
id: entry.id,
section: 'docs',

View file

@ -0,0 +1,148 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
const successMock = mock(() => {});
const errorMock = mock(() => {});
mock.module('~/composables/useNotification', () => ({
useNotification: () => ({ success: successMock, error: errorMock }),
}));
type MockResponseInput = {
ok: boolean;
status: number;
jsonData: unknown;
};
const createMockResponse = ({ ok, status, jsonData }: MockResponseInput): Response => {
return {
ok,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
redirected: false,
statusText: ok ? 'OK' : 'Error',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this;
},
async json() {
return jsonData;
},
text: async () => JSON.stringify(jsonData),
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response;
};
let utils: Awaited<typeof import('~/utils/index')>;
let useDiagnostics: typeof import('~/composables/useDiagnostics').useDiagnostics;
beforeAll(async () => {
utils = await import('~/utils/index');
({ useDiagnostics } = await import('~/composables/useDiagnostics'));
});
beforeEach(() => {
successMock.mockClear();
errorMock.mockClear();
useDiagnostics().__resetForTesting();
});
afterEach(() => {
useDiagnostics().__resetForTesting();
});
describe('useDiagnostics', () => {
it('load_diagnostics', async () => {
const requestSpy = spyOn(utils, 'request');
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
status: 'error',
generated_at: 1,
summary: { total: 2, pass: 0, fail: 1, warn: 0, skip: 1, required_failed: 1 },
runtime: {
app_version: '1.0.0',
app_branch: 'main',
app_commit_sha: 'abc123',
app_build_date: '20260526',
started: 1,
uptime_seconds: 15,
platform: 'linux',
platform_release: '6.8',
platform_machine: 'x86_64',
python_version: '3.13.1',
python_minimum: '3.13',
is_native: false,
console_enabled: false,
},
requirements: {
python: {
current: '3.13.1',
required: '3.13',
supported: true,
note: 'Python runtime context.',
},
},
checks: [
{
id: 'deno',
label: 'deno',
required: true,
group: 'youtube',
status: 'fail',
description: 'Used for yt-dlp YouTube support.',
message: 'Missing.',
details: { command: 'deno' },
},
{
id: 'browser_endpoint',
label: 'Remote browser',
group: 'browser',
status: 'skip',
description: 'Endpoint used by the browser extractor.',
message: 'Not configured.',
details: {},
},
],
},
}),
);
const diagnostics = useDiagnostics();
const result = await diagnostics.loadDiagnostics(true);
expect(requestSpy).toHaveBeenCalledWith('/api/system/diagnostics?refresh=1');
expect(result?.status).toBe('error');
expect(diagnostics.diagnostics.value?.summary.required_failed).toBe(1);
expect(diagnostics.groupOrder.value).toEqual(['youtube', 'browser']);
expect(diagnostics.groupedChecks.value.youtube?.[0]?.id).toBe('deno');
requestSpy.mockRestore();
});
it('store_load_error', async () => {
const requestSpy = spyOn(utils, 'request');
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Backend exploded' },
}),
);
const diagnostics = useDiagnostics();
const result = await diagnostics.loadDiagnostics(true);
expect(result).toBeNull();
expect(diagnostics.lastError.value).toBe('Backend exploded');
expect(errorMock).toHaveBeenCalledWith('Backend exploded');
requestSpy.mockRestore();
});
});

1008
uv.lock

File diff suppressed because it is too large Load diff