Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
+7
View File
@@ -0,0 +1,7 @@
**/node_modules
**/dist
**/.vite
**/__pycache__
**/*.pyc
.git
.env
+34
View File
@@ -3,6 +3,24 @@ JELLYFIN_API_KEY=your-api-key
# Optional if /Users works with your API key. Otherwise set the id of the Jellyfin user whose library views should be shown.
JELLYFIN_USER_ID=
# Optional Jellyseerr enrichment for the Users tab.
JELLYSEERR_URL=https://requests.example.com
JELLYSEERR_API_KEY=your-jellyseerr-api-key
# Optional logging level for backend diagnostics.
LOG_LEVEL=INFO
# Optional SMTP settings for the Users -> message popup.
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_ADDRESS=no-reply@example.com
SMTP_FROM_NAME=Media Library Viewer
SMTP_USE_TLS=true
SMTP_USE_SSL=false
SMTP_TIMEOUT=30
SSH_HOST=media-server.example.com
SSH_USERNAME=username
SSH_PORT=22
@@ -12,3 +30,19 @@ REMOTE_MEDIA_ROOT=/mnt/media
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough.
# Example: Jellyfin gives /media/... but SSH host requires /srv/media/...
REMOTE_PATH_PREFIX=
# Authentik / OIDC
# Backend validates every API request with a Bearer JWT.
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
# Frontend OIDC settings (Vite build/runtime env)
VITE_OIDC_ENABLED=true
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
VITE_OIDC_CLIENT_ID=media-library-viewer
VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
+44 -6
View File
@@ -23,13 +23,32 @@ The project consists of two subprojects:
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts
- SQLite-indexed media table with full-library sort/filter
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
- Remote file browser with ffprobe preview and job execution
- Jellyfin API integration for library metadata
- Jellyfin API integration for library metadata and user identity data
- SSH-based file inspection and remote job templates
## Quick Start
### Backend
### Docker Compose (recommended)
Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend:
```bash
docker compose up --build
```
Open the app at http://localhost:8080.
Local development with hot reload:
```bash
docker compose -f docker-compose.dev.yml up --build
```
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000.
### Manual backend/frontend development
```bash
cd backend
@@ -39,16 +58,12 @@ pip install -e '.[dev]'
uvicorn media_library_viewer_api.main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Frontend runs on http://localhost:5173 and proxies API requests to http://localhost:8000.
## Configuration
Create a `.env` file in the project root:
@@ -58,6 +73,13 @@ 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
SSH_HOST=media-server.example.com
SSH_USERNAME=username
SSH_PORT=22
@@ -66,6 +88,21 @@ SSH_PASSWORD=
REMOTE_MEDIA_ROOT=/srv/media
REMOTE_PATH_PREFIX=
# 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
# Frontend OIDC settings
VITE_OIDC_ENABLED=true
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
VITE_OIDC_CLIENT_ID=media-library-viewer
VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
```
## Remote server requirements
@@ -98,3 +135,4 @@ cd frontend && npx tsc --noEmit && npm run build
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`).
+17
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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)
+37 -3
View File
@@ -7,11 +7,16 @@ JELLYFIN_URL etc. are picked up directly without nested-model complications.
from __future__ import annotations
import logging
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings
from media_library_viewer_api.logging_utils import describe_settings
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
"""Flat application settings read from env vars / .env file."""
@@ -21,6 +26,31 @@ class Settings(BaseSettings):
jellyfin_api_key: str = ""
jellyfin_user_id: str = ""
# Jellyseerr (optional)
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
# Logging
log_level: str = "INFO"
# Auth / OIDC (Authentik-compatible JWT validation)
auth_enabled: bool = False
oidc_issuer_url: str = ""
oidc_audience: str = ""
oidc_jwks_url: str = ""
oidc_clock_skew_seconds: int = 30
# SMTP (optional, used for Users -> message popup)
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
smtp_from_name: str = "Media Library Viewer"
smtp_use_tls: bool = True
smtp_use_ssl: bool = False
smtp_timeout: int = 30
# SSH
ssh_host: str = ""
ssh_username: str = ""
@@ -61,6 +91,10 @@ def _find_env_file() -> str | None:
def get_settings() -> Settings:
"""Return a cached Settings instance."""
env_file = _find_env_file()
if env_file:
return Settings(_env_file=env_file)
return Settings()
settings = Settings(_env_file=env_file) if env_file else Settings()
logger.info(
"Loaded backend settings from %s: %s",
env_file or "environment/defaults",
describe_settings(settings),
)
return settings
@@ -6,24 +6,53 @@ dependency system. Uses lru_cache so connections are reused across requests.
from __future__ import annotations
import logging
from functools import lru_cache
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue
logger = logging.getLogger(__name__)
@lru_cache
def get_jellyfin_client() -> JellyfinClient:
"""Return a cached Jellyfin client."""
settings = get_settings()
logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "<unset>")
return JellyfinClient(settings.jellyfin_url, settings.jellyfin_api_key)
@lru_cache
def get_jellyseerr_client() -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
settings = get_settings()
if not settings.jellyseerr_url or not settings.jellyseerr_api_key:
logger.info(
"Jellyseerr client not configured (url=%s, api_key=%s)",
"set" if settings.jellyseerr_url else "missing",
"set" if settings.jellyseerr_api_key else "missing",
)
return None
logger.info("Creating Jellyseerr client for %s", settings.jellyseerr_url.rstrip("/") or "<unset>")
return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key)
@lru_cache
def get_ssh_client() -> RemoteSSHClient:
"""Return a cached SSH client (connects on first use)."""
settings = get_settings()
logger.info(
"Creating SSH client host=%s user=%s port=%s key=%s password=%s",
settings.ssh_host or "<unset>",
settings.ssh_username or "<unset>",
settings.ssh_port,
settings.ssh_key_filename or "<unset>",
"set" if settings.ssh_password else "missing",
)
client = RemoteSSHClient(
host=settings.ssh_host,
username=settings.ssh_username,
@@ -31,10 +60,19 @@ def get_ssh_client() -> RemoteSSHClient:
key_filename=settings.ssh_key_filename or None,
password=settings.ssh_password or None,
)
client.connect()
try:
client.connect()
except Exception:
logger.exception("Failed to establish SSH connection to %s", settings.ssh_host or "<unset>")
raise
return client
def get_mail_queue() -> MailQueue:
"""Return the singleton background email queue."""
return _get_mail_queue()
def get_user_id() -> str:
"""Return the configured Jellyfin user ID, or discover the first available user."""
settings = get_settings()
@@ -7,12 +7,15 @@ confirmations/dry-runs.
from __future__ import annotations
import logging
import shlex
from dataclasses import dataclass
from typing import Mapping
from media_library_viewer_api.clients.ssh import CommandResult, RemoteSSHClient
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class JobTemplate:
@@ -56,4 +59,5 @@ def run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout: int = 600) -
"""Render and execute a configured job template for a selected remote path."""
template = JOB_TEMPLATES[job_key]
command = template.render({"path": path})
logger.info("Executing job template key=%s path=%s timeout=%s", job_key, path, timeout)
return ssh.run(command, timeout=timeout)
@@ -0,0 +1,66 @@
"""Logging helpers for the backend."""
from __future__ import annotations
import logging
import os
from urllib.parse import urlsplit
def configure_logging(level_name: str | None = None) -> int:
"""Configure root logging once and return the numeric log level."""
resolved_name = (level_name or os.getenv("LOG_LEVEL", "INFO")).upper()
level = getattr(logging, resolved_name, logging.INFO)
root = logging.getLogger()
if not root.handlers:
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
root.setLevel(level)
logging.getLogger("media_library_viewer_api").setLevel(level)
logging.getLogger("uvicorn").setLevel(level)
logging.getLogger("uvicorn.error").setLevel(level)
logging.getLogger("uvicorn.access").setLevel(level)
logging.getLogger("paramiko").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
return level
def _sanitize_url(url: str | None) -> str:
if not url:
return "<unset>"
parsed = urlsplit(url.strip())
if not parsed.scheme or not parsed.netloc:
return url.rstrip("/")
return f"{parsed.scheme}://{parsed.netloc}"
def describe_settings(settings: object) -> dict[str, str]:
"""Return a secret-safe summary of the current backend settings."""
return {
"log_level": str(getattr(settings, "log_level", "INFO") or "INFO").upper(),
"auth_enabled": str(bool(getattr(settings, "auth_enabled", True))),
"oidc_issuer_url": _sanitize_url(getattr(settings, "oidc_issuer_url", "")),
"oidc_audience": getattr(settings, "oidc_audience", "") or "<unset>",
"oidc_jwks_url": _sanitize_url(getattr(settings, "oidc_jwks_url", "")),
"jellyfin_url": _sanitize_url(getattr(settings, "jellyfin_url", "")),
"jellyfin_api_key": "set" if getattr(settings, "jellyfin_api_key", "") else "missing",
"jellyfin_user_id": getattr(settings, "jellyfin_user_id", "") or "<auto>",
"jellyseerr_url": _sanitize_url(getattr(settings, "jellyseerr_url", "")),
"jellyseerr_api_key": "set" if getattr(settings, "jellyseerr_api_key", "") else "missing",
"ssh_host": getattr(settings, "ssh_host", "") or "<unset>",
"ssh_username": getattr(settings, "ssh_username", "") or "<unset>",
"ssh_port": str(getattr(settings, "ssh_port", 22) or 22),
"ssh_key_filename": "set" if getattr(settings, "ssh_key_filename", "") else "missing",
"ssh_password": "set" if getattr(settings, "ssh_password", "") else "missing",
"smtp_host": _sanitize_url(getattr(settings, "smtp_host", "")),
"smtp_port": str(getattr(settings, "smtp_port", 587) or 587),
"smtp_username": "set" if getattr(settings, "smtp_username", "") else "missing",
"smtp_from_address": getattr(settings, "smtp_from_address", "") or "<unset>",
"smtp_from_name": getattr(settings, "smtp_from_name", "") or "<unset>",
"smtp_use_tls": str(bool(getattr(settings, "smtp_use_tls", True))),
"smtp_use_ssl": str(bool(getattr(settings, "smtp_use_ssl", False))),
"remote_media_root": getattr(settings, "remote_media_root", "") or "<unset>",
"remote_path_prefix": getattr(settings, "remote_path_prefix", "") or "<unset>",
}
+47 -3
View File
@@ -2,25 +2,41 @@
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users
from media_library_viewer_api.dependencies import get_mail_queue
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan — startup/shutdown."""
settings = get_settings()
configure_logging(settings.log_level)
validate_auth_settings(settings)
logger.info("Backend startup complete: %s", describe_settings(settings))
mail_queue = get_mail_queue()
mail_queue.start()
yield
mail_queue.stop()
logger.info("Backend shutdown complete")
app = FastAPI(
title="Media Library Viewer API",
version="0.1.0",
description="Backend API for Jellyfin media browsing, SSH file inspection, and server monitoring.",
description="Backend API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access.",
lifespan=lifespan,
)
@@ -38,17 +54,45 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def enforce_jwt_auth(request: Request, call_next):
return await require_jwt_auth(request, call_next)
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log every API request with timing and outcome."""
start = time.perf_counter()
client_host = request.client.host if request.client else "unknown"
logger.info("request start %s %s client=%s", request.method, request.url.path, client_host)
try:
response = await call_next(request)
except Exception:
logger.exception("request error %s %s client=%s", request.method, request.url.path, client_host)
raise
elapsed_ms = (time.perf_counter() - start) * 1000.0
logger.info(
"request end %s %s status=%s elapsed_ms=%.1f",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
)
return response
# Register routers
app.include_router(dashboard.router)
app.include_router(monitoring.router)
app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
@app.get("/api/health")
def health_check() -> dict[str, str]:
"""Simple health check endpoint."""
logger.debug("health check requested")
return {"status": "ok"}
@@ -2,8 +2,11 @@
from __future__ import annotations
import logging
import posixpath
logger = logging.getLogger(__name__)
def apply_remote_path_prefix(path: str, prefix: str) -> str:
"""Apply an optional fallback prefix for Jellyfin->SSH path handoff."""
@@ -14,10 +17,16 @@ def apply_remote_path_prefix(path: str, prefix: str) -> str:
return path
normalized_prefix = normalized_prefix.rstrip("/")
if path == normalized_prefix or path.startswith(normalized_prefix + "/"):
return posixpath.normpath(path)
resolved = posixpath.normpath(path)
logger.debug("Path prefix already applied path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved)
return resolved
if path.startswith("/"):
return posixpath.normpath(normalized_prefix + path)
return posixpath.normpath(posixpath.join(normalized_prefix, path))
resolved = posixpath.normpath(normalized_prefix + path)
logger.debug("Applied path prefix path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved)
return resolved
resolved = posixpath.normpath(posixpath.join(normalized_prefix, path))
logger.debug("Joined path prefix path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved)
return resolved
def map_path_to_media_root(path: str, media_root: str) -> str:
@@ -39,6 +48,7 @@ def map_path_to_media_root(path: str, media_root: str) -> str:
path_absolute = "/" + "/".join(raw_parts)
if path_absolute == normalized_root or path_absolute.startswith(normalized_root + "/"):
logger.debug("Path already under media root path=%s media_root=%s", path, normalized_root)
return path_absolute
root_anchor = posixpath.basename(normalized_root)
@@ -48,7 +58,9 @@ def map_path_to_media_root(path: str, media_root: str) -> str:
if root_anchor in raw_parts:
anchor_index = raw_parts.index(root_anchor)
remainder_parts = raw_parts[anchor_index + 1:]
return posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
resolved = posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
logger.debug("Mapped path to media root path=%s media_root=%s resolved=%s", path, normalized_root, resolved)
return resolved
return path
@@ -64,5 +76,8 @@ def resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str)
return path
mapped = map_path_to_media_root(path, media_root)
if mapped and mapped != path:
logger.debug("Resolved media path via media root original=%s resolved=%s", path, mapped)
return mapped
return apply_remote_path_prefix(mapped or path, fallback_prefix)
resolved = apply_remote_path_prefix(mapped or path, fallback_prefix)
logger.debug("Resolved media path via fallback original=%s resolved=%s", path, resolved)
return resolved
@@ -1,7 +1,8 @@
"""Dashboard router — media counts, per-library breakdown, now-playing."""
"""Dashboard router — media counts, per-library breakdown, activity."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
@@ -9,6 +10,8 @@ from fastapi import APIRouter, Depends
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -18,7 +21,9 @@ def get_counts(
user_id: str = Depends(get_user_id),
) -> dict[str, int]:
"""Return total movie/series/episode counts."""
return client.media_counts(user_id)
counts = client.media_counts(user_id)
logger.info("Dashboard counts user_id=%s counts=%s", user_id, counts)
return counts
@router.get("/libraries")
@@ -28,26 +33,31 @@ def get_library_counts(
) -> list[dict[str, Any]]:
"""Return per-library item counts broken down by type."""
libraries = client.libraries(user_id)
logger.info("Dashboard libraries user_id=%s count=%s", user_id, len(libraries))
return client.library_item_counts(user_id, libraries)
@router.get("/now-playing")
def get_now_playing(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Return currently active playback sessions with transcode info."""
sessions = client.active_sessions()
results = []
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize Jellyfin sessions into dashboard activity rows."""
results: list[dict[str, Any]] = []
for session in sessions:
item = session.get("NowPlayingItem") or {}
play_state = session.get("PlayState") or {}
transcoding = session.get("TranscodingInfo") or {}
has_item = bool(item)
series = item.get("SeriesName") or ""
title = f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
title = (
f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
) if has_item else "(idle)"
if not has_item:
state_label = "idle"
else:
state_label = "paused" if play_state.get("IsPaused") else "playing"
is_transcoding = bool(transcoding)
transcode_type = []
transcode_type: list[str] = []
if is_transcoding:
if transcoding.get("IsVideoDirect") is False:
transcode_type.append("video")
@@ -59,11 +69,32 @@ def get_now_playing(
results.append({
"user": session.get("UserName") or "Unknown",
"title": title,
"type": item.get("Type", ""),
"state": "paused" if play_state.get("IsPaused") else "playing",
"type": item.get("Type", "") if has_item else "",
"state": state_label,
"transcoding": "yes" if is_transcoding else "no",
"transcoding_type": ", ".join(transcode_type),
"device": session.get("DeviceName") or session.get("Client") or "",
"session_id": session.get("Id") or "",
})
return results
@router.get("/activity")
def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Return activity rows for active sessions (playing and idle/logged-in)."""
sessions = client.sessions()
rows = _map_sessions_to_activity_rows(sessions)
state_rank = {"playing": 0, "paused": 1, "idle": 2}
rows.sort(key=lambda r: (state_rank.get(str(r.get("state")), 9), str(r.get("user", "")).lower()))
logger.info("Dashboard activity sessions=%s rows=%s", len(sessions), len(rows))
return rows
@router.get("/now-playing")
def get_now_playing(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Backward-compatible alias; returns full activity rows."""
return get_activity(client)
@@ -3,15 +3,18 @@
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.path_utils import resolve_remote_media_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"])
@@ -23,8 +26,10 @@ def list_directory(
"""List a remote directory."""
result = ssh.list_dir(path)
if result.exit_status != 0:
logger.warning("Files list failed path=%s exit_status=%s", path, result.exit_status)
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory")
entries = json.loads(result.stdout)
logger.info("Files list path=%s entries=%s", path, len(entries))
return {
"path": path,
"entries": entries,
@@ -41,7 +46,9 @@ def get_ffprobe(
try:
data = ssh.ffprobe_json(path)
except RuntimeError as exc:
logger.warning("Files ffprobe failed path=%s error=%s", path, exc)
raise HTTPException(status_code=400, detail=str(exc))
logger.info("Files ffprobe path=%s", path)
return data
@@ -53,7 +60,9 @@ def get_stat(
"""Run stat on a remote path."""
result = ssh.stat_path(path)
if result.exit_status != 0:
logger.warning("Files stat failed path=%s exit_status=%s", path, result.exit_status)
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "stat failed")
logger.info("Files stat path=%s", path)
return {"path": path, "output": result.stdout}
@@ -64,4 +73,5 @@ def resolve_path(
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
settings = get_settings()
resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix)
logger.info("Files resolve path original=%s resolved=%s", path, resolved)
return {"original": path, "resolved": resolved}
@@ -2,15 +2,18 @@
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.jobs import JOB_TEMPLATES, run_job
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@@ -22,7 +25,7 @@ class RunJobRequest(BaseModel):
@router.get("/templates")
def get_templates() -> list[dict[str, str]]:
"""Return available job templates."""
return [
templates = [
{
"key": key,
"name": template.name,
@@ -30,6 +33,8 @@ def get_templates() -> list[dict[str, str]]:
}
for key, template in JOB_TEMPLATES.items()
]
logger.info("Jobs templates requested count=%s", len(templates))
return templates
@router.post("/run")
@@ -39,9 +44,12 @@ def post_run_job(
) -> dict[str, Any]:
"""Run a job template on a remote path."""
if request.job_key not in JOB_TEMPLATES:
logger.warning("Unknown job requested key=%s path=%s", request.job_key, request.path)
raise HTTPException(status_code=400, detail=f"Unknown job key: {request.job_key}")
logger.info("Running job key=%s path=%s", request.job_key, request.path)
result = run_job(ssh, request.job_key, request.path)
logger.info("Job finished key=%s exit_status=%s path=%s", request.job_key, result.exit_status, request.path)
return {
"job_key": request.job_key,
"path": request.path,
@@ -1,45 +1,262 @@
"""Media router — index status, build, and query."""
"""Media router — index status, build, stop, and query."""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.services.media_index import MediaIndex, build_media_index
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.services.media_index import MediaIndex
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"])
_build_lock = threading.Lock()
def get_media_index() -> MediaIndex:
return MediaIndex()
@router.get("/status")
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Return media index status (exists, count, last updated, build duration)."""
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
for key, value in state.items():
index.set_metadata(key, "" if value is None else value)
def _staging_db_path(index: MediaIndex) -> Path:
return index.db_path.with_name(f"{index.db_path.stem}.building{index.db_path.suffix}")
def _pid_is_alive(pid: int | None) -> bool:
if not pid:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _clean_stale_build_state(index: MediaIndex) -> Any:
status = index.status()
if status.build_running and not _pid_is_alive(status.build_pid):
logger.warning("Detected stale media build state pid=%s", status.build_pid)
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "stale",
"build_message": "Previous media index build stopped unexpectedly",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "Worker process is no longer running",
},
)
status = index.status()
return status
def _serialize_status(status: Any) -> dict[str, Any]:
return {
"exists": status.exists,
"item_count": status.item_count,
"updated_at": status.updated_at,
"updated_at_label": status.updated_at_label,
"build_duration_seconds": status.build_duration_seconds,
"build_running": status.build_running,
"build_stage": status.build_stage,
"build_message": status.build_message,
"build_progress": status.build_progress,
"build_items_processed": status.build_items_processed,
"build_items_total": status.build_items_total,
"build_current_library": status.build_current_library,
"build_library_index": status.build_library_index,
"build_libraries_total": status.build_libraries_total,
"build_library_progress": status.build_library_progress,
"build_library_items_processed": status.build_library_items_processed,
"build_library_items_total": status.build_library_items_total,
"build_elapsed_seconds": status.build_elapsed_seconds,
"build_eta_seconds": status.build_eta_seconds,
"build_library_elapsed_seconds": status.build_library_elapsed_seconds,
"build_library_eta_seconds": status.build_library_eta_seconds,
"build_cancel_requested": status.build_cancel_requested,
"build_pid": status.build_pid,
"build_error": status.build_error,
}
@router.post("/build")
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
return [
sys.executable,
"-m",
"media_library_viewer_api.workers.media_index_worker",
"--index-path",
str(final_db_path),
"--staging-path",
str(staging_db_path),
]
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
staging_path = _staging_db_path(index)
staging_path.unlink(missing_ok=True)
return subprocess.Popen(
_worker_command(index.db_path, staging_path),
start_new_session=True,
env=os.environ.copy(),
)
@router.get("/status")
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Return media index status (exists, count, build progress, and errors)."""
with _build_lock:
media_status = _clean_stale_build_state(index)
logger.info("Media status requested running=%s stage=%s", media_status.build_running, media_status.build_stage)
return _serialize_status(media_status)
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id),
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Rebuild the media index from Jellyfin."""
libraries = client.libraries(user_id)
count = build_media_index(client, user_id, libraries, index)
return {"indexed_items": count}
"""Start a media index build in a subprocess worker."""
with _build_lock:
current_status = _clean_stale_build_state(index)
if current_status.build_running and _pid_is_alive(current_status.build_pid):
logger.warning("Media build already running pid=%s", current_status.build_pid)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
libraries = client.libraries(user_id)
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
process = _start_worker(index)
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "queued",
"build_message": "Media index build queued",
"build_progress": None,
"build_items_processed": 0,
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": len(libraries),
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": None,
"build_eta_seconds": None,
"build_library_elapsed_seconds": None,
"build_library_eta_seconds": None,
"build_cancel_requested": False,
"build_pid": process.pid,
"build_error": "",
},
)
media_status = index.status()
logger.info("Media index build started pid=%s", process.pid)
return {"status": "started", **_serialize_status(media_status)}
@router.post("/stop", status_code=status.HTTP_202_ACCEPTED)
def stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Request cooperative cancellation of a running media index build."""
with _build_lock:
media_status = _clean_stale_build_state(index)
if not media_status.build_running:
logger.warning("Stop requested but no media build is running")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
logger.info("Cooperative stop requested for media build pid=%s", media_status.build_pid)
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "canceling",
"build_message": "Stopping media index build...",
"build_cancel_requested": True,
"build_error": "",
},
)
media_status = index.status()
logger.info("Media stop requested acknowledged")
return {"status": "stop_requested", **_serialize_status(media_status)}
@router.post("/force-stop", status_code=status.HTTP_202_ACCEPTED)
def force_stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Terminate the media index worker process if it is stuck."""
with _build_lock:
media_status = _clean_stale_build_state(index)
if not media_status.build_running:
logger.warning("Force stop requested but no media build is running")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
pid = media_status.build_pid
if not pid or not _pid_is_alive(pid):
logger.warning("Force stop requested but build worker is not alive pid=%s", pid)
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "stale",
"build_message": "Media index worker is not running",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "Worker process is not running",
},
)
media_status = index.status()
return {"status": "already_stopped", **_serialize_status(media_status)}
logger.info("Force stopping media build pid=%s", pid)
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
deadline = time.time() + 3.0
while time.time() < deadline and _pid_is_alive(pid):
time.sleep(0.1)
if _pid_is_alive(pid):
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "force-stopped",
"build_message": "Media index build force stopped",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
},
)
media_status = index.status()
return {"status": "force_stopped", **_serialize_status(media_status)}
@router.get("/query")
@@ -57,13 +274,25 @@ def query_media(
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Query the media index with filters, sorting, and pagination."""
# If no library IDs provided, use all libraries
# If no library IDs provided, use all libraries.
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
if not library_ids:
all_libs = client.libraries(user_id)
library_ids = [lib["Id"] for lib in all_libs]
media_types = [t.strip() for t in types.split(",") if t.strip()]
logger.info(
"Media query user_id=%s libraries=%s types=%s search=%s hdr=%s sort=%s/%s limit=%s offset=%s",
user_id,
len(library_ids or []),
",".join(media_types),
search or "<none>",
hdr_filter,
sort_key,
sort_order,
limit,
offset,
)
rows, total = index.query(
library_ids=library_ids,
@@ -76,6 +305,7 @@ def query_media(
offset=offset,
)
logger.info("Media query returned total=%s rows=%s", total, len(rows))
return {
"items": rows,
"total": total,
@@ -2,13 +2,16 @@
from __future__ import annotations
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.dependencies import get_ssh_client
logger = logging.getLogger(__name__)
from media_library_viewer_api.clients.resources import (
disk_space,
read_resource_metrics,
@@ -26,19 +29,26 @@ router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@router.get("/status")
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Return collector running status."""
return {"status": resource_collector_status(ssh)}
status = resource_collector_status(ssh)
logger.info("Monitoring status requested: %s", status)
return {"status": status}
@router.get("/metrics")
def get_metrics(
max_lines: int = 1000,
last_seconds: int = 3600,
max_lines: int = 70_000,
last_seconds: int | None = None,
ssh: RemoteSSHClient = Depends(get_ssh_client),
) -> dict[str, Any]:
"""Return resource metric samples from the remote collector."""
rows = read_resource_metrics(ssh, max_lines=max_lines)
cutoff_ts = time.time() - last_seconds
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
if last_seconds is None:
filtered = rows
cutoff_ts = 0.0
else:
cutoff_ts = time.time() - last_seconds
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
logger.info("Monitoring metrics requested total=%s filtered=%s last_seconds=%s", len(rows), len(filtered), last_seconds)
return {
"samples": filtered,
"total_samples": len(rows),
@@ -52,6 +62,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str,
"""Return disk space for the configured media root."""
settings = get_settings()
path = settings.media_root or "/"
logger.info("Monitoring disk requested path=%s", path)
return disk_space(ssh, path)
@@ -59,6 +70,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str,
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Start the remote resource collector."""
message = start_resource_collector(ssh)
logger.info("Monitoring collector start result: %s", message)
return {"message": message}
@@ -66,6 +78,7 @@ def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Stop the remote resource collector."""
message = stop_resource_collector(ssh)
logger.info("Monitoring collector stop result: %s", message)
return {"message": message}
@@ -73,10 +86,13 @@ def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Restart the remote resource collector."""
message = restart_resource_collector(ssh)
logger.info("Monitoring collector restart result: %s", message)
return {"message": message}
@router.get("/diagnostics")
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Return collector debug info for troubleshooting."""
return {"diagnostics": resource_collector_debug_info(ssh)}
diagnostics = resource_collector_debug_info(ssh)
logger.info("Monitoring diagnostics requested")
return {"diagnostics": diagnostics}
@@ -0,0 +1,394 @@
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
)
from media_library_viewer_api.services.mailer import EmailAttachment, test_smtp_connection, validate_smtp_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/users", tags=["users"])
_PERMISSION_FLAGS = [
(2, "admin"),
(4, "manage_settings"),
(8, "manage_users"),
(16, "manage_requests"),
(32, "request"),
(64, "vote"),
(128, "auto_approve"),
(256, "auto_approve_movie"),
(512, "auto_approve_tv"),
(1024, "request_4k"),
(2048, "request_4k_movie"),
(4096, "request_4k_tv"),
(8192, "request_advanced"),
(16384, "request_view"),
(32768, "auto_approve_4k"),
(65536, "auto_approve_4k_movie"),
(131072, "auto_approve_4k_tv"),
(262144, "request_movie"),
(524288, "request_tv"),
(1048576, "manage_issues"),
(2097152, "view_issues"),
]
_USER_TYPES = {
1: "plex",
2: "local",
3: "jellyfin",
4: "emby",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _permission_labels(permissions: int) -> list[str]:
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
return labels or ["none"]
def _role_label(permissions: int) -> str:
if permissions & 2:
return "admin"
if permissions & (4 | 8 | 16):
return "manager"
if permissions & (32 | 64 | 128):
return "requester"
return "user"
def _account_type(user_type: Any) -> str:
return _USER_TYPES.get(_safe_int(user_type), "unknown")
def _merge_users(
jellyfin_users: list[dict[str, Any]],
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
jellyseerr_users: list[dict[str, Any]] | None,
jellyseerr_client: JellyseerrClient | None,
) -> dict[str, Any]:
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _looks_like_email(value: Any) -> bool:
text = str(value or "").strip()
return bool(text and "@" in text and " " not in text)
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
if _looks_like_email(value):
return source, str(value).strip()
return "", ""
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
text = str(value or "").strip()
if text:
return source, text
return "", ""
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
return ", ".join(
[
f"name={name_source or 'none'}",
f"email={email_source or 'none'}",
f"avatar={avatar_source or 'none'}",
f"access={access_source or 'none'}",
]
)
def _lookup_keys(item: dict[str, Any]) -> list[str]:
return [
_normalize(item.get("id")),
_normalize(item.get("Id")),
_normalize(item.get("userId")),
_normalize(item.get("user_id")),
_normalize(item.get("jellyfinUserId")),
_normalize(item.get("jellyfin_user_id")),
_normalize(item.get("jellyfinUsername")),
_normalize(item.get("jellyfin_username")),
_normalize(item.get("username")),
_normalize(item.get("displayName")),
_normalize(item.get("display_name")),
]
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
for item in jellyseerr_jellyfin_users or []:
for key in (
item.get("id"),
item.get("Id"),
item.get("userId"),
item.get("user_id"),
item.get("jellyfinUserId"),
item.get("jellyfin_user_id"),
):
normalized = _normalize(key)
if normalized:
linked_by_jellyfin_id[normalized] = item
seerr_by_key: dict[str, dict[str, Any]] = {}
for item in jellyseerr_users or []:
for key in _lookup_keys(item):
if key:
seerr_by_key[key] = item
items: list[dict[str, Any]] = []
enriched_count = 0
for user in jellyfin_users:
jellyfin_id = str(user.get("Id") or user.get("id") or "")
jellyfin_name = str(user.get("Name") or user.get("name") or "")
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
seerr_user = None
for candidate in [
jellyfin_name,
(jf_link or {}).get("jellyfinUsername"),
(jf_link or {}).get("jellyfin_username"),
(jf_link or {}).get("username"),
(jf_link or {}).get("displayName"),
(jf_link or {}).get("display_name"),
]:
seerr_user = seerr_by_key.get(_normalize(candidate))
if seerr_user:
break
email_source, email = _pick_source_and_value(
[
("jellyseerr:user", (seerr_user or {}).get("email")),
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
]
)
avatar_source, avatar = _first_value(
[
("jellyseerr:user", (seerr_user or {}).get("avatar")),
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
]
)
if avatar and jellyseerr_client:
avatar = jellyseerr_client.absolute_url(avatar)
permissions = _safe_int((seerr_user or {}).get("permissions"))
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
role = _role_label(permissions)
access_source = "jellyseerr:user" if seerr_user else ""
name_source = "jellyfin"
summary = _source_summary(name_source, email_source, avatar_source, access_source)
if seerr_user or jf_link:
enriched_count += 1
items.append(
{
"jellyfin_id": jellyfin_id,
"username": jellyfin_name,
"display_name": jellyfin_name,
"email": email,
"email_source": email_source,
"avatar": avatar,
"avatar_source": avatar_source,
"contactable": bool(email),
"source": summary,
"source_summary": summary,
"name_source": name_source,
"access_source": access_source,
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId")) or None,
"jellyseerr_username": str(
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
),
"user_type": user_type or None,
"user_type_label": _account_type(user_type),
"role": role,
"permissions": permissions,
"permissions_label": ", ".join(_permission_labels(permissions)),
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
}
)
logger.info(
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
len(jellyfin_users),
len(jellyseerr_jellyfin_users or []),
len(jellyseerr_users or []),
enriched_count,
)
return {
"items": items,
"total": len(items),
"jellyseerr_configured": jellyseerr_client is not None,
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
"jellyseerr_user_count": len(jellyseerr_users or []),
"enriched_count": enriched_count,
}
@router.get("")
def get_users(
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
) -> dict[str, Any]:
"""Return the known users, enriched with Jellyseerr data when available."""
jellyfin_users = jellyfin.users()
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
jellyseerr_users: list[dict[str, Any]] | None = None
jellyseerr_error = ""
if jellyseerr:
try:
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
try:
jellyseerr_users = jellyseerr.users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr user list fetch failed")
jellyseerr_error = (
f"{jellyseerr_error}; " if jellyseerr_error else ""
) + f"Jellyseerr user list fetch failed: {exc}"
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
result["jellyseerr_error"] = jellyseerr_error
logger.info(
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
result["total"],
result["jellyseerr_configured"],
result["jellyseerr_available"],
result["enriched_count"],
bool(jellyseerr_error),
)
return result
@router.get("/message/status")
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
"""Return the current background email queue status."""
return mail_queue.status()
@router.post("/message/test-smtp")
def test_user_message_smtp() -> dict[str, Any]:
"""Test the configured SMTP connection without sending an email."""
settings = get_settings()
return test_smtp_connection(settings)
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
async def post_user_message(
recipient_ids: str = Form(...),
subject: str = Form(...),
html_body: str = Form(""),
text_body: str = Form(""),
attachments: list[UploadFile] | None = File(default=None),
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
mail_queue=Depends(get_mail_queue),
) -> dict[str, Any]:
"""Queue a single email to the selected users without blocking the API."""
try:
requested_ids = json.loads(recipient_ids)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
if not isinstance(requested_ids, list):
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
if not cleaned_ids:
raise HTTPException(status_code=400, detail="At least one recipient is required")
subject = subject.strip()
if not subject:
raise HTTPException(status_code=400, detail="Subject is required")
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
recipients: list[str] = []
recipient_labels: list[str] = []
skipped: list[dict[str, str]] = []
for user_id in cleaned_ids:
item = users_by_id.get(user_id)
if not item:
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
continue
email = str(item.get("email") or "").strip()
if not email:
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
continue
recipients.append(email)
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
if not recipients:
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
settings = get_settings()
validate_smtp_settings(settings)
queue_status = mail_queue.status()
if not queue_status["worker_running"]:
raise HTTPException(status_code=503, detail="Email queue worker is not running")
attachment_payloads: list[EmailAttachment] = []
for upload in attachments or []:
data = await upload.read()
if not data:
continue
attachment_payloads.append(
EmailAttachment(
filename=upload.filename or "attachment",
content_type=upload.content_type or "application/octet-stream",
data=data,
)
)
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=attachment_payloads,
)
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip() or str(
getattr(settings, "smtp_username", "") or ""
).strip()
logger.info(
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
request_id,
subject,
len(recipients),
len(attachment_payloads),
len(skipped),
)
return {
"status": "queued",
"request_id": request_id,
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_payloads),
"subject": subject,
"recipient_labels": recipient_labels,
"skipped": skipped,
}
@@ -0,0 +1,232 @@
"""In-process background queue for outbound user emails.
The queue keeps SMTP delivery off the request path so message composition
returns quickly and the rest of the API remains responsive while the worker
thread performs the blocking SMTP call.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from media_library_viewer_api.services.mailer import EmailAttachment, describe_smtp_error, send_email_message
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class QueuedEmailMessage:
"""A queued outbound email request."""
request_id: str
settings: Any
recipients: list[str]
subject: str
html_body: str
text_body: str
attachments: list[EmailAttachment] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
class MailQueue:
"""Single-worker in-process queue for SMTP delivery."""
def __init__(self) -> None:
self._queue: queue.Queue[QueuedEmailMessage | None] = queue.Queue()
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._pending_count = 0
self._active_request_id: str | None = None
self._last_request_id: str | None = None
self._last_result: str | None = None
self._last_error = ""
self._last_error_at: float | None = None
self._last_success_at: float | None = None
self._last_activity_at: float | None = None
self._sent_count = 0
self._failed_count = 0
def start(self) -> None:
"""Start the worker thread if it is not already running."""
with self._lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="mail-queue-worker", daemon=True)
self._thread.start()
logger.info("Mail queue worker started")
def stop(self, timeout: float = 5.0) -> None:
"""Stop the worker thread and wait briefly for shutdown."""
with self._lock:
thread = self._thread
if not thread:
return
self._stop_event.set()
self._queue.put(None)
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Mail queue worker did not stop within %.1fs", timeout)
else:
logger.info("Mail queue worker stopped")
with self._lock:
if self._thread is thread:
self._thread = None
def enqueue(
self,
*,
settings: Any,
recipients: list[str],
subject: str,
html_body: str,
text_body: str = "",
attachments: list[EmailAttachment] | None = None,
) -> str:
"""Queue an outbound email and return a request identifier."""
request_id = uuid.uuid4().hex
message = QueuedEmailMessage(
request_id=request_id,
settings=settings,
recipients=list(recipients),
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=list(attachments or []),
)
with self._lock:
self._pending_count += 1
self._last_request_id = request_id
self._last_result = "queued"
self._last_activity_at = time.time()
self._queue.put(message)
logger.info(
"Queued email request_id=%s recipients=%s attachments=%s subject=%s",
request_id,
len(message.recipients),
len(message.attachments),
subject,
)
return request_id
def status(self) -> dict[str, Any]:
"""Return a snapshot of the queue state for health/status endpoints."""
with self._lock:
worker_running = bool(self._thread and self._thread.is_alive())
stop_requested = self._stop_event.is_set()
pending_count = self._pending_count
active_request_id = self._active_request_id
last_request_id = self._last_request_id
last_result = self._last_result
last_error = self._last_error
last_error_at = self._last_error_at
last_success_at = self._last_success_at
last_activity_at = self._last_activity_at
sent_count = self._sent_count
failed_count = self._failed_count
if not worker_running:
state = "stopped" if stop_requested else "error"
elif active_request_id or pending_count > 0:
state = "busy"
elif last_result == "failed" and last_error:
state = "error"
else:
state = "idle"
return {
"state": state,
"worker_running": worker_running,
"stop_requested": stop_requested,
"pending_count": pending_count,
"active_request_id": active_request_id,
"last_request_id": last_request_id,
"last_result": last_result,
"last_error": last_error,
"last_error_at": last_error_at,
"last_success_at": last_success_at,
"last_activity_at": last_activity_at,
"sent_count": sent_count,
"failed_count": failed_count,
}
def _run(self) -> None:
while not self._stop_event.is_set():
try:
message = self._queue.get(timeout=0.5)
except queue.Empty:
continue
try:
if message is None:
continue
with self._lock:
self._pending_count = max(0, self._pending_count - 1)
self._active_request_id = message.request_id
self._last_request_id = message.request_id
self._last_result = "sending"
self._last_activity_at = time.time()
logger.info(
"Mail queue sending request_id=%s recipients=%s attachments=%s subject=%s",
message.request_id,
len(message.recipients),
len(message.attachments),
message.subject,
)
result: dict[str, Any] = send_email_message(
message.settings,
recipients=message.recipients,
subject=message.subject,
html_body=message.html_body,
text_body=message.text_body,
attachments=message.attachments,
)
with self._lock:
self._active_request_id = None
self._last_result = "sent"
self._last_success_at = time.time()
self._last_activity_at = self._last_success_at
self._last_error = ""
self._last_error_at = None
self._sent_count += 1
logger.info(
"Mail queue sent request_id=%s mode=%s auth_user=%s recipient_count=%s attachment_count=%s",
message.request_id,
(result.get("selected_mode") or {}).get("label", "<unknown>"),
result.get("authenticated_as") or "<none>",
result.get("recipient_count", 0),
result.get("attachment_count", 0),
)
except Exception as exc:
friendly_error = describe_smtp_error(exc)
with self._lock:
self._active_request_id = None
self._last_result = "failed"
self._last_error = friendly_error
self._last_error_at = time.time()
self._last_activity_at = self._last_error_at
self._failed_count += 1
logger.exception(
"Mail queue delivery failed request_id=%s error=%s",
getattr(message, "request_id", "unknown"),
friendly_error,
)
finally:
self._queue.task_done()
_MAIL_QUEUE = MailQueue()
def get_mail_queue() -> MailQueue:
"""Return the singleton mail queue."""
return _MAIL_QUEUE
@@ -0,0 +1,540 @@
"""SMTP email sending helpers for user communication workflows."""
from __future__ import annotations
import logging
import mimetypes
import socket
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from html.parser import HTMLParser
from typing import Any, Iterable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EmailAttachment:
"""Attachment payload passed from the API layer."""
filename: str
content_type: str
data: bytes
class _HTMLToTextParser(HTMLParser):
"""Small HTML-to-text helper for plain-text fallback bodies."""
block_tags = {"p", "div", "section", "article", "header", "footer", "li", "tr", "td", "th", "br"}
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs): # type: ignore[override]
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
self.parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if data:
self.parts.append(data)
def text(self) -> str:
return "".join(self.parts)
def html_to_text(html: str) -> str:
"""Convert a small HTML body to readable plain text."""
parser = _HTMLToTextParser()
parser.feed(html or "")
text = parser.text()
lines = [line.rstrip() for line in text.splitlines()]
return "\n".join(line for line in lines if line).strip()
def _from_address(settings: object) -> str:
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip()
if from_address:
return from_address
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
if smtp_username:
return smtp_username
raise ValueError("SMTP from address is required (set SMTP_FROM_ADDRESS or SMTP_USERNAME)")
def validate_smtp_settings(settings: object) -> None:
"""Validate that the SMTP configuration is sufficient to send mail."""
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
if not smtp_host:
raise ValueError("SMTP host is required")
_from_address(settings)
def _smtp_settings(settings: object) -> dict[str, object]:
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
if not smtp_host:
raise ValueError("SMTP host is required")
smtp_port = int(getattr(settings, "smtp_port", 587) or 587)
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
smtp_password = str(getattr(settings, "smtp_password", "") or "")
use_tls = bool(getattr(settings, "smtp_use_tls", True))
use_ssl = bool(getattr(settings, "smtp_use_ssl", False))
smtp_timeout = int(getattr(settings, "smtp_timeout", 30) or 30)
return {
"smtp_host": smtp_host,
"smtp_port": smtp_port,
"smtp_username": smtp_username,
"smtp_password": smtp_password,
"use_tls": use_tls,
"use_ssl": use_ssl,
"smtp_timeout": smtp_timeout,
}
def _smtp_mode_label(mode: dict[str, object]) -> str:
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
return f"{mode['smtp_host']}:{mode['smtp_port']} via {transport}"
def _smtp_mode_candidates(settings: object) -> list[dict[str, Any]]:
base = _smtp_settings(settings)
candidates = [dict(base, mode_label="configured")]
smtp_host = str(base["smtp_host"]).lower()
if "fastmail.com" in smtp_host:
fastmail_ssl = {
**base,
"smtp_port": 465,
"use_tls": False,
"use_ssl": True,
"mode_label": "Fastmail SSL 465",
}
fastmail_tls = {
**base,
"smtp_port": 587,
"use_tls": True,
"use_ssl": False,
"mode_label": "Fastmail STARTTLS 587",
}
for mode in (fastmail_ssl, fastmail_tls):
if not any(
candidate["smtp_port"] == mode["smtp_port"]
and candidate["use_tls"] == mode["use_tls"]
and candidate["use_ssl"] == mode["use_ssl"]
for candidate in candidates
):
candidates.append(mode)
return candidates
def _probe_smtp_connection(mode: dict[str, Any]) -> None:
context = ssl.create_default_context()
smtp_host = str(mode["smtp_host"])
smtp_port = int(mode["smtp_port"])
smtp_username = str(mode["smtp_username"])
smtp_password = str(mode["smtp_password"])
use_tls = bool(mode["use_tls"])
use_ssl = bool(mode["use_ssl"])
smtp_timeout = int(mode["smtp_timeout"])
if use_ssl:
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
else:
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
with smtp_connection as smtp:
if use_tls and not use_ssl:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp.ehlo()
if smtp_username:
smtp.login(smtp_username, smtp_password)
smtp.noop()
def _smtp_sender_not_authorized(error: Exception) -> bool:
code = getattr(error, "smtp_code", None)
raw_error = getattr(error, "smtp_error", b"")
if isinstance(raw_error, bytes):
raw_error_text = raw_error.decode(errors="ignore")
else:
raw_error_text = str(raw_error)
text = f"{code} {raw_error_text} {error}".lower()
return code in {551, 553} or "not authorised to send from this header address" in text or "not authorized to send from this header address" in text
def _smtp_attempt_metadata(mode: dict[str, Any]) -> dict[str, Any]:
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
return {
"label": str(mode.get("mode_label") or _smtp_mode_label(mode)),
"smtp_host": str(mode["smtp_host"]),
"smtp_port": int(mode["smtp_port"]),
"use_tls": bool(mode["use_tls"]),
"use_ssl": bool(mode["use_ssl"]),
"transport": transport,
"auth_user": str(mode.get("smtp_username") or "") or "<none>",
}
def describe_smtp_error(error: Exception) -> str:
"""Convert SMTP failures into operator-friendly messages."""
chain: list[Exception] = []
current: Exception | None = error
while current is not None and current not in chain:
chain.append(current)
current = current.__cause__ if isinstance(current.__cause__, Exception) else None
for item in chain:
text = str(item).strip()
lowered = text.lower()
if isinstance(item, (TimeoutError, socket.timeout)) or "timed out" in lowered:
return (
"SMTP connection timed out while waiting for the server greeting. "
"Check host, port, network access, and SMTP_TIMEOUT."
)
if isinstance(item, smtplib.SMTPAuthenticationError):
return (
"SMTP authentication failed. Check SMTP_USERNAME and SMTP_PASSWORD "
"(Fastmail and similar providers usually require an app password)."
)
if isinstance(item, (smtplib.SMTPDataError, smtplib.SMTPResponseException)):
smtp_code = getattr(item, "smtp_code", None)
if smtp_code in {551, 553} or "not authorised to send from this header address" in lowered or "not authorized to send from this header address" in lowered:
return (
"SMTP server rejected the configured From address. Use an authorized alias "
"for this account or change SMTP_FROM_ADDRESS to a sender the provider allows."
)
if isinstance(item, smtplib.SMTPConnectError):
return "SMTP connection was rejected by the server. Check the host and port."
if isinstance(item, smtplib.SMTPServerDisconnected) and "timed out" in lowered:
return (
"SMTP connection timed out while waiting for the server greeting. "
"Check host, port, network access, and SMTP_TIMEOUT."
)
return f"SMTP delivery failed: {error}"
def test_smtp_connection(settings: object) -> dict[str, object]:
"""Validate SMTP connectivity and authentication without sending a message."""
try:
base = _smtp_settings(settings)
except ValueError as exc:
return {
"status": "error",
"message": str(exc),
"attempts": [],
"selected_mode": None,
"from_address": "",
"from_name": str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer",
"smtp_host": str(getattr(settings, "smtp_host", "") or "").strip(),
"smtp_port": int(getattr(settings, "smtp_port", 587) or 587),
"use_tls": bool(getattr(settings, "smtp_use_tls", True)),
"use_ssl": bool(getattr(settings, "smtp_use_ssl", False)),
"authenticated": False,
}
from_address = _from_address(settings)
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
attempts: list[dict[str, Any]] = []
last_error = ""
logger.info(
"SMTP test requested from_address=%s auth_user=%s",
from_address,
base["smtp_username"] or "<none>",
)
for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode)
logger.info(
"SMTP test attempting label=%s host=%s port=%s transport=%s auth_user=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
)
try:
_probe_smtp_connection(mode)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP test succeeded label=%s host=%s port=%s transport=%s auth_user=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
)
return {
"status": "ok",
"message": f"SMTP connection successful using {meta['label']}",
"from_address": from_address,
"from_name": from_name,
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"authenticated": bool(str(mode["smtp_username"])),
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc:
last_error = describe_smtp_error(exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
}
)
logger.warning(
"SMTP test failed label=%s host=%s port=%s transport=%s auth_user=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
last_error,
)
logger.error(
"SMTP test exhausted attempts=%s error=%s",
[attempt["label"] for attempt in attempts],
last_error or "SMTP test failed.",
)
return {
"status": "error",
"message": last_error or "SMTP test failed.",
"from_address": from_address,
"from_name": from_name,
"smtp_host": base["smtp_host"],
"smtp_port": base["smtp_port"],
"use_tls": base["use_tls"],
"use_ssl": base["use_ssl"],
"authenticated": bool(base["smtp_username"]),
"selected_mode": None,
"attempts": attempts,
}
def build_email_message(
settings: object,
recipients: list[str],
subject: str,
html_body: str,
text_body: str,
attachments: Iterable[EmailAttachment] = (),
*,
sender_address: str | None = None,
reply_to_address: str | None = None,
) -> tuple[EmailMessage, str]:
"""Build a MIME email message with HTML and attachments."""
from_address = sender_address or _from_address(settings)
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name, from_address))
msg["To"] = "Undisclosed recipients:;"
msg["Reply-To"] = reply_to_address or from_address
plain_text = text_body.strip() or html_to_text(html_body)
if html_body.strip():
msg.set_content(plain_text or " ")
msg.add_alternative(html_body, subtype="html")
else:
msg.set_content(plain_text or "")
for attachment in attachments:
content_type = attachment.content_type or mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream"
maintype, subtype = content_type.split("/", 1) if "/" in content_type else ("application", "octet-stream")
msg.add_attachment(
attachment.data,
maintype=maintype,
subtype=subtype,
filename=attachment.filename or "attachment",
)
return msg, from_address
def _send_email_via_mode(
mode: dict[str, Any],
message: EmailMessage,
recipients: list[str],
from_address: str,
) -> None:
context = ssl.create_default_context()
smtp_host = str(mode["smtp_host"])
smtp_port = int(mode["smtp_port"])
smtp_username = str(mode["smtp_username"])
smtp_password = str(mode["smtp_password"])
use_tls = bool(mode["use_tls"])
use_ssl = bool(mode["use_ssl"])
smtp_timeout = int(mode["smtp_timeout"])
if use_ssl:
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
else:
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
with smtp_connection as smtp:
if use_tls and not use_ssl:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp.ehlo()
if smtp_username:
smtp.login(smtp_username, smtp_password)
smtp.send_message(message, from_addr=from_address, to_addrs=recipients)
def send_email_message(
settings: object,
recipients: list[str],
subject: str,
html_body: str,
text_body: str = "",
attachments: Iterable[EmailAttachment] = (),
) -> dict[str, object]:
"""Send a single outbound email to a recipient list via SMTP BCC."""
if not recipients:
raise ValueError("At least one recipient is required")
attachment_list = list(attachments)
preferred_from_address = _from_address(settings)
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
message, from_address = build_email_message(
settings,
recipients,
subject,
html_body,
text_body,
attachment_list,
sender_address=preferred_from_address,
reply_to_address=preferred_from_address,
)
logger.info(
"SMTP send requested subject=%s recipients=%s from_address=%s auth_user=%s attachments=%s",
subject,
len(recipients),
from_address,
smtp_username or "<none>",
len(attachment_list),
)
attempts: list[dict[str, Any]] = []
last_error = ""
for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode)
logger.info(
"SMTP send attempting label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
try:
_send_email_via_mode(mode, message, recipients, from_address)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
return {
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_list),
"subject": subject,
"authenticated_as": smtp_username or None,
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc:
last_error = describe_smtp_error(exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
}
)
if _smtp_sender_not_authorized(exc):
logger.warning(
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
else:
logger.warning(
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
raise RuntimeError(last_error or "SMTP delivery failed")
@@ -7,14 +7,18 @@ through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
from __future__ import annotations
import logging
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from typing import Any, Callable, Iterable
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
from media_library_viewer_api.path_utils import resolve_remote_media_path
logger = logging.getLogger(__name__)
# Local generated database. It is ignored by git and can be rebuilt from
# Jellyfin metadata whenever needed.
@@ -42,6 +46,19 @@ SORT_COLUMNS = {
}
def _estimate_remaining_seconds(elapsed_seconds: float, progress: float | None) -> float | None:
if progress is None:
return None
progress = max(0.0, min(1.0, progress))
if progress <= 0.0:
return None
return max(0.0, elapsed_seconds * (1.0 - progress) / progress)
class MediaIndexBuildCancelled(Exception):
"""Raised when a media index build is requested to stop."""
@dataclass(frozen=True)
class MediaIndexStatus:
"""Lightweight status object displayed by the Media tab."""
@@ -51,6 +68,25 @@ class MediaIndexStatus:
updated_at: int | None = None
updated_at_label: str = ""
build_duration_seconds: float | None = None
build_running: bool = False
build_stage: str = ""
build_message: str = ""
build_progress: float | None = None
build_items_processed: int = 0
build_items_total: int = 0
build_current_library: str = ""
build_library_index: int = 0
build_libraries_total: int = 0
build_library_progress: float | None = None
build_library_items_processed: int = 0
build_library_items_total: int = 0
build_elapsed_seconds: float | None = None
build_eta_seconds: float | None = None
build_library_elapsed_seconds: float | None = None
build_library_eta_seconds: float | None = None
build_cancel_requested: bool = False
build_pid: int | None = None
build_error: str = ""
class MediaIndex:
@@ -66,8 +102,10 @@ class MediaIndex:
def connect(self) -> sqlite3.Connection:
"""Open a sqlite connection configured to return Row objects."""
conn = sqlite3.connect(self.db_path)
conn = sqlite3.connect(self.db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_schema(self) -> None:
@@ -170,24 +208,68 @@ class MediaIndex:
try:
with self.connect() as conn:
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone()
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
meta = {
row[0]: row[1]
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
}
except sqlite3.Error:
return MediaIndexStatus(exists=False)
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
updated_at_raw = meta.get("updated_at", "")
updated_at = int(updated_at_raw) if str(updated_at_raw).isdigit() else None
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
duration_raw = meta.get("build_duration_seconds")
build_duration = None
if duration_row:
if duration_raw is not None:
try:
build_duration = float(duration_row[0])
build_duration = float(duration_raw)
except (TypeError, ValueError):
build_duration = None
def _bool(key: str, default: bool = False) -> bool:
value = str(meta.get(key, str(default))).strip().lower()
return value in {"1", "true", "yes", "on"}
def _int(key: str, default: int = 0) -> int:
value = meta.get(key, default)
try:
return int(value)
except (TypeError, ValueError):
return default
def _float(key: str) -> float | None:
value = meta.get(key)
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
return MediaIndexStatus(
exists=True,
item_count=item_count,
updated_at=updated_at,
updated_at_label=label,
build_duration_seconds=build_duration,
build_running=_bool("build_running"),
build_stage=str(meta.get("build_stage", "")),
build_message=str(meta.get("build_message", "")),
build_progress=_float("build_progress"),
build_items_processed=_int("build_items_processed"),
build_items_total=_int("build_items_total"),
build_current_library=str(meta.get("build_current_library", "")),
build_library_index=_int("build_library_index"),
build_libraries_total=_int("build_libraries_total"),
build_library_progress=_float("build_library_progress"),
build_library_items_processed=_int("build_library_items_processed"),
build_library_items_total=_int("build_library_items_total"),
build_elapsed_seconds=_float("build_elapsed_seconds"),
build_eta_seconds=_float("build_eta_seconds"),
build_library_elapsed_seconds=_float("build_library_elapsed_seconds"),
build_library_eta_seconds=_float("build_library_eta_seconds"),
build_cancel_requested=_bool("build_cancel_requested"),
build_pid=_int("build_pid") or None,
build_error=str(meta.get("build_error", "")),
)
def query(
@@ -245,18 +327,79 @@ def build_media_index(
libraries: list[dict[str, Any]],
index: MediaIndex | None = None,
page_size: int = 500,
media_root: str = "",
fallback_prefix: str = "",
progress_callback: Callable[[dict[str, Any]], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> int:
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
index = index or MediaIndex()
started_at = time.perf_counter()
normalized_rows: list[dict[str, Any]] = []
for library in libraries:
processed_total = 0
expected_total = 0
current_library_name = ""
current_library_index = 0
current_library_processed = 0
current_library_total = 0
current_library_started_at = started_at
def ensure_not_cancelled() -> None:
if should_cancel and should_cancel():
raise MediaIndexBuildCancelled()
def emit(stage: str, message: str) -> None:
if not progress_callback:
return
elapsed_seconds = time.perf_counter() - started_at
library_elapsed_seconds = time.perf_counter() - current_library_started_at
overall_progress = (processed_total / expected_total) if expected_total else None
library_progress = (current_library_processed / current_library_total) if current_library_total else None
progress_callback(
{
"stage": stage,
"message": message,
"processed": processed_total,
"total": expected_total,
"progress": overall_progress,
"elapsed_seconds": elapsed_seconds,
"eta_seconds": _estimate_remaining_seconds(elapsed_seconds, overall_progress),
"library": current_library_name,
"library_index": current_library_index,
"libraries_total": len(libraries),
"library_processed": current_library_processed,
"library_total": current_library_total,
"library_progress": library_progress,
"library_elapsed_seconds": library_elapsed_seconds if current_library_total else None,
"library_eta_seconds": _estimate_remaining_seconds(library_elapsed_seconds, library_progress),
}
)
ensure_not_cancelled()
logger.info("Media index build starting libraries=%s page_size=%s", len(libraries), page_size)
emit("starting", "Starting media index build")
for library_index, library in enumerate(libraries, start=1):
library_id = library.get("Id")
library_name = library.get("Name", "")
current_library_name = library.get("Name", "")
current_library_index = library_index
current_library_processed = 0
current_library_total = 0
current_library_started_at = time.perf_counter()
if not library_id:
continue
ensure_not_cancelled()
logger.info(
"Media index scanning library index=%s/%s name=%s id=%s",
library_index,
len(libraries),
current_library_name or "Library",
library_id,
)
emit("library-starting", f"Scanning {current_library_name or 'Library'}")
start = 0
discovered_library_total = None
while True:
ensure_not_cancelled()
response = client.items(
user_id=user_id,
parent_id=library_id,
@@ -267,12 +410,49 @@ def build_media_index(
sort_by="SortName",
sort_order="Ascending",
)
ensure_not_cancelled()
items = response.get("Items", [])
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
if discovered_library_total is None:
discovered_library_total = int(response.get("TotalRecordCount", len(items)))
current_library_total = max(discovered_library_total, 0)
expected_total += current_library_total
normalized_rows.extend(
{
**row,
"path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix),
}
for row in (
normalize_media_item(item, library_id, current_library_name)
for item in items
)
)
processed_total += len(items)
current_library_processed += len(items)
start += len(items)
ensure_not_cancelled()
emit(
"building",
f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items",
)
logger.debug(
"Media index progress library=%s processed=%s/%s total_processed=%s",
current_library_name or "Library",
current_library_processed,
current_library_total,
processed_total,
)
total = int(response.get("TotalRecordCount", start))
if not items or start >= total:
break
ensure_not_cancelled()
logger.info("Media index finalizing rows=%s", len(normalized_rows))
emit("finalizing", "Writing index to disk")
ensure_not_cancelled()
count = index.replace_items(normalized_rows)
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
duration = time.perf_counter() - started_at
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
processed_total = count
current_library_processed = current_library_total
emit("completed", f"Indexed {count} items in {duration:.1f}s")
logger.info("Media index build completed count=%s duration=%.2fs", count, duration)
return count
@@ -0,0 +1 @@
"""Worker entrypoints for background tasks."""
@@ -0,0 +1,200 @@
"""Subprocess worker that builds the media index.
The FastAPI app starts this worker as a separate Python process so the build can
be cooperatively canceled or force-killed without taking down the API server.
"""
from __future__ import annotations
import argparse
import logging
import os
import time
from pathlib import Path
from typing import Any
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.services.media_index import (
MediaIndex,
MediaIndexBuildCancelled,
build_media_index,
)
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
logger = logging.getLogger(__name__)
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
for key, value in state.items():
index.set_metadata(key, "" if value is None else value)
def _cancel_requested(index: MediaIndex) -> bool:
return index.status().build_cancel_requested
def _start_state(index: MediaIndex, pid: int, library_count: int) -> None:
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "starting",
"build_message": "Starting media index build",
"build_progress": None,
"build_items_processed": 0,
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": library_count,
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": None,
"build_eta_seconds": None,
"build_library_elapsed_seconds": None,
"build_library_eta_seconds": None,
"build_cancel_requested": False,
"build_pid": pid,
"build_error": "",
},
)
def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> None:
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": state.get("stage", ""),
"build_message": state.get("message", ""),
"build_progress": state.get("progress"),
"build_items_processed": state.get("processed", 0),
"build_items_total": state.get("total", 0),
"build_current_library": state.get("library", ""),
"build_library_index": state.get("library_index", 0),
"build_libraries_total": state.get("libraries_total", 0),
"build_library_progress": state.get("library_progress"),
"build_library_items_processed": state.get("library_processed", 0),
"build_library_items_total": state.get("library_total", 0),
"build_elapsed_seconds": state.get("elapsed_seconds"),
"build_eta_seconds": state.get("eta_seconds"),
"build_library_elapsed_seconds": state.get("library_elapsed_seconds"),
"build_library_eta_seconds": state.get("library_eta_seconds"),
"build_cancel_requested": False,
"build_pid": pid,
"build_error": "",
},
)
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
"""Run the media index build in a subprocess."""
settings = get_settings()
configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client()
user_id = get_user_id()
libraries = client.libraries(user_id)
final_index = MediaIndex(final_index_path)
staging_index = MediaIndex(staging_index_path)
pid = os.getpid()
started_at = time.perf_counter()
staging_path = Path(staging_index.db_path)
staging_path.unlink(missing_ok=True)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
try:
count = build_media_index(
client,
user_id,
libraries,
index=staging_index,
media_root=settings.media_root,
fallback_prefix=settings.path_prefix,
progress_callback=lambda state: _progress_callback(final_index, pid, state),
should_cancel=lambda: _cancel_requested(final_index),
)
# Swap the staging database into place atomically.
os.replace(staging_index.db_path, final_index.db_path)
completed_index = MediaIndex(final_index.db_path)
logger.info("Media index worker completed count=%s", count)
elapsed = time.perf_counter() - started_at
completed_status = completed_index.status()
_set_build_metadata(
completed_index,
{
"build_running": False,
"build_stage": "completed",
"build_message": "Media index build complete",
"build_progress": 1.0,
"build_items_processed": count,
"build_items_total": count,
"build_current_library": "",
"build_library_index": len(libraries),
"build_libraries_total": len(libraries),
"build_library_progress": 1.0,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": elapsed,
"build_eta_seconds": 0.0,
"build_library_elapsed_seconds": 0.0,
"build_library_eta_seconds": 0.0,
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
# Keep the duration reported by the staging build.
"build_duration_seconds": completed_status.build_duration_seconds or elapsed,
},
)
return 0
except MediaIndexBuildCancelled:
logger.info("Media index worker canceled")
_set_build_metadata(
final_index,
{
"build_running": False,
"build_stage": "canceled",
"build_message": "Media index build canceled",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
},
)
return 130
except Exception as exc: # pragma: no cover - defensive subprocess error handling
logger.exception("Media index worker failed")
_set_build_metadata(
final_index,
{
"build_running": False,
"build_stage": "error",
"build_message": "Media index build failed",
"build_cancel_requested": False,
"build_pid": "",
"build_error": str(exc),
},
)
return 1
finally:
# If the build did not complete successfully, the staging DB is disposable.
if staging_path.exists() and staging_path != Path(final_index.db_path):
try:
staging_path.unlink()
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-path", required=True)
args = parser.parse_args()
return run_build(args.index_path, args.staging_path)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+420 -10
View File
@@ -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:
+8
View File
@@ -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
+81
View File
@@ -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()
+207
View File
@@ -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()
+129 -1
View File
@@ -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]
+38
View File
@@ -0,0 +1,38 @@
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
container_name: backend
command: uvicorn media_library_viewer_api.main:app --host 0.0.0.0 --port 8000 --reload
env_file:
- .env
environment:
AUTH_ENABLED: "false"
ports:
- "8000:8000"
volumes:
- ./backend:/app/backend
restart: unless-stopped
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
target: dev
container_name: frontend
environment:
VITE_API_URL: "/api"
VITE_OIDC_ENABLED: "false"
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
ports:
- "5173:5173"
volumes:
- ./frontend:/app/frontend
- frontend_node_modules:/app/frontend/node_modules
depends_on:
- backend
restart: unless-stopped
volumes:
frontend_node_modules:
+45
View File
@@ -0,0 +1,45 @@
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
env_file:
- .env
environment:
AUTH_ENABLED: "true"
restart: unless-stopped
expose:
- "8000"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import json, urllib.request; print(json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health'))['status'])",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
target: prod
args:
VITE_API_URL: "/api"
VITE_OIDC_ENABLED: ${VITE_OIDC_ENABLED:-true}
VITE_OIDC_ISSUER: ${VITE_OIDC_ISSUER}
VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID}
VITE_OIDC_SCOPE: ${VITE_OIDC_SCOPE:-openid profile email}
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI}
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
depends_on:
backend:
condition: service_healthy
ports:
- "8080:80"
restart: unless-stopped
+49 -200
View File
@@ -34,12 +34,42 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- File browser interaction should stay explicit and simple: read-only listing plus explicit Open/Select actions rather than another row-selection grid.
- Use valid Jellyfin `Fields` query values only, because invalid field names can cause `400 Bad Request` responses.
### Users & Communication
- Provide a Users tab that lists all available users the system knows about.
- Use Jellyfin as the base source of truth for the user list.
- Optionally enrich Jellyfin users with Jellyseerr data when Jellyseerr is configured and reachable.
- Be tolerant of Jellyseerr response-shape differences across versions; for example, some endpoints may return a wrapped `{ users: [...] }` payload instead of a raw list.
- Surface whatever contact/identity fields are available from the configured source(s), such as email, avatar/thumb, role/permissions, and notification/contact eligibility.
- Email should only render actual email addresses; usernames or other non-email identifiers should be suppressed instead of shown as email.
- Keep communication actions separate from listing/identity data so the UI can support future email/notification workflows without redesigning the user list.
- SMTP-backed user messages should be queued asynchronously and return immediately; delivery must not block the rest of the API request path.
- The Users tab should expose a live queue status indicator so users can see when the outbound email queue is idle, busy, stopped, or failing.
- The queue status indicator should clearly show the current queue item count.
- The Users tab should include a one-click SMTP test action that validates connectivity/authentication without sending a real message.
- The SMTP test action should visibly show when it is running.
- The SMTP test should surface the chosen protocol/port and, for Fastmail, try both 465/SSL and 587/STARTTLS so configuration mismatches are easier to diagnose.
- Rework the Users data into an internal merged state so user identity can be combined with related now-playing/session data.
- Clicking a now-playing row should navigate to the Users tab and open the matching user detail drawer, keeping the selection deep-linkable.
- Provide an explicit "Open in Users" action in now-playing rows in addition to row-click navigation.
- Provide a compact per-field source summary for the Users detail drawer so it is obvious which backend source supplied name, email, avatar, and access data.
- Jellyseerr user list pagination must use `take`/`skip`, not `page`/`pageSize`.
- The Users tab table should stay compact and readable: center the avatar and email cells, keep backend source diagnostics out of the table itself, and prefer a simpler hand-built row layout when a dense grid makes text positioning awkward.
- The Users table activity column should stay compact and show only a brief status badge for playing/paused/idle/no-session state instead of a multi-line activity summary.
- The Dashboard activity panel should reuse the same compact session-table styling as the Users activity details so the two views feel consistent.
- In the shared session activity table, the user column should come before state, title/type, and device because the user is the most relevant identifier.
- The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts.
- The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter.
- The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented.
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
### Remote Filesystem over SSH
- Connect to a remote media server via SSH.
- Use strict SSH host key behavior; users should connect manually once to populate `known_hosts`.
- Browse remote directories and files rooted at a configurable default media path.
- File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`).
- Media index paths should be stored in the SSH-visible form by default, using the same Jellyfin-to-SSH mapping so the Media tab and file browser agree on paths.
- Support a configurable Jellyfin-to-SSH fallback path prefix for cases where `REMOTE_MEDIA_ROOT` mapping alone is not sufficient.
- Support manual path entry and refresh.
- Remote file listing must be compact, structured, and navigable.
@@ -94,22 +124,31 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
### Dashboard / Server Monitoring
- Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview.
- Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests.
- Provide Docker Compose deployment files at the repository root for production and local development.
- Show Jellyfin media counts for movies, series, and series episodes on the dashboard.
- Show currently playing Jellyfin sessions on the dashboard, including user, media title, playback state, and whether transcoding is active.
- Show dashboard session activity from Jellyfin, including both currently playing sessions and logged-in idle sessions.
- Activity rows should include user, media title (or `(idle)`), playback state (`playing`/`paused`/`idle`), and whether transcoding is active.
- Provide a dashboard tab with a compact server resource overview over SSH.
- Provide a separate Monitoring tab for detailed resource charts, collector controls, diagnostics, and raw samples.
- The Monitoring tab should request all retained collector samples by default, while the dashboard overview can continue to use a shorter recent window.
- Show CPU and RAM usage for the last hour.
- Show IO wait percentage for the last hour.
- On the dashboard overview, summarize monitoring metrics as 10-minute averages with high/low values for quick inspection.
- Show average and spike/peak values for network throughput and disk I/O.
- Show used, available, and total disk space for the configured media root, falling back to `/`.
- Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library.
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
- Last-hour charts require the collector to have been running long enough to collect samples.
- Network throughput should be shown split into down/download and up/upload.
- Because the collector keeps only a bounded history, the Monitoring tab can safely load all retained samples up to the retention/max-lines cap.
- Network throughput should be shown as a combined traffic chart with download and upload lines.
- Network throughput should use bytes-per-second display units such as KB/s, MB/s, and GB/s to avoid bit/byte ambiguity.
- Disk throughput should be shown split into read and write.
- Disk throughput should be shown as a combined I/O chart with read and write lines.
- Network and disk throughput charts should scale values into readable units such as KB/s, MB/s, and GB/s.
- Each Monitoring chart should show compact summary chips such as min/avg/max for quick inspection.
- The Monitoring toolbar should offer quick time-range buttons such as 1h, 8h, 1 day, and 7 days in addition to free brush selection.
### Remote Jobs
@@ -120,202 +159,12 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- Command template values must be shell-quoted before execution.
- Future destructive jobs should require explicit confirmation.
## Current Architecture
- `app.py` - Thin root Streamlit entrypoint for `streamlit run app.py`.
- `pyproject.toml` - Authoritative package metadata, dependencies, and tool configuration.
- `requirements.txt` - Convenience install file that installs the local package editable.
- `src/media_library_viewer/app.py` - Thin Streamlit orchestration layer.
- `src/media_library_viewer/ui/` - Streamlit UI modules split by feature area (dashboard, media, file browser, library, preview/tools).
- `src/media_library_viewer/clients/jellyfin.py` - Jellyfin API wrapper.
- `src/media_library_viewer/clients/ssh.py` - SSH command execution, directory listing, `stat`, and `ffprobe` helpers.
- `src/media_library_viewer/domain/` - UI-independent normalization/domain helpers.
- `src/media_library_viewer/services/` - UI-independent application services such as the SQLite media index.
- `src/media_library_viewer/jobs.py` - Remote job template definitions and runner.
- `src/media_library_viewer/utils.py` - Formatting and media metadata summarization helpers.
- `src/media_library_viewer/config.py` - Environment variable and `.env` configuration loading.
- `docs/REQUIREMENTS.md` - Living requirements and decision log.
- `tests/` - Reserved for future test coverage.
## Key Implementation Decisions
- Prefer the Jellyfin API for library and server metadata.
- Prefer SSH plus `ffprobe` for disk-authoritative stream/container metadata.
- Use API-key auth for Jellyfin, but select a user explicitly for user-scoped endpoints.
- Use `streamlit-aggrid` as a required dependency for Media table row selection. Avoid optional frontend fallbacks that create multiple interaction models.
- Keep remote jobs template-based to reduce accidental destructive actions.
- Keep the Phase 1 UI compact and structured rather than using large per-row buttons.
- Use a `src/` package layout so the project can grow without accumulating many root-level modules.
- Keep root `app.py` as a compatibility/convenience wrapper for Streamlit.
- Keep clients, domain normalization, and application services independent from Streamlit so the frontend can later be replaced by React/FastAPI or another UI.
- Keep Streamlit rendering split into small UI modules so interaction bugs can be debugged in feature-local code instead of one monolithic app file.
## Security and Safety Requirements
- Do not hardcode secrets.
- Use `.env`, environment variables, or Streamlit secrets for credentials.
- Keep `.env` and Streamlit secrets out of version control.
- Reject unknown SSH host keys by default.
- Treat SSH jobs as potentially dangerous and keep them explicit/template-based.
- Add confirmation steps before implementing cleanup, delete, transcode-replace, or other destructive workflows.
## Configuration Requirements
Supported environment variables:
```bash
JELLYFIN_URL=
JELLYFIN_API_KEY=
JELLYFIN_USER_ID=
SSH_HOST=
SSH_USERNAME=
SSH_PORT=22
SSH_KEY_FILENAME=
SSH_PASSWORD=
REMOTE_MEDIA_ROOT=
REMOTE_PATH_PREFIX=
```
## Known External Requirements
Remote server should have:
- Linux `/proc` and `/sys/block` for resource metrics
- `/bin/sh` for POSIX command execution, even when the user's login shell is fish or another non-POSIX shell
- POSIX shell utilities including `awk`, `date`, `tail`, `df`, `kill`, and `nohup`
- `python3`
- GNU/coreutils-compatible `find` and `stat`
- `ffprobe` for media metadata inspection
Local app dependencies are declared in `pyproject.toml`; `requirements.txt` installs the package editable for convenience. Runtime dependencies include:
- `streamlit`
- `streamlit-aggrid`
- `requests`
- `paramiko`
- `python-dotenv`
- `pandas`
## Backlog / Future Extensions
- Add transcode job templates.
- Add cleanup job templates with dry-run and explicit confirmation.
- Add subtitle/audio-track diagnostics.
- Add sidecar file inspection for `.nfo`, `.srt`, images, and metadata files.
- Compare Jellyfin metadata against disk metadata and sidecars.
- Add long-running job tracking/log streaming.
- Add saved presets for common media roots and job templates.
- Add file previews for text sidecars.
- Add richer HDR/Dolby Vision/bit-depth summaries from `ffprobe`.
- Add optional integration with existing monitoring stacks such as Prometheus/node_exporter, Netdata, or sysstat/sar.
## Decision Log
### 2026-04-30 - Initial app plan
- Planned a Streamlit app that uses the Jellyfin API as the primary metadata source.
- Decided SSH should be used for disk inspection and future maintenance jobs.
### 2026-04-30 - Phase 1 implementation
- Created the initial app structure with Jellyfin, SSH, jobs, config, and utility modules.
- Added safe/read-only remote job templates.
- Added `ffprobe` and `stat` inspection.
### 2026-04-30 - Jellyfin API fixes
- Replaced `/Users/Me` usage with `GET /Users` plus user selection.
- Added `JELLYFIN_USER_ID` override.
- Cleaned Jellyfin `Fields` values to avoid 400 responses.
- Added defensive stripping of trailing `/web` from Jellyfin URLs.
### 2026-04-30 - Remote file browser evolution
- Added interactive remote listing.
- Removed emoji and hard-to-render characters.
- Added search, filtering, sorting, pagination, and compact listing summary.
- Reworked listing from large button rows into a compact table.
- Switched to `streamlit-aggrid` for file-browser-like row click behavior.
- Removed visible checkbox/selection column behavior.
- Added `[UP] ..` top row for parent directory navigation.
### 2026-04-30 - Selected-file metadata preview
- Added a requirement for automatic `ffprobe` preview when known video files are selected.
- Initially explored asynchronous/non-blocking preview, then changed to a blocking call with a spinner because it is more streamlined for this app.
- Decided to cache preview results briefly and provide a manual reload action.
- Decided `ffprobe` output should be separated into container, video, audio, and subtitle sections to avoid sparse mixed-stream tables.
### 2026-04-30 - Process requirement
- Added this living requirements and decision log document.
- Added a global agent skill to encourage maintaining such a document for future projects.
### 2026-04-30 - Repository restructuring
- Restructured the project into a larger-project-ready `src/media_library_viewer/` package layout.
- Kept a thin root `app.py` entrypoint so `streamlit run app.py` remains the primary launch command.
- Moved service clients into `src/media_library_viewer/clients/`.
- Added `pyproject.toml` with runtime dependencies, development extras, Ruff configuration, and pytest configuration.
- Simplified `requirements.txt` to install the local project editable.
- Expanded `.gitignore` for Python caches, build artifacts, virtual environments, local secrets, editor files, and logs.
### 2026-04-30 - Resource dashboard
- Added a dashboard requirement for CPU, RAM, network, disk I/O, and disk space overview.
- Decided that true last-hour metrics require collection over time; implemented a lightweight SSH-started remote collector instead of requiring Prometheus, Netdata, or sysstat.
- The collector stores JSONL samples in `/tmp` and can be started/stopped from the dashboard.
- Charts show the last hour of collected samples; the dashboard becomes more useful once the collector has been running for a while.
- Last-hour filtering uses epoch seconds rather than local naive datetimes to avoid timezone-offset issues between the app host and remote sample timestamps.
- Fixed SSH command execution to explicitly use `/bin/sh -c` so POSIX resource commands work even when the remote user's login shell is fish.
- Added explicit Streamlit keys to dashboard/file/tool buttons to avoid duplicate auto-generated element IDs as the UI grows.
- Changed the resource collector script from bash-specific syntax to POSIX `/bin/sh` syntax and added dashboard diagnostics/restart controls for collector troubleshooting.
- Added 7-day metrics file pruning plus a 70,000-line safety cap to prevent the JSONL file from growing without bound.
- Split network charts and metrics into download and upload, and disk charts and metrics into read and write.
- Changed network display units from bits per second to bytes per second to avoid Kbps/KB/s ambiguity; the collector still stores bit-rate compatibility fields for old/debug consumers.
- Scaled network and disk throughput charts into readable units such as KB/s, MB/s, and GB/s instead of plotting raw base units.
- Updated collector startup to remove old temporary metrics/log files when a new collector process is started after a schema/display change.
- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Monitoring tab; the Dashboard now keeps a compact overview.
- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Monitoring tab.
- Renamed the remote files tab to File browser.
- Added Jellyfin media counts for movies, series, and episodes to the Dashboard using lightweight count queries.
- Added a Dashboard now-playing section sourced from Jellyfin sessions, showing who is currently playing what and whether each session is transcoding.
### 2026-04-30 - Media inventory tab
- Added a paginated Media tab for file-oriented Jellyfin metadata.
- Decided not to fetch all media at once because large libraries can make API responses and Streamlit rendering slow.
- Decided to derive length, size, bitrate, HDR flag, date added, codec, and resolution from Jellyfin metadata for now.
- Added series name, season, and episode number for episode rows.
- Added server-side sort/order controls and read-only AG Grid column sorting/filtering for the loaded page.
- Reworked the Media tab to use a local SQLite media index for full-library sorting/filtering, including numeric sorting for size and bitrate.
- Added last index build duration metadata to the Media tab status line.
- Replaced single-library selection with multi-library selection so users can include/exclude multiple libraries in the indexed table.
- Changed HDR display from blank/no-value to explicit yes/no.
- Added row selection plus an Open folder action in the Media tab that sets the File browser to the containing folder.
- Restored row-based Media table selection while keeping File browser state changes limited to the explicit Open folder button.
- Updated Media tab behavior so selecting a row automatically syncs the File browser folder to that item's containing directory; removed the extra Open folder button step.
- Restored File browser table row selection with AG Grid (single-select), using a table interaction style consistent with the Media tab.
- Reintroduced open-on-select behavior in File browser: selecting a directory row (including `[UP] ..`) opens it immediately, while file rows update selected target path.
- Refined Media table column presentation with explicit user-friendly headers and null-safe display formatting to keep the grid readable and consistent.
- Renamed Resources tab to Monitoring; added IO wait (iowait) percentage to the collector script, metrics, dashboard summary, and detailed charts.
- Removed the Jellyfin library poster-grid tab and its associated cached API calls and UI module; the Media index tab now covers library browsing needs.
- Simplified File browser navigation: removed Up/Go/Select folder buttons; pressing Enter in the path text input navigates directly.
- Added broad inline/module documentation across clients, domain, services, and Streamlit adapter modules to make debugging and future frontend extraction easier.
- Simplified the File browser by removing its interactive AG Grid and using a read-only listing with explicit Open/Select controls, reducing cross-tab state interactions with the Media grid.
- Removed optional/compatibility code paths around the Media table grid and old file-browser state aliases to keep the interaction model easier to reason about during debugging.
- Split the Streamlit frontend into dedicated UI modules (dashboard, media, file browser, library, preview/tools) and reduced `app.py` to orchestration glue.
- Reviewed remote path handling and kept shell interactions routed through quoted paths (`shlex.quote`) while UI/path-parent operations use POSIX path handling, preserving paths with spaces.
- Fixed file browser handoff/navigation to reset stale search/filter/page state when changing folders, preventing old filters from hiding all entries in the newly opened folder.
- Reworked file browser state to separate current directory from selected path. Selecting a file no longer changes the directory being listed, while opening a folder updates the current directory and keeps path input synchronized.
- Made remote directory listing fail explicitly when the current path is not a directory and recover by listing the parent, preventing file paths from appearing as empty directories.
- Made File browser Refresh/Select folder apply a manually typed path if it differs from the current folder, reducing confusion when manually navigating.
- Moved media normalization into `domain/media.py` and index/query logic into `services/media_index.py` to make the project less Streamlit-specific and easier to expose through a future API/React frontend.
- Deferred full ffprobe enrichment for every item to a future cached/background scan.
- Fixed network byte parsing to split `/proc/net/dev` lines at the colon first, so interface indentation differences do not shift fields and accidentally report packet counts instead of byte counts.
- Fixed a follow-up `/proc/net/dev` parsing issue where leading whitespace after the colon could produce an empty first split field in some `awk` implementations, resulting in zero network rates. Added `/proc/net/dev` snapshots to collector diagnostics.
- Simplified File browser directory error behavior: stopped automatic parent-directory fallback and now show the direct listing error for the current path.
- Added configurable `REMOTE_PATH_PREFIX` support so Jellyfin paths can be mapped to SSH-visible paths when opening folders from Media/Library tabs (for example `/media/...` -> `/srv/media/...`).
- Updated path handoff logic to prefer mapping through `REMOTE_MEDIA_ROOT` (anchor replacement using the root basename, e.g. `media`) and use `REMOTE_PATH_PREFIX` as fallback.
- Added a public-repo readiness note in README describing what local/sensitive files must stay out of version control.
- Added `LICENSE` (MIT) and `CONTRIBUTING.md` for public-repo baseline documentation.
- 2026-05-03: Reaffirmed that the Monitoring tab charts should be rendered directly with D3 and expose brush-based time-range selection plus moving averages.
- 2026-05-03: Added hover tooltips, summary chips, a moving vertical cursor, snapped point markers, and a selected-range label to the D3 Monitoring charts for faster visual inspection.
- 2026-05-03: Combined network download/upload into one traffic chart and disk read/write into one I/O chart for clearer Monitoring layout.
- 2026-05-03: Confirmed the shared session activity table should keep the session identifier as a caption under the user name instead of a full column.
- 2026-05-03: Confirmed the shared session table should keep the compact overall status summary line above the rows.
- 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample.
- 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows.
+51
View File
@@ -0,0 +1,51 @@
FROM node:22-alpine AS build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
ARG VITE_API_URL=/api
ARG VITE_OIDC_ENABLED=false
ARG VITE_OIDC_ISSUER=
ARG VITE_OIDC_CLIENT_ID=
ARG VITE_OIDC_SCOPE=openid profile email
ARG VITE_OIDC_REDIRECT_URI=
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
ENV VITE_API_URL=${VITE_API_URL} \
VITE_OIDC_ENABLED=${VITE_OIDC_ENABLED} \
VITE_OIDC_ISSUER=${VITE_OIDC_ISSUER} \
VITE_OIDC_CLIENT_ID=${VITE_OIDC_CLIENT_ID} \
VITE_OIDC_SCOPE=${VITE_OIDC_SCOPE} \
VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI} \
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET}
RUN npm run build
FROM nginx:1.27-alpine AS prod
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/frontend/dist /usr/share/nginx/html
EXPOSE 80
FROM node:22-alpine AS dev
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
ENV VITE_API_URL=/api \
VITE_OIDC_ENABLED=false \
VITE_DEV_API_PROXY_TARGET=http://backend:8000
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
+1
View File
@@ -48,6 +48,7 @@ Output goes to `frontend/dist/`.
- **Dashboard** (`/`) — Now playing, server overview, library stats
- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls
- **Media** (`/media`) — Full-library table with sort/filter/search
- **Users** (`/users`) — Read-only Jellyfin user list with optional Jellyseerr enrichment
- **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs
## Environment Variables
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1422 -368
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -10,18 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@mui/x-data-grid": "^9.0.4",
"@tanstack/react-query": "^5.100.6",
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"d3": "^7.9.0",
"oidc-client-ts": "^3.5.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1",
"react-router-dom": "^7.14.2",
"recharts": "^3.8.1",
"tailwindcss": "^4.2.4"
"recharts": "^3.8.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/d3": "^7.4.3",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+220 -46
View File
@@ -1,62 +1,236 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import {
BrowserRouter,
Routes,
Route,
NavLink,
useLocation,
} from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@mui/material/styles";
import {
AppBar,
Toolbar,
Typography,
Box,
Tabs,
Tab,
Container,
CssBaseline,
Chip,
useMediaQuery,
Button,
Stack,
Card,
CardContent,
CircularProgress,
} from "@mui/material";
import { useEffect, useMemo } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { getAppTheme } from "./theme";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
const navLinks = [
{ to: "/", label: "Dashboard" },
{ to: "/monitoring", label: "Monitoring" },
{ to: "/media", label: "Media" },
{ to: "/files", label: "File Browser" },
];
function Shell({
darkMode,
authLabel,
onSignOut,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
}) {
const location = useLocation();
const current = location.pathname;
function NavBar() {
return (
<nav className="border-b px-6 py-3 flex gap-6 items-center bg-white sticky top-0 z-10">
<span className="font-bold text-lg mr-4">Media Library Viewer</span>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === "/"}
className={({ isActive }) =>
`text-sm px-2 py-1 rounded ${isActive ? "bg-gray-100 font-medium" : "text-gray-600 hover:text-gray-900"}`
}
>
{link.label}
</NavLink>
))}
</nav>
<>
<CssBaseline />
<AppBar position="sticky" color="inherit" elevation={0}>
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}>
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}>
Media Library Viewer
</Typography>
<Tabs
value={current}
textColor="primary"
indicatorColor="primary"
sx={{ flex: 1 }}
>
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
<Tab
value="/monitoring"
label="Monitoring"
component={NavLink}
to="/monitoring"
/>
<Tab value="/media" label="Media" component={NavLink} to="/media" />
<Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab
value="/files"
label="File Browser"
component={NavLink}
to="/files"
/>
</Tabs>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{authLabel && (
<Chip size="small" variant="outlined" label={authLabel} />
)}
<Chip
size="small"
variant="outlined"
label={darkMode ? "Dark" : "Light"}
/>
{onSignOut && (
<Button size="small" variant="text" onClick={onSignOut}>
Sign out
</Button>
)}
</Stack>
</Toolbar>
</AppBar>
<Container maxWidth={false} sx={{ py: 3 }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</Container>
</>
);
}
function LoadingScreen({ label }: { label: string }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 420, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<CircularProgress />
<Typography variant="h6">{label}</Typography>
</Stack>
</CardContent>
</Card>
</Box>
);
}
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 460, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<Typography variant="h5">Sign in required</Typography>
<Typography variant="body2" color="text.secondary">
Use your Authentik account to access the media library viewer.
</Typography>
<Button variant="contained" onClick={onSignIn}>
Sign in with OIDC
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}
function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
const auth = useAuth();
useEffect(() => {
setAccessToken(auth.user?.access_token ?? null);
}, [auth.user?.access_token]);
const authLabel = useMemo(() => {
const profile = auth.user?.profile as Record<string, unknown> | undefined;
return String(
profile?.name ??
profile?.preferred_username ??
profile?.email ??
auth.user?.profile?.sub ??
"Authenticated",
);
}, [auth.user]);
if (auth.isLoading || auth.activeNavigator) {
return <LoadingScreen label="Checking sign-in…" />;
}
if (auth.error) {
return (
<LoadingScreen
label={`Authentication error: ${auth.error.message || "Unable to sign in"}`}
/>
);
}
if (!auth.isAuthenticated) {
return <SignInScreen onSignIn={() => void auth.signinRedirect()} />;
}
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
/>
</BrowserRouter>
</Box>
);
}
function AppInner() {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
[prefersDarkMode],
);
return (
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
{isOidcConfigured() ? (
<AuthProvider {...getOidcConfig()}>
<AuthenticatedApp darkMode={prefersDarkMode} />
</AuthProvider>
) : (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell darkMode={prefersDarkMode} />
</BrowserRouter>
</Box>
)}
</QueryClientProvider>
</ThemeProvider>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<NavBar />
<main className="max-w-screen-2xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</main>
</div>
</BrowserRouter>
</QueryClientProvider>
);
return <AppInner />;
}
+98 -18
View File
@@ -2,14 +2,20 @@
* Typed API client for the FastAPI backend.
*/
import { getAccessToken } from "../auth";
import type {
MediaCounts,
LibraryCount,
UserDirectoryResponse,
UserMessageResponse,
UserMessageQueueStatus,
SmtpTestResponse,
NowPlayingSession,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
MediaIndexStatus,
MediaIndexActionResponse,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
@@ -17,36 +23,89 @@ import type {
ResolvedPath,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//i.test(value) || value.startsWith("//");
}
function buildUrl(path: string, params?: Record<string, string>): string {
if (!isAbsoluteUrl(BASE_URL)) {
const url = new URL(path, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "")
url.searchParams.set(key, value);
});
}
return url.toString();
}
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(path, BASE_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") url.searchParams.set(key, value);
});
}
const response = await fetch(url.toString());
return url.toString();
}
async function readErrorDetail(response: Response): Promise<string> {
const text = await response.text();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const detail = parsed.detail ?? parsed.message;
if (typeof detail === "string" && detail.trim()) {
return detail;
}
} catch {
// Fall back to the raw response body below.
}
return text;
}
function buildHeaders(isJsonBody: boolean): Headers {
const headers = new Headers();
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (isJsonBody) headers.set("Content-Type", "application/json");
return headers;
}
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const response = await fetch(buildUrl(path, params), {
headers: buildHeaders(false),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
const url = new URL(path, BASE_URL);
const response = await fetch(url.toString(), {
const response = await fetch(buildUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: buildHeaders(true),
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
async function postForm<T>(path: string, body: FormData): Promise<T> {
const headers = buildHeaders(false);
const response = await fetch(buildUrl(path), {
method: "POST",
headers,
body,
});
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
@@ -55,15 +114,23 @@ async function post<T>(path: string, body?: unknown): Promise<T> {
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
export const fetchLibraries = () =>
get<LibraryCount[]>("/api/dashboard/libraries");
export const fetchNowPlaying = () =>
get<NowPlayingSession[]>("/api/dashboard/now-playing");
export const fetchActivity = () =>
get<NowPlayingSession[]>("/api/dashboard/activity");
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
// Backward-compatible alias used by older hooks/components.
export const fetchNowPlaying = fetchActivity;
// Monitoring
export const fetchMonitoringStatus = () =>
get<MonitoringStatus>("/api/monitoring/status");
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
export const fetchMonitoringMetrics = (
lastSeconds?: number | null,
maxLines = 70_000,
) =>
get<MonitoringMetrics>("/api/monitoring/metrics", {
last_seconds: String(lastSeconds),
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
max_lines: String(maxLines),
});
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
export const startCollector = () =>
@@ -77,7 +144,11 @@ export const restartCollector = () =>
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<{ indexed_items: number }>("/api/media/build");
post<MediaIndexActionResponse>("/api/media/build");
export const stopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/stop");
export const forceStopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/force-stop");
export const queryMedia = (params: {
libraries?: string;
types?: string;
@@ -114,3 +185,12 @@ export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
export const fetchUserMessageQueueStatus = () =>
get<UserMessageQueueStatus>("/api/users/message/status");
export const testUserSmtpConnection = () =>
post<SmtpTestResponse>("/api/users/message/test-smtp");
export const sendUserMessage = (formData: FormData) =>
postForm<UserMessageResponse>("/api/users/message", formData);
+42
View File
@@ -0,0 +1,42 @@
let accessToken: string | null = null;
export function isOidcConfigured(): boolean {
const enabled =
(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase() !== "false";
return Boolean(
enabled &&
import.meta.env.VITE_OIDC_ISSUER &&
import.meta.env.VITE_OIDC_CLIENT_ID,
);
}
export function getOidcConfig() {
return {
authority: import.meta.env.VITE_OIDC_ISSUER as string,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID as string,
redirect_uri:
import.meta.env.VITE_OIDC_REDIRECT_URI || window.location.origin,
post_logout_redirect_uri:
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ||
window.location.origin,
scope: import.meta.env.VITE_OIDC_SCOPE || "openid profile email",
response_type: "code" as const,
automaticSilentRenew: false,
loadUserInfo: true,
onSigninCallback: () => {
window.history.replaceState(
{},
document.title,
window.location.pathname + window.location.search,
);
},
};
}
export function setAccessToken(token: string | null | undefined) {
accessToken = token ?? null;
}
export function getAccessToken(): string | null {
return accessToken;
}
+39 -38
View File
@@ -1,3 +1,4 @@
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
import type { LibraryCount } from "../types";
interface Props {
@@ -9,47 +10,47 @@ export function LibraryOverview({ libraries }: Props) {
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<div className="grid grid-cols-2 gap-6">
{movieLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Movie libraries
</p>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
Movie libraries
</Typography>
<Stack spacing={1.5}>
{movieLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Movies: <strong>{lib.movies.toLocaleString()}</strong>
</span>
</div>
</div>
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
{tvLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
TV libraries
</p>
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
TV libraries
</Typography>
<Stack spacing={1.5}>
{tvLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Series: <strong>{lib.series.toLocaleString()}</strong>
</span>
</div>
</div>
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
</div>
</Stack>
</Grid>
</Grid>
);
}
+25 -9
View File
@@ -1,3 +1,5 @@
import { Card, CardContent, Typography } from "@mui/material";
interface Props {
label: string;
value: string;
@@ -6,14 +8,28 @@ interface Props {
export function MetricCard({ label, value, subtext }: Props) {
return (
<div className="rounded-lg border p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
{subtext && (
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">
{subtext}
</p>
)}
</div>
<Card variant="outlined">
<CardContent>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
{label}
</Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}>
{value}
</Typography>
{subtext && (
<Typography
variant="caption"
color="text.secondary"
sx={{ whiteSpace: "pre-line" }}
>
{subtext}
</Typography>
)}
</CardContent>
</Card>
);
}
+784 -148
View File
@@ -1,28 +1,70 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as d3 from "d3";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
FormControlLabel,
Grid,
Typography,
} from "@mui/material";
import type { MonitoringSample } from "../types";
interface Props {
samples: MonitoringSample[];
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
type MetricKey =
| "cpu"
| "iowait"
| "mem"
| "netDown"
| "netUp"
| "diskRead"
| "diskWrite";
interface DataPoint {
ts: number;
cpu: number;
iowait: number;
mem: number;
netDown: number;
netUp: number;
diskRead: number;
diskWrite: number;
}
interface MetricConfig {
key: MetricKey;
label: string;
color: string;
}
interface ChartProps {
title: string;
data: DataPoint[];
metrics: MetricConfig[];
showAverages: boolean;
averages: Record<MetricKey, number[]>;
yFormatter?: (v: number) => string;
}
interface BrushProps {
data: DataPoint[];
selectionRange: [number, number] | null;
onBrush: (range: [number, number] | null) => void;
}
const MOVING_AVG_WINDOW = 10;
const CHART_HEIGHT = 280;
const BRUSH_HEIGHT = 84;
const BRUSH_LABEL_HEIGHT = 24;
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B/s";
if (!bytes) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = bytes;
let unitIdx = 0;
@@ -33,147 +75,741 @@ function formatBytes(bytes: number): string {
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function movingAverage(values: number[]): number[] {
if (values.length === 0) return [];
return values.map((_, index) => {
const start = Math.max(0, index - MOVING_AVG_WINDOW + 1);
const slice = values.slice(start, index + 1);
return slice.reduce((sum, value) => sum + value, 0) / slice.length;
});
}
function buildAverages(samples: DataPoint[]): Record<string, number[]> {
return {
cpu: movingAverage(samples.map((sample) => sample.cpu)),
iowait: movingAverage(samples.map((sample) => sample.iowait)),
mem: movingAverage(samples.map((sample) => sample.mem)),
netDown: movingAverage(samples.map((sample) => sample.netDown)),
netUp: movingAverage(samples.map((sample) => sample.netUp)),
diskRead: movingAverage(samples.map((sample) => sample.diskRead)),
diskWrite: movingAverage(samples.map((sample) => sample.diskWrite)),
};
}
function formatRangeLabel(range: [number, number] | null) {
if (!range) return "Full range";
return `${formatTime(range[0])} ${formatTime(range[1])}`;
}
function metricsKey(metrics: MetricConfig[]) {
return metrics.map((m) => `${m.key}:${m.label}:${m.color}`).join("|");
}
// ══════════════════════════════════════════════════════════════════════════
// Shared brush slider (<MonitoringBrush>)
// ══════════════════════════════════════════════════════════════════════════
function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const brushGroupRef = useRef<d3.Selection<
SVGGElement,
unknown,
null,
unknown
> | null>(null);
const brushRef = useRef<d3.BrushBehavior<unknown> | null>(null);
const brushXRef = useRef<d3.ScaleTime<number, number> | null>(null);
const isUserBrushingRef = useRef(false);
const isProgrammaticMoveRef = useRef(false);
const margin = { top: 18, right: 24, bottom: 22, left: 48 };
const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom;
const brushedColor = "rgba(99, 102, 241, 0.25)";
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Build the brush UI when the available data or layout width changes.
useEffect(() => {
if (!svgRef.current || width === 0 || data.length === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const root = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const minTs = d3.min(data, (d) => d.ts) ?? 0;
const maxTs = d3.max(data, (d) => d.ts) ?? 0;
const x = d3
.scaleTime()
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
.range([0, innerWidth]);
brushXRef.current = x;
const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1);
const y = d3
.scaleLinear()
.domain([0, yMax * 1.1])
.range([innerHeight, 0])
.nice();
root
.append("g")
.call(d3.axisLeft(y).ticks(3))
.selectAll("text")
.style("font-size", "9px");
root
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(
d3
.axisBottom(x)
.ticks(Math.min(data.length || 1, 12))
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
)
.selectAll("text")
.style("font-size", "8.5px");
root
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.08)
.call(
d3
.axisLeft(y)
.ticks(3)
.tickSize(-innerWidth)
.tickFormat(() => ""),
);
const overviewMetrics: Array<{ key: MetricKey; color: string }> = [
{ key: "cpu", color: "#2563eb" },
{ key: "mem", color: "#16a34a" },
];
overviewMetrics.forEach(({ key, color }) => {
const line = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((d) => y((d[key] as number) || 0))
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", 1.2)
.attr("opacity", 0.6)
.attr("d", line);
});
const brush = d3
.brushX()
.handleSize(14)
.extent([
[0, 0],
[innerWidth, innerHeight],
])
.on("start", () => {
isUserBrushingRef.current = true;
})
.on("brush", (event: d3.D3BrushEvent<unknown>) => {
if (isProgrammaticMoveRef.current) return;
if (!event.selection) return;
const sel = event.selection as [number, number];
const start = Math.floor(x.invert(sel[0]).getTime() / 1000);
const end = Math.floor(x.invert(sel[1]).getTime() / 1000);
onBrush([start, end]);
})
.on("end", (event: d3.D3BrushEvent<unknown>) => {
isUserBrushingRef.current = false;
if (isProgrammaticMoveRef.current) return;
if (!event.selection) onBrush(null);
});
const brushG = root.append("g").call(brush);
brushGroupRef.current = brushG;
brushRef.current = brush;
brushG
.selectAll("rect.selection")
.attr("fill", brushedColor)
.attr("stroke", "#6366f1")
.attr("stroke-width", 1);
brushG
.selectAll("rect.handle")
.attr("fill", "#6366f1")
.attr("stroke", "#fff")
.attr("rx", 2)
.attr("ry", 2)
.style("cursor", "ew-resize");
}, [data, width, margin.left, margin.top, innerHeight, onBrush]);
// Keep the brush selection in sync with external changes (zoom buttons / reset)
useEffect(() => {
if (!brushGroupRef.current || !brushRef.current || !brushXRef.current)
return;
if (isUserBrushingRef.current) return;
const x = brushXRef.current;
const brush = brushRef.current;
const brushG = brushGroupRef.current;
const selection = selectionRange
? ([x(selectionRange[0]), x(selectionRange[1])] as [number, number])
: (x.range() as unknown as [number, number]);
isProgrammaticMoveRef.current = true;
const moveBrush = brush.move as unknown as (
group: d3.Selection<SVGGElement, unknown, null, unknown>,
selection: d3.BrushSelection,
) => void;
moveBrush(brushG, selection as d3.BrushSelection);
window.setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 0);
}, [selectionRange, width, margin.left, margin.right]);
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
return (
<Box sx={{ width: "100%" }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5, pl: 0.5 }}
>
Time range drag the left/right ends or the middle
</Typography>
<Box
ref={containerRef}
sx={{ width: "100%", height: totalHeight, px: 2 }}
>
<svg
ref={svgRef}
width={width}
height={totalHeight}
style={{ overflow: "visible" }}
/>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// Parent: MonitoringCharts
// ══════════════════════════════════════════════════════════════════════════
export function MonitoringCharts({ samples }: Props) {
if (samples.length === 0) {
const [showAverages, setShowAverages] = useState(false);
const [selectionRange, setSelectionRange] = useState<[number, number] | null>(
null,
);
const baseData = useMemo<DataPoint[]>(
() =>
samples.map((sample) => ({
ts: sample.ts,
cpu: sample.cpu_pct,
iowait: sample.iowait_pct ?? 0,
mem: sample.mem_pct,
netDown: sample.net_rx_bytes_per_sec,
netUp: sample.net_tx_bytes_per_sec,
diskRead: sample.disk_read_bps,
diskWrite: sample.disk_write_bps,
})),
[samples],
);
const averages = useMemo(() => buildAverages(baseData), [baseData]);
const displayData = useMemo(() => {
if (!selectionRange) return baseData;
const [start, end] = selectionRange;
return baseData.filter((sample) => sample.ts >= start && sample.ts <= end);
}, [baseData, selectionRange]);
const zoomOptions = useMemo(
() => [
{ label: "1h", seconds: 60 * 60 },
{ label: "8h", seconds: 8 * 60 * 60 },
{ label: "1 day", seconds: 24 * 60 * 60 },
{ label: "7 days", seconds: 7 * 24 * 60 * 60 },
],
[],
);
const zoomTo = useCallback(
(seconds: number) => {
if (!baseData.length) return;
const start = Math.max(
baseData[0].ts,
baseData[baseData.length - 1].ts - seconds,
);
setSelectionRange([start, baseData[baseData.length - 1].ts]);
},
[baseData],
);
const selectionLabel = useMemo(
() =>
selectionRange
? `${formatRangeLabel(selectionRange)} · ${displayData.length} samples`
: `All ${baseData.length} samples`,
[selectionRange, displayData.length, baseData.length],
);
if (!samples.length) {
return (
<p className="text-sm text-gray-500">No monitoring samples available.</p>
<Typography color="text.secondary">
No monitoring samples available.
</Typography>
);
}
const data = samples.map((s) => ({
time: formatTime(s.ts),
ts: s.ts,
cpu: s.cpu_pct,
iowait: s.iowait_pct ?? 0,
mem: s.mem_pct,
net_down: s.net_rx_bytes_per_sec,
net_up: s.net_tx_bytes_per_sec,
disk_read: s.disk_read_bps,
disk_write: s.disk_write_bps,
}));
return (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">
CPU, IO Wait, and RAM - last hour
</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis unit="%" domain={[0, 100]} />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="cpu"
name="CPU %"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
<Box sx={{ width: "100%" }}>
{/* Toolbar */}
<Box
sx={{
mb: 2,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
}}
>
<FormControlLabel
control={
<Checkbox
checked={showAverages}
onChange={(event) => setShowAverages(event.target.checked)}
/>
<Line
type="monotone"
dataKey="iowait"
name="IO Wait %"
stroke="#dc2626"
dot={false}
strokeWidth={1.5}
/>
<Line
type="monotone"
dataKey="mem"
name="RAM %"
stroke="#16a34a"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
}
label={`Show ${MOVING_AVG_WINDOW}-point moving average`}
/>
<Box
sx={{
display: "flex",
gap: 1,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Chip size="small" label={selectionLabel} variant="outlined" />
{zoomOptions.map((option) => (
<Button
key={option.label}
variant="outlined"
size="small"
onClick={() => zoomTo(option.seconds)}
>
{option.label}
</Button>
))}
<Button
variant="outlined"
size="small"
disabled={!selectionRange}
onClick={() => setSelectionRange(null)}
>
Reset zoom
</Button>
</Box>
</Box>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Network download</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_down"
name="Download"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Network upload</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_up"
name="Upload"
stroke="#9333ea"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Shared brush slider above all graphs */}
<MonitoringBrush
data={baseData}
selectionRange={selectionRange}
onBrush={setSelectionRange}
/>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Disk read</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_read"
name="Read"
stroke="#ea580c"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Disk write</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_write"
name="Write"
stroke="#0891b2"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Chart grid */}
<Grid container spacing={3}>
<Grid size={12}>
<MonitoringD3Chart
title="CPU, IO Wait, and RAM"
data={displayData}
metrics={[
{ key: "cpu", label: "CPU %", color: "#2563eb" },
{ key: "iowait", label: "IO Wait %", color: "#dc2626" },
{ key: "mem", label: "RAM %", color: "#16a34a" },
]}
showAverages={showAverages}
averages={averages}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MonitoringD3Chart
title="Network traffic"
data={displayData}
metrics={[
{ key: "netDown", label: "Download", color: "#2563eb" },
{ key: "netUp", label: "Upload", color: "#9333ea" },
]}
showAverages={showAverages}
averages={averages}
yFormatter={formatBytes}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MonitoringD3Chart
title="Disk I/O"
data={displayData}
metrics={[
{ key: "diskRead", label: "Read", color: "#ea580c" },
{ key: "diskWrite", label: "Write", color: "#0891b2" },
]}
showAverages={showAverages}
averages={averages}
yFormatter={formatBytes}
/>
</Grid>
</Grid>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// MonitoringD3Chart single chart (lines + hover, no brush)
// ══════════════════════════════════════════════════════════════════════════
function MonitoringD3Chart({
title,
data,
metrics,
showAverages,
averages,
yFormatter,
}: ChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const mk = metricsKey(metrics);
const margin = useMemo(
() => ({ top: 18, right: 24, bottom: 26, left: 56 }),
[],
);
const innerHeight = CHART_HEIGHT - margin.top - margin.bottom;
const summary = useMemo(() => {
const values = metrics.flatMap((metric) =>
data.map((sample) => (sample[metric.key] as number) || 0),
);
const avg = values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0;
return { min: d3.min(values) ?? 0, avg, max: d3.max(values) ?? 0 };
}, [data, metrics]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// One effect rebuild chart layer only
useEffect(() => {
if (!svgRef.current || width === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const root = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
if (data.length === 0) return;
const x = d3
.scaleTime()
.domain(d3.extent(data, (d) => new Date(d.ts * 1000)) as [Date, Date])
.range([0, innerWidth]);
const yMax =
d3.max(data, (d) =>
Math.max(...metrics.map((m) => (d[m.key] as number) || 0)),
) ?? 1;
const y = d3
.scaleLinear()
.domain([0, yMax * 1.1])
.nice()
.range([innerHeight, 0]);
// X axis
root
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(
d3
.axisBottom(x)
.ticks(Math.min(data.length || 1, 10))
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
)
.selectAll("text")
.style("font-size", "10px");
// Y axis
const yAxis = d3.axisLeft(y).ticks(5);
if (yFormatter) yAxis.tickFormat((v) => yFormatter(Number(v)));
root.append("g").call(yAxis).selectAll("text").style("font-size", "10px");
// Grid
root
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.1)
.call(
d3
.axisLeft(y)
.ticks(5)
.tickSize(-innerWidth)
.tickFormat(() => ""),
);
// ── Lines ───────────────────────────────────────────
metrics.forEach((metric) => {
const line = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((d) => y((d[metric.key] as number) || 0))
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", metric.color)
.attr("stroke-width", 1.6)
.attr("d", line);
if (showAverages && averages?.[metric.key]) {
const avgLine = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((_, i) =>
y(averages[metric.key as keyof typeof averages]?.[i] || 0),
)
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", metric.color)
.attr("stroke-width", 1.4)
.attr("stroke-dasharray", "5,3")
.attr("opacity", 0.7)
.attr("d", avgLine);
}
});
// ── Cursor line ─────────────────────────────────────
const cursorLine = root
.append("line")
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.45)
.attr("stroke-dasharray", "4,4")
.style("display", "none");
// ── Cursor markers ──────────────────────────────────
const cursorMarkers = root
.append("g")
.attr("pointer-events", "none")
.style("display", "none");
cursorMarkers
.selectAll<SVGCircleElement, MetricConfig>("circle")
.data(metrics)
.join("circle")
.attr("r", 4.5)
.attr("stroke", "#fff")
.attr("stroke-width", 1.4);
// ── Tooltip ─────────────────────────────────────────
const tooltip = root
.append("g")
.attr("pointer-events", "none")
.style("display", "none");
tooltip
.append("rect")
.attr("rx", 6)
.attr("ry", 6)
.attr("fill", "rgba(15,23,42,0.92)");
const tooltipText = tooltip
.append("text")
.attr("fill", "#fff")
.attr("font-size", 11)
.attr("font-family", "monospace");
// ── Hit area ────────────────────────────────────────
const bisect = d3.bisector((d: DataPoint) => d.ts).center;
root
.append("rect")
.attr("width", innerWidth)
.attr("height", innerHeight)
.attr("fill", "transparent")
.attr("pointer-events", "all")
.on("mousemove", (event) => {
const [mx, my] = d3.pointer(event, root.node() as SVGGElement);
const ts = x.invert(mx).getTime() / 1000;
const idx = bisect(data, ts);
const sample = data[Math.max(0, Math.min(data.length - 1, idx))];
if (!sample) return;
const xP = x(new Date(sample.ts * 1000));
cursorLine.style("display", null).attr("x1", xP).attr("x2", xP);
cursorMarkers
.style("display", null)
.attr("transform", `translate(${xP},0)`)
.selectAll<SVGCircleElement, MetricConfig>("circle")
.data(metrics)
.attr("cx", 0)
.attr("cy", (m) => y((sample[m.key] as number) || 0))
.attr("fill", (m) => m.color);
const lines = [
formatTime(sample.ts),
...metrics.map((m) => {
const raw = (sample[m.key] as number) || 0;
const avgV =
showAverages &&
averages?.[m.key as keyof typeof averages]?.[idx] != null
? averages[m.key as keyof typeof averages][idx]
: null;
const fmt = yFormatter ? yFormatter(raw) : `${raw.toFixed(1)}%`;
return avgV == null
? `${m.label}: ${fmt}`
: `${m.label}: ${fmt} (avg ${yFormatter ? yFormatter(avgV) : avgV.toFixed(1)})`;
}),
];
const lh = 14,
pad = 8;
const bw = Math.min(
Math.max(...lines.map((l) => l.length)) * 6.5 + pad * 2,
260,
);
const bh = lines.length * lh + pad * 2;
const px = Math.min(mx + 12, innerWidth - bw - 4);
const py = Math.max(4, Math.min(my - bh - 12, innerHeight - bh - 4));
tooltip
.style("display", null)
.attr("transform", `translate(${px},${py})`);
tooltip.select("rect").attr("width", bw).attr("height", bh);
tooltipText.selectAll("tspan").remove();
lines.forEach((line, i) =>
tooltipText
.append("tspan")
.attr("x", pad)
.attr("y", pad + 12 + i * lh)
.text(line),
);
})
.on("mouseleave", () => {
tooltip.style("display", "none");
cursorLine.style("display", "none");
cursorMarkers.style("display", "none");
});
}, [
data,
mk,
showAverages,
averages,
width,
yFormatter,
margin.left,
margin.top,
innerHeight,
]);
// ── JSX ───────────────────────────────────────────────
return (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
{title}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Chip
size="small"
label={`Min ${yFormatter ? yFormatter(summary.min) : summary.min.toFixed(1)}`}
variant="outlined"
/>
<Chip
size="small"
label={`Avg ${yFormatter ? yFormatter(summary.avg) : summary.avg.toFixed(1)}`}
variant="outlined"
/>
<Chip
size="small"
label={`Max ${yFormatter ? yFormatter(summary.max) : summary.max.toFixed(1)}`}
variant="outlined"
/>
</Box>
<Box ref={containerRef} sx={{ width: "100%", height: CHART_HEIGHT }}>
<svg
ref={svgRef}
width={width}
height={CHART_HEIGHT}
style={{ overflow: "visible" }}
/>
</Box>
<Box sx={{ mt: 1, display: "flex", gap: 2, flexWrap: "wrap" }}>
{metrics.map((metric) => (
<Box
key={metric.key}
sx={{ display: "flex", alignItems: "center", gap: 0.5 }}
>
<Box
sx={{
width: 12,
height: 12,
bgcolor: metric.color,
borderRadius: "2px",
}}
/>
<Typography variant="caption">{metric.label}</Typography>
</Box>
))}
{showAverages ? (
<Typography variant="caption">Dashed = moving average</Typography>
) : null}
</Box>
</CardContent>
</Card>
);
}
+13 -37
View File
@@ -1,46 +1,22 @@
import { Card, CardContent } from "@mui/material";
import type { NowPlayingSession } from "../types";
import { SessionActivityPanel } from "./SessionActivityPanel";
interface Props {
sessions: NowPlayingSession[];
onSelectSession?: (session: NowPlayingSession) => void;
}
export function NowPlaying({ sessions }: Props) {
if (sessions.length === 0) {
return (
<p className="text-sm text-gray-500">
No active playback sessions right now.
</p>
);
}
export function NowPlaying({ sessions, onSelectSession }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-left text-gray-600">
<th className="py-2 pr-4">User</th>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">State</th>
<th className="py-2 pr-4">Transcoding</th>
<th className="py-2 pr-4">Transcode type</th>
<th className="py-2 pr-4">Device</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.session_id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{s.user}</td>
<td className="py-2 pr-4">{s.title}</td>
<td className="py-2 pr-4">{s.type}</td>
<td className="py-2 pr-4">{s.state}</td>
<td className="py-2 pr-4">{s.transcoding}</td>
<td className="py-2 pr-4">{s.transcoding_type}</td>
<td className="py-2 pr-4">{s.device}</td>
</tr>
))}
</tbody>
</table>
</div>
<Card variant="outlined">
<CardContent>
<SessionActivityPanel
sessions={sessions}
onSelectSession={onSelectSession}
emptyMessage="No recent user activity sessions right now."
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,248 @@
import {
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import type { NowPlayingSession } from "../types";
interface Props {
sessions: NowPlayingSession[];
emptyMessage?: string;
selectedUserLabel?: string;
onSelectSession?: (session: NowPlayingSession) => void;
}
function formatStateLabel(state: string): string {
const normalized = String(state || "")
.trim()
.toLowerCase();
if (normalized === "playing") {
return "Playing";
}
if (normalized === "paused") {
return "Paused";
}
if (normalized === "idle") {
return "Idle";
}
return normalized
? normalized.charAt(0).toUpperCase() + normalized.slice(1)
: "Unknown";
}
function buildStatusSummary(sessions: NowPlayingSession[]) {
const playing = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "playing",
).length;
const paused = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "paused",
).length;
const idle = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "idle",
).length;
return `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${playing} playing · ${paused} paused · ${idle} idle`;
}
export function SessionActivityPanel({
sessions,
emptyMessage = "No live sessions matched to this user.",
selectedUserLabel,
onSelectSession,
}: Props) {
const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) {
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
}
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1 }}
>
<Table size="small" stickyHeader aria-label="Session activity details">
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 160,
}}
>
User
</TableCell>
<TableCell
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
>
State
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Title / Type
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Device
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 118,
}}
>
Transcoding
</TableCell>
{onSelectSession ? (
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
) : null}
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell
colSpan={onSelectSession ? 6 : 5}
sx={{ py: 0.75, bgcolor: "background.paper" }}
>
<Typography variant="caption" color="text.secondary">
{buildStatusSummary(sessions)}
</Typography>
</TableCell>
</TableRow>
{sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state);
return (
<TableRow
key={session.session_id}
hover
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
onClick={
onSelectSession ? () => onSelectSession(session) : undefined
}
>
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
<Typography
variant="body2"
noWrap
title={session.user || userFallback}
>
{session.user || userFallback}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
title={session.session_id}
>
{session.session_id}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Chip
size="small"
label={sessionLabel}
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography
variant="body2"
noWrap
title={session.title || ""}
>
{session.title || "(idle)"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{session.type || "—"}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography variant="body2" noWrap>
{session.device || "Unknown device"}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Typography variant="body2" noWrap>
{session.transcoding === "yes"
? session.transcoding_type
? `yes (${session.transcoding_type})`
: "yes"
: "no"}
</Typography>
</TableCell>
{onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Button
size="small"
variant="outlined"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
}}
>
Open in Users
</Button>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
+7 -4
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client";
import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client";
export function useCounts() {
return useQuery({
@@ -17,10 +17,13 @@ export function useLibraries() {
});
}
export function useNowPlaying() {
export function useActivity() {
return useQuery({
queryKey: ["dashboard", "now-playing"],
queryFn: fetchNowPlaying,
queryKey: ["dashboard", "activity"],
queryFn: fetchActivity,
refetchInterval: 15_000,
});
}
// Backward-compatible alias used by older code.
export const useNowPlaying = useActivity;
+38 -3
View File
@@ -1,11 +1,20 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client";
import {
fetchMediaStatus,
buildMediaIndex,
queryMedia,
stopMediaIndexBuild,
forceStopMediaIndexBuild,
} from "../api/client";
export function useMediaStatus() {
return useQuery({
queryKey: ["media", "status"],
queryFn: fetchMediaStatus,
staleTime: 60_000,
staleTime: 5_000,
refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false,
refetchIntervalInBackground: true,
});
}
@@ -21,6 +30,8 @@ export function useMediaQuery(params: {
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
// Feature: Sync file browser with selected media path
return useQuery({
queryKey: ["media", "query", queryParams],
queryFn: () => queryMedia(queryParams),
@@ -29,12 +40,36 @@ export function useMediaQuery(params: {
});
}
function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] });
}
export function useBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: buildMediaIndex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["media"] });
invalidateMedia(queryClient);
},
});
}
export function useStopBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: stopMediaIndexBuild,
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useForceStopBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: forceStopMediaIndexBuild,
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
+3 -3
View File
@@ -16,10 +16,10 @@ export function useMonitoringStatus() {
});
}
export function useMonitoringMetrics(lastSeconds = 3600) {
export function useMonitoringMetrics() {
return useQuery({
queryKey: ["monitoring", "metrics", lastSeconds],
queryFn: () => fetchMonitoringMetrics(lastSeconds),
queryKey: ["monitoring", "metrics"],
queryFn: () => fetchMonitoringMetrics(),
refetchInterval: 15_000,
});
}
+13
View File
@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { sendUserMessage } from "../api/client";
export function useSendUserMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: sendUserMessage,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "message-queue"] });
},
});
}
+8
View File
@@ -0,0 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import { testUserSmtpConnection } from "../api/client";
export function useTestUserSmtp() {
return useMutation({
mutationFn: testUserSmtpConnection,
});
}
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUserMessageQueueStatus } from "../api/client";
export function useUserMessageQueueStatus() {
return useQuery({
queryKey: ["users", "message-queue"],
queryFn: fetchUserMessageQueueStatus,
refetchInterval: 5_000,
staleTime: 0,
});
}
+10
View File
@@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 30_000,
});
}
+7 -1
View File
@@ -1 +1,7 @@
@import "tailwindcss";
html,
body,
#root {
margin: 0;
width: 100%;
min-height: 100%;
}
+207 -77
View File
@@ -1,4 +1,7 @@
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
import { useMemo } from "react";
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
@@ -20,96 +23,223 @@ function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
function formatPct(value: number): string {
return `${value.toFixed(1)}%`;
}
function summarize(values: number[]) {
if (values.length === 0) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return {
avg: total / values.length,
min: Math.min(...values),
max: Math.max(...values),
};
}
export function Dashboard() {
const navigate = useNavigate();
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: nowPlaying } = useNowPlaying();
const { data: activity } = useActivity();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const latest = metrics?.samples?.at(-1);
const monitoringWindow = useMemo(() => {
const samples = metrics?.samples ?? [];
if (samples.length === 0) return [];
const latestTs = samples.at(-1)?.ts ?? 0;
const windowStart = latestTs - 10 * 60;
const windowed = samples.filter((sample) => sample.ts >= windowStart);
return windowed.length > 0 ? windowed : samples;
}, [metrics?.samples]);
const cpuSummary = summarize(
monitoringWindow.map((sample) => sample.cpu_pct),
);
const iowaitSummary = summarize(
monitoringWindow
.map((sample) => sample.iowait_pct)
.filter((value): value is number => value !== undefined),
);
const memSummary = summarize(
monitoringWindow.map((sample) => sample.mem_pct),
);
const netRxSummary = summarize(
monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec),
);
const netTxSummary = summarize(
monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec),
);
const diskReadSummary = summarize(
monitoringWindow.map((sample) => sample.disk_read_bps),
);
const diskWriteSummary = summarize(
monitoringWindow.map((sample) => sample.disk_write_bps),
);
return (
<div className="space-y-8">
{/* Now Playing */}
<section>
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
</section>
<hr />
{/* Server Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
<Stack spacing={3}>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Activity
</Typography>
{activity && (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
/>
<MetricCard
label="RAM"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
/>
</div>
{disk && (
<div className="mt-3 grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
)}
</section>
</Box>
<hr />
<Divider />
{/* Media Library Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Monitoring Overview
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU (10m avg)"
value={cpuSummary ? formatPct(cpuSummary.avg) : "-"}
subtext={
cpuSummary
? `High: ${formatPct(cpuSummary.max)}\nLow: ${formatPct(cpuSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait (10m avg)"
value={iowaitSummary ? formatPct(iowaitSummary.avg) : "-"}
subtext={
iowaitSummary
? `High: ${formatPct(iowaitSummary.max)}\nLow: ${formatPct(iowaitSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM (10m avg)"
value={memSummary ? formatPct(memSummary.avg) : "-"}
subtext={
memSummary
? `High: ${formatPct(memSummary.max)}\nLow: ${formatPct(memSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down (10m avg)"
value={netRxSummary ? formatRate(netRxSummary.avg) : "-"}
subtext={
netRxSummary
? `High: ${formatRate(netRxSummary.max)}\nLow: ${formatRate(netRxSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up (10m avg)"
value={netTxSummary ? formatRate(netTxSummary.avg) : "-"}
subtext={
netTxSummary
? `High: ${formatRate(netTxSummary.max)}\nLow: ${formatRate(netTxSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read (10m avg)"
value={diskReadSummary ? formatRate(diskReadSummary.avg) : "-"}
subtext={
diskReadSummary
? `High: ${formatRate(diskReadSummary.max)}\nLow: ${formatRate(diskReadSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write (10m avg)"
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
subtext={
diskWriteSummary
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
: undefined
}
/>
</Grid>
</Grid>
{disk && (
<Grid container spacing={1.5} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</Grid>
</Grid>
)}
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Library Stats
</Typography>
{counts && (
<div className="grid grid-cols-4 gap-3 mb-4">
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
<MetricCard label="Series" value={counts.series.toLocaleString()} />
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</div>
<Grid container spacing={1.5} sx={{ mb: 2 }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Movies"
value={counts.movies.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Series"
value={counts.series.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</Grid>
</Grid>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</section>
</div>
</Box>
</Stack>
);
}
+671 -113
View File
@@ -1,5 +1,23 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useDirectoryListing,
useFfprobe,
@@ -8,6 +26,7 @@ import {
} from "../hooks/useFiles";
interface DisplayRow {
id: string;
type: string;
name: string;
ext: string;
@@ -16,6 +35,46 @@ interface DisplayRow {
path: string;
}
interface FfprobeStream {
index?: number;
codec_type?: string;
codec_name?: string;
codec_long_name?: string;
profile?: string;
width?: number;
height?: number;
bit_rate?: string | number;
duration?: string | number;
channels?: number;
sample_rate?: string | number;
channel_layout?: string;
pix_fmt?: string;
sample_aspect_ratio?: string;
display_aspect_ratio?: string;
field_order?: string;
level?: number | string;
color_range?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
tags?: Record<string, string>;
}
interface FfprobeFormat {
filename?: string;
format_name?: string;
format_long_name?: string;
duration?: string | number;
size?: string | number;
bit_rate?: string | number;
tags?: Record<string, string>;
}
interface FfprobeData {
format?: FfprobeFormat;
streams?: FfprobeStream[];
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -33,6 +92,45 @@ function formatTime(epoch: number): string {
return new Date(epoch * 1000).toLocaleString();
}
function humanBytes(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const bytes = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(bytes)) return "-";
return formatSize(bytes);
}
function humanRate(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const rate = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(rate)) return "-";
const units = ["bps", "Kbps", "Mbps", "Gbps"];
let v = rate;
let unitIdx = 0;
while (v >= 1000 && unitIdx < units.length - 1) {
v /= 1000;
unitIdx++;
}
return `${v.toFixed(1)} ${units[unitIdx]}`;
}
function humanDuration(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const seconds = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0)
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
return `${minutes}:${String(secs).padStart(2, "0")}`;
}
function fieldLabel(_key: string, value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
@@ -48,38 +146,446 @@ function isVideoFile(name: string): boolean {
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
export function FileBrowser() {
const [currentDir, setCurrentDir] = useState("/");
const [pathInput, setPathInput] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
return (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
ffprobe details
</Typography>
<Typography variant="caption" color="text.secondary">
{path}
</Typography>
</Box>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Container / format
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Format:</b> {fieldLabel("format", format.format_name)}
</Typography>
<Typography variant="body2">
<b>Long name:</b>{" "}
{fieldLabel("format_long_name", format.format_long_name)}
</Typography>
<Typography variant="body2">
<b>Duration:</b> {humanDuration(format.duration)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Size:</b> {humanBytes(format.size)}
</Typography>
<Typography variant="body2">
<b>Bitrate:</b> {humanRate(format.bit_rate)}
</Typography>
<Typography variant="body2">
<b>Filename:</b> {fieldLabel("filename", format.filename)}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Streams
</Typography>
<Stack spacing={1.5}>
{videoStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Video streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{videoStreams.map((stream, index) => (
<Box
key={`video-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="primary"
label={stream.codec_type ?? "video"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.codec_long_name && (
<Chip
size="small"
variant="outlined"
label={stream.codec_long_name}
/>
)}
{stream.profile && (
<Chip
size="small"
variant="outlined"
label={stream.profile}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
{stream.width && stream.height && (
<Chip
size="small"
variant="outlined"
label={`${stream.width}×${stream.height}`}
/>
)}
{stream.pix_fmt && (
<Chip
size="small"
variant="outlined"
label={stream.pix_fmt}
/>
)}
{stream.display_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`DAR ${stream.display_aspect_ratio}`}
/>
)}
{stream.sample_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`SAR ${stream.sample_aspect_ratio}`}
/>
)}
{stream.level !== undefined &&
stream.level !== null && (
<Chip
size="small"
variant="outlined"
label={`L${stream.level}`}
/>
)}
{stream.field_order &&
stream.field_order !== "unknown" && (
<Chip
size="small"
variant="outlined"
label={stream.field_order}
/>
)}
{(stream.color_range ||
stream.color_space ||
stream.color_transfer ||
stream.color_primaries) && (
<Chip
size="small"
color={
(stream.color_transfer ?? "")
.toLowerCase()
.includes("2084") ||
(stream.color_transfer ?? "")
.toLowerCase()
.includes("b67") ||
(stream.color_space ?? "")
.toLowerCase()
.includes("bt2020") ||
(stream.color_primaries ?? "")
.toLowerCase()
.includes("bt2020")
? "warning"
: "default"
}
variant="outlined"
label={[
stream.color_range,
stream.color_space,
stream.color_transfer,
stream.color_primaries,
]
.filter(Boolean)
.join(" / ")}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{audioStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Audio streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{audioStreams.map((stream, index) => (
<Box
key={`audio-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="secondary"
label={stream.codec_type ?? "audio"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.channels && (
<Chip
size="small"
variant="outlined"
label={`${stream.channels} ch`}
/>
)}
{stream.sample_rate && (
<Chip
size="small"
variant="outlined"
label={`${stream.sample_rate} Hz`}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.codec_long_name
? `${stream.codec_long_name}. `
: ""}
{stream.channel_layout
? `Layout: ${stream.channel_layout}. `
: ""}
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{subtitleStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Subtitle streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{subtitleStreams.map((stream, index) => (
<Box
key={`subtitle-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="info"
label={stream.codec_type ?? "subtitle"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.tags?.language && (
<Chip
size="small"
variant="outlined"
label={stream.tags.language}
/>
)}
{stream.tags?.title && (
<Chip
size="small"
variant="outlined"
label={stream.tags.title}
/>
)}
</Stack>
</Box>
))}
</Stack>
</Box>
)}
{streams.length === 0 && (
<Typography variant="body2" color="text.secondary">
No streams found.
</Typography>
)}
</Stack>
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Tags
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<Chip
key={key}
size="small"
label={`${key}: ${value}`}
variant="outlined"
/>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
);
}
export function FileBrowser() {
const [searchParams] = useSearchParams();
const initialRequestedPath = searchParams.get("path") ?? "/";
const initialSelectedPath =
initialRequestedPath !== "/" &&
(isVideoFile(initialRequestedPath) || initialRequestedPath.includes("."))
? initialRequestedPath.replace(/\/+$/, "")
: null;
const initialCurrentDir = initialSelectedPath
? initialSelectedPath.replace(/\/[^/]+$/, "") || "/"
: initialRequestedPath.replace(/\/+$/, "") || "/";
const [currentDir, setCurrentDir] = useState(initialCurrentDir);
const [pathInput, setPathInput] = useState(initialCurrentDir);
const [selectedPath, setSelectedPath] = useState<string | null>(
initialSelectedPath,
);
const [selectedJob, setSelectedJob] = useState<string>("");
const {
data: listing,
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
error: ffprobeError,
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
const navigate = (path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
id: `up-${parent}`,
type: "up",
name: "..",
ext: "",
@@ -92,131 +598,183 @@ export function FileBrowser() {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
rows.push({
id: path,
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
path,
});
}
}
const columnDefs = [
{ field: "type" as const, headerName: "Type", width: 80 },
{ field: "name" as const, headerName: "Name", flex: 2 },
{ field: "ext" as const, headerName: "Ext", width: 80 },
{ field: "size" as const, headerName: "Size", width: 110 },
{ field: "modified" as const, headerName: "Modified", width: 180 },
const columns: GridColDef<DisplayRow>[] = [
{ field: "type", headerName: "Type", width: 90 },
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
{ field: "ext", headerName: "Ext", width: 90 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "modified", headerName: "Modified", width: 190 },
];
const onRowClicked = useCallback(
(event: { data?: DisplayRow }) => {
const row = event.data;
if (!row) return;
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
} else {
setSelectedPath(row.path);
}
},
[navigate],
);
const rowSelectionModel: GridRowSelectionModel = selectedPath
? { type: "include", ids: new Set([selectedPath]) }
: { type: "include", ids: new Set() };
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
<Stack spacing={2}>
<Typography variant="h5">File Browser</Typography>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
className="border rounded px-3 py-1 text-sm flex-1"
placeholder="Remote path (press Enter to navigate)"
/>
<button
onClick={() => navigate(pathInput || "/")}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}>
Open
</Button>
<Button variant="outlined" onClick={() => refetch()}>
Refresh
</button>
</div>
</Button>
</Stack>
{/* Status */}
<div className="flex gap-6 text-xs text-gray-500">
<span>
Current: <code>{currentDir}</code>
</span>
{selectedPath && (
<span>
Selected: <code>{selectedPath}</code>
</span>
)}
{listing && <span>Entries: {listing.count}</span>}
</div>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{error && <Alert severity="error">{String(error)}</Alert>}
{/* File listing grid */}
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact<DisplayRow>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
rowSelection="single"
onRowClicked={onRowClicked}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
suppressCellFocus
animateRows={false}
rowSelectionModel={rowSelectionModel}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up") navigate(row.path);
else setSelectedPath(row.path);
}}
/>
</div>
</Box>
{/* ffprobe preview */}
{selectedPath && isVideoFile(selectedPath) && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">
ffprobe preview: <code className="text-xs">{selectedPath}</code>
</h3>
{ffprobeData ? (
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
{JSON.stringify(ffprobeData, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
)}
</section>
<Card variant="outlined">
<CardContent>
{ffprobeError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{String(ffprobeError)}
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Typography color="text.secondary">
Loading ffprobe data...
</Typography>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Typography color="text.secondary">
No ffprobe data available.
</Typography>
)}
</CardContent>
</Card>
)}
{/* Jobs */}
{selectedPath && templates && templates.length > 0 && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
<div className="flex gap-2 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.key}
onClick={() =>
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
title={tpl.description}
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Jobs
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
{selectedTemplate.description}
</Typography>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
mt: 1.5,
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
}}
>
{tpl.name}
</button>
))}
</div>
{runJob.data && (
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</section>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</CardContent>
</Card>
)}
</div>
</Stack>
);
}
+336 -142
View File
@@ -1,15 +1,49 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
LinearProgress,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useMediaStatus,
useMediaQuery,
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import type { MediaItem } from "../types";
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
export function Media() {
const navigate = useNavigate();
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const stopBuildIndex = useStopBuildIndex();
const forceStopBuildIndex = useForceStopBuildIndex();
const [search, setSearch] = useState("");
const [types, setTypes] = useState("Movie,Episode");
@@ -30,181 +64,341 @@ export function Media() {
enabled: status?.exists ?? false,
});
const gridRef = useRef<AgGridReact<MediaItem>>(null);
const columnDefs = [
{ field: "title" as const, headerName: "Title", minWidth: 150 },
{ field: "series" as const, headerName: "Series", minWidth: 120 },
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
{
field: "runtime_min" as const,
headerName: "Runtime (min)",
maxWidth: 125,
},
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
{ field: "path" as const, headerName: "Path", minWidth: 200 },
const columns: GridColDef<MediaItem>[] = [
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
{ field: "season", headerName: "Season", width: 90 },
{ field: "episode", headerName: "Episode", width: 100 },
{ field: "type", headerName: "Type", width: 100 },
{ field: "year", headerName: "Year", width: 90 },
{ field: "runtime_min", headerName: "Runtime", width: 110 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "bitrate", headerName: "Bitrate", width: 130 },
{ field: "hdr", headerName: "HDR", width: 80 },
{ field: "video", headerName: "Video codec", width: 130 },
{ field: "resolution", headerName: "Resolution", width: 120 },
{ field: "date_added", headerName: "Date added", width: 120 },
{ field: "library", headerName: "Library", width: 140 },
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
];
const onGridReady = useCallback(() => {
gridRef.current?.api?.sizeColumnsToFit();
}, []);
const rows = useMemo(
() =>
(queryResult?.items ?? []).map((item) => ({
...item,
id: item.id || item.path,
})),
[queryResult],
);
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
const totalPages = queryResult
? Math.max(1, Math.ceil(queryResult.total / limit))
: 1;
const buildRunning = status?.build_running ?? false;
const buildProgress = status?.build_progress ?? null;
const buildLibraryProgress = status?.build_library_progress ?? null;
const buildCancelRequested = status?.build_cancel_requested ?? false;
const buildLabel = buildRunning
? status?.build_message || "Building media index..."
: status?.build_error
? `Build failed: ${status.build_error}`
: "";
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
const etaLabel =
buildRunning && status?.build_eta_seconds != null
? formatDuration(status.build_eta_seconds)
: "-";
const libraryElapsedLabel = formatDuration(
status?.build_library_elapsed_seconds,
);
const libraryEtaLabel =
buildRunning && status?.build_library_eta_seconds != null
? formatDuration(status.build_library_eta_seconds)
: "-";
const libraryLabel =
status?.build_current_library ||
(status?.build_library_index && status?.build_libraries_total
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
: "Current library");
return (
<div className="space-y-4">
{/* Status and controls */}
<div className="flex items-center gap-4">
<Stack spacing={2}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Media</Typography>
{status?.exists ? (
<span className="text-sm text-gray-600">
<Typography variant="body2" color="text.secondary">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
</span>
{status.updated_at_label
? ` | updated ${status.updated_at_label}`
: ""}
</Typography>
) : (
<span className="text-sm text-amber-600">No index built yet.</span>
<Alert severity="warning" sx={{ py: 0 }}>
No index built yet.
</Alert>
)}
<button
<Button
variant="outlined"
onClick={() => buildIndex.mutate()}
disabled={buildIndex.isPending}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
disabled={
buildIndex.isPending || buildRunning || buildCancelRequested
}
>
{buildIndex.isPending ? "Building..." : "Build index"}
</button>
</div>
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
</Button>
{buildRunning && (
<>
<Button
variant="outlined"
color="error"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
>
{buildCancelRequested || stopBuildIndex.isPending
? "Stopping..."
: "Stop build"}
</Button>
<Button
variant="outlined"
color="warning"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
{forceStopBuildIndex.isPending
? "Force stopping..."
: "Force stop"}
</Button>
</>
)}
{(buildRunning || status?.build_error) && (
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
<Stack spacing={1}>
<Typography
variant="body2"
color={status?.build_error ? "error" : "text.secondary"}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</Typography>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="text-xs text-gray-500 block">Search</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm w-48"
placeholder="Search title, series, path..."
/>
</div>
<div>
<label className="text-xs text-gray-500 block">Types</label>
<select
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="Movie,Episode">Movies + Episodes</option>
<option value="Movie">Movies only</option>
<option value="Episode">Episodes only</option>
<option value="Movie,Episode,Video">All video</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">HDR</label>
<select
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="All">All</option>
<option value="HDR only">HDR only</option>
<option value="SDR/unknown only">SDR/unknown only</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Sort</label>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="title">Title</option>
<option value="series">Series</option>
<option value="size">Size</option>
<option value="bitrate">Bitrate</option>
<option value="runtime">Runtime</option>
<option value="year">Year</option>
<option value="date_added">Date added</option>
<option value="resolution">Resolution</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Order</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</div>
</div>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildProgress != null ? "determinate" : "indeterminate"
}
value={
buildProgress != null
? Math.max(0, Math.min(100, buildProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</Typography>
</Stack>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildLibraryProgress != null
? "determinate"
: "indeterminate"
}
value={
buildLibraryProgress != null
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_library_items_processed?.toLocaleString() ?? 0}
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
items
</Typography>
</Stack>
</Stack>
</Box>
)}
</Stack>
<Card variant="outlined">
<CardContent>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Search"
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
/>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Types</InputLabel>
<Select
label="Types"
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
>
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
<MenuItem value="Movie">Movies only</MenuItem>
<MenuItem value="Episode">Episodes only</MenuItem>
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>HDR</InputLabel>
<Select
label="HDR"
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="HDR only">HDR only</MenuItem>
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Sort</InputLabel>
<Select
label="Sort"
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
>
{[
["title", "Title"],
["series", "Series"],
["size", "Size"],
["bitrate", "Bitrate"],
["runtime", "Runtime"],
["year", "Year"],
["date_added", "Date added"],
["resolution", "Resolution"],
].map(([k, l]) => (
<MenuItem key={k} value={k}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Order</InputLabel>
<Select
label="Order"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
>
<MenuItem value="Ascending">Ascending</MenuItem>
<MenuItem value="Descending">Descending</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Results info */}
{queryResult && (
<p className="text-xs text-gray-500">
<Typography variant="caption" color="text.secondary">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</p>
</Typography>
)}
{/* AG Grid table */}
{status?.exists && (
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
<AgGridReact<MediaItem>
ref={gridRef}
rowData={queryResult?.items ?? []}
columnDefs={columnDefs}
rowSelection="single"
onGridReady={onGridReady}
<Box
sx={{
height: 640,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
suppressCellFocus
animateRows={false}
checkboxSelection={false}
disableRowSelectionOnClick
onRowClick={(params) => {
const row = params.row as MediaItem;
navigate(`/files?path=${encodeURIComponent(row.path)}`);
}}
pageSizeOptions={[100]}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
/>
</div>
</Box>
)}
{/* Pagination */}
{queryResult && totalPages > 1 && (
<div className="flex gap-2 items-center">
<button
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button
variant="outlined"
size="small"
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={page <= 1}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Prev
</button>
<span className="text-sm">
</Button>
<Typography variant="body2">
Page {page} / {totalPages}
</span>
<button
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => setOffset(offset + limit)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Next
</button>
</div>
</Button>
</Stack>
)}
</div>
</Stack>
);
}
+67 -34
View File
@@ -1,3 +1,12 @@
import {
Box,
Button,
Chip,
Divider,
Grid,
Stack,
Typography,
} from "@mui/material";
import {
useMonitoringStatus,
useMonitoringMetrics,
@@ -32,7 +41,6 @@ export function Monitoring() {
const samples = metrics?.samples ?? [];
const latest = samples.at(-1);
// Compute averages and peaks
const avg = (arr: number[]) =>
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
@@ -46,95 +54,120 @@ export function Monitoring() {
const diskWriteArr = samples.map((s) => s.disk_write_bps);
return (
<div className="space-y-8">
{/* Controls */}
<section className="flex items-center gap-4">
<span className="text-sm text-gray-600">
Collector:{" "}
<code className="bg-gray-100 px-1 rounded">
{status?.status ?? "unknown"}
</code>
</span>
<button
<Stack spacing={3}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Monitoring</Typography>
<Chip
label={status?.status ?? "unknown"}
color="primary"
variant="outlined"
/>
<Button
size="small"
variant="outlined"
onClick={() => start.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={start.isPending}
>
Start
</button>
<button
</Button>
<Button
size="small"
variant="outlined"
onClick={() => restart.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={restart.isPending}
>
Restart
</button>
<button
</Button>
<Button
size="small"
variant="outlined"
onClick={() => stop.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={stop.isPending}
>
Stop
</button>
</section>
</Button>
</Stack>
{/* Metrics summary */}
<section>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</div>
</section>
</Grid>
</Grid>
{/* Disk space */}
{disk && (
<section>
<div className="grid grid-cols-4 gap-3">
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</div>
</section>
</Grid>
</Grid>
)}
{/* Charts */}
<section>
<Divider />
<Box>
<MonitoringCharts samples={samples} />
</section>
</div>
</Box>
</Stack>
);
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { createTheme, type PaletteMode } from "@mui/material/styles";
export function getAppTheme(mode: PaletteMode) {
const isDark = mode === "dark";
return createTheme({
palette: {
mode,
primary: { main: "#4f8cff" },
background: {
default: isDark ? "#0f172a" : "#f4f6fb",
paper: isDark ? "#111827" : "#ffffff",
},
},
shape: { borderRadius: 12 },
typography: {
fontFamily:
'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
h5: { fontWeight: 700 },
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backdropFilter: "blur(8px)",
backgroundImage: "none",
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderColor: isDark
? "rgba(148,163,184,0.25)"
: "rgba(15,23,42,0.08)",
boxShadow: isDark
? "0 2px 12px rgba(2,6,23,0.35)"
: "0 2px 8px rgba(15,23,42,0.04)",
},
},
},
},
});
}
+135
View File
@@ -17,6 +17,99 @@ export interface LibraryCount {
total: number;
}
export interface UserDirectoryItem {
jellyfin_id: string;
username: string;
display_name: string;
email: string;
email_source: string;
avatar: string;
avatar_source: string;
contactable: boolean;
source: string;
source_summary: string;
name_source: string;
access_source: string;
jellyseerr_user_id: number | null;
jellyseerr_username: string;
user_type: number | null;
user_type_label: string;
role: string;
permissions: number;
permissions_label: string;
request_count: number | null;
}
export interface UserDirectoryResponse {
items: UserDirectoryItem[];
total: number;
jellyseerr_configured: boolean;
jellyseerr_available: boolean;
jellyseerr_error: string;
jellyseerr_jellyfin_user_count: number;
jellyseerr_user_count: number;
enriched_count: number;
}
export interface UserMessageResponse {
status: string;
request_id: string;
subject: string;
from_address: string;
recipient_count: number;
attachment_count: number;
recipient_labels: string[];
skipped: Array<{ jellyfin_id: string; reason: string }>;
}
export interface UserMessageQueueStatus {
state: "idle" | "busy" | "error" | "stopped";
worker_running: boolean;
stop_requested: boolean;
pending_count: number;
active_request_id: string | null;
last_request_id: string | null;
last_result: string | null;
last_error: string;
last_error_at: number | null;
last_success_at: number | null;
last_activity_at: number | null;
sent_count: number;
failed_count: number;
}
export interface SmtpTestAttempt {
label: string;
smtp_host: string;
smtp_port: number;
use_tls: boolean;
use_ssl: boolean;
status: "ok" | "failed";
error?: string;
}
export interface SmtpTestSelectedMode {
label: string;
smtp_host: string;
smtp_port: number;
use_tls: boolean;
use_ssl: boolean;
}
export interface SmtpTestResponse {
status: "ok" | "error";
message: string;
from_address: string;
from_name: string;
smtp_host: string;
smtp_port: number;
use_tls: boolean;
use_ssl: boolean;
authenticated: boolean;
selected_mode: SmtpTestSelectedMode | null;
attempts: SmtpTestAttempt[];
}
export interface NowPlayingSession {
user: string;
title: string;
@@ -65,6 +158,48 @@ export interface MediaIndexStatus {
updated_at: number | null;
updated_at_label: string;
build_duration_seconds: number | null;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
}
export interface MediaIndexActionResponse {
status: string;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
}
export interface MediaItem {
+29
View File
@@ -0,0 +1,29 @@
import type { NowPlayingSession, UserDirectoryItem } from "./types";
export interface UserActivitySummary {
sessions: NowPlayingSession[];
active_count: number;
idle_count: number;
label: string;
summary: string;
primary_session: NowPlayingSession | null;
}
export interface UserStateItem extends UserDirectoryItem {
activity: UserActivitySummary;
activity_label: string;
activity_summary: string;
activity_count: number;
activity_active_count: number;
activity_idle_count: number;
}
export declare function mergeUsersWithActivity(
users: UserDirectoryItem[],
sessions: NowPlayingSession[],
): UserStateItem[];
export declare function resolveUserSelection(
users: Array<UserDirectoryItem | UserStateItem>,
identifier: string,
): UserStateItem | UserDirectoryItem | null;
+100
View File
@@ -0,0 +1,100 @@
function normalize(value) {
return String(value ?? "")
.trim()
.toLowerCase();
}
function userKeys(user) {
return [user.jellyfin_id, user.username, user.display_name]
.map(normalize)
.filter(Boolean);
}
function sessionMatchesUser(user, session) {
const sessionUser = normalize(session.user);
if (!sessionUser) {
return false;
}
return userKeys(user).some((key) => key === sessionUser);
}
function buildActivitySummary(sessions) {
const activeSessions = sessions.filter((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "playing" || state === "paused";
});
const idleSessions = sessions.filter((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "idle";
});
const primarySession = activeSessions[0] || sessions[0] || null;
const hasPlaying = activeSessions.some((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "playing";
});
const hasPaused = activeSessions.some((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "paused";
});
const label = !sessions.length
? "No sessions"
: hasPlaying
? "Playing"
: hasPaused
? "Paused"
: "Idle";
const summary =
sessions.length === 0
? "No live sessions"
: [
`${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
activeSessions.length ? `${activeSessions.length} active` : null,
idleSessions.length ? `${idleSessions.length} idle` : null,
]
.filter(Boolean)
.join(" · ");
return {
sessions,
active_count: activeSessions.length,
idle_count: idleSessions.length,
label,
summary,
primary_session: primarySession,
};
}
export function mergeUsersWithActivity(users, sessions) {
return users.map((user) => {
const matchingSessions = sessions.filter((session) =>
sessionMatchesUser(user, session),
);
const activity = buildActivitySummary(matchingSessions);
return {
...user,
activity,
activity_label: activity.label,
activity_summary: activity.summary,
activity_count: activity.sessions.length,
activity_active_count: activity.active_count,
activity_idle_count: activity.idle_count,
};
});
}
export function resolveUserSelection(users, identifier) {
const needle = normalize(identifier);
if (!needle) {
return null;
}
return (
users.find((user) => userKeys(user).some((key) => key === needle)) || null
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { UserDirectoryItem } from "./types";
export interface UserDetailField {
label: string;
value: string;
}
export interface UserContactAction {
label: string;
enabled: boolean;
hint: string;
}
export interface UserContactState {
label: string;
description: string;
}
export interface UserDrawerModel {
title: string;
subtitle: string;
contactState: UserContactState;
contactActions: UserContactAction[];
identity: UserDetailField[];
permissions: string[];
syncStatus: string;
source: string;
}
export declare function buildUserDrawerModel(
user: UserDirectoryItem,
): UserDrawerModel;
+90
View File
@@ -0,0 +1,90 @@
function displayName(user) {
return user.display_name || user.username || user.jellyfin_id;
}
function splitValues(value) {
return String(value || "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
function syncStatus(user) {
if (
user.jellyseerr_user_id !== null &&
user.jellyseerr_user_id !== undefined
) {
return "Linked with Jellyseerr";
}
if (String(user.source_summary || user.source || "").includes("jellyseerr")) {
return "Enriched via Jellyseerr";
}
return "Jellyfin only";
}
export function buildUserDrawerModel(user) {
const title = displayName(user);
const subtitle = user.email || "No email address available";
const permissions = splitValues(user.permissions_label);
const source = String(user.source_summary || user.source || "jellyfin");
const roleLabel = user.role
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
: "Unknown";
const contactLabel = user.contactable ? "Contactable" : "Read-only";
const contactDescription = user.contactable
? "An email address is available for future communication workflows."
: "No direct contact route is available yet.";
return {
title,
subtitle,
contactState: {
label: contactLabel,
description: contactDescription,
},
contactActions: [
{
label: "Email",
enabled: false,
hint: user.contactable
? "Email action is planned for a future release."
: "Disabled until an email address is available.",
},
{
label: "Notify",
enabled: false,
hint: "Notification workflows are not wired up yet.",
},
],
identity: [
{ label: "Email", value: user.email || "—" },
{ label: "Email source", value: user.email_source || "none" },
{ label: "Avatar source", value: user.avatar_source || "none" },
{ label: "Jellyfin ID", value: user.jellyfin_id },
{ label: "Name source", value: user.name_source || "jellyfin" },
{ label: "Access source", value: user.access_source || "none" },
{
label: "Jellyseerr user ID",
value:
user.jellyseerr_user_id === null ||
user.jellyseerr_user_id === undefined
? "Not linked"
: `#${user.jellyseerr_user_id}`,
},
{ label: "Account type", value: user.user_type_label || "unknown" },
{ label: "Role", value: roleLabel },
{ label: "Permissions", value: permissions.join(", ") || "none" },
{
label: "Requests",
value:
user.request_count === null || user.request_count === undefined
? "—"
: String(user.request_count),
},
{ label: "Source", value: source },
],
permissions,
syncStatus: syncStatus(user),
source,
};
}
+131
View File
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
mergeUsersWithActivity,
resolveUserSelection,
} from "../src/userState.js";
test("mergeUsersWithActivity attaches session activity to user state", () => {
const merged = mergeUsersWithActivity(
[
{
jellyfin_id: "jf1",
username: "alex",
display_name: "alex",
email: "alex@example.com",
email_source: "jellyseerr:user",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "name=jellyfin",
name_source: "jellyfin",
access_source: "",
jellyseerr_user_id: 7,
jellyseerr_username: "alex",
user_type: 3,
user_type_label: "jellyfin",
role: "admin",
permissions: 10,
permissions_label: "admin, manage_users",
request_count: 3,
},
],
[
{
user: "alex",
title: "Test Movie",
type: "Movie",
state: "playing",
transcoding: "yes",
transcoding_type: "audio",
device: "Chrome",
session_id: "sess1",
},
],
);
assert.equal(merged[0].activity_count, 1);
assert.equal(merged[0].activity_active_count, 1);
assert.equal(merged[0].activity_label, "Playing");
assert.equal(merged[0].activity.summary, "1 session · 1 active");
assert.equal(merged[0].activity.sessions[0].title, "Test Movie");
});
test("mergeUsersWithActivity marks paused sessions as paused", () => {
const merged = mergeUsersWithActivity(
[
{
jellyfin_id: "jf2",
username: "sam",
display_name: "Sam",
email: "sam@example.com",
email_source: "jellyseerr:user",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "name=jellyfin",
name_source: "jellyfin",
access_source: "",
jellyseerr_user_id: 8,
jellyseerr_username: "sam",
user_type: 3,
user_type_label: "jellyfin",
role: "user",
permissions: 0,
permissions_label: "",
request_count: 0,
},
],
[
{
user: "sam",
title: "Paused Episode",
type: "Episode",
state: "paused",
transcoding: "no",
transcoding_type: "",
device: "Firefox",
session_id: "sess2",
},
],
);
assert.equal(merged[0].activity_label, "Paused");
assert.equal(merged[0].activity.summary, "1 session · 1 active");
});
test("resolveUserSelection matches id username and display name", () => {
const users = mergeUsersWithActivity(
[
{
jellyfin_id: "jf1",
username: "alex",
display_name: "Alexander",
email: "alex@example.com",
email_source: "jellyseerr:user",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "name=jellyfin",
name_source: "jellyfin",
access_source: "",
jellyseerr_user_id: 7,
jellyseerr_username: "alex",
user_type: 3,
user_type_label: "jellyfin",
role: "admin",
permissions: 10,
permissions_label: "admin, manage_users",
request_count: 3,
},
],
[],
);
assert.equal(resolveUserSelection(users, "jf1")?.username, "alex");
assert.equal(resolveUserSelection(users, "alex")?.jellyfin_id, "jf1");
assert.equal(resolveUserSelection(users, "Alexander")?.jellyfin_id, "jf1");
});
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildUserDrawerModel } from "../src/users.js";
test("buildUserDrawerModel surfaces contact and permission details", () => {
const model = buildUserDrawerModel({
jellyfin_id: "jf1",
username: "alex",
display_name: "alex",
email: "alex@example.com",
avatar: "https://example.com/avatar.png",
avatar_source: "jellyseerr:user",
email_source: "jellyseerr:user",
name_source: "jellyfin",
access_source: "jellyseerr:user",
contactable: true,
source_summary:
"name=jellyfin, email=jellyseerr:user, avatar=jellyseerr:user, access=jellyseerr:user",
source: "jellyfin, jellyseerr:user",
jellyseerr_user_id: 7,
jellyseerr_username: "alex",
user_type: 3,
user_type_label: "jellyfin",
role: "admin",
permissions: 10,
permissions_label: "admin, manage_users",
request_count: 3,
});
assert.equal(model.title, "alex");
assert.equal(model.subtitle, "alex@example.com");
assert.equal(model.contactState.label, "Contactable");
assert.equal(model.contactActions[0].label, "Email");
assert.equal(model.contactActions[0].enabled, false);
assert.equal(model.permissions.length >= 2, true);
assert.equal(
model.identity.some((field) => field.label === "Email source"),
true,
);
assert.equal(model.identity.length >= 4, true);
assert.equal(model.source.includes("email=jellyseerr:user"), true);
assert.equal(model.syncStatus.includes("Jellyseerr"), true);
});
+19 -8
View File
@@ -1,12 +1,23 @@
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": "http://localhost:8000",
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const proxyTarget =
process.env.VITE_DEV_API_PROXY_TARGET ||
env.VITE_DEV_API_PROXY_TARGET ||
"http://localhost:8000";
return {
plugins: [react()],
server: {
proxy: {
"/api": {
target: proxyTarget,
changeOrigin: true,
secure: false,
},
},
},
},
};
});