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