Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
@@ -0,0 +1,117 @@
"""OIDC/JWT authentication helpers for backend API requests."""
from __future__ import annotations
import logging
from functools import lru_cache
from typing import Any
from urllib.parse import urljoin
import jwt
import requests
from fastapi import Request
from fastapi.responses import JSONResponse
from jwt import PyJWKClient
from jwt.exceptions import InvalidTokenError
from media_library_viewer_api.config import Settings, get_settings
logger = logging.getLogger(__name__)
EXEMPT_PATHS = {
"/api/health",
"/docs",
"/openapi.json",
"/redoc",
}
def _normalize_issuer_url(issuer_url: str) -> str:
return issuer_url.rstrip("/") + "/" if issuer_url else ""
@lru_cache
def get_oidc_metadata(issuer_url: str) -> dict[str, Any]:
normalized = _normalize_issuer_url(issuer_url)
discovery_url = urljoin(normalized, ".well-known/openid-configuration")
response = requests.get(discovery_url, timeout=10)
response.raise_for_status()
metadata = response.json()
if not isinstance(metadata, dict):
raise RuntimeError("OIDC discovery response was not a JSON object")
return metadata
@lru_cache
def get_jwk_client(jwks_url: str) -> PyJWKClient:
return PyJWKClient(jwks_url)
def _split_audience(audience: str) -> list[str]:
return [item.strip() for item in audience.split(",") if item.strip()]
def validate_auth_settings(settings: Settings) -> None:
if not settings.auth_enabled:
return
if not settings.oidc_issuer_url:
raise RuntimeError("AUTH_ENABLED is true but OIDC_ISSUER_URL is not configured")
if not settings.oidc_audience:
raise RuntimeError("AUTH_ENABLED is true but OIDC_AUDIENCE is not configured")
def validate_bearer_jwt(authorization: str | None, settings: Settings | None = None) -> dict[str, Any]:
settings = settings or get_settings()
validate_auth_settings(settings)
if not settings.auth_enabled:
return {}
if not authorization:
raise PermissionError("Missing Authorization header")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token.strip():
raise PermissionError("Authorization header must use Bearer token format")
issuer_url = _normalize_issuer_url(settings.oidc_issuer_url)
metadata = get_oidc_metadata(issuer_url)
jwks_url = settings.oidc_jwks_url.strip() or str(metadata.get("jwks_uri") or "")
if not jwks_url:
raise RuntimeError("OIDC discovery metadata does not include a JWKS URL")
jwk_client = get_jwk_client(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(token).key
audience = _split_audience(settings.oidc_audience)
claims = jwt.decode(
token,
signing_key,
algorithms=list(metadata.get("id_token_signing_alg_values_supported") or ["RS256"]),
audience=audience[0] if len(audience) == 1 else audience,
issuer=issuer_url,
leeway=int(settings.oidc_clock_skew_seconds or 0),
options={"require": ["exp", "iss"]},
)
return claims
async def require_jwt_auth(request: Request, call_next):
settings = get_settings()
path = request.url.path
if request.method == "OPTIONS" or path in EXEMPT_PATHS or not path.startswith("/api"):
return await call_next(request)
try:
claims = validate_bearer_jwt(request.headers.get("authorization"), settings)
except PermissionError as exc:
logger.warning("JWT auth rejected path=%s reason=%s", path, exc)
return JSONResponse(status_code=401, content={"detail": str(exc)})
except InvalidTokenError as exc:
logger.warning("JWT auth token invalid path=%s error=%s", path, exc)
return JSONResponse(status_code=401, content={"detail": "Invalid bearer token"})
except Exception as exc: # pragma: no cover - safety net for OIDC/JWKS failures
logger.exception("JWT auth validation failed path=%s", path)
return JSONResponse(status_code=500, content={"detail": str(exc)})
request.state.jwt_claims = claims
request.state.jwt_subject = claims.get("sub") if isinstance(claims, dict) else None
return await call_next(request)
@@ -7,10 +7,13 @@ future FastAPI/React frontend can reuse the same client.
from __future__ import annotations
from typing import Any
import logging
from typing import Any, cast
import requests
logger = logging.getLogger(__name__)
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
# documented/commonly supported optional fields; invalid names cause 400s.
@@ -54,9 +57,10 @@ class JellyfinClient:
}
)
def get(self, path: str, **params: Any) -> dict[str, Any]:
def get(self, path: str, **params: Any) -> Any:
"""GET a Jellyfin endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
logger.debug("Jellyfin GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
)
@@ -64,10 +68,12 @@ class JellyfinClient:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning("Jellyfin GET %s failed status=%s url=%s", path, response.status_code, response.url)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Jellyfin GET %s ok status=%s", path, response.status_code)
return response.json()
def users(self) -> list[dict[str, Any]]:
@@ -77,11 +83,15 @@ class JellyfinClient:
/Users/Me often fails with API-key auth. The user id selected here is
then used for user-scoped library endpoints.
"""
return self.get("/Users")
users = self.get("/Users")
logger.info("Jellyfin returned %s visible users", len(users))
return users
def libraries(self, user_id: str) -> list[dict[str, Any]]:
"""Return top-level library views visible to the selected Jellyfin user."""
return self.get(f"/Users/{user_id}/Views").get("Items", [])
items = self.get(f"/Users/{user_id}/Views").get("Items", [])
logger.info("Jellyfin returned %s libraries for user_id=%s", len(items), user_id)
return items
def items(
self,
@@ -101,6 +111,17 @@ class JellyfinClient:
builder. Keep arguments close to Jellyfin's own query parameters so the
service layer can request server-side pagination and basic sorting.
"""
logger.debug(
"Jellyfin items user_id=%s parent_id=%s start=%s limit=%s types=%s search=%s sort=%s/%s",
user_id,
parent_id or "<root>",
start_index,
limit,
include_item_types or "<all>",
search or "<none>",
sort_by,
sort_order,
)
return self.get(
f"/Users/{user_id}/Items",
ParentId=parent_id,
@@ -123,7 +144,15 @@ class JellyfinClient:
IncludeItemTypes=include_item_types,
Limit=0,
)
return int(response.get("TotalRecordCount", 0))
count = int(response.get("TotalRecordCount", 0))
logger.debug(
"Jellyfin item count user_id=%s parent_id=%s types=%s count=%s",
user_id,
parent_id or "<root>",
include_item_types,
count,
)
return count
def media_counts(self, user_id: str) -> dict[str, int]:
"""Return dashboard-level counts for the main media types."""
@@ -156,11 +185,20 @@ class JellyfinClient:
})
return results
def sessions(self, active_within_seconds: int | None = None) -> list[dict[str, Any]]:
"""Return Jellyfin sessions (playing and idle/logged-in).
When ``active_within_seconds`` is None, no recency filter is sent and
Jellyfin decides which sessions to include.
"""
payload: Any = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
return cast(list[dict[str, Any]], payload) if isinstance(payload, list) else []
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
"""Return currently active sessions that have a now-playing item."""
payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
sessions = payload if isinstance(payload, list) else []
return [session for session in sessions if session.get("NowPlayingItem")]
"""Return sessions that currently have a now-playing item."""
sessions = [session for session in self.sessions(active_within_seconds) if session.get("NowPlayingItem")]
logger.info("Jellyfin active sessions within %ss: %s", active_within_seconds, len(sessions))
return sessions
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
"""Build an authenticated image URL suitable for st.image/browser use."""
@@ -0,0 +1,137 @@
"""Jellyseerr HTTP API client.
Jellyseerr is optional. When configured, it can enrich the Jellyfin user list
with email addresses, avatars, permissions, and request metadata.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class JellyseerrClient:
"""Small wrapper around the Jellyseerr REST API."""
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
if not base_url:
raise ValueError("Jellyseerr URL is required")
if not api_key:
raise ValueError("Jellyseerr API key is required")
self.base_url = base_url.rstrip("/")
if self.base_url.endswith("/api/v1"):
self.base_url = self.base_url[:-7]
self.api_key = api_key
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update(
{
"X-Api-Key": api_key,
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any:
"""GET a Jellyseerr endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
logger.debug("Jellyseerr GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}/api/v1{path}", params=clean_params, timeout=self.timeout
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning(
"Jellyseerr GET %s failed status=%s url=%s", path, response.status_code, response.url
)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Jellyseerr GET %s ok status=%s", path, response.status_code)
return response.json()
def absolute_url(self, path: str | None) -> str:
"""Return an absolute URL for Jellyseerr-relative assets."""
if not path:
return ""
if path.startswith("http://") or path.startswith("https://"):
return path
if not path.startswith("/"):
path = f"/{path}"
return f"{self.base_url}{path}"
def jellyfin_users(self) -> list[dict[str, Any]]:
"""Return Jellyfin-linked users known to Jellyseerr.
Jellyseerr has used both a top-level list payload and a wrapped
`{ "users": [...] }` payload in different versions/docs, so accept
either shape.
"""
payload = self.get("/settings/jellyfin/users")
if isinstance(payload, list):
users = [item for item in payload if isinstance(item, dict)]
logger.info("Jellyseerr returned %s Jellyfin-linked users", len(users))
return users
if isinstance(payload, dict):
users = payload.get("users")
if isinstance(users, list):
mapped = [item for item in users if isinstance(item, dict)]
logger.info("Jellyseerr returned %s Jellyfin-linked users (wrapped payload)", len(mapped))
return mapped
logger.info("Jellyseerr returned no Jellyfin-linked users")
return []
def users(self, page_size: int = 1000) -> list[dict[str, Any]]:
"""Return Jellyseerr users via the paginated /user list endpoint.
Jellyseerr's list endpoint uses ``take`` and ``skip`` query params,
not ``page``.
"""
results: list[dict[str, Any]] = []
take = max(1, int(page_size))
skip = 0
total_results: int | None = None
while True:
payload = self.get("/user", take=take, skip=skip)
if not isinstance(payload, dict):
return results
page_results = payload.get("results") or []
page_items = [item for item in page_results if isinstance(item, dict)] if isinstance(page_results, list) else []
results.extend(page_items)
page_info = payload.get("pageInfo") or {}
if isinstance(page_info, dict):
try:
page_total = int(page_info.get("results") or 0)
if page_total:
total_results = page_total
except (TypeError, ValueError):
pass
logger.debug(
"Jellyseerr user page skip=%s take=%s -> %s results (total=%s)",
skip,
take,
len(page_items),
total_results if total_results is not None else "unknown",
)
if not page_items:
break
skip += len(page_items)
if len(page_items) < take:
break
if total_results is not None and skip >= total_results:
break
logger.info("Jellyseerr returned %s users", len(results))
return results
@@ -9,12 +9,15 @@ Lines. This module starts/stops the collector and reads those JSONL samples.
from __future__ import annotations
import json
import logging
import shlex
from dataclasses import dataclass
from typing import Any
from media_library_viewer_api.clients.ssh import RemoteSSHClient
logger = logging.getLogger(__name__)
# POSIX shell script copied to the remote server by start_resource_collector().
# Keep this script bash-free because many NAS/media servers have minimal shells.
COLLECTOR_SCRIPT = r'''#!/bin/sh
@@ -199,9 +202,14 @@ else
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
fi
"""
logger.info(
"Starting remote resource collector interval=%ss retention=%ss max_lines=%s", interval_seconds, retention_seconds, max_lines
)
result = ssh.run(command, timeout=20)
if result.exit_status != 0:
logger.warning("Failed to start remote resource collector: %s", result.stderr or result.stdout)
raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector")
logger.info("Remote resource collector start response: %s", result.stdout.strip())
return result.stdout.strip()
@@ -221,9 +229,12 @@ else
echo "not running"
fi
"""
logger.info("Stopping remote resource collector")
result = ssh.run(command, timeout=20)
if result.exit_status != 0:
logger.warning("Failed to stop remote resource collector: %s", result.stderr or result.stdout)
raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector")
logger.info("Remote resource collector stop response: %s", result.stdout.strip())
return result.stdout.strip()
@@ -234,6 +245,7 @@ def restart_resource_collector(
max_lines: int = 70_000,
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
) -> str:
logger.info("Restarting remote resource collector")
stop_message = stop_resource_collector(ssh, paths)
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
return f"{stop_message}\n{start_message}"
@@ -250,8 +262,11 @@ fi
"""
result = ssh.run(command, timeout=10)
if result.exit_status != 0:
logger.warning("Failed to read collector status: %s", result.stderr or result.stdout)
raise RuntimeError(result.stderr or result.stdout or "failed to check collector status")
return result.stdout.strip()
status = result.stdout.strip()
logger.info("Resource collector status: %s", status)
return status
def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
@@ -281,15 +296,20 @@ if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_
echo "netdev_snapshot:"
cat /proc/net/dev 2>&1 || true
"""
logger.info("Collecting resource collector diagnostics")
result = ssh.run(command, timeout=20)
return (result.stdout or "") + (result.stderr or "")
output = (result.stdout or "") + (result.stderr or "")
logger.debug("Resource collector diagnostics length=%s", len(output))
return output
def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]:
"""Read recent JSONL metric samples from the remote collector file."""
command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true"
logger.debug("Reading up to %s resource metric lines", max_lines)
result = ssh.run(command, timeout=20)
if result.exit_status != 0:
logger.warning("Failed to read resource metrics: %s", result.stderr or result.stdout)
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
rows = []
for line in result.stdout.splitlines():
@@ -310,7 +330,11 @@ def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
+ shlex.quote(path or "/")
+ " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'"
)
logger.debug("Reading disk space for path=%s", path)
result = ssh.run(command, timeout=20)
if result.exit_status != 0 or not result.stdout.strip():
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
return json.loads(result.stdout)
data = json.loads(result.stdout)
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
return data
@@ -10,6 +10,7 @@ commands are shell-quoted by callers. This is important for two reasons:
from __future__ import annotations
import json
import logging
import posixpath
import shlex
from dataclasses import dataclass
@@ -17,6 +18,8 @@ from typing import Any
import paramiko
logger = logging.getLogger(__name__)
@dataclass
class CommandResult:
@@ -88,14 +91,25 @@ class RemoteSSHClient:
"""
client = self.connect()
shell_command = f"/bin/sh -c {shlex.quote(command)}"
logger.debug("SSH run host=%s timeout=%s command=%s", self.host, timeout or self.timeout, command)
stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout)
exit_status = stdout.channel.recv_exit_status()
return CommandResult(
result = CommandResult(
command=command,
exit_status=exit_status,
stdout=stdout.read().decode(errors="replace"),
stderr=stderr.read().decode(errors="replace"),
)
if result.exit_status == 0:
logger.debug("SSH command ok host=%s exit_status=%s", self.host, result.exit_status)
else:
logger.warning(
"SSH command failed host=%s exit_status=%s stderr=%s",
self.host,
result.exit_status,
result.stderr.strip() or result.stdout.strip(),
)
return result
def list_dir(self, path: str) -> CommandResult:
"""List one remote directory as JSON.
@@ -123,12 +137,16 @@ class RemoteSSHClient:
"print(json.dumps(rows))"
)
)
return self.run(command)
result = self.run(command)
logger.info("SSH list_dir path=%s exit_status=%s", path, result.exit_status)
return result
def stat_path(self, path: str) -> CommandResult:
"""Run stat for a remote file or directory path."""
quoted = shlex.quote(path)
return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
result = self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
logger.info("SSH stat path=%s exit_status=%s", path, result.exit_status)
return result
def ffprobe_json(self, path: str) -> dict[str, Any]:
"""Run ffprobe and parse JSON output for a remote media file."""
@@ -137,6 +155,7 @@ class RemoteSSHClient:
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
timeout=60,
)
logger.info("SSH ffprobe path=%s exit_status=%s", path, result.exit_status)
if result.exit_status != 0:
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
return json.loads(result.stdout)
+37 -3
View File
@@ -7,11 +7,16 @@ JELLYFIN_URL etc. are picked up directly without nested-model complications.
from __future__ import annotations
import logging
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings
from media_library_viewer_api.logging_utils import describe_settings
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
"""Flat application settings read from env vars / .env file."""
@@ -21,6 +26,31 @@ class Settings(BaseSettings):
jellyfin_api_key: str = ""
jellyfin_user_id: str = ""
# Jellyseerr (optional)
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
# Logging
log_level: str = "INFO"
# Auth / OIDC (Authentik-compatible JWT validation)
auth_enabled: bool = False
oidc_issuer_url: str = ""
oidc_audience: str = ""
oidc_jwks_url: str = ""
oidc_clock_skew_seconds: int = 30
# SMTP (optional, used for Users -> message popup)
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
smtp_from_name: str = "Media Library Viewer"
smtp_use_tls: bool = True
smtp_use_ssl: bool = False
smtp_timeout: int = 30
# SSH
ssh_host: str = ""
ssh_username: str = ""
@@ -61,6 +91,10 @@ def _find_env_file() -> str | None:
def get_settings() -> Settings:
"""Return a cached Settings instance."""
env_file = _find_env_file()
if env_file:
return Settings(_env_file=env_file)
return Settings()
settings = Settings(_env_file=env_file) if env_file else Settings()
logger.info(
"Loaded backend settings from %s: %s",
env_file or "environment/defaults",
describe_settings(settings),
)
return settings
@@ -6,24 +6,53 @@ dependency system. Uses lru_cache so connections are reused across requests.
from __future__ import annotations
import logging
from functools import lru_cache
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue
logger = logging.getLogger(__name__)
@lru_cache
def get_jellyfin_client() -> JellyfinClient:
"""Return a cached Jellyfin client."""
settings = get_settings()
logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "<unset>")
return JellyfinClient(settings.jellyfin_url, settings.jellyfin_api_key)
@lru_cache
def get_jellyseerr_client() -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
settings = get_settings()
if not settings.jellyseerr_url or not settings.jellyseerr_api_key:
logger.info(
"Jellyseerr client not configured (url=%s, api_key=%s)",
"set" if settings.jellyseerr_url else "missing",
"set" if settings.jellyseerr_api_key else "missing",
)
return None
logger.info("Creating Jellyseerr client for %s", settings.jellyseerr_url.rstrip("/") or "<unset>")
return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key)
@lru_cache
def get_ssh_client() -> RemoteSSHClient:
"""Return a cached SSH client (connects on first use)."""
settings = get_settings()
logger.info(
"Creating SSH client host=%s user=%s port=%s key=%s password=%s",
settings.ssh_host or "<unset>",
settings.ssh_username or "<unset>",
settings.ssh_port,
settings.ssh_key_filename or "<unset>",
"set" if settings.ssh_password else "missing",
)
client = RemoteSSHClient(
host=settings.ssh_host,
username=settings.ssh_username,
@@ -31,10 +60,19 @@ def get_ssh_client() -> RemoteSSHClient:
key_filename=settings.ssh_key_filename or None,
password=settings.ssh_password or None,
)
client.connect()
try:
client.connect()
except Exception:
logger.exception("Failed to establish SSH connection to %s", settings.ssh_host or "<unset>")
raise
return client
def get_mail_queue() -> MailQueue:
"""Return the singleton background email queue."""
return _get_mail_queue()
def get_user_id() -> str:
"""Return the configured Jellyfin user ID, or discover the first available user."""
settings = get_settings()
@@ -7,12 +7,15 @@ confirmations/dry-runs.
from __future__ import annotations
import logging
import shlex
from dataclasses import dataclass
from typing import Mapping
from media_library_viewer_api.clients.ssh import CommandResult, RemoteSSHClient
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class JobTemplate:
@@ -56,4 +59,5 @@ def run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout: int = 600) -
"""Render and execute a configured job template for a selected remote path."""
template = JOB_TEMPLATES[job_key]
command = template.render({"path": path})
logger.info("Executing job template key=%s path=%s timeout=%s", job_key, path, timeout)
return ssh.run(command, timeout=timeout)
@@ -0,0 +1,66 @@
"""Logging helpers for the backend."""
from __future__ import annotations
import logging
import os
from urllib.parse import urlsplit
def configure_logging(level_name: str | None = None) -> int:
"""Configure root logging once and return the numeric log level."""
resolved_name = (level_name or os.getenv("LOG_LEVEL", "INFO")).upper()
level = getattr(logging, resolved_name, logging.INFO)
root = logging.getLogger()
if not root.handlers:
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
root.setLevel(level)
logging.getLogger("media_library_viewer_api").setLevel(level)
logging.getLogger("uvicorn").setLevel(level)
logging.getLogger("uvicorn.error").setLevel(level)
logging.getLogger("uvicorn.access").setLevel(level)
logging.getLogger("paramiko").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
return level
def _sanitize_url(url: str | None) -> str:
if not url:
return "<unset>"
parsed = urlsplit(url.strip())
if not parsed.scheme or not parsed.netloc:
return url.rstrip("/")
return f"{parsed.scheme}://{parsed.netloc}"
def describe_settings(settings: object) -> dict[str, str]:
"""Return a secret-safe summary of the current backend settings."""
return {
"log_level": str(getattr(settings, "log_level", "INFO") or "INFO").upper(),
"auth_enabled": str(bool(getattr(settings, "auth_enabled", True))),
"oidc_issuer_url": _sanitize_url(getattr(settings, "oidc_issuer_url", "")),
"oidc_audience": getattr(settings, "oidc_audience", "") or "<unset>",
"oidc_jwks_url": _sanitize_url(getattr(settings, "oidc_jwks_url", "")),
"jellyfin_url": _sanitize_url(getattr(settings, "jellyfin_url", "")),
"jellyfin_api_key": "set" if getattr(settings, "jellyfin_api_key", "") else "missing",
"jellyfin_user_id": getattr(settings, "jellyfin_user_id", "") or "<auto>",
"jellyseerr_url": _sanitize_url(getattr(settings, "jellyseerr_url", "")),
"jellyseerr_api_key": "set" if getattr(settings, "jellyseerr_api_key", "") else "missing",
"ssh_host": getattr(settings, "ssh_host", "") or "<unset>",
"ssh_username": getattr(settings, "ssh_username", "") or "<unset>",
"ssh_port": str(getattr(settings, "ssh_port", 22) or 22),
"ssh_key_filename": "set" if getattr(settings, "ssh_key_filename", "") else "missing",
"ssh_password": "set" if getattr(settings, "ssh_password", "") else "missing",
"smtp_host": _sanitize_url(getattr(settings, "smtp_host", "")),
"smtp_port": str(getattr(settings, "smtp_port", 587) or 587),
"smtp_username": "set" if getattr(settings, "smtp_username", "") else "missing",
"smtp_from_address": getattr(settings, "smtp_from_address", "") or "<unset>",
"smtp_from_name": getattr(settings, "smtp_from_name", "") or "<unset>",
"smtp_use_tls": str(bool(getattr(settings, "smtp_use_tls", True))),
"smtp_use_ssl": str(bool(getattr(settings, "smtp_use_ssl", False))),
"remote_media_root": getattr(settings, "remote_media_root", "") or "<unset>",
"remote_path_prefix": getattr(settings, "remote_path_prefix", "") or "<unset>",
}
+47 -3
View File
@@ -2,25 +2,41 @@
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users
from media_library_viewer_api.dependencies import get_mail_queue
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan — startup/shutdown."""
settings = get_settings()
configure_logging(settings.log_level)
validate_auth_settings(settings)
logger.info("Backend startup complete: %s", describe_settings(settings))
mail_queue = get_mail_queue()
mail_queue.start()
yield
mail_queue.stop()
logger.info("Backend shutdown complete")
app = FastAPI(
title="Media Library Viewer API",
version="0.1.0",
description="Backend API for Jellyfin media browsing, SSH file inspection, and server monitoring.",
description="Backend API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access.",
lifespan=lifespan,
)
@@ -38,17 +54,45 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def enforce_jwt_auth(request: Request, call_next):
return await require_jwt_auth(request, call_next)
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log every API request with timing and outcome."""
start = time.perf_counter()
client_host = request.client.host if request.client else "unknown"
logger.info("request start %s %s client=%s", request.method, request.url.path, client_host)
try:
response = await call_next(request)
except Exception:
logger.exception("request error %s %s client=%s", request.method, request.url.path, client_host)
raise
elapsed_ms = (time.perf_counter() - start) * 1000.0
logger.info(
"request end %s %s status=%s elapsed_ms=%.1f",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
)
return response
# Register routers
app.include_router(dashboard.router)
app.include_router(monitoring.router)
app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
@app.get("/api/health")
def health_check() -> dict[str, str]:
"""Simple health check endpoint."""
logger.debug("health check requested")
return {"status": "ok"}
@@ -2,8 +2,11 @@
from __future__ import annotations
import logging
import posixpath
logger = logging.getLogger(__name__)
def apply_remote_path_prefix(path: str, prefix: str) -> str:
"""Apply an optional fallback prefix for Jellyfin->SSH path handoff."""
@@ -14,10 +17,16 @@ def apply_remote_path_prefix(path: str, prefix: str) -> str:
return path
normalized_prefix = normalized_prefix.rstrip("/")
if path == normalized_prefix or path.startswith(normalized_prefix + "/"):
return posixpath.normpath(path)
resolved = posixpath.normpath(path)
logger.debug("Path prefix already applied path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved)
return resolved
if path.startswith("/"):
return posixpath.normpath(normalized_prefix + path)
return posixpath.normpath(posixpath.join(normalized_prefix, path))
resolved = posixpath.normpath(normalized_prefix + path)
logger.debug("Applied path prefix path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved)
return resolved
resolved = posixpath.normpath(posixpath.join(normalized_prefix, path))
logger.debug("Joined path prefix path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved)
return resolved
def map_path_to_media_root(path: str, media_root: str) -> str:
@@ -39,6 +48,7 @@ def map_path_to_media_root(path: str, media_root: str) -> str:
path_absolute = "/" + "/".join(raw_parts)
if path_absolute == normalized_root or path_absolute.startswith(normalized_root + "/"):
logger.debug("Path already under media root path=%s media_root=%s", path, normalized_root)
return path_absolute
root_anchor = posixpath.basename(normalized_root)
@@ -48,7 +58,9 @@ def map_path_to_media_root(path: str, media_root: str) -> str:
if root_anchor in raw_parts:
anchor_index = raw_parts.index(root_anchor)
remainder_parts = raw_parts[anchor_index + 1:]
return posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
resolved = posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
logger.debug("Mapped path to media root path=%s media_root=%s resolved=%s", path, normalized_root, resolved)
return resolved
return path
@@ -64,5 +76,8 @@ def resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str)
return path
mapped = map_path_to_media_root(path, media_root)
if mapped and mapped != path:
logger.debug("Resolved media path via media root original=%s resolved=%s", path, mapped)
return mapped
return apply_remote_path_prefix(mapped or path, fallback_prefix)
resolved = apply_remote_path_prefix(mapped or path, fallback_prefix)
logger.debug("Resolved media path via fallback original=%s resolved=%s", path, resolved)
return resolved
@@ -1,7 +1,8 @@
"""Dashboard router — media counts, per-library breakdown, now-playing."""
"""Dashboard router — media counts, per-library breakdown, activity."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
@@ -9,6 +10,8 @@ from fastapi import APIRouter, Depends
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -18,7 +21,9 @@ def get_counts(
user_id: str = Depends(get_user_id),
) -> dict[str, int]:
"""Return total movie/series/episode counts."""
return client.media_counts(user_id)
counts = client.media_counts(user_id)
logger.info("Dashboard counts user_id=%s counts=%s", user_id, counts)
return counts
@router.get("/libraries")
@@ -28,26 +33,31 @@ def get_library_counts(
) -> list[dict[str, Any]]:
"""Return per-library item counts broken down by type."""
libraries = client.libraries(user_id)
logger.info("Dashboard libraries user_id=%s count=%s", user_id, len(libraries))
return client.library_item_counts(user_id, libraries)
@router.get("/now-playing")
def get_now_playing(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Return currently active playback sessions with transcode info."""
sessions = client.active_sessions()
results = []
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize Jellyfin sessions into dashboard activity rows."""
results: list[dict[str, Any]] = []
for session in sessions:
item = session.get("NowPlayingItem") or {}
play_state = session.get("PlayState") or {}
transcoding = session.get("TranscodingInfo") or {}
has_item = bool(item)
series = item.get("SeriesName") or ""
title = f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
title = (
f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
) if has_item else "(idle)"
if not has_item:
state_label = "idle"
else:
state_label = "paused" if play_state.get("IsPaused") else "playing"
is_transcoding = bool(transcoding)
transcode_type = []
transcode_type: list[str] = []
if is_transcoding:
if transcoding.get("IsVideoDirect") is False:
transcode_type.append("video")
@@ -59,11 +69,32 @@ def get_now_playing(
results.append({
"user": session.get("UserName") or "Unknown",
"title": title,
"type": item.get("Type", ""),
"state": "paused" if play_state.get("IsPaused") else "playing",
"type": item.get("Type", "") if has_item else "",
"state": state_label,
"transcoding": "yes" if is_transcoding else "no",
"transcoding_type": ", ".join(transcode_type),
"device": session.get("DeviceName") or session.get("Client") or "",
"session_id": session.get("Id") or "",
})
return results
@router.get("/activity")
def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Return activity rows for active sessions (playing and idle/logged-in)."""
sessions = client.sessions()
rows = _map_sessions_to_activity_rows(sessions)
state_rank = {"playing": 0, "paused": 1, "idle": 2}
rows.sort(key=lambda r: (state_rank.get(str(r.get("state")), 9), str(r.get("user", "")).lower()))
logger.info("Dashboard activity sessions=%s rows=%s", len(sessions), len(rows))
return rows
@router.get("/now-playing")
def get_now_playing(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Backward-compatible alias; returns full activity rows."""
return get_activity(client)
@@ -3,15 +3,18 @@
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.path_utils import resolve_remote_media_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"])
@@ -23,8 +26,10 @@ def list_directory(
"""List a remote directory."""
result = ssh.list_dir(path)
if result.exit_status != 0:
logger.warning("Files list failed path=%s exit_status=%s", path, result.exit_status)
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory")
entries = json.loads(result.stdout)
logger.info("Files list path=%s entries=%s", path, len(entries))
return {
"path": path,
"entries": entries,
@@ -41,7 +46,9 @@ def get_ffprobe(
try:
data = ssh.ffprobe_json(path)
except RuntimeError as exc:
logger.warning("Files ffprobe failed path=%s error=%s", path, exc)
raise HTTPException(status_code=400, detail=str(exc))
logger.info("Files ffprobe path=%s", path)
return data
@@ -53,7 +60,9 @@ def get_stat(
"""Run stat on a remote path."""
result = ssh.stat_path(path)
if result.exit_status != 0:
logger.warning("Files stat failed path=%s exit_status=%s", path, result.exit_status)
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "stat failed")
logger.info("Files stat path=%s", path)
return {"path": path, "output": result.stdout}
@@ -64,4 +73,5 @@ def resolve_path(
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
settings = get_settings()
resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix)
logger.info("Files resolve path original=%s resolved=%s", path, resolved)
return {"original": path, "resolved": resolved}
@@ -2,15 +2,18 @@
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.jobs import JOB_TEMPLATES, run_job
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@@ -22,7 +25,7 @@ class RunJobRequest(BaseModel):
@router.get("/templates")
def get_templates() -> list[dict[str, str]]:
"""Return available job templates."""
return [
templates = [
{
"key": key,
"name": template.name,
@@ -30,6 +33,8 @@ def get_templates() -> list[dict[str, str]]:
}
for key, template in JOB_TEMPLATES.items()
]
logger.info("Jobs templates requested count=%s", len(templates))
return templates
@router.post("/run")
@@ -39,9 +44,12 @@ def post_run_job(
) -> dict[str, Any]:
"""Run a job template on a remote path."""
if request.job_key not in JOB_TEMPLATES:
logger.warning("Unknown job requested key=%s path=%s", request.job_key, request.path)
raise HTTPException(status_code=400, detail=f"Unknown job key: {request.job_key}")
logger.info("Running job key=%s path=%s", request.job_key, request.path)
result = run_job(ssh, request.job_key, request.path)
logger.info("Job finished key=%s exit_status=%s path=%s", request.job_key, result.exit_status, request.path)
return {
"job_key": request.job_key,
"path": request.path,
@@ -1,45 +1,262 @@
"""Media router — index status, build, and query."""
"""Media router — index status, build, stop, and query."""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.services.media_index import MediaIndex, build_media_index
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.services.media_index import MediaIndex
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"])
_build_lock = threading.Lock()
def get_media_index() -> MediaIndex:
return MediaIndex()
@router.get("/status")
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Return media index status (exists, count, last updated, build duration)."""
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
for key, value in state.items():
index.set_metadata(key, "" if value is None else value)
def _staging_db_path(index: MediaIndex) -> Path:
return index.db_path.with_name(f"{index.db_path.stem}.building{index.db_path.suffix}")
def _pid_is_alive(pid: int | None) -> bool:
if not pid:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _clean_stale_build_state(index: MediaIndex) -> Any:
status = index.status()
if status.build_running and not _pid_is_alive(status.build_pid):
logger.warning("Detected stale media build state pid=%s", status.build_pid)
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "stale",
"build_message": "Previous media index build stopped unexpectedly",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "Worker process is no longer running",
},
)
status = index.status()
return status
def _serialize_status(status: Any) -> dict[str, Any]:
return {
"exists": status.exists,
"item_count": status.item_count,
"updated_at": status.updated_at,
"updated_at_label": status.updated_at_label,
"build_duration_seconds": status.build_duration_seconds,
"build_running": status.build_running,
"build_stage": status.build_stage,
"build_message": status.build_message,
"build_progress": status.build_progress,
"build_items_processed": status.build_items_processed,
"build_items_total": status.build_items_total,
"build_current_library": status.build_current_library,
"build_library_index": status.build_library_index,
"build_libraries_total": status.build_libraries_total,
"build_library_progress": status.build_library_progress,
"build_library_items_processed": status.build_library_items_processed,
"build_library_items_total": status.build_library_items_total,
"build_elapsed_seconds": status.build_elapsed_seconds,
"build_eta_seconds": status.build_eta_seconds,
"build_library_elapsed_seconds": status.build_library_elapsed_seconds,
"build_library_eta_seconds": status.build_library_eta_seconds,
"build_cancel_requested": status.build_cancel_requested,
"build_pid": status.build_pid,
"build_error": status.build_error,
}
@router.post("/build")
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
return [
sys.executable,
"-m",
"media_library_viewer_api.workers.media_index_worker",
"--index-path",
str(final_db_path),
"--staging-path",
str(staging_db_path),
]
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
staging_path = _staging_db_path(index)
staging_path.unlink(missing_ok=True)
return subprocess.Popen(
_worker_command(index.db_path, staging_path),
start_new_session=True,
env=os.environ.copy(),
)
@router.get("/status")
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Return media index status (exists, count, build progress, and errors)."""
with _build_lock:
media_status = _clean_stale_build_state(index)
logger.info("Media status requested running=%s stage=%s", media_status.build_running, media_status.build_stage)
return _serialize_status(media_status)
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id),
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Rebuild the media index from Jellyfin."""
libraries = client.libraries(user_id)
count = build_media_index(client, user_id, libraries, index)
return {"indexed_items": count}
"""Start a media index build in a subprocess worker."""
with _build_lock:
current_status = _clean_stale_build_state(index)
if current_status.build_running and _pid_is_alive(current_status.build_pid):
logger.warning("Media build already running pid=%s", current_status.build_pid)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
libraries = client.libraries(user_id)
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
process = _start_worker(index)
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "queued",
"build_message": "Media index build queued",
"build_progress": None,
"build_items_processed": 0,
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": len(libraries),
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": None,
"build_eta_seconds": None,
"build_library_elapsed_seconds": None,
"build_library_eta_seconds": None,
"build_cancel_requested": False,
"build_pid": process.pid,
"build_error": "",
},
)
media_status = index.status()
logger.info("Media index build started pid=%s", process.pid)
return {"status": "started", **_serialize_status(media_status)}
@router.post("/stop", status_code=status.HTTP_202_ACCEPTED)
def stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Request cooperative cancellation of a running media index build."""
with _build_lock:
media_status = _clean_stale_build_state(index)
if not media_status.build_running:
logger.warning("Stop requested but no media build is running")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
logger.info("Cooperative stop requested for media build pid=%s", media_status.build_pid)
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "canceling",
"build_message": "Stopping media index build...",
"build_cancel_requested": True,
"build_error": "",
},
)
media_status = index.status()
logger.info("Media stop requested acknowledged")
return {"status": "stop_requested", **_serialize_status(media_status)}
@router.post("/force-stop", status_code=status.HTTP_202_ACCEPTED)
def force_stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Terminate the media index worker process if it is stuck."""
with _build_lock:
media_status = _clean_stale_build_state(index)
if not media_status.build_running:
logger.warning("Force stop requested but no media build is running")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
pid = media_status.build_pid
if not pid or not _pid_is_alive(pid):
logger.warning("Force stop requested but build worker is not alive pid=%s", pid)
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "stale",
"build_message": "Media index worker is not running",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "Worker process is not running",
},
)
media_status = index.status()
return {"status": "already_stopped", **_serialize_status(media_status)}
logger.info("Force stopping media build pid=%s", pid)
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
deadline = time.time() + 3.0
while time.time() < deadline and _pid_is_alive(pid):
time.sleep(0.1)
if _pid_is_alive(pid):
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "force-stopped",
"build_message": "Media index build force stopped",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
},
)
media_status = index.status()
return {"status": "force_stopped", **_serialize_status(media_status)}
@router.get("/query")
@@ -57,13 +274,25 @@ def query_media(
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Query the media index with filters, sorting, and pagination."""
# If no library IDs provided, use all libraries
# If no library IDs provided, use all libraries.
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
if not library_ids:
all_libs = client.libraries(user_id)
library_ids = [lib["Id"] for lib in all_libs]
media_types = [t.strip() for t in types.split(",") if t.strip()]
logger.info(
"Media query user_id=%s libraries=%s types=%s search=%s hdr=%s sort=%s/%s limit=%s offset=%s",
user_id,
len(library_ids or []),
",".join(media_types),
search or "<none>",
hdr_filter,
sort_key,
sort_order,
limit,
offset,
)
rows, total = index.query(
library_ids=library_ids,
@@ -76,6 +305,7 @@ def query_media(
offset=offset,
)
logger.info("Media query returned total=%s rows=%s", total, len(rows))
return {
"items": rows,
"total": total,
@@ -2,13 +2,16 @@
from __future__ import annotations
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.dependencies import get_ssh_client
logger = logging.getLogger(__name__)
from media_library_viewer_api.clients.resources import (
disk_space,
read_resource_metrics,
@@ -26,19 +29,26 @@ router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@router.get("/status")
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Return collector running status."""
return {"status": resource_collector_status(ssh)}
status = resource_collector_status(ssh)
logger.info("Monitoring status requested: %s", status)
return {"status": status}
@router.get("/metrics")
def get_metrics(
max_lines: int = 1000,
last_seconds: int = 3600,
max_lines: int = 70_000,
last_seconds: int | None = None,
ssh: RemoteSSHClient = Depends(get_ssh_client),
) -> dict[str, Any]:
"""Return resource metric samples from the remote collector."""
rows = read_resource_metrics(ssh, max_lines=max_lines)
cutoff_ts = time.time() - last_seconds
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
if last_seconds is None:
filtered = rows
cutoff_ts = 0.0
else:
cutoff_ts = time.time() - last_seconds
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
logger.info("Monitoring metrics requested total=%s filtered=%s last_seconds=%s", len(rows), len(filtered), last_seconds)
return {
"samples": filtered,
"total_samples": len(rows),
@@ -52,6 +62,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str,
"""Return disk space for the configured media root."""
settings = get_settings()
path = settings.media_root or "/"
logger.info("Monitoring disk requested path=%s", path)
return disk_space(ssh, path)
@@ -59,6 +70,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str,
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Start the remote resource collector."""
message = start_resource_collector(ssh)
logger.info("Monitoring collector start result: %s", message)
return {"message": message}
@@ -66,6 +78,7 @@ def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Stop the remote resource collector."""
message = stop_resource_collector(ssh)
logger.info("Monitoring collector stop result: %s", message)
return {"message": message}
@@ -73,10 +86,13 @@ def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Restart the remote resource collector."""
message = restart_resource_collector(ssh)
logger.info("Monitoring collector restart result: %s", message)
return {"message": message}
@router.get("/diagnostics")
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Return collector debug info for troubleshooting."""
return {"diagnostics": resource_collector_debug_info(ssh)}
diagnostics = resource_collector_debug_info(ssh)
logger.info("Monitoring diagnostics requested")
return {"diagnostics": diagnostics}
@@ -0,0 +1,394 @@
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
)
from media_library_viewer_api.services.mailer import EmailAttachment, test_smtp_connection, validate_smtp_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/users", tags=["users"])
_PERMISSION_FLAGS = [
(2, "admin"),
(4, "manage_settings"),
(8, "manage_users"),
(16, "manage_requests"),
(32, "request"),
(64, "vote"),
(128, "auto_approve"),
(256, "auto_approve_movie"),
(512, "auto_approve_tv"),
(1024, "request_4k"),
(2048, "request_4k_movie"),
(4096, "request_4k_tv"),
(8192, "request_advanced"),
(16384, "request_view"),
(32768, "auto_approve_4k"),
(65536, "auto_approve_4k_movie"),
(131072, "auto_approve_4k_tv"),
(262144, "request_movie"),
(524288, "request_tv"),
(1048576, "manage_issues"),
(2097152, "view_issues"),
]
_USER_TYPES = {
1: "plex",
2: "local",
3: "jellyfin",
4: "emby",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _permission_labels(permissions: int) -> list[str]:
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
return labels or ["none"]
def _role_label(permissions: int) -> str:
if permissions & 2:
return "admin"
if permissions & (4 | 8 | 16):
return "manager"
if permissions & (32 | 64 | 128):
return "requester"
return "user"
def _account_type(user_type: Any) -> str:
return _USER_TYPES.get(_safe_int(user_type), "unknown")
def _merge_users(
jellyfin_users: list[dict[str, Any]],
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
jellyseerr_users: list[dict[str, Any]] | None,
jellyseerr_client: JellyseerrClient | None,
) -> dict[str, Any]:
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _looks_like_email(value: Any) -> bool:
text = str(value or "").strip()
return bool(text and "@" in text and " " not in text)
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
if _looks_like_email(value):
return source, str(value).strip()
return "", ""
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
text = str(value or "").strip()
if text:
return source, text
return "", ""
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
return ", ".join(
[
f"name={name_source or 'none'}",
f"email={email_source or 'none'}",
f"avatar={avatar_source or 'none'}",
f"access={access_source or 'none'}",
]
)
def _lookup_keys(item: dict[str, Any]) -> list[str]:
return [
_normalize(item.get("id")),
_normalize(item.get("Id")),
_normalize(item.get("userId")),
_normalize(item.get("user_id")),
_normalize(item.get("jellyfinUserId")),
_normalize(item.get("jellyfin_user_id")),
_normalize(item.get("jellyfinUsername")),
_normalize(item.get("jellyfin_username")),
_normalize(item.get("username")),
_normalize(item.get("displayName")),
_normalize(item.get("display_name")),
]
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
for item in jellyseerr_jellyfin_users or []:
for key in (
item.get("id"),
item.get("Id"),
item.get("userId"),
item.get("user_id"),
item.get("jellyfinUserId"),
item.get("jellyfin_user_id"),
):
normalized = _normalize(key)
if normalized:
linked_by_jellyfin_id[normalized] = item
seerr_by_key: dict[str, dict[str, Any]] = {}
for item in jellyseerr_users or []:
for key in _lookup_keys(item):
if key:
seerr_by_key[key] = item
items: list[dict[str, Any]] = []
enriched_count = 0
for user in jellyfin_users:
jellyfin_id = str(user.get("Id") or user.get("id") or "")
jellyfin_name = str(user.get("Name") or user.get("name") or "")
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
seerr_user = None
for candidate in [
jellyfin_name,
(jf_link or {}).get("jellyfinUsername"),
(jf_link or {}).get("jellyfin_username"),
(jf_link or {}).get("username"),
(jf_link or {}).get("displayName"),
(jf_link or {}).get("display_name"),
]:
seerr_user = seerr_by_key.get(_normalize(candidate))
if seerr_user:
break
email_source, email = _pick_source_and_value(
[
("jellyseerr:user", (seerr_user or {}).get("email")),
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
]
)
avatar_source, avatar = _first_value(
[
("jellyseerr:user", (seerr_user or {}).get("avatar")),
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
]
)
if avatar and jellyseerr_client:
avatar = jellyseerr_client.absolute_url(avatar)
permissions = _safe_int((seerr_user or {}).get("permissions"))
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
role = _role_label(permissions)
access_source = "jellyseerr:user" if seerr_user else ""
name_source = "jellyfin"
summary = _source_summary(name_source, email_source, avatar_source, access_source)
if seerr_user or jf_link:
enriched_count += 1
items.append(
{
"jellyfin_id": jellyfin_id,
"username": jellyfin_name,
"display_name": jellyfin_name,
"email": email,
"email_source": email_source,
"avatar": avatar,
"avatar_source": avatar_source,
"contactable": bool(email),
"source": summary,
"source_summary": summary,
"name_source": name_source,
"access_source": access_source,
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId")) or None,
"jellyseerr_username": str(
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
),
"user_type": user_type or None,
"user_type_label": _account_type(user_type),
"role": role,
"permissions": permissions,
"permissions_label": ", ".join(_permission_labels(permissions)),
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
}
)
logger.info(
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
len(jellyfin_users),
len(jellyseerr_jellyfin_users or []),
len(jellyseerr_users or []),
enriched_count,
)
return {
"items": items,
"total": len(items),
"jellyseerr_configured": jellyseerr_client is not None,
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
"jellyseerr_user_count": len(jellyseerr_users or []),
"enriched_count": enriched_count,
}
@router.get("")
def get_users(
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
) -> dict[str, Any]:
"""Return the known users, enriched with Jellyseerr data when available."""
jellyfin_users = jellyfin.users()
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
jellyseerr_users: list[dict[str, Any]] | None = None
jellyseerr_error = ""
if jellyseerr:
try:
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
try:
jellyseerr_users = jellyseerr.users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr user list fetch failed")
jellyseerr_error = (
f"{jellyseerr_error}; " if jellyseerr_error else ""
) + f"Jellyseerr user list fetch failed: {exc}"
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
result["jellyseerr_error"] = jellyseerr_error
logger.info(
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
result["total"],
result["jellyseerr_configured"],
result["jellyseerr_available"],
result["enriched_count"],
bool(jellyseerr_error),
)
return result
@router.get("/message/status")
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
"""Return the current background email queue status."""
return mail_queue.status()
@router.post("/message/test-smtp")
def test_user_message_smtp() -> dict[str, Any]:
"""Test the configured SMTP connection without sending an email."""
settings = get_settings()
return test_smtp_connection(settings)
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
async def post_user_message(
recipient_ids: str = Form(...),
subject: str = Form(...),
html_body: str = Form(""),
text_body: str = Form(""),
attachments: list[UploadFile] | None = File(default=None),
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
mail_queue=Depends(get_mail_queue),
) -> dict[str, Any]:
"""Queue a single email to the selected users without blocking the API."""
try:
requested_ids = json.loads(recipient_ids)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
if not isinstance(requested_ids, list):
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
if not cleaned_ids:
raise HTTPException(status_code=400, detail="At least one recipient is required")
subject = subject.strip()
if not subject:
raise HTTPException(status_code=400, detail="Subject is required")
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
recipients: list[str] = []
recipient_labels: list[str] = []
skipped: list[dict[str, str]] = []
for user_id in cleaned_ids:
item = users_by_id.get(user_id)
if not item:
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
continue
email = str(item.get("email") or "").strip()
if not email:
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
continue
recipients.append(email)
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
if not recipients:
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
settings = get_settings()
validate_smtp_settings(settings)
queue_status = mail_queue.status()
if not queue_status["worker_running"]:
raise HTTPException(status_code=503, detail="Email queue worker is not running")
attachment_payloads: list[EmailAttachment] = []
for upload in attachments or []:
data = await upload.read()
if not data:
continue
attachment_payloads.append(
EmailAttachment(
filename=upload.filename or "attachment",
content_type=upload.content_type or "application/octet-stream",
data=data,
)
)
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=attachment_payloads,
)
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip() or str(
getattr(settings, "smtp_username", "") or ""
).strip()
logger.info(
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
request_id,
subject,
len(recipients),
len(attachment_payloads),
len(skipped),
)
return {
"status": "queued",
"request_id": request_id,
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_payloads),
"subject": subject,
"recipient_labels": recipient_labels,
"skipped": skipped,
}
@@ -0,0 +1,232 @@
"""In-process background queue for outbound user emails.
The queue keeps SMTP delivery off the request path so message composition
returns quickly and the rest of the API remains responsive while the worker
thread performs the blocking SMTP call.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from media_library_viewer_api.services.mailer import EmailAttachment, describe_smtp_error, send_email_message
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class QueuedEmailMessage:
"""A queued outbound email request."""
request_id: str
settings: Any
recipients: list[str]
subject: str
html_body: str
text_body: str
attachments: list[EmailAttachment] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
class MailQueue:
"""Single-worker in-process queue for SMTP delivery."""
def __init__(self) -> None:
self._queue: queue.Queue[QueuedEmailMessage | None] = queue.Queue()
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._pending_count = 0
self._active_request_id: str | None = None
self._last_request_id: str | None = None
self._last_result: str | None = None
self._last_error = ""
self._last_error_at: float | None = None
self._last_success_at: float | None = None
self._last_activity_at: float | None = None
self._sent_count = 0
self._failed_count = 0
def start(self) -> None:
"""Start the worker thread if it is not already running."""
with self._lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="mail-queue-worker", daemon=True)
self._thread.start()
logger.info("Mail queue worker started")
def stop(self, timeout: float = 5.0) -> None:
"""Stop the worker thread and wait briefly for shutdown."""
with self._lock:
thread = self._thread
if not thread:
return
self._stop_event.set()
self._queue.put(None)
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Mail queue worker did not stop within %.1fs", timeout)
else:
logger.info("Mail queue worker stopped")
with self._lock:
if self._thread is thread:
self._thread = None
def enqueue(
self,
*,
settings: Any,
recipients: list[str],
subject: str,
html_body: str,
text_body: str = "",
attachments: list[EmailAttachment] | None = None,
) -> str:
"""Queue an outbound email and return a request identifier."""
request_id = uuid.uuid4().hex
message = QueuedEmailMessage(
request_id=request_id,
settings=settings,
recipients=list(recipients),
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=list(attachments or []),
)
with self._lock:
self._pending_count += 1
self._last_request_id = request_id
self._last_result = "queued"
self._last_activity_at = time.time()
self._queue.put(message)
logger.info(
"Queued email request_id=%s recipients=%s attachments=%s subject=%s",
request_id,
len(message.recipients),
len(message.attachments),
subject,
)
return request_id
def status(self) -> dict[str, Any]:
"""Return a snapshot of the queue state for health/status endpoints."""
with self._lock:
worker_running = bool(self._thread and self._thread.is_alive())
stop_requested = self._stop_event.is_set()
pending_count = self._pending_count
active_request_id = self._active_request_id
last_request_id = self._last_request_id
last_result = self._last_result
last_error = self._last_error
last_error_at = self._last_error_at
last_success_at = self._last_success_at
last_activity_at = self._last_activity_at
sent_count = self._sent_count
failed_count = self._failed_count
if not worker_running:
state = "stopped" if stop_requested else "error"
elif active_request_id or pending_count > 0:
state = "busy"
elif last_result == "failed" and last_error:
state = "error"
else:
state = "idle"
return {
"state": state,
"worker_running": worker_running,
"stop_requested": stop_requested,
"pending_count": pending_count,
"active_request_id": active_request_id,
"last_request_id": last_request_id,
"last_result": last_result,
"last_error": last_error,
"last_error_at": last_error_at,
"last_success_at": last_success_at,
"last_activity_at": last_activity_at,
"sent_count": sent_count,
"failed_count": failed_count,
}
def _run(self) -> None:
while not self._stop_event.is_set():
try:
message = self._queue.get(timeout=0.5)
except queue.Empty:
continue
try:
if message is None:
continue
with self._lock:
self._pending_count = max(0, self._pending_count - 1)
self._active_request_id = message.request_id
self._last_request_id = message.request_id
self._last_result = "sending"
self._last_activity_at = time.time()
logger.info(
"Mail queue sending request_id=%s recipients=%s attachments=%s subject=%s",
message.request_id,
len(message.recipients),
len(message.attachments),
message.subject,
)
result: dict[str, Any] = send_email_message(
message.settings,
recipients=message.recipients,
subject=message.subject,
html_body=message.html_body,
text_body=message.text_body,
attachments=message.attachments,
)
with self._lock:
self._active_request_id = None
self._last_result = "sent"
self._last_success_at = time.time()
self._last_activity_at = self._last_success_at
self._last_error = ""
self._last_error_at = None
self._sent_count += 1
logger.info(
"Mail queue sent request_id=%s mode=%s auth_user=%s recipient_count=%s attachment_count=%s",
message.request_id,
(result.get("selected_mode") or {}).get("label", "<unknown>"),
result.get("authenticated_as") or "<none>",
result.get("recipient_count", 0),
result.get("attachment_count", 0),
)
except Exception as exc:
friendly_error = describe_smtp_error(exc)
with self._lock:
self._active_request_id = None
self._last_result = "failed"
self._last_error = friendly_error
self._last_error_at = time.time()
self._last_activity_at = self._last_error_at
self._failed_count += 1
logger.exception(
"Mail queue delivery failed request_id=%s error=%s",
getattr(message, "request_id", "unknown"),
friendly_error,
)
finally:
self._queue.task_done()
_MAIL_QUEUE = MailQueue()
def get_mail_queue() -> MailQueue:
"""Return the singleton mail queue."""
return _MAIL_QUEUE
@@ -0,0 +1,540 @@
"""SMTP email sending helpers for user communication workflows."""
from __future__ import annotations
import logging
import mimetypes
import socket
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from html.parser import HTMLParser
from typing import Any, Iterable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EmailAttachment:
"""Attachment payload passed from the API layer."""
filename: str
content_type: str
data: bytes
class _HTMLToTextParser(HTMLParser):
"""Small HTML-to-text helper for plain-text fallback bodies."""
block_tags = {"p", "div", "section", "article", "header", "footer", "li", "tr", "td", "th", "br"}
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs): # type: ignore[override]
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
self.parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if data:
self.parts.append(data)
def text(self) -> str:
return "".join(self.parts)
def html_to_text(html: str) -> str:
"""Convert a small HTML body to readable plain text."""
parser = _HTMLToTextParser()
parser.feed(html or "")
text = parser.text()
lines = [line.rstrip() for line in text.splitlines()]
return "\n".join(line for line in lines if line).strip()
def _from_address(settings: object) -> str:
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip()
if from_address:
return from_address
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
if smtp_username:
return smtp_username
raise ValueError("SMTP from address is required (set SMTP_FROM_ADDRESS or SMTP_USERNAME)")
def validate_smtp_settings(settings: object) -> None:
"""Validate that the SMTP configuration is sufficient to send mail."""
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
if not smtp_host:
raise ValueError("SMTP host is required")
_from_address(settings)
def _smtp_settings(settings: object) -> dict[str, object]:
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
if not smtp_host:
raise ValueError("SMTP host is required")
smtp_port = int(getattr(settings, "smtp_port", 587) or 587)
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
smtp_password = str(getattr(settings, "smtp_password", "") or "")
use_tls = bool(getattr(settings, "smtp_use_tls", True))
use_ssl = bool(getattr(settings, "smtp_use_ssl", False))
smtp_timeout = int(getattr(settings, "smtp_timeout", 30) or 30)
return {
"smtp_host": smtp_host,
"smtp_port": smtp_port,
"smtp_username": smtp_username,
"smtp_password": smtp_password,
"use_tls": use_tls,
"use_ssl": use_ssl,
"smtp_timeout": smtp_timeout,
}
def _smtp_mode_label(mode: dict[str, object]) -> str:
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
return f"{mode['smtp_host']}:{mode['smtp_port']} via {transport}"
def _smtp_mode_candidates(settings: object) -> list[dict[str, Any]]:
base = _smtp_settings(settings)
candidates = [dict(base, mode_label="configured")]
smtp_host = str(base["smtp_host"]).lower()
if "fastmail.com" in smtp_host:
fastmail_ssl = {
**base,
"smtp_port": 465,
"use_tls": False,
"use_ssl": True,
"mode_label": "Fastmail SSL 465",
}
fastmail_tls = {
**base,
"smtp_port": 587,
"use_tls": True,
"use_ssl": False,
"mode_label": "Fastmail STARTTLS 587",
}
for mode in (fastmail_ssl, fastmail_tls):
if not any(
candidate["smtp_port"] == mode["smtp_port"]
and candidate["use_tls"] == mode["use_tls"]
and candidate["use_ssl"] == mode["use_ssl"]
for candidate in candidates
):
candidates.append(mode)
return candidates
def _probe_smtp_connection(mode: dict[str, Any]) -> None:
context = ssl.create_default_context()
smtp_host = str(mode["smtp_host"])
smtp_port = int(mode["smtp_port"])
smtp_username = str(mode["smtp_username"])
smtp_password = str(mode["smtp_password"])
use_tls = bool(mode["use_tls"])
use_ssl = bool(mode["use_ssl"])
smtp_timeout = int(mode["smtp_timeout"])
if use_ssl:
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
else:
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
with smtp_connection as smtp:
if use_tls and not use_ssl:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp.ehlo()
if smtp_username:
smtp.login(smtp_username, smtp_password)
smtp.noop()
def _smtp_sender_not_authorized(error: Exception) -> bool:
code = getattr(error, "smtp_code", None)
raw_error = getattr(error, "smtp_error", b"")
if isinstance(raw_error, bytes):
raw_error_text = raw_error.decode(errors="ignore")
else:
raw_error_text = str(raw_error)
text = f"{code} {raw_error_text} {error}".lower()
return code in {551, 553} or "not authorised to send from this header address" in text or "not authorized to send from this header address" in text
def _smtp_attempt_metadata(mode: dict[str, Any]) -> dict[str, Any]:
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
return {
"label": str(mode.get("mode_label") or _smtp_mode_label(mode)),
"smtp_host": str(mode["smtp_host"]),
"smtp_port": int(mode["smtp_port"]),
"use_tls": bool(mode["use_tls"]),
"use_ssl": bool(mode["use_ssl"]),
"transport": transport,
"auth_user": str(mode.get("smtp_username") or "") or "<none>",
}
def describe_smtp_error(error: Exception) -> str:
"""Convert SMTP failures into operator-friendly messages."""
chain: list[Exception] = []
current: Exception | None = error
while current is not None and current not in chain:
chain.append(current)
current = current.__cause__ if isinstance(current.__cause__, Exception) else None
for item in chain:
text = str(item).strip()
lowered = text.lower()
if isinstance(item, (TimeoutError, socket.timeout)) or "timed out" in lowered:
return (
"SMTP connection timed out while waiting for the server greeting. "
"Check host, port, network access, and SMTP_TIMEOUT."
)
if isinstance(item, smtplib.SMTPAuthenticationError):
return (
"SMTP authentication failed. Check SMTP_USERNAME and SMTP_PASSWORD "
"(Fastmail and similar providers usually require an app password)."
)
if isinstance(item, (smtplib.SMTPDataError, smtplib.SMTPResponseException)):
smtp_code = getattr(item, "smtp_code", None)
if smtp_code in {551, 553} or "not authorised to send from this header address" in lowered or "not authorized to send from this header address" in lowered:
return (
"SMTP server rejected the configured From address. Use an authorized alias "
"for this account or change SMTP_FROM_ADDRESS to a sender the provider allows."
)
if isinstance(item, smtplib.SMTPConnectError):
return "SMTP connection was rejected by the server. Check the host and port."
if isinstance(item, smtplib.SMTPServerDisconnected) and "timed out" in lowered:
return (
"SMTP connection timed out while waiting for the server greeting. "
"Check host, port, network access, and SMTP_TIMEOUT."
)
return f"SMTP delivery failed: {error}"
def test_smtp_connection(settings: object) -> dict[str, object]:
"""Validate SMTP connectivity and authentication without sending a message."""
try:
base = _smtp_settings(settings)
except ValueError as exc:
return {
"status": "error",
"message": str(exc),
"attempts": [],
"selected_mode": None,
"from_address": "",
"from_name": str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer",
"smtp_host": str(getattr(settings, "smtp_host", "") or "").strip(),
"smtp_port": int(getattr(settings, "smtp_port", 587) or 587),
"use_tls": bool(getattr(settings, "smtp_use_tls", True)),
"use_ssl": bool(getattr(settings, "smtp_use_ssl", False)),
"authenticated": False,
}
from_address = _from_address(settings)
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
attempts: list[dict[str, Any]] = []
last_error = ""
logger.info(
"SMTP test requested from_address=%s auth_user=%s",
from_address,
base["smtp_username"] or "<none>",
)
for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode)
logger.info(
"SMTP test attempting label=%s host=%s port=%s transport=%s auth_user=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
)
try:
_probe_smtp_connection(mode)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP test succeeded label=%s host=%s port=%s transport=%s auth_user=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
)
return {
"status": "ok",
"message": f"SMTP connection successful using {meta['label']}",
"from_address": from_address,
"from_name": from_name,
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"authenticated": bool(str(mode["smtp_username"])),
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc:
last_error = describe_smtp_error(exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
}
)
logger.warning(
"SMTP test failed label=%s host=%s port=%s transport=%s auth_user=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
last_error,
)
logger.error(
"SMTP test exhausted attempts=%s error=%s",
[attempt["label"] for attempt in attempts],
last_error or "SMTP test failed.",
)
return {
"status": "error",
"message": last_error or "SMTP test failed.",
"from_address": from_address,
"from_name": from_name,
"smtp_host": base["smtp_host"],
"smtp_port": base["smtp_port"],
"use_tls": base["use_tls"],
"use_ssl": base["use_ssl"],
"authenticated": bool(base["smtp_username"]),
"selected_mode": None,
"attempts": attempts,
}
def build_email_message(
settings: object,
recipients: list[str],
subject: str,
html_body: str,
text_body: str,
attachments: Iterable[EmailAttachment] = (),
*,
sender_address: str | None = None,
reply_to_address: str | None = None,
) -> tuple[EmailMessage, str]:
"""Build a MIME email message with HTML and attachments."""
from_address = sender_address or _from_address(settings)
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name, from_address))
msg["To"] = "Undisclosed recipients:;"
msg["Reply-To"] = reply_to_address or from_address
plain_text = text_body.strip() or html_to_text(html_body)
if html_body.strip():
msg.set_content(plain_text or " ")
msg.add_alternative(html_body, subtype="html")
else:
msg.set_content(plain_text or "")
for attachment in attachments:
content_type = attachment.content_type or mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream"
maintype, subtype = content_type.split("/", 1) if "/" in content_type else ("application", "octet-stream")
msg.add_attachment(
attachment.data,
maintype=maintype,
subtype=subtype,
filename=attachment.filename or "attachment",
)
return msg, from_address
def _send_email_via_mode(
mode: dict[str, Any],
message: EmailMessage,
recipients: list[str],
from_address: str,
) -> None:
context = ssl.create_default_context()
smtp_host = str(mode["smtp_host"])
smtp_port = int(mode["smtp_port"])
smtp_username = str(mode["smtp_username"])
smtp_password = str(mode["smtp_password"])
use_tls = bool(mode["use_tls"])
use_ssl = bool(mode["use_ssl"])
smtp_timeout = int(mode["smtp_timeout"])
if use_ssl:
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
else:
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
with smtp_connection as smtp:
if use_tls and not use_ssl:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp.ehlo()
if smtp_username:
smtp.login(smtp_username, smtp_password)
smtp.send_message(message, from_addr=from_address, to_addrs=recipients)
def send_email_message(
settings: object,
recipients: list[str],
subject: str,
html_body: str,
text_body: str = "",
attachments: Iterable[EmailAttachment] = (),
) -> dict[str, object]:
"""Send a single outbound email to a recipient list via SMTP BCC."""
if not recipients:
raise ValueError("At least one recipient is required")
attachment_list = list(attachments)
preferred_from_address = _from_address(settings)
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
message, from_address = build_email_message(
settings,
recipients,
subject,
html_body,
text_body,
attachment_list,
sender_address=preferred_from_address,
reply_to_address=preferred_from_address,
)
logger.info(
"SMTP send requested subject=%s recipients=%s from_address=%s auth_user=%s attachments=%s",
subject,
len(recipients),
from_address,
smtp_username or "<none>",
len(attachment_list),
)
attempts: list[dict[str, Any]] = []
last_error = ""
for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode)
logger.info(
"SMTP send attempting label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
try:
_send_email_via_mode(mode, message, recipients, from_address)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
return {
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_list),
"subject": subject,
"authenticated_as": smtp_username or None,
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc:
last_error = describe_smtp_error(exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
}
)
if _smtp_sender_not_authorized(exc):
logger.warning(
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
else:
logger.warning(
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
raise RuntimeError(last_error or "SMTP delivery failed")
@@ -7,14 +7,18 @@ through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
from __future__ import annotations
import logging
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from typing import Any, Callable, Iterable
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
from media_library_viewer_api.path_utils import resolve_remote_media_path
logger = logging.getLogger(__name__)
# Local generated database. It is ignored by git and can be rebuilt from
# Jellyfin metadata whenever needed.
@@ -42,6 +46,19 @@ SORT_COLUMNS = {
}
def _estimate_remaining_seconds(elapsed_seconds: float, progress: float | None) -> float | None:
if progress is None:
return None
progress = max(0.0, min(1.0, progress))
if progress <= 0.0:
return None
return max(0.0, elapsed_seconds * (1.0 - progress) / progress)
class MediaIndexBuildCancelled(Exception):
"""Raised when a media index build is requested to stop."""
@dataclass(frozen=True)
class MediaIndexStatus:
"""Lightweight status object displayed by the Media tab."""
@@ -51,6 +68,25 @@ class MediaIndexStatus:
updated_at: int | None = None
updated_at_label: str = ""
build_duration_seconds: float | None = None
build_running: bool = False
build_stage: str = ""
build_message: str = ""
build_progress: float | None = None
build_items_processed: int = 0
build_items_total: int = 0
build_current_library: str = ""
build_library_index: int = 0
build_libraries_total: int = 0
build_library_progress: float | None = None
build_library_items_processed: int = 0
build_library_items_total: int = 0
build_elapsed_seconds: float | None = None
build_eta_seconds: float | None = None
build_library_elapsed_seconds: float | None = None
build_library_eta_seconds: float | None = None
build_cancel_requested: bool = False
build_pid: int | None = None
build_error: str = ""
class MediaIndex:
@@ -66,8 +102,10 @@ class MediaIndex:
def connect(self) -> sqlite3.Connection:
"""Open a sqlite connection configured to return Row objects."""
conn = sqlite3.connect(self.db_path)
conn = sqlite3.connect(self.db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_schema(self) -> None:
@@ -170,24 +208,68 @@ class MediaIndex:
try:
with self.connect() as conn:
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone()
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
meta = {
row[0]: row[1]
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
}
except sqlite3.Error:
return MediaIndexStatus(exists=False)
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
updated_at_raw = meta.get("updated_at", "")
updated_at = int(updated_at_raw) if str(updated_at_raw).isdigit() else None
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
duration_raw = meta.get("build_duration_seconds")
build_duration = None
if duration_row:
if duration_raw is not None:
try:
build_duration = float(duration_row[0])
build_duration = float(duration_raw)
except (TypeError, ValueError):
build_duration = None
def _bool(key: str, default: bool = False) -> bool:
value = str(meta.get(key, str(default))).strip().lower()
return value in {"1", "true", "yes", "on"}
def _int(key: str, default: int = 0) -> int:
value = meta.get(key, default)
try:
return int(value)
except (TypeError, ValueError):
return default
def _float(key: str) -> float | None:
value = meta.get(key)
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
return MediaIndexStatus(
exists=True,
item_count=item_count,
updated_at=updated_at,
updated_at_label=label,
build_duration_seconds=build_duration,
build_running=_bool("build_running"),
build_stage=str(meta.get("build_stage", "")),
build_message=str(meta.get("build_message", "")),
build_progress=_float("build_progress"),
build_items_processed=_int("build_items_processed"),
build_items_total=_int("build_items_total"),
build_current_library=str(meta.get("build_current_library", "")),
build_library_index=_int("build_library_index"),
build_libraries_total=_int("build_libraries_total"),
build_library_progress=_float("build_library_progress"),
build_library_items_processed=_int("build_library_items_processed"),
build_library_items_total=_int("build_library_items_total"),
build_elapsed_seconds=_float("build_elapsed_seconds"),
build_eta_seconds=_float("build_eta_seconds"),
build_library_elapsed_seconds=_float("build_library_elapsed_seconds"),
build_library_eta_seconds=_float("build_library_eta_seconds"),
build_cancel_requested=_bool("build_cancel_requested"),
build_pid=_int("build_pid") or None,
build_error=str(meta.get("build_error", "")),
)
def query(
@@ -245,18 +327,79 @@ def build_media_index(
libraries: list[dict[str, Any]],
index: MediaIndex | None = None,
page_size: int = 500,
media_root: str = "",
fallback_prefix: str = "",
progress_callback: Callable[[dict[str, Any]], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> int:
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
index = index or MediaIndex()
started_at = time.perf_counter()
normalized_rows: list[dict[str, Any]] = []
for library in libraries:
processed_total = 0
expected_total = 0
current_library_name = ""
current_library_index = 0
current_library_processed = 0
current_library_total = 0
current_library_started_at = started_at
def ensure_not_cancelled() -> None:
if should_cancel and should_cancel():
raise MediaIndexBuildCancelled()
def emit(stage: str, message: str) -> None:
if not progress_callback:
return
elapsed_seconds = time.perf_counter() - started_at
library_elapsed_seconds = time.perf_counter() - current_library_started_at
overall_progress = (processed_total / expected_total) if expected_total else None
library_progress = (current_library_processed / current_library_total) if current_library_total else None
progress_callback(
{
"stage": stage,
"message": message,
"processed": processed_total,
"total": expected_total,
"progress": overall_progress,
"elapsed_seconds": elapsed_seconds,
"eta_seconds": _estimate_remaining_seconds(elapsed_seconds, overall_progress),
"library": current_library_name,
"library_index": current_library_index,
"libraries_total": len(libraries),
"library_processed": current_library_processed,
"library_total": current_library_total,
"library_progress": library_progress,
"library_elapsed_seconds": library_elapsed_seconds if current_library_total else None,
"library_eta_seconds": _estimate_remaining_seconds(library_elapsed_seconds, library_progress),
}
)
ensure_not_cancelled()
logger.info("Media index build starting libraries=%s page_size=%s", len(libraries), page_size)
emit("starting", "Starting media index build")
for library_index, library in enumerate(libraries, start=1):
library_id = library.get("Id")
library_name = library.get("Name", "")
current_library_name = library.get("Name", "")
current_library_index = library_index
current_library_processed = 0
current_library_total = 0
current_library_started_at = time.perf_counter()
if not library_id:
continue
ensure_not_cancelled()
logger.info(
"Media index scanning library index=%s/%s name=%s id=%s",
library_index,
len(libraries),
current_library_name or "Library",
library_id,
)
emit("library-starting", f"Scanning {current_library_name or 'Library'}")
start = 0
discovered_library_total = None
while True:
ensure_not_cancelled()
response = client.items(
user_id=user_id,
parent_id=library_id,
@@ -267,12 +410,49 @@ def build_media_index(
sort_by="SortName",
sort_order="Ascending",
)
ensure_not_cancelled()
items = response.get("Items", [])
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
if discovered_library_total is None:
discovered_library_total = int(response.get("TotalRecordCount", len(items)))
current_library_total = max(discovered_library_total, 0)
expected_total += current_library_total
normalized_rows.extend(
{
**row,
"path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix),
}
for row in (
normalize_media_item(item, library_id, current_library_name)
for item in items
)
)
processed_total += len(items)
current_library_processed += len(items)
start += len(items)
ensure_not_cancelled()
emit(
"building",
f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items",
)
logger.debug(
"Media index progress library=%s processed=%s/%s total_processed=%s",
current_library_name or "Library",
current_library_processed,
current_library_total,
processed_total,
)
total = int(response.get("TotalRecordCount", start))
if not items or start >= total:
break
ensure_not_cancelled()
logger.info("Media index finalizing rows=%s", len(normalized_rows))
emit("finalizing", "Writing index to disk")
ensure_not_cancelled()
count = index.replace_items(normalized_rows)
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
duration = time.perf_counter() - started_at
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
processed_total = count
current_library_processed = current_library_total
emit("completed", f"Indexed {count} items in {duration:.1f}s")
logger.info("Media index build completed count=%s duration=%.2fs", count, duration)
return count
@@ -0,0 +1 @@
"""Worker entrypoints for background tasks."""
@@ -0,0 +1,200 @@
"""Subprocess worker that builds the media index.
The FastAPI app starts this worker as a separate Python process so the build can
be cooperatively canceled or force-killed without taking down the API server.
"""
from __future__ import annotations
import argparse
import logging
import os
import time
from pathlib import Path
from typing import Any
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.services.media_index import (
MediaIndex,
MediaIndexBuildCancelled,
build_media_index,
)
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
logger = logging.getLogger(__name__)
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
for key, value in state.items():
index.set_metadata(key, "" if value is None else value)
def _cancel_requested(index: MediaIndex) -> bool:
return index.status().build_cancel_requested
def _start_state(index: MediaIndex, pid: int, library_count: int) -> None:
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "starting",
"build_message": "Starting media index build",
"build_progress": None,
"build_items_processed": 0,
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": library_count,
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": None,
"build_eta_seconds": None,
"build_library_elapsed_seconds": None,
"build_library_eta_seconds": None,
"build_cancel_requested": False,
"build_pid": pid,
"build_error": "",
},
)
def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> None:
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": state.get("stage", ""),
"build_message": state.get("message", ""),
"build_progress": state.get("progress"),
"build_items_processed": state.get("processed", 0),
"build_items_total": state.get("total", 0),
"build_current_library": state.get("library", ""),
"build_library_index": state.get("library_index", 0),
"build_libraries_total": state.get("libraries_total", 0),
"build_library_progress": state.get("library_progress"),
"build_library_items_processed": state.get("library_processed", 0),
"build_library_items_total": state.get("library_total", 0),
"build_elapsed_seconds": state.get("elapsed_seconds"),
"build_eta_seconds": state.get("eta_seconds"),
"build_library_elapsed_seconds": state.get("library_elapsed_seconds"),
"build_library_eta_seconds": state.get("library_eta_seconds"),
"build_cancel_requested": False,
"build_pid": pid,
"build_error": "",
},
)
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
"""Run the media index build in a subprocess."""
settings = get_settings()
configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client()
user_id = get_user_id()
libraries = client.libraries(user_id)
final_index = MediaIndex(final_index_path)
staging_index = MediaIndex(staging_index_path)
pid = os.getpid()
started_at = time.perf_counter()
staging_path = Path(staging_index.db_path)
staging_path.unlink(missing_ok=True)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
try:
count = build_media_index(
client,
user_id,
libraries,
index=staging_index,
media_root=settings.media_root,
fallback_prefix=settings.path_prefix,
progress_callback=lambda state: _progress_callback(final_index, pid, state),
should_cancel=lambda: _cancel_requested(final_index),
)
# Swap the staging database into place atomically.
os.replace(staging_index.db_path, final_index.db_path)
completed_index = MediaIndex(final_index.db_path)
logger.info("Media index worker completed count=%s", count)
elapsed = time.perf_counter() - started_at
completed_status = completed_index.status()
_set_build_metadata(
completed_index,
{
"build_running": False,
"build_stage": "completed",
"build_message": "Media index build complete",
"build_progress": 1.0,
"build_items_processed": count,
"build_items_total": count,
"build_current_library": "",
"build_library_index": len(libraries),
"build_libraries_total": len(libraries),
"build_library_progress": 1.0,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": elapsed,
"build_eta_seconds": 0.0,
"build_library_elapsed_seconds": 0.0,
"build_library_eta_seconds": 0.0,
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
# Keep the duration reported by the staging build.
"build_duration_seconds": completed_status.build_duration_seconds or elapsed,
},
)
return 0
except MediaIndexBuildCancelled:
logger.info("Media index worker canceled")
_set_build_metadata(
final_index,
{
"build_running": False,
"build_stage": "canceled",
"build_message": "Media index build canceled",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
},
)
return 130
except Exception as exc: # pragma: no cover - defensive subprocess error handling
logger.exception("Media index worker failed")
_set_build_metadata(
final_index,
{
"build_running": False,
"build_stage": "error",
"build_message": "Media index build failed",
"build_cancel_requested": False,
"build_pid": "",
"build_error": str(exc),
},
)
return 1
finally:
# If the build did not complete successfully, the staging DB is disposable.
if staging_path.exists() and staging_path != Path(final_index.db_path):
try:
staging_path.unlink()
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-path", required=True)
args = parser.parse_args()
return run_build(args.index_path, args.staging_path)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())