Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
COPY backend/pyproject.toml ./pyproject.toml
|
||||
COPY backend/src ./src
|
||||
COPY backend/tests ./tests
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "media_library_viewer_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+22
-1
@@ -1,6 +1,6 @@
|
||||
# Backend - Media Library Viewer API
|
||||
|
||||
FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access.
|
||||
|
||||
## Project structure
|
||||
|
||||
@@ -21,10 +21,12 @@ backend/
|
||||
│ │ ├── dashboard.py
|
||||
│ │ ├── monitoring.py
|
||||
│ │ ├── media.py
|
||||
│ │ ├── users.py
|
||||
│ │ ├── files.py
|
||||
│ │ └── jobs.py
|
||||
│ ├── clients/
|
||||
│ │ ├── jellyfin.py
|
||||
│ │ ├── jellyseerr.py
|
||||
│ │ ├── resources.py
|
||||
│ │ └── ssh.py
|
||||
│ ├── domain/
|
||||
@@ -52,6 +54,20 @@ JELLYFIN_URL=https://jellyfin.example.com
|
||||
JELLYFIN_API_KEY=your-api-key
|
||||
JELLYFIN_USER_ID=
|
||||
|
||||
# Optional Jellyseerr enrichment for the Users tab
|
||||
JELLYSEERR_URL=https://requests.example.com
|
||||
JELLYSEERR_API_KEY=your-jellyseerr-api-key
|
||||
|
||||
# Optional backend logging level
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Authentik / OIDC
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
||||
OIDC_AUDIENCE=media-library-viewer
|
||||
OIDC_JWKS_URL=
|
||||
OIDC_CLOCK_SKEW_SECONDS=30
|
||||
|
||||
SSH_HOST=media-server.example.com
|
||||
SSH_USERNAME=username
|
||||
SSH_PORT=22
|
||||
@@ -77,6 +93,10 @@ PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||
|
||||
API docs available at: http://localhost:8000/docs
|
||||
|
||||
## Docker
|
||||
|
||||
The repository root includes a production `docker-compose.yml` and a development `docker-compose.dev.yml`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/dashboard/counts` — Movie/series/episode totals
|
||||
@@ -96,3 +116,4 @@ API docs available at: http://localhost:8000/docs
|
||||
- `GET /api/files/resolve-path?path=` — Path resolution
|
||||
- `GET /api/jobs/templates` — Available jobs
|
||||
- `POST /api/jobs/run` — Execute a job
|
||||
- `GET /api/users` — Jellyfin users with optional Jellyseerr enrichment
|
||||
|
||||
@@ -11,6 +11,8 @@ dependencies = [
|
||||
"requests>=2.28",
|
||||
"python-dotenv>=1.0",
|
||||
"pandas>=2.0",
|
||||
"PyJWT[crypto]>=2.8",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>",
|
||||
}
|
||||
@@ -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())
|
||||
+420
-10
@@ -5,14 +5,23 @@ without requiring real remote connections.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.dependencies import get_ssh_client, get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_ssh_client,
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.routers.media import get_media_index
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
@@ -26,11 +35,15 @@ def mock_jellyfin():
|
||||
{"Id": "lib1", "Name": "Movies", "CollectionType": "movies"},
|
||||
{"Id": "lib2", "Name": "TV Shows", "CollectionType": "tvshows"},
|
||||
]
|
||||
client.users.return_value = [
|
||||
{"Id": "jf1", "Name": "alex"},
|
||||
{"Id": "jf2", "Name": "sam"},
|
||||
]
|
||||
client.library_item_counts.return_value = [
|
||||
{"library": "Movies", "type": "movies", "movies": 100, "series": 0, "episodes": 0, "total": 100},
|
||||
{"library": "TV Shows", "type": "tvshows", "movies": 0, "series": 20, "episodes": 500, "total": 520},
|
||||
]
|
||||
client.active_sessions.return_value = [
|
||||
client.sessions.return_value = [
|
||||
{
|
||||
"Id": "sess1",
|
||||
"UserName": "alex",
|
||||
@@ -38,11 +51,51 @@ def mock_jellyfin():
|
||||
"NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"},
|
||||
"PlayState": {"IsPaused": False},
|
||||
"TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False},
|
||||
}
|
||||
},
|
||||
{
|
||||
"Id": "sess2",
|
||||
"UserName": "sam",
|
||||
"DeviceName": "Android",
|
||||
"NowPlayingItem": None,
|
||||
"PlayState": {},
|
||||
"TranscodingInfo": None,
|
||||
},
|
||||
]
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jellyseerr():
|
||||
"""Mock Jellyseerr client."""
|
||||
client = MagicMock()
|
||||
client.jellyfin_users.return_value = [
|
||||
{"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"},
|
||||
{"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"},
|
||||
]
|
||||
client.users.return_value = [
|
||||
{
|
||||
"id": 7,
|
||||
"username": "alex",
|
||||
"email": "alex@example.com",
|
||||
"avatar": "/avatarproxy/alex",
|
||||
"userType": 3,
|
||||
"permissions": 10,
|
||||
"requestCount": 3,
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"username": "sam",
|
||||
"email": "sam@example.com",
|
||||
"avatar": "/avatarproxy/sam",
|
||||
"userType": 2,
|
||||
"permissions": 32,
|
||||
"requestCount": 1,
|
||||
},
|
||||
]
|
||||
client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssh():
|
||||
"""Mock SSH client."""
|
||||
@@ -73,9 +126,10 @@ def mock_ssh():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(mock_jellyfin, mock_ssh):
|
||||
def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh):
|
||||
"""FastAPI test client with mocked dependencies."""
|
||||
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
|
||||
app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr
|
||||
app.dependency_overrides[get_ssh_client] = lambda: mock_ssh
|
||||
app.dependency_overrides[get_user_id] = lambda: "user123"
|
||||
client = TestClient(app)
|
||||
@@ -111,17 +165,244 @@ class TestDashboard:
|
||||
assert data[0]["library"] == "Movies"
|
||||
assert data[1]["library"] == "TV Shows"
|
||||
|
||||
def test_now_playing(self, test_client):
|
||||
def test_activity(self, test_client):
|
||||
response = test_client.get("/api/dashboard/activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
|
||||
playing_row = next(row for row in data if row["user"] == "alex")
|
||||
assert playing_row["title"] == "Test Movie"
|
||||
assert playing_row["state"] == "playing"
|
||||
assert playing_row["transcoding"] == "yes"
|
||||
assert "audio" in playing_row["transcoding_type"]
|
||||
|
||||
idle_row = next(row for row in data if row["user"] == "sam")
|
||||
assert idle_row["state"] == "idle"
|
||||
assert idle_row["title"] == "(idle)"
|
||||
|
||||
def test_now_playing_alias(self, test_client):
|
||||
response = test_client.get("/api/dashboard/now-playing")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["user"] == "alex"
|
||||
assert data[0]["title"] == "Test Movie"
|
||||
assert data[0]["transcoding"] == "yes"
|
||||
assert "audio" in data[0]["transcoding_type"]
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
# --- Users ---
|
||||
|
||||
class TestUsers:
|
||||
def test_users_list_enriched(self, test_client):
|
||||
response = test_client.get("/api/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert data["jellyseerr_configured"] is True
|
||||
assert data["jellyseerr_available"] is True
|
||||
assert data["jellyseerr_error"] == ""
|
||||
|
||||
alex = next(item for item in data["items"] if item["username"] == "alex")
|
||||
assert alex["email"] == "alex@example.com"
|
||||
assert alex["email_source"] == "jellyseerr:user"
|
||||
assert alex["contactable"] is True
|
||||
assert alex["avatar"].startswith("https://requests.example.com/")
|
||||
assert alex["avatar_source"] == "jellyseerr:user"
|
||||
assert alex["permissions"] == 10
|
||||
assert alex["permissions_label"] == "admin, manage_users"
|
||||
assert alex["role"] == "admin"
|
||||
assert alex["user_type_label"] == "jellyfin"
|
||||
assert alex["request_count"] == 3
|
||||
assert "name=jellyfin" in alex["source_summary"]
|
||||
assert "email=jellyseerr:user" in alex["source_summary"]
|
||||
|
||||
sam = next(item for item in data["items"] if item["username"] == "sam")
|
||||
assert sam["role"] == "requester"
|
||||
assert sam["user_type_label"] == "local"
|
||||
assert sam["email"] == "sam@example.com"
|
||||
|
||||
def test_users_message_status(self, test_client):
|
||||
mail_queue = MagicMock()
|
||||
mail_queue.status.return_value = {
|
||||
"state": "idle",
|
||||
"worker_running": True,
|
||||
"stop_requested": False,
|
||||
"pending_count": 0,
|
||||
"active_request_id": None,
|
||||
"last_request_id": None,
|
||||
"last_result": None,
|
||||
"last_error": "",
|
||||
"last_error_at": None,
|
||||
"last_success_at": None,
|
||||
"last_activity_at": None,
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
|
||||
try:
|
||||
response = test_client.get("/api/users/message/status")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "idle"
|
||||
assert response.json()["pending_count"] == 0
|
||||
|
||||
def test_users_message_test_smtp(self, test_client):
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.fastmail.com",
|
||||
smtp_port=587,
|
||||
smtp_username="main@fastmail.com",
|
||||
smtp_password="app-password",
|
||||
smtp_from_address="alias@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
app.dependency_overrides[get_mail_queue] = lambda: MagicMock()
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings), patch(
|
||||
"media_library_viewer_api.routers.users.test_smtp_connection",
|
||||
return_value={
|
||||
"status": "ok",
|
||||
"message": "SMTP connection successful using Fastmail STARTTLS 587",
|
||||
"from_address": "alias@example.com",
|
||||
"from_name": "Media Library Viewer",
|
||||
"smtp_host": "smtp.fastmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
"authenticated": True,
|
||||
"selected_mode": {
|
||||
"label": "Fastmail STARTTLS 587",
|
||||
"smtp_host": "smtp.fastmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"label": "Fastmail STARTTLS 587",
|
||||
"smtp_host": "smtp.fastmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
"status": "ok",
|
||||
}
|
||||
],
|
||||
},
|
||||
):
|
||||
response = test_client.post("/api/users/message/test-smtp")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["smtp_host"] == "smtp.fastmail.com"
|
||||
assert data["selected_mode"]["label"] == "Fastmail STARTTLS 587"
|
||||
|
||||
def test_users_message_test_smtp_timeout_message(self, test_client):
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.fastmail.com",
|
||||
smtp_port=587,
|
||||
smtp_username="main@fastmail.com",
|
||||
smtp_password="app-password",
|
||||
smtp_from_address="alias@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
app.dependency_overrides[get_mail_queue] = lambda: MagicMock()
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings), patch(
|
||||
"media_library_viewer_api.routers.users.test_smtp_connection",
|
||||
return_value={
|
||||
"status": "error",
|
||||
"message": "SMTP connection timed out while waiting for the server greeting. Check host, port, network access, and SMTP_TIMEOUT.",
|
||||
"from_address": "alias@example.com",
|
||||
"from_name": "Media Library Viewer",
|
||||
"smtp_host": "smtp.fastmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
"authenticated": True,
|
||||
"selected_mode": None,
|
||||
"attempts": [
|
||||
{
|
||||
"label": "configured",
|
||||
"smtp_host": "smtp.fastmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
"status": "failed",
|
||||
"error": "SMTP connection timed out while waiting for the server greeting. Check host, port, network access, and SMTP_TIMEOUT.",
|
||||
}
|
||||
],
|
||||
},
|
||||
):
|
||||
response = test_client.post("/api/users/message/test-smtp")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "error"
|
||||
assert "timed out" in response.json()["message"].lower()
|
||||
|
||||
def test_users_message_is_queued(self, test_client):
|
||||
mail_queue = MagicMock()
|
||||
mail_queue.status.return_value = {
|
||||
"state": "idle",
|
||||
"worker_running": True,
|
||||
"stop_requested": False,
|
||||
"pending_count": 0,
|
||||
"active_request_id": None,
|
||||
"last_request_id": None,
|
||||
"last_result": None,
|
||||
"last_error": "",
|
||||
"last_error_at": None,
|
||||
"last_success_at": None,
|
||||
"last_activity_at": None,
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
mail_queue.enqueue.return_value = "mail-123456"
|
||||
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="mailer@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings):
|
||||
response = test_client.post(
|
||||
"/api/users/message",
|
||||
data={
|
||||
"recipient_ids": json.dumps(["jf1", "jf2"]),
|
||||
"subject": "Hello team",
|
||||
"html_body": "<p>Hi there</p>",
|
||||
"text_body": "Hi there",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["status"] == "queued"
|
||||
assert data["request_id"] == "mail-123456"
|
||||
assert data["recipient_count"] == 2
|
||||
assert data["attachment_count"] == 0
|
||||
mail_queue.enqueue.assert_called_once()
|
||||
kwargs = mail_queue.enqueue.call_args.kwargs
|
||||
assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"]
|
||||
assert kwargs["subject"] == "Hello team"
|
||||
assert kwargs["settings"] is settings
|
||||
|
||||
# --- Files ---
|
||||
|
||||
class TestFiles:
|
||||
@@ -163,6 +444,135 @@ class TestFiles:
|
||||
assert "Permission denied" in response.text
|
||||
|
||||
|
||||
# --- Media index build ---
|
||||
|
||||
class TestMediaIndexApi:
|
||||
def test_status_includes_build_progress(self, test_client, tmp_path):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
index.init_schema()
|
||||
index.set_metadata("build_running", "true")
|
||||
index.set_metadata("build_stage", "building")
|
||||
index.set_metadata("build_message", "3 / 10 items")
|
||||
index.set_metadata("build_progress", "0.3")
|
||||
index.set_metadata("build_items_processed", "3")
|
||||
index.set_metadata("build_items_total", "10")
|
||||
index.set_metadata("build_current_library", "Movies")
|
||||
index.set_metadata("build_library_index", "1")
|
||||
index.set_metadata("build_libraries_total", "2")
|
||||
index.set_metadata("build_library_progress", "0.5")
|
||||
index.set_metadata("build_library_items_processed", "1")
|
||||
index.set_metadata("build_library_items_total", "2")
|
||||
index.set_metadata("build_elapsed_seconds", "12.0")
|
||||
index.set_metadata("build_eta_seconds", "28.0")
|
||||
index.set_metadata("build_library_elapsed_seconds", "4.0")
|
||||
index.set_metadata("build_library_eta_seconds", "4.0")
|
||||
index.set_metadata("build_pid", "4321")
|
||||
app.dependency_overrides[get_media_index] = lambda: index
|
||||
with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True):
|
||||
response = test_client.get("/api/media/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["build_running"] is True
|
||||
assert data["build_stage"] == "building"
|
||||
assert data["build_message"] == "3 / 10 items"
|
||||
assert data["build_progress"] == 0.3
|
||||
assert data["build_items_processed"] == 3
|
||||
assert data["build_items_total"] == 10
|
||||
assert data["build_current_library"] == "Movies"
|
||||
assert data["build_library_index"] == 1
|
||||
assert data["build_libraries_total"] == 2
|
||||
assert data["build_library_progress"] == 0.5
|
||||
assert data["build_library_items_processed"] == 1
|
||||
assert data["build_library_items_total"] == 2
|
||||
assert data["build_elapsed_seconds"] == 12.0
|
||||
assert data["build_eta_seconds"] == 28.0
|
||||
assert data["build_library_elapsed_seconds"] == 4.0
|
||||
assert data["build_library_eta_seconds"] == 4.0
|
||||
assert data["build_pid"] == 4321
|
||||
|
||||
def test_build_returns_started_when_background_build_is_queued(self, test_client, tmp_path, mock_jellyfin):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
app.dependency_overrides[get_media_index] = lambda: index
|
||||
|
||||
class FakeProcess:
|
||||
pid = 4321
|
||||
|
||||
with patch("media_library_viewer_api.routers.media._start_worker", return_value=FakeProcess()) as start_worker:
|
||||
try:
|
||||
response = test_client.post("/api/media/build")
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["status"] == "started"
|
||||
assert data["build_running"] is True
|
||||
assert data["build_stage"] == "queued"
|
||||
assert data["build_libraries_total"] == len(mock_jellyfin.libraries.return_value)
|
||||
assert data["build_pid"] == 4321
|
||||
start_worker.assert_called_once()
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_media_index, None)
|
||||
|
||||
def test_stop_requests_cancel(self, test_client, tmp_path):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
index.init_schema()
|
||||
index.set_metadata("build_running", "true")
|
||||
index.set_metadata("build_stage", "building")
|
||||
index.set_metadata("build_pid", "4321")
|
||||
app.dependency_overrides[get_media_index] = lambda: index
|
||||
|
||||
with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True):
|
||||
try:
|
||||
response = test_client.post("/api/media/stop")
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["status"] == "stop_requested"
|
||||
assert data["build_cancel_requested"] is True
|
||||
status = test_client.get("/api/media/status").json()
|
||||
assert status["build_cancel_requested"] is True
|
||||
assert status["build_stage"] == "canceling"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_media_index, None)
|
||||
|
||||
def test_force_stop_terminates_worker(self, test_client, tmp_path):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
index.init_schema()
|
||||
index.set_metadata("build_running", "true")
|
||||
index.set_metadata("build_stage", "building")
|
||||
index.set_metadata("build_pid", "4321")
|
||||
app.dependency_overrides[get_media_index] = lambda: index
|
||||
|
||||
alive_calls = {"count": 0}
|
||||
|
||||
def fake_pid_is_alive(pid):
|
||||
alive_calls["count"] += 1
|
||||
return alive_calls["count"] <= 2
|
||||
|
||||
with patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive), patch(
|
||||
"media_library_viewer_api.routers.media.os.killpg"
|
||||
) as killpg, patch("media_library_viewer_api.routers.media.time.sleep", return_value=None):
|
||||
try:
|
||||
response = test_client.post("/api/media/force-stop")
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["status"] == "force_stopped"
|
||||
killpg.assert_called()
|
||||
status = test_client.get("/api/media/status").json()
|
||||
assert status["build_running"] is False
|
||||
assert status["build_stage"] == "force-stopped"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_media_index, None)
|
||||
|
||||
def test_force_stop_returns_conflict_when_idle(self, test_client, tmp_path):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
index.init_schema()
|
||||
app.dependency_overrides[get_media_index] = lambda: index
|
||||
|
||||
try:
|
||||
response = test_client.post("/api/media/force-stop")
|
||||
assert response.status_code == 409
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_media_index, None)
|
||||
|
||||
|
||||
# --- Jobs ---
|
||||
|
||||
class TestJobs:
|
||||
|
||||
@@ -12,12 +12,17 @@ class TestSettings:
|
||||
assert settings.ssh_host == ""
|
||||
assert settings.ssh_port == 22
|
||||
assert settings.jellyfin_url == ""
|
||||
assert settings.jellyseerr_url == ""
|
||||
assert settings.log_level == "INFO"
|
||||
assert settings.remote_media_root == ""
|
||||
|
||||
def test_from_env(self):
|
||||
env = {
|
||||
"JELLYFIN_URL": "https://test.example.com",
|
||||
"JELLYFIN_API_KEY": "key123",
|
||||
"JELLYSEERR_URL": "https://requests.example.com",
|
||||
"JELLYSEERR_API_KEY": "seerr123",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
"SSH_HOST": "192.168.1.1",
|
||||
"SSH_USERNAME": "testuser",
|
||||
"SSH_PORT": "2222",
|
||||
@@ -27,6 +32,9 @@ class TestSettings:
|
||||
settings = Settings(_env_file=None)
|
||||
assert settings.jellyfin_url == "https://test.example.com"
|
||||
assert settings.jellyfin_api_key == "key123"
|
||||
assert settings.jellyseerr_url == "https://requests.example.com"
|
||||
assert settings.jellyseerr_api_key == "seerr123"
|
||||
assert settings.log_level == "DEBUG"
|
||||
assert settings.ssh_host == "192.168.1.1"
|
||||
assert settings.ssh_username == "testuser"
|
||||
assert settings.ssh_port == 2222
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for the Jellyseerr client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
|
||||
|
||||
class JellyseerrClientTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = JellyseerrClient("https://requests.example.com/api/v1", "api-key")
|
||||
self.session = MagicMock()
|
||||
self.client.session = self.session
|
||||
|
||||
def test_jellyfin_users_accepts_wrapped_payload(self) -> None:
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"users": [
|
||||
{"id": "jf1", "username": "alex", "email": "alex@example.com", "thumb": "/avatar"},
|
||||
{"id": "jf2", "username": "sam", "email": "sam@example.com", "thumb": "/avatar2"},
|
||||
]
|
||||
}
|
||||
self.session.get.return_value = response
|
||||
|
||||
users = self.client.jellyfin_users()
|
||||
|
||||
self.assertEqual(len(users), 2)
|
||||
self.assertEqual(users[0]["email"], "alex@example.com")
|
||||
self.assertEqual(users[1]["username"], "sam")
|
||||
self.session.get.assert_called_once()
|
||||
|
||||
def test_jellyfin_users_accepts_list_payload(self) -> None:
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = [
|
||||
{"id": "jf1", "username": "alex"},
|
||||
{"id": "jf2", "username": "sam"},
|
||||
]
|
||||
self.session.get.return_value = response
|
||||
|
||||
users = self.client.jellyfin_users()
|
||||
|
||||
self.assertEqual([u["username"] for u in users], ["alex", "sam"])
|
||||
|
||||
def test_users_uses_take_and_skip(self) -> None:
|
||||
first = MagicMock()
|
||||
first.raise_for_status.return_value = None
|
||||
first.json.return_value = {
|
||||
"pageInfo": {"results": 3, "pages": 2, "pageSize": 2, "page": 1},
|
||||
"results": [
|
||||
{"id": 1, "username": "alex", "email": "alex@example.com"},
|
||||
{"id": 2, "username": "sam", "email": "sam@example.com"},
|
||||
],
|
||||
}
|
||||
second = MagicMock()
|
||||
second.raise_for_status.return_value = None
|
||||
second.json.return_value = {
|
||||
"pageInfo": {"results": 3, "pages": 2, "pageSize": 2, "page": 2},
|
||||
"results": [
|
||||
{"id": 3, "username": "max", "email": "max@example.com"},
|
||||
],
|
||||
}
|
||||
self.session.get.side_effect = [first, second]
|
||||
|
||||
users = self.client.users(page_size=2)
|
||||
|
||||
self.assertEqual([u["username"] for u in users], ["alex", "sam", "max"])
|
||||
self.assertEqual(self.session.get.call_args_list[0].kwargs["params"], {"take": 2, "skip": 0})
|
||||
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"], {"take": 2, "skip": 2})
|
||||
|
||||
def test_absolute_url_normalizes_relative_paths(self) -> None:
|
||||
self.assertEqual(self.client.absolute_url("/avatar.png"), "https://requests.example.com/avatar.png")
|
||||
self.assertEqual(self.client.absolute_url("avatar.png"), "https://requests.example.com/avatar.png")
|
||||
self.assertEqual(self.client.absolute_url("https://cdn.example.com/x.png"), "https://cdn.example.com/x.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Unit tests for SMTP mail helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from media_library_viewer_api.services.mailer import (
|
||||
EmailAttachment,
|
||||
describe_smtp_error,
|
||||
html_to_text,
|
||||
send_email_message,
|
||||
test_smtp_connection as smtp_connection_probe,
|
||||
)
|
||||
|
||||
|
||||
class _SMTPContext:
|
||||
def __init__(self, smtp: MagicMock):
|
||||
self.smtp = smtp
|
||||
|
||||
def __enter__(self):
|
||||
return self.smtp
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class MailerTests(unittest.TestCase):
|
||||
def test_html_to_text_strips_tags(self) -> None:
|
||||
text = html_to_text("<p>Hello <strong>world</strong></p><p>Line 2</p>")
|
||||
self.assertIn("Hello", text)
|
||||
self.assertIn("world", text)
|
||||
self.assertIn("Line 2", text)
|
||||
|
||||
def test_send_email_message_uses_smtp_with_attachments(self) -> None:
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="mailer@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
smtp = MagicMock()
|
||||
smtp.send_message.return_value = {}
|
||||
smtp_factory = MagicMock(return_value=_SMTPContext(smtp))
|
||||
|
||||
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch(
|
||||
"media_library_viewer_api.services.mailer.smtplib.SMTP_SSL"
|
||||
) as smtp_ssl:
|
||||
result = send_email_message(
|
||||
settings,
|
||||
recipients=["alex@example.com", "sam@example.com"],
|
||||
subject="Hello",
|
||||
html_body="<p><strong>Hi</strong> there</p>",
|
||||
attachments=[EmailAttachment(filename="note.txt", content_type="text/plain", data=b"note")],
|
||||
)
|
||||
|
||||
smtp_ssl.assert_not_called()
|
||||
smtp.starttls.assert_called_once()
|
||||
smtp.login.assert_called_once_with("mailer@example.com", "secret")
|
||||
smtp.send_message.assert_called_once()
|
||||
message = smtp.send_message.call_args.args[0]
|
||||
self.assertEqual(message["Subject"], "Hello")
|
||||
self.assertEqual(message["From"], "Media Library Viewer <mailer@example.com>")
|
||||
self.assertEqual(result["recipient_count"], 2)
|
||||
self.assertEqual(result["attachment_count"], 1)
|
||||
|
||||
def test_test_smtp_connection_uses_starttls_and_login(self) -> None:
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="alias@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
smtp = MagicMock()
|
||||
smtp_factory = MagicMock(return_value=_SMTPContext(smtp))
|
||||
|
||||
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch(
|
||||
"media_library_viewer_api.services.mailer.smtplib.SMTP_SSL"
|
||||
) as smtp_ssl:
|
||||
result = smtp_connection_probe(settings)
|
||||
|
||||
smtp_ssl.assert_not_called()
|
||||
smtp.ehlo.assert_called()
|
||||
smtp.starttls.assert_called_once()
|
||||
smtp.login.assert_called_once_with("mailer@example.com", "secret")
|
||||
smtp.noop.assert_called_once()
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["from_address"], "alias@example.com")
|
||||
self.assertEqual(result["smtp_host"], "smtp.example.com")
|
||||
self.assertEqual(result["selected_mode"]["label"], "configured")
|
||||
|
||||
def test_test_smtp_connection_falls_back_to_fastmail_mode(self) -> None:
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.fastmail.com",
|
||||
smtp_port=465,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="alias@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=False,
|
||||
smtp_use_ssl=True,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
smtp_ssl = MagicMock(side_effect=TimeoutError("timed out"))
|
||||
fallback_smtp = MagicMock()
|
||||
smtp_factory = MagicMock(return_value=_SMTPContext(fallback_smtp))
|
||||
|
||||
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP_SSL", smtp_ssl), patch(
|
||||
"media_library_viewer_api.services.mailer.smtplib.SMTP",
|
||||
smtp_factory,
|
||||
):
|
||||
result = smtp_connection_probe(settings)
|
||||
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["selected_mode"]["label"], "Fastmail STARTTLS 587")
|
||||
self.assertEqual(len(result["attempts"]), 2)
|
||||
self.assertEqual(result["attempts"][0]["status"], "failed")
|
||||
self.assertEqual(result["attempts"][1]["status"], "ok")
|
||||
fallback_smtp.starttls.assert_called_once()
|
||||
fallback_smtp.login.assert_called_once_with("mailer@example.com", "secret")
|
||||
|
||||
def test_send_email_message_falls_back_to_fastmail_mode(self) -> None:
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.fastmail.com",
|
||||
smtp_port=465,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="alias@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=False,
|
||||
smtp_use_ssl=True,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
smtp_ssl_factory = MagicMock(side_effect=TimeoutError("timed out"))
|
||||
fallback_smtp = MagicMock()
|
||||
fallback_factory = MagicMock(return_value=_SMTPContext(fallback_smtp))
|
||||
|
||||
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP_SSL", smtp_ssl_factory), patch(
|
||||
"media_library_viewer_api.services.mailer.smtplib.SMTP",
|
||||
fallback_factory,
|
||||
):
|
||||
result = send_email_message(
|
||||
settings,
|
||||
recipients=["alex@example.com"],
|
||||
subject="Hello",
|
||||
html_body="<p>Hello</p>",
|
||||
)
|
||||
|
||||
self.assertEqual(result["selected_mode"]["label"], "Fastmail STARTTLS 587")
|
||||
self.assertEqual(result["authenticated_as"], "mailer@example.com")
|
||||
self.assertEqual(len(result["attempts"]), 2)
|
||||
self.assertEqual(result["attempts"][0]["status"], "failed")
|
||||
self.assertEqual(result["attempts"][1]["status"], "ok")
|
||||
fallback_smtp.send_message.assert_called_once()
|
||||
|
||||
def test_send_email_message_rejects_unauthorized_from_address(self) -> None:
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="alias@example.com",
|
||||
smtp_from_name="Media Library Viewer",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
smtp = MagicMock()
|
||||
smtp.send_message.side_effect = smtplib.SMTPDataError(
|
||||
551, b"5.7.1 Not authorised to send from this header address"
|
||||
)
|
||||
smtp_factory = MagicMock(return_value=_SMTPContext(smtp))
|
||||
|
||||
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch(
|
||||
"media_library_viewer_api.services.mailer.smtplib.SMTP_SSL"
|
||||
) as smtp_ssl:
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
send_email_message(
|
||||
settings,
|
||||
recipients=["alex@example.com"],
|
||||
subject="Hello",
|
||||
html_body="<p>Hello</p>",
|
||||
)
|
||||
|
||||
smtp_ssl.assert_not_called()
|
||||
self.assertIn("authorized alias", str(ctx.exception).lower())
|
||||
self.assertEqual(smtp.send_message.call_count, 1)
|
||||
|
||||
def test_describe_smtp_error_handles_timeout(self) -> None:
|
||||
detail = describe_smtp_error(TimeoutError("timed out"))
|
||||
self.assertIn("timed out", detail.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,12 @@
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from typing import Any
|
||||
from media_library_viewer_api.services.media_index import (
|
||||
MediaIndex,
|
||||
MediaIndexBuildCancelled,
|
||||
build_media_index,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -242,3 +247,126 @@ class TestMediaIndexMetadata:
|
||||
index.set_metadata("build_duration_seconds", "12.5")
|
||||
status = index.status()
|
||||
assert status.build_duration_seconds == 12.5
|
||||
|
||||
|
||||
class TestMediaIndexBuildPaths:
|
||||
def test_build_media_index_patches_remote_media_root(self, tmp_path):
|
||||
class FakeClient:
|
||||
def items(self, **kwargs):
|
||||
return {
|
||||
"Items": [
|
||||
{
|
||||
"Id": "m1",
|
||||
"Name": "Movie One",
|
||||
"Type": "Movie",
|
||||
"Path": "/media/movies/Movie One/file.mkv",
|
||||
}
|
||||
],
|
||||
"TotalRecordCount": 1,
|
||||
}
|
||||
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
count = build_media_index(
|
||||
FakeClient(),
|
||||
"user1",
|
||||
[{"Id": "lib1", "Name": "Movies"}],
|
||||
index,
|
||||
media_root="/srv/media",
|
||||
fallback_prefix="",
|
||||
)
|
||||
assert count == 1
|
||||
rows, total = index.query(library_ids=["lib1"], media_types=["Movie"])
|
||||
assert total == 1
|
||||
assert rows[0]["path"] == "/srv/media/movies/Movie One/file.mkv"
|
||||
|
||||
def test_build_media_index_reports_progress(self, tmp_path):
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def items(self, **kwargs):
|
||||
self.calls.append(kwargs.get("start_index", 0))
|
||||
if kwargs.get("start_index", 0) == 0:
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
||||
],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
||||
],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
count = build_media_index(
|
||||
FakeClient(),
|
||||
"user1",
|
||||
[{"Id": "lib1", "Name": "Movies"}],
|
||||
index,
|
||||
page_size=1,
|
||||
progress_callback=events.append,
|
||||
)
|
||||
assert count == 2
|
||||
assert events[0]["stage"] == "starting"
|
||||
assert events[0]["progress"] is None
|
||||
assert any(event["stage"] == "building" for event in events)
|
||||
building_event = next(event for event in events if event["stage"] == "building")
|
||||
assert building_event["library"] == "Movies"
|
||||
assert building_event["library_progress"] in (0.5, 1.0)
|
||||
assert "elapsed_seconds" in building_event
|
||||
assert "eta_seconds" in building_event
|
||||
assert events[-1]["stage"] == "completed"
|
||||
assert events[-1]["progress"] == 1.0
|
||||
assert events[-1]["library_progress"] == 1.0
|
||||
|
||||
def test_build_media_index_can_be_cancelled(self, tmp_path):
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def items(self, **kwargs):
|
||||
self.calls.append(kwargs.get("start_index", 0))
|
||||
if kwargs.get("start_index", 0) == 0:
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
||||
],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
||||
],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
cancel_after_building = [False]
|
||||
|
||||
def progress_callback(state: dict[str, Any]) -> None:
|
||||
events.append(state)
|
||||
if state["stage"] == "building":
|
||||
cancel_after_building[0] = True
|
||||
|
||||
def should_cancel() -> bool:
|
||||
return cancel_after_building[0]
|
||||
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
client = FakeClient()
|
||||
with pytest.raises(MediaIndexBuildCancelled):
|
||||
build_media_index(
|
||||
client,
|
||||
"user1",
|
||||
[{"Id": "lib1", "Name": "Movies"}],
|
||||
index,
|
||||
page_size=1,
|
||||
progress_callback=progress_callback,
|
||||
should_cancel=should_cancel,
|
||||
)
|
||||
assert any(event["stage"] == "building" for event in events)
|
||||
assert events[-1]["stage"] == "building"
|
||||
assert client.calls == [0]
|
||||
|
||||
Reference in New Issue
Block a user