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.
|
# 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=
|
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_HOST=media-server.example.com
|
||||||
SSH_USERNAME=username
|
SSH_USERNAME=username
|
||||||
SSH_PORT=22
|
SSH_PORT=22
|
||||||
@@ -12,3 +30,19 @@ REMOTE_MEDIA_ROOT=/mnt/media
|
|||||||
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough.
|
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough.
|
||||||
# Example: Jellyfin gives /media/... but SSH host requires /srv/media/...
|
# Example: Jellyfin gives /media/... but SSH host requires /srv/media/...
|
||||||
REMOTE_PATH_PREFIX=
|
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
|
- 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
|
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts
|
||||||
- SQLite-indexed media table with full-library sort/filter
|
- 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
|
- 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
|
- SSH-based file inspection and remote job templates
|
||||||
|
|
||||||
## Quick Start
|
## 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
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -39,16 +58,12 @@ pip install -e '.[dev]'
|
|||||||
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend runs on http://localhost:5173 and proxies API requests to http://localhost:8000.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Create a `.env` file in the project root:
|
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_API_KEY=your-api-key
|
||||||
JELLYFIN_USER_ID=
|
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_HOST=media-server.example.com
|
||||||
SSH_USERNAME=username
|
SSH_USERNAME=username
|
||||||
SSH_PORT=22
|
SSH_PORT=22
|
||||||
@@ -66,6 +88,21 @@ SSH_PASSWORD=
|
|||||||
|
|
||||||
REMOTE_MEDIA_ROOT=/srv/media
|
REMOTE_MEDIA_ROOT=/srv/media
|
||||||
REMOTE_PATH_PREFIX=
|
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
|
## 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.
|
- 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`.
|
- 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.
|
- 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
|
# 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
|
## Project structure
|
||||||
|
|
||||||
@@ -21,10 +21,12 @@ backend/
|
|||||||
│ │ ├── dashboard.py
|
│ │ ├── dashboard.py
|
||||||
│ │ ├── monitoring.py
|
│ │ ├── monitoring.py
|
||||||
│ │ ├── media.py
|
│ │ ├── media.py
|
||||||
|
│ │ ├── users.py
|
||||||
│ │ ├── files.py
|
│ │ ├── files.py
|
||||||
│ │ └── jobs.py
|
│ │ └── jobs.py
|
||||||
│ ├── clients/
|
│ ├── clients/
|
||||||
│ │ ├── jellyfin.py
|
│ │ ├── jellyfin.py
|
||||||
|
│ │ ├── jellyseerr.py
|
||||||
│ │ ├── resources.py
|
│ │ ├── resources.py
|
||||||
│ │ └── ssh.py
|
│ │ └── ssh.py
|
||||||
│ ├── domain/
|
│ ├── domain/
|
||||||
@@ -52,6 +54,20 @@ JELLYFIN_URL=https://jellyfin.example.com
|
|||||||
JELLYFIN_API_KEY=your-api-key
|
JELLYFIN_API_KEY=your-api-key
|
||||||
JELLYFIN_USER_ID=
|
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_HOST=media-server.example.com
|
||||||
SSH_USERNAME=username
|
SSH_USERNAME=username
|
||||||
SSH_PORT=22
|
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
|
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
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /api/dashboard/counts` — Movie/series/episode totals
|
- `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/files/resolve-path?path=` — Path resolution
|
||||||
- `GET /api/jobs/templates` — Available jobs
|
- `GET /api/jobs/templates` — Available jobs
|
||||||
- `POST /api/jobs/run` — Execute a job
|
- `POST /api/jobs/run` — Execute a job
|
||||||
|
- `GET /api/users` — Jellyfin users with optional Jellyseerr enrichment
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ dependencies = [
|
|||||||
"requests>=2.28",
|
"requests>=2.28",
|
||||||
"python-dotenv>=1.0",
|
"python-dotenv>=1.0",
|
||||||
"pandas>=2.0",
|
"pandas>=2.0",
|
||||||
|
"PyJWT[crypto]>=2.8",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
import logging
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
|
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
|
||||||
# documented/commonly supported optional fields; invalid names cause 400s.
|
# 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."""
|
"""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 != ""}
|
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(
|
response = self.session.get(
|
||||||
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
||||||
)
|
)
|
||||||
@@ -64,10 +68,12 @@ class JellyfinClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.HTTPError as exc:
|
except requests.HTTPError as exc:
|
||||||
detail = response.text[:500]
|
detail = response.text[:500]
|
||||||
|
logger.warning("Jellyfin GET %s failed status=%s url=%s", path, response.status_code, response.url)
|
||||||
raise requests.HTTPError(
|
raise requests.HTTPError(
|
||||||
f"{response.status_code} for {response.url}: {detail}",
|
f"{response.status_code} for {response.url}: {detail}",
|
||||||
response=response,
|
response=response,
|
||||||
) from exc
|
) from exc
|
||||||
|
logger.debug("Jellyfin GET %s ok status=%s", path, response.status_code)
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
def users(self) -> list[dict[str, Any]]:
|
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
|
/Users/Me often fails with API-key auth. The user id selected here is
|
||||||
then used for user-scoped library endpoints.
|
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]]:
|
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
"""Return top-level library views visible to the selected Jellyfin user."""
|
"""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(
|
def items(
|
||||||
self,
|
self,
|
||||||
@@ -101,6 +111,17 @@ class JellyfinClient:
|
|||||||
builder. Keep arguments close to Jellyfin's own query parameters so the
|
builder. Keep arguments close to Jellyfin's own query parameters so the
|
||||||
service layer can request server-side pagination and basic sorting.
|
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(
|
return self.get(
|
||||||
f"/Users/{user_id}/Items",
|
f"/Users/{user_id}/Items",
|
||||||
ParentId=parent_id,
|
ParentId=parent_id,
|
||||||
@@ -123,7 +144,15 @@ class JellyfinClient:
|
|||||||
IncludeItemTypes=include_item_types,
|
IncludeItemTypes=include_item_types,
|
||||||
Limit=0,
|
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]:
|
def media_counts(self, user_id: str) -> dict[str, int]:
|
||||||
"""Return dashboard-level counts for the main media types."""
|
"""Return dashboard-level counts for the main media types."""
|
||||||
@@ -156,11 +185,20 @@ class JellyfinClient:
|
|||||||
})
|
})
|
||||||
return results
|
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]]:
|
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
|
||||||
"""Return currently active sessions that have a now-playing item."""
|
"""Return sessions that currently have a now-playing item."""
|
||||||
payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
|
sessions = [session for session in self.sessions(active_within_seconds) if session.get("NowPlayingItem")]
|
||||||
sessions = payload if isinstance(payload, list) else []
|
logger.info("Jellyfin active sessions within %ss: %s", active_within_seconds, len(sessions))
|
||||||
return [session for session in sessions if session.get("NowPlayingItem")]
|
return sessions
|
||||||
|
|
||||||
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
|
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
|
||||||
"""Build an authenticated image URL suitable for st.image/browser use."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
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().
|
# 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.
|
# Keep this script bash-free because many NAS/media servers have minimal shells.
|
||||||
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
||||||
@@ -199,9 +202,14 @@ else
|
|||||||
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||||
fi
|
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)
|
result = ssh.run(command, timeout=20)
|
||||||
if result.exit_status != 0:
|
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")
|
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()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -221,9 +229,12 @@ else
|
|||||||
echo "not running"
|
echo "not running"
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
|
logger.info("Stopping remote resource collector")
|
||||||
result = ssh.run(command, timeout=20)
|
result = ssh.run(command, timeout=20)
|
||||||
if result.exit_status != 0:
|
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")
|
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()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -234,6 +245,7 @@ def restart_resource_collector(
|
|||||||
max_lines: int = 70_000,
|
max_lines: int = 70_000,
|
||||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||||
) -> str:
|
) -> str:
|
||||||
|
logger.info("Restarting remote resource collector")
|
||||||
stop_message = stop_resource_collector(ssh, paths)
|
stop_message = stop_resource_collector(ssh, paths)
|
||||||
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
||||||
return f"{stop_message}\n{start_message}"
|
return f"{stop_message}\n{start_message}"
|
||||||
@@ -250,8 +262,11 @@ fi
|
|||||||
"""
|
"""
|
||||||
result = ssh.run(command, timeout=10)
|
result = ssh.run(command, timeout=10)
|
||||||
if result.exit_status != 0:
|
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")
|
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:
|
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:"
|
echo "netdev_snapshot:"
|
||||||
cat /proc/net/dev 2>&1 || true
|
cat /proc/net/dev 2>&1 || true
|
||||||
"""
|
"""
|
||||||
|
logger.info("Collecting resource collector diagnostics")
|
||||||
result = ssh.run(command, timeout=20)
|
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]]:
|
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."""
|
"""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"
|
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)
|
result = ssh.run(command, timeout=20)
|
||||||
if result.exit_status != 0:
|
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")
|
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
|
||||||
rows = []
|
rows = []
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
@@ -310,7 +330,11 @@ def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
|
|||||||
+ shlex.quote(path or "/")
|
+ 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}'"
|
+ " | 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)
|
result = ssh.run(command, timeout=20)
|
||||||
if result.exit_status != 0 or not result.stdout.strip():
|
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")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import posixpath
|
import posixpath
|
||||||
import shlex
|
import shlex
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -17,6 +18,8 @@ from typing import Any
|
|||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
@@ -88,14 +91,25 @@ class RemoteSSHClient:
|
|||||||
"""
|
"""
|
||||||
client = self.connect()
|
client = self.connect()
|
||||||
shell_command = f"/bin/sh -c {shlex.quote(command)}"
|
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)
|
stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout)
|
||||||
exit_status = stdout.channel.recv_exit_status()
|
exit_status = stdout.channel.recv_exit_status()
|
||||||
return CommandResult(
|
result = CommandResult(
|
||||||
command=command,
|
command=command,
|
||||||
exit_status=exit_status,
|
exit_status=exit_status,
|
||||||
stdout=stdout.read().decode(errors="replace"),
|
stdout=stdout.read().decode(errors="replace"),
|
||||||
stderr=stderr.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:
|
def list_dir(self, path: str) -> CommandResult:
|
||||||
"""List one remote directory as JSON.
|
"""List one remote directory as JSON.
|
||||||
@@ -123,12 +137,16 @@ class RemoteSSHClient:
|
|||||||
"print(json.dumps(rows))"
|
"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:
|
def stat_path(self, path: str) -> CommandResult:
|
||||||
"""Run stat for a remote file or directory path."""
|
"""Run stat for a remote file or directory path."""
|
||||||
quoted = shlex.quote(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]:
|
def ffprobe_json(self, path: str) -> dict[str, Any]:
|
||||||
"""Run ffprobe and parse JSON output for a remote media file."""
|
"""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,
|
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
|
logger.info("SSH ffprobe path=%s exit_status=%s", path, result.exit_status)
|
||||||
if result.exit_status != 0:
|
if result.exit_status != 0:
|
||||||
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ JELLYFIN_URL etc. are picked up directly without nested-model complications.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
from media_library_viewer_api.logging_utils import describe_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Flat application settings read from env vars / .env file."""
|
"""Flat application settings read from env vars / .env file."""
|
||||||
@@ -21,6 +26,31 @@ class Settings(BaseSettings):
|
|||||||
jellyfin_api_key: str = ""
|
jellyfin_api_key: str = ""
|
||||||
jellyfin_user_id: 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
|
||||||
ssh_host: str = ""
|
ssh_host: str = ""
|
||||||
ssh_username: str = ""
|
ssh_username: str = ""
|
||||||
@@ -61,6 +91,10 @@ def _find_env_file() -> str | None:
|
|||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Return a cached Settings instance."""
|
"""Return a cached Settings instance."""
|
||||||
env_file = _find_env_file()
|
env_file = _find_env_file()
|
||||||
if env_file:
|
settings = Settings(_env_file=env_file) if env_file else Settings()
|
||||||
return Settings(_env_file=env_file)
|
logger.info(
|
||||||
return Settings()
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
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.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.config import get_settings
|
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
|
@lru_cache
|
||||||
def get_jellyfin_client() -> JellyfinClient:
|
def get_jellyfin_client() -> JellyfinClient:
|
||||||
"""Return a cached Jellyfin client."""
|
"""Return a cached Jellyfin client."""
|
||||||
settings = get_settings()
|
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)
|
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
|
@lru_cache
|
||||||
def get_ssh_client() -> RemoteSSHClient:
|
def get_ssh_client() -> RemoteSSHClient:
|
||||||
"""Return a cached SSH client (connects on first use)."""
|
"""Return a cached SSH client (connects on first use)."""
|
||||||
settings = get_settings()
|
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(
|
client = RemoteSSHClient(
|
||||||
host=settings.ssh_host,
|
host=settings.ssh_host,
|
||||||
username=settings.ssh_username,
|
username=settings.ssh_username,
|
||||||
@@ -31,10 +60,19 @@ def get_ssh_client() -> RemoteSSHClient:
|
|||||||
key_filename=settings.ssh_key_filename or None,
|
key_filename=settings.ssh_key_filename or None,
|
||||||
password=settings.ssh_password or None,
|
password=settings.ssh_password or None,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
client.connect()
|
client.connect()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to establish SSH connection to %s", settings.ssh_host or "<unset>")
|
||||||
|
raise
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_mail_queue() -> MailQueue:
|
||||||
|
"""Return the singleton background email queue."""
|
||||||
|
return _get_mail_queue()
|
||||||
|
|
||||||
|
|
||||||
def get_user_id() -> str:
|
def get_user_id() -> str:
|
||||||
"""Return the configured Jellyfin user ID, or discover the first available user."""
|
"""Return the configured Jellyfin user ID, or discover the first available user."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ confirmations/dry-runs.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
from media_library_viewer_api.clients.ssh import CommandResult, RemoteSSHClient
|
from media_library_viewer_api.clients.ssh import CommandResult, RemoteSSHClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class JobTemplate:
|
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."""
|
"""Render and execute a configured job template for a selected remote path."""
|
||||||
template = JOB_TEMPLATES[job_key]
|
template = JOB_TEMPLATES[job_key]
|
||||||
command = template.render({"path": path})
|
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)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan — startup/shutdown."""
|
"""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
|
yield
|
||||||
|
mail_queue.stop()
|
||||||
|
logger.info("Backend shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Media Library Viewer API",
|
title="Media Library Viewer API",
|
||||||
version="0.1.0",
|
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,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,17 +54,45 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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
|
# Register routers
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(monitoring.router)
|
app.include_router(monitoring.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health_check() -> dict[str, str]:
|
def health_check() -> dict[str, str]:
|
||||||
"""Simple health check endpoint."""
|
"""Simple health check endpoint."""
|
||||||
|
logger.debug("health check requested")
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import posixpath
|
import posixpath
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def apply_remote_path_prefix(path: str, prefix: str) -> str:
|
def apply_remote_path_prefix(path: str, prefix: str) -> str:
|
||||||
"""Apply an optional fallback prefix for Jellyfin->SSH path handoff."""
|
"""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
|
return path
|
||||||
normalized_prefix = normalized_prefix.rstrip("/")
|
normalized_prefix = normalized_prefix.rstrip("/")
|
||||||
if path == normalized_prefix or path.startswith(normalized_prefix + "/"):
|
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("/"):
|
if path.startswith("/"):
|
||||||
return posixpath.normpath(normalized_prefix + path)
|
resolved = posixpath.normpath(normalized_prefix + path)
|
||||||
return posixpath.normpath(posixpath.join(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:
|
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)
|
path_absolute = "/" + "/".join(raw_parts)
|
||||||
if path_absolute == normalized_root or path_absolute.startswith(normalized_root + "/"):
|
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
|
return path_absolute
|
||||||
|
|
||||||
root_anchor = posixpath.basename(normalized_root)
|
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:
|
if root_anchor in raw_parts:
|
||||||
anchor_index = raw_parts.index(root_anchor)
|
anchor_index = raw_parts.index(root_anchor)
|
||||||
remainder_parts = raw_parts[anchor_index + 1:]
|
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
|
return path
|
||||||
|
|
||||||
@@ -64,5 +76,8 @@ def resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str)
|
|||||||
return path
|
return path
|
||||||
mapped = map_path_to_media_root(path, media_root)
|
mapped = map_path_to_media_root(path, media_root)
|
||||||
if mapped and mapped != path:
|
if mapped and mapped != path:
|
||||||
|
logger.debug("Resolved media path via media root original=%s resolved=%s", path, mapped)
|
||||||
return 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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.dependencies import get_jellyfin_client, get_user_id
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +21,9 @@ def get_counts(
|
|||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Return total movie/series/episode counts."""
|
"""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")
|
@router.get("/libraries")
|
||||||
@@ -28,26 +33,31 @@ def get_library_counts(
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return per-library item counts broken down by type."""
|
"""Return per-library item counts broken down by type."""
|
||||||
libraries = client.libraries(user_id)
|
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)
|
return client.library_item_counts(user_id, libraries)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/now-playing")
|
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
def get_now_playing(
|
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
results: list[dict[str, Any]] = []
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Return currently active playback sessions with transcode info."""
|
|
||||||
sessions = client.active_sessions()
|
|
||||||
results = []
|
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
item = session.get("NowPlayingItem") or {}
|
item = session.get("NowPlayingItem") or {}
|
||||||
play_state = session.get("PlayState") or {}
|
play_state = session.get("PlayState") or {}
|
||||||
transcoding = session.get("TranscodingInfo") or {}
|
transcoding = session.get("TranscodingInfo") or {}
|
||||||
|
|
||||||
|
has_item = bool(item)
|
||||||
series = item.get("SeriesName") or ""
|
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)
|
is_transcoding = bool(transcoding)
|
||||||
transcode_type = []
|
transcode_type: list[str] = []
|
||||||
if is_transcoding:
|
if is_transcoding:
|
||||||
if transcoding.get("IsVideoDirect") is False:
|
if transcoding.get("IsVideoDirect") is False:
|
||||||
transcode_type.append("video")
|
transcode_type.append("video")
|
||||||
@@ -59,11 +69,32 @@ def get_now_playing(
|
|||||||
results.append({
|
results.append({
|
||||||
"user": session.get("UserName") or "Unknown",
|
"user": session.get("UserName") or "Unknown",
|
||||||
"title": title,
|
"title": title,
|
||||||
"type": item.get("Type", ""),
|
"type": item.get("Type", "") if has_item else "",
|
||||||
"state": "paused" if play_state.get("IsPaused") else "playing",
|
"state": state_label,
|
||||||
"transcoding": "yes" if is_transcoding else "no",
|
"transcoding": "yes" if is_transcoding else "no",
|
||||||
"transcoding_type": ", ".join(transcode_type),
|
"transcoding_type": ", ".join(transcode_type),
|
||||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||||
"session_id": session.get("Id") or "",
|
"session_id": session.get("Id") or "",
|
||||||
})
|
})
|
||||||
return results
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from typing import Any
|
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.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.config import get_settings
|
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
|
from media_library_viewer_api.path_utils import resolve_remote_media_path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
@@ -23,8 +26,10 @@ def list_directory(
|
|||||||
"""List a remote directory."""
|
"""List a remote directory."""
|
||||||
result = ssh.list_dir(path)
|
result = ssh.list_dir(path)
|
||||||
if result.exit_status != 0:
|
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")
|
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory")
|
||||||
entries = json.loads(result.stdout)
|
entries = json.loads(result.stdout)
|
||||||
|
logger.info("Files list path=%s entries=%s", path, len(entries))
|
||||||
return {
|
return {
|
||||||
"path": path,
|
"path": path,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
@@ -41,7 +46,9 @@ def get_ffprobe(
|
|||||||
try:
|
try:
|
||||||
data = ssh.ffprobe_json(path)
|
data = ssh.ffprobe_json(path)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.warning("Files ffprobe failed path=%s error=%s", path, exc)
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
logger.info("Files ffprobe path=%s", path)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +60,9 @@ def get_stat(
|
|||||||
"""Run stat on a remote path."""
|
"""Run stat on a remote path."""
|
||||||
result = ssh.stat_path(path)
|
result = ssh.stat_path(path)
|
||||||
if result.exit_status != 0:
|
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")
|
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}
|
return {"path": path, "output": result.stdout}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,4 +73,5 @@ def resolve_path(
|
|||||||
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
|
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix)
|
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}
|
return {"original": path, "resolved": resolved}
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
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.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
|
from media_library_viewer_api.jobs import JOB_TEMPLATES, run_job
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ class RunJobRequest(BaseModel):
|
|||||||
@router.get("/templates")
|
@router.get("/templates")
|
||||||
def get_templates() -> list[dict[str, str]]:
|
def get_templates() -> list[dict[str, str]]:
|
||||||
"""Return available job templates."""
|
"""Return available job templates."""
|
||||||
return [
|
templates = [
|
||||||
{
|
{
|
||||||
"key": key,
|
"key": key,
|
||||||
"name": template.name,
|
"name": template.name,
|
||||||
@@ -30,6 +33,8 @@ def get_templates() -> list[dict[str, str]]:
|
|||||||
}
|
}
|
||||||
for key, template in JOB_TEMPLATES.items()
|
for key, template in JOB_TEMPLATES.items()
|
||||||
]
|
]
|
||||||
|
logger.info("Jobs templates requested count=%s", len(templates))
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run")
|
@router.post("/run")
|
||||||
@@ -39,9 +44,12 @@ def post_run_job(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a job template on a remote path."""
|
"""Run a job template on a remote path."""
|
||||||
if request.job_key not in JOB_TEMPLATES:
|
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}")
|
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)
|
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 {
|
return {
|
||||||
"job_key": request.job_key,
|
"job_key": request.job_key,
|
||||||
"path": request.path,
|
"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
|
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 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.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"])
|
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||||
|
_build_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_media_index() -> MediaIndex:
|
def get_media_index() -> MediaIndex:
|
||||||
return MediaIndex()
|
return MediaIndex()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
|
||||||
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
for key, value in state.items():
|
||||||
"""Return media index status (exists, count, last updated, build duration)."""
|
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()
|
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 {
|
return {
|
||||||
"exists": status.exists,
|
"exists": status.exists,
|
||||||
"item_count": status.item_count,
|
"item_count": status.item_count,
|
||||||
"updated_at": status.updated_at,
|
"updated_at": status.updated_at,
|
||||||
"updated_at_label": status.updated_at_label,
|
"updated_at_label": status.updated_at_label,
|
||||||
"build_duration_seconds": status.build_duration_seconds,
|
"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(
|
def post_build_index(
|
||||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
index: MediaIndex = Depends(get_media_index),
|
index: MediaIndex = Depends(get_media_index),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rebuild the media index from Jellyfin."""
|
"""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)
|
libraries = client.libraries(user_id)
|
||||||
count = build_media_index(client, user_id, libraries, index)
|
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
||||||
return {"indexed_items": count}
|
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")
|
@router.get("/query")
|
||||||
@@ -57,13 +274,25 @@ def query_media(
|
|||||||
index: MediaIndex = Depends(get_media_index),
|
index: MediaIndex = Depends(get_media_index),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Query the media index with filters, sorting, and pagination."""
|
"""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
|
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
|
||||||
if not library_ids:
|
if not library_ids:
|
||||||
all_libs = client.libraries(user_id)
|
all_libs = client.libraries(user_id)
|
||||||
library_ids = [lib["Id"] for lib in all_libs]
|
library_ids = [lib["Id"] for lib in all_libs]
|
||||||
|
|
||||||
media_types = [t.strip() for t in types.split(",") if t.strip()]
|
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(
|
rows, total = index.query(
|
||||||
library_ids=library_ids,
|
library_ids=library_ids,
|
||||||
@@ -76,6 +305,7 @@ def query_media(
|
|||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Media query returned total=%s rows=%s", total, len(rows))
|
||||||
return {
|
return {
|
||||||
"items": rows,
|
"items": rows,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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.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 (
|
from media_library_viewer_api.clients.resources import (
|
||||||
disk_space,
|
disk_space,
|
||||||
read_resource_metrics,
|
read_resource_metrics,
|
||||||
@@ -26,19 +29,26 @@ router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
|||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
"""Return collector running status."""
|
"""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")
|
@router.get("/metrics")
|
||||||
def get_metrics(
|
def get_metrics(
|
||||||
max_lines: int = 1000,
|
max_lines: int = 70_000,
|
||||||
last_seconds: int = 3600,
|
last_seconds: int | None = None,
|
||||||
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return resource metric samples from the remote collector."""
|
"""Return resource metric samples from the remote collector."""
|
||||||
rows = read_resource_metrics(ssh, max_lines=max_lines)
|
rows = read_resource_metrics(ssh, max_lines=max_lines)
|
||||||
|
if last_seconds is None:
|
||||||
|
filtered = rows
|
||||||
|
cutoff_ts = 0.0
|
||||||
|
else:
|
||||||
cutoff_ts = time.time() - last_seconds
|
cutoff_ts = time.time() - last_seconds
|
||||||
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
|
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 {
|
return {
|
||||||
"samples": filtered,
|
"samples": filtered,
|
||||||
"total_samples": len(rows),
|
"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."""
|
"""Return disk space for the configured media root."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
path = settings.media_root or "/"
|
path = settings.media_root or "/"
|
||||||
|
logger.info("Monitoring disk requested path=%s", path)
|
||||||
return disk_space(ssh, 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]:
|
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
"""Start the remote resource collector."""
|
"""Start the remote resource collector."""
|
||||||
message = start_resource_collector(ssh)
|
message = start_resource_collector(ssh)
|
||||||
|
logger.info("Monitoring collector start result: %s", message)
|
||||||
return {"message": 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]:
|
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
"""Stop the remote resource collector."""
|
"""Stop the remote resource collector."""
|
||||||
message = stop_resource_collector(ssh)
|
message = stop_resource_collector(ssh)
|
||||||
|
logger.info("Monitoring collector stop result: %s", message)
|
||||||
return {"message": 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]:
|
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
"""Restart the remote resource collector."""
|
"""Restart the remote resource collector."""
|
||||||
message = restart_resource_collector(ssh)
|
message = restart_resource_collector(ssh)
|
||||||
|
logger.info("Monitoring collector restart result: %s", message)
|
||||||
return {"message": message}
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/diagnostics")
|
@router.get("/diagnostics")
|
||||||
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
"""Return collector debug info for troubleshooting."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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.clients.jellyfin import JellyfinClient
|
||||||
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
|
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
|
# Local generated database. It is ignored by git and can be rebuilt from
|
||||||
# Jellyfin metadata whenever needed.
|
# 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)
|
@dataclass(frozen=True)
|
||||||
class MediaIndexStatus:
|
class MediaIndexStatus:
|
||||||
"""Lightweight status object displayed by the Media tab."""
|
"""Lightweight status object displayed by the Media tab."""
|
||||||
@@ -51,6 +68,25 @@ class MediaIndexStatus:
|
|||||||
updated_at: int | None = None
|
updated_at: int | None = None
|
||||||
updated_at_label: str = ""
|
updated_at_label: str = ""
|
||||||
build_duration_seconds: float | None = None
|
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:
|
class MediaIndex:
|
||||||
@@ -66,8 +102,10 @@ class MediaIndex:
|
|||||||
|
|
||||||
def connect(self) -> sqlite3.Connection:
|
def connect(self) -> sqlite3.Connection:
|
||||||
"""Open a sqlite connection configured to return Row objects."""
|
"""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.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def init_schema(self) -> None:
|
def init_schema(self) -> None:
|
||||||
@@ -170,24 +208,68 @@ class MediaIndex:
|
|||||||
try:
|
try:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
|
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()
|
meta = {
|
||||||
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
|
row[0]: row[1]
|
||||||
|
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
|
||||||
|
}
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
return MediaIndexStatus(exists=False)
|
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 ""
|
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
|
build_duration = None
|
||||||
if duration_row:
|
if duration_raw is not None:
|
||||||
try:
|
try:
|
||||||
build_duration = float(duration_row[0])
|
build_duration = float(duration_raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
build_duration = None
|
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(
|
return MediaIndexStatus(
|
||||||
exists=True,
|
exists=True,
|
||||||
item_count=item_count,
|
item_count=item_count,
|
||||||
updated_at=updated_at,
|
updated_at=updated_at,
|
||||||
updated_at_label=label,
|
updated_at_label=label,
|
||||||
build_duration_seconds=build_duration,
|
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(
|
def query(
|
||||||
@@ -245,18 +327,79 @@ def build_media_index(
|
|||||||
libraries: list[dict[str, Any]],
|
libraries: list[dict[str, Any]],
|
||||||
index: MediaIndex | None = None,
|
index: MediaIndex | None = None,
|
||||||
page_size: int = 500,
|
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:
|
) -> int:
|
||||||
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
||||||
index = index or MediaIndex()
|
index = index or MediaIndex()
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
normalized_rows: list[dict[str, Any]] = []
|
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_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:
|
if not library_id:
|
||||||
continue
|
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
|
start = 0
|
||||||
|
discovered_library_total = None
|
||||||
while True:
|
while True:
|
||||||
|
ensure_not_cancelled()
|
||||||
response = client.items(
|
response = client.items(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
parent_id=library_id,
|
parent_id=library_id,
|
||||||
@@ -267,12 +410,49 @@ def build_media_index(
|
|||||||
sort_by="SortName",
|
sort_by="SortName",
|
||||||
sort_order="Ascending",
|
sort_order="Ascending",
|
||||||
)
|
)
|
||||||
|
ensure_not_cancelled()
|
||||||
items = response.get("Items", [])
|
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)
|
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))
|
total = int(response.get("TotalRecordCount", start))
|
||||||
if not items or start >= total:
|
if not items or start >= total:
|
||||||
break
|
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)
|
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
|
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
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from media_library_viewer_api.main import app
|
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.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 ---
|
# --- Fixtures ---
|
||||||
@@ -26,11 +35,15 @@ def mock_jellyfin():
|
|||||||
{"Id": "lib1", "Name": "Movies", "CollectionType": "movies"},
|
{"Id": "lib1", "Name": "Movies", "CollectionType": "movies"},
|
||||||
{"Id": "lib2", "Name": "TV Shows", "CollectionType": "tvshows"},
|
{"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 = [
|
client.library_item_counts.return_value = [
|
||||||
{"library": "Movies", "type": "movies", "movies": 100, "series": 0, "episodes": 0, "total": 100},
|
{"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},
|
{"library": "TV Shows", "type": "tvshows", "movies": 0, "series": 20, "episodes": 500, "total": 520},
|
||||||
]
|
]
|
||||||
client.active_sessions.return_value = [
|
client.sessions.return_value = [
|
||||||
{
|
{
|
||||||
"Id": "sess1",
|
"Id": "sess1",
|
||||||
"UserName": "alex",
|
"UserName": "alex",
|
||||||
@@ -38,11 +51,51 @@ def mock_jellyfin():
|
|||||||
"NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"},
|
"NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"},
|
||||||
"PlayState": {"IsPaused": False},
|
"PlayState": {"IsPaused": False},
|
||||||
"TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False},
|
"TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False},
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"Id": "sess2",
|
||||||
|
"UserName": "sam",
|
||||||
|
"DeviceName": "Android",
|
||||||
|
"NowPlayingItem": None,
|
||||||
|
"PlayState": {},
|
||||||
|
"TranscodingInfo": None,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
return client
|
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
|
@pytest.fixture
|
||||||
def mock_ssh():
|
def mock_ssh():
|
||||||
"""Mock SSH client."""
|
"""Mock SSH client."""
|
||||||
@@ -73,9 +126,10 @@ def mock_ssh():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client(mock_jellyfin, mock_ssh):
|
def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh):
|
||||||
"""FastAPI test client with mocked dependencies."""
|
"""FastAPI test client with mocked dependencies."""
|
||||||
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
|
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_ssh_client] = lambda: mock_ssh
|
||||||
app.dependency_overrides[get_user_id] = lambda: "user123"
|
app.dependency_overrides[get_user_id] = lambda: "user123"
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
@@ -111,17 +165,244 @@ class TestDashboard:
|
|||||||
assert data[0]["library"] == "Movies"
|
assert data[0]["library"] == "Movies"
|
||||||
assert data[1]["library"] == "TV Shows"
|
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")
|
response = test_client.get("/api/dashboard/now-playing")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data) == 1
|
assert len(data) == 2
|
||||||
assert data[0]["user"] == "alex"
|
|
||||||
assert data[0]["title"] == "Test Movie"
|
|
||||||
assert data[0]["transcoding"] == "yes"
|
|
||||||
assert "audio" in data[0]["transcoding_type"]
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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 ---
|
# --- Files ---
|
||||||
|
|
||||||
class TestFiles:
|
class TestFiles:
|
||||||
@@ -163,6 +444,135 @@ class TestFiles:
|
|||||||
assert "Permission denied" in response.text
|
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 ---
|
# --- Jobs ---
|
||||||
|
|
||||||
class TestJobs:
|
class TestJobs:
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ class TestSettings:
|
|||||||
assert settings.ssh_host == ""
|
assert settings.ssh_host == ""
|
||||||
assert settings.ssh_port == 22
|
assert settings.ssh_port == 22
|
||||||
assert settings.jellyfin_url == ""
|
assert settings.jellyfin_url == ""
|
||||||
|
assert settings.jellyseerr_url == ""
|
||||||
|
assert settings.log_level == "INFO"
|
||||||
assert settings.remote_media_root == ""
|
assert settings.remote_media_root == ""
|
||||||
|
|
||||||
def test_from_env(self):
|
def test_from_env(self):
|
||||||
env = {
|
env = {
|
||||||
"JELLYFIN_URL": "https://test.example.com",
|
"JELLYFIN_URL": "https://test.example.com",
|
||||||
"JELLYFIN_API_KEY": "key123",
|
"JELLYFIN_API_KEY": "key123",
|
||||||
|
"JELLYSEERR_URL": "https://requests.example.com",
|
||||||
|
"JELLYSEERR_API_KEY": "seerr123",
|
||||||
|
"LOG_LEVEL": "DEBUG",
|
||||||
"SSH_HOST": "192.168.1.1",
|
"SSH_HOST": "192.168.1.1",
|
||||||
"SSH_USERNAME": "testuser",
|
"SSH_USERNAME": "testuser",
|
||||||
"SSH_PORT": "2222",
|
"SSH_PORT": "2222",
|
||||||
@@ -27,6 +32,9 @@ class TestSettings:
|
|||||||
settings = Settings(_env_file=None)
|
settings = Settings(_env_file=None)
|
||||||
assert settings.jellyfin_url == "https://test.example.com"
|
assert settings.jellyfin_url == "https://test.example.com"
|
||||||
assert settings.jellyfin_api_key == "key123"
|
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_host == "192.168.1.1"
|
||||||
assert settings.ssh_username == "testuser"
|
assert settings.ssh_username == "testuser"
|
||||||
assert settings.ssh_port == 2222
|
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 pytest
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
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
|
@pytest.fixture
|
||||||
@@ -242,3 +247,126 @@ class TestMediaIndexMetadata:
|
|||||||
index.set_metadata("build_duration_seconds", "12.5")
|
index.set_metadata("build_duration_seconds", "12.5")
|
||||||
status = index.status()
|
status = index.status()
|
||||||
assert status.build_duration_seconds == 12.5
|
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.
|
- 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.
|
- 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
|
### Remote Filesystem over SSH
|
||||||
|
|
||||||
- Connect to a remote media server via SSH.
|
- Connect to a remote media server via SSH.
|
||||||
- Use strict SSH host key behavior; users should connect manually once to populate `known_hosts`.
|
- 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.
|
- 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`).
|
- 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 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.
|
- Support manual path entry and refresh.
|
||||||
- Remote file listing must be compact, structured, and navigable.
|
- 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
|
### Dashboard / Server Monitoring
|
||||||
|
|
||||||
- Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview.
|
- 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 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 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.
|
- 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 CPU and RAM usage for the last hour.
|
||||||
- Show IO wait percentage 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 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 `/`.
|
- 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`.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- Command template values must be shell-quoted before execution.
|
||||||
- Future destructive jobs should require explicit confirmation.
|
- 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
|
## Decision Log
|
||||||
|
|
||||||
### 2026-04-30 - Initial app plan
|
- 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.
|
||||||
- Planned a Streamlit app that uses the Jellyfin API as the primary metadata source.
|
- 2026-05-03: Combined network download/upload into one traffic chart and disk read/write into one I/O chart for clearer Monitoring layout.
|
||||||
- Decided SSH should be used for disk inspection and future maintenance jobs.
|
- 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-04-30 - Phase 1 implementation
|
- 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.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -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
|
- **Dashboard** (`/`) — Now playing, server overview, library stats
|
||||||
- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls
|
- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls
|
||||||
- **Media** (`/media`) — Full-library table with sort/filter/search
|
- **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
|
- **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs
|
||||||
|
|
||||||
## Environment Variables
|
## 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
+1421
-367
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -10,18 +10,23 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@tanstack/react-query": "^5.100.6",
|
||||||
"ag-grid-community": "^35.2.1",
|
"d3": "^7.9.0",
|
||||||
"ag-grid-react": "^35.2.1",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-oidc-context": "^3.3.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1"
|
||||||
"tailwindcss": "^4.2.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
+216
-42
@@ -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 { 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 { Dashboard } from "./pages/Dashboard";
|
||||||
import { Monitoring } from "./pages/Monitoring";
|
import { Monitoring } from "./pages/Monitoring";
|
||||||
import { Media } from "./pages/Media";
|
import { Media } from "./pages/Media";
|
||||||
|
import { UsersPage } from "./pages/Users";
|
||||||
import { FileBrowser } from "./pages/FileBrowser";
|
import { FileBrowser } from "./pages/FileBrowser";
|
||||||
|
import { getAppTheme } from "./theme";
|
||||||
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const navLinks = [
|
function Shell({
|
||||||
{ to: "/", label: "Dashboard" },
|
darkMode,
|
||||||
{ to: "/monitoring", label: "Monitoring" },
|
authLabel,
|
||||||
{ to: "/media", label: "Media" },
|
onSignOut,
|
||||||
{ to: "/files", label: "File Browser" },
|
}: {
|
||||||
];
|
darkMode: boolean;
|
||||||
|
authLabel?: string;
|
||||||
|
onSignOut?: () => void;
|
||||||
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
|
const current = location.pathname;
|
||||||
|
|
||||||
function NavBar() {
|
|
||||||
return (
|
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>
|
<CssBaseline />
|
||||||
{navLinks.map((link) => (
|
<AppBar position="sticky" color="inherit" elevation={0}>
|
||||||
<NavLink
|
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}>
|
||||||
key={link.to}
|
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}>
|
||||||
to={link.to}
|
Media Library Viewer
|
||||||
end={link.to === "/"}
|
</Typography>
|
||||||
className={({ isActive }) =>
|
<Tabs
|
||||||
`text-sm px-2 py-1 rounded ${isActive ? "bg-gray-100 font-medium" : "text-gray-600 hover:text-gray-900"}`
|
value={current}
|
||||||
}
|
textColor="primary"
|
||||||
|
indicatorColor="primary"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{link.label}
|
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
|
||||||
</NavLink>
|
<Tab
|
||||||
))}
|
value="/monitoring"
|
||||||
</nav>
|
label="Monitoring"
|
||||||
);
|
component={NavLink}
|
||||||
}
|
to="/monitoring"
|
||||||
|
/>
|
||||||
export default function App() {
|
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
||||||
return (
|
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
||||||
<QueryClientProvider client={queryClient}>
|
<Tab
|
||||||
<BrowserRouter>
|
value="/files"
|
||||||
<div className="min-h-screen bg-gray-50">
|
label="File Browser"
|
||||||
<NavBar />
|
component={NavLink}
|
||||||
<main className="max-w-screen-2xl mx-auto px-6 py-6">
|
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>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/monitoring" element={<Monitoring />} />
|
<Route path="/monitoring" element={<Monitoring />} />
|
||||||
<Route path="/media" element={<Media />} />
|
<Route path="/media" element={<Media />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/files" element={<FileBrowser />} />
|
<Route path="/files" element={<FileBrowser />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</Container>
|
||||||
</div>
|
</>
|
||||||
</BrowserRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <AppInner />;
|
||||||
|
}
|
||||||
|
|||||||
+98
-18
@@ -2,14 +2,20 @@
|
|||||||
* Typed API client for the FastAPI backend.
|
* Typed API client for the FastAPI backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getAccessToken } from "../auth";
|
||||||
import type {
|
import type {
|
||||||
MediaCounts,
|
MediaCounts,
|
||||||
LibraryCount,
|
LibraryCount,
|
||||||
|
UserDirectoryResponse,
|
||||||
|
UserMessageResponse,
|
||||||
|
UserMessageQueueStatus,
|
||||||
|
SmtpTestResponse,
|
||||||
NowPlayingSession,
|
NowPlayingSession,
|
||||||
MonitoringStatus,
|
MonitoringStatus,
|
||||||
MonitoringMetrics,
|
MonitoringMetrics,
|
||||||
DiskSpace,
|
DiskSpace,
|
||||||
MediaIndexStatus,
|
MediaIndexStatus,
|
||||||
|
MediaIndexActionResponse,
|
||||||
MediaQueryResponse,
|
MediaQueryResponse,
|
||||||
DirectoryListing,
|
DirectoryListing,
|
||||||
JobTemplate,
|
JobTemplate,
|
||||||
@@ -17,36 +23,89 @@ import type {
|
|||||||
ResolvedPath,
|
ResolvedPath,
|
||||||
} from "../types";
|
} 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);
|
const url = new URL(path, BASE_URL);
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== "") url.searchParams.set(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) {
|
if (!response.ok) {
|
||||||
const detail = await response.text();
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||||
throw new Error(`${response.status}: ${detail}`);
|
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post<T>(path: string, body?: unknown): Promise<T> {
|
async function post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const url = new URL(path, BASE_URL);
|
const response = await fetch(buildUrl(path), {
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: buildHeaders(true),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text();
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||||
throw new Error(`${response.status}: ${detail}`);
|
}
|
||||||
|
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();
|
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 fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
|
||||||
export const fetchLibraries = () =>
|
export const fetchLibraries = () =>
|
||||||
get<LibraryCount[]>("/api/dashboard/libraries");
|
get<LibraryCount[]>("/api/dashboard/libraries");
|
||||||
export const fetchNowPlaying = () =>
|
export const fetchActivity = () =>
|
||||||
get<NowPlayingSession[]>("/api/dashboard/now-playing");
|
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
|
// Monitoring
|
||||||
export const fetchMonitoringStatus = () =>
|
export const fetchMonitoringStatus = () =>
|
||||||
get<MonitoringStatus>("/api/monitoring/status");
|
get<MonitoringStatus>("/api/monitoring/status");
|
||||||
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
|
export const fetchMonitoringMetrics = (
|
||||||
|
lastSeconds?: number | null,
|
||||||
|
maxLines = 70_000,
|
||||||
|
) =>
|
||||||
get<MonitoringMetrics>("/api/monitoring/metrics", {
|
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 fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
|
||||||
export const startCollector = () =>
|
export const startCollector = () =>
|
||||||
@@ -77,7 +144,11 @@ export const restartCollector = () =>
|
|||||||
export const fetchMediaStatus = () =>
|
export const fetchMediaStatus = () =>
|
||||||
get<MediaIndexStatus>("/api/media/status");
|
get<MediaIndexStatus>("/api/media/status");
|
||||||
export const buildMediaIndex = () =>
|
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: {
|
export const queryMedia = (params: {
|
||||||
libraries?: string;
|
libraries?: string;
|
||||||
types?: string;
|
types?: string;
|
||||||
@@ -114,3 +185,12 @@ export const fetchJobTemplates = () =>
|
|||||||
get<JobTemplate[]>("/api/jobs/templates");
|
get<JobTemplate[]>("/api/jobs/templates");
|
||||||
export const runJob = (jobKey: string, path: string) =>
|
export const runJob = (jobKey: string, path: string) =>
|
||||||
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
|
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";
|
import type { LibraryCount } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,47 +10,47 @@ export function LibraryOverview({ libraries }: Props) {
|
|||||||
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<Grid container spacing={2}>
|
||||||
{movieLibs.length > 0 && (
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<div>
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
|
||||||
Movie libraries
|
Movie libraries
|
||||||
</p>
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
{movieLibs.map((lib) => (
|
{movieLibs.map((lib) => (
|
||||||
<div key={lib.library} className="rounded-lg border p-4 mb-2">
|
<Card key={lib.library} variant="outlined">
|
||||||
<p className="font-semibold">{lib.library}</p>
|
<CardContent>
|
||||||
<div className="flex gap-6 mt-2 text-sm">
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
<span>
|
{lib.library}
|
||||||
Total: <strong>{lib.total.toLocaleString()}</strong>
|
</Typography>
|
||||||
</span>
|
<Typography variant="body2" color="text.secondary">
|
||||||
<span>
|
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
||||||
Movies: <strong>{lib.movies.toLocaleString()}</strong>
|
{lib.movies.toLocaleString()}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
</Grid>
|
||||||
{tvLibs.length > 0 && (
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<div>
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
|
||||||
TV libraries
|
TV libraries
|
||||||
</p>
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
{tvLibs.map((lib) => (
|
{tvLibs.map((lib) => (
|
||||||
<div key={lib.library} className="rounded-lg border p-4 mb-2">
|
<Card key={lib.library} variant="outlined">
|
||||||
<p className="font-semibold">{lib.library}</p>
|
<CardContent>
|
||||||
<div className="flex gap-6 mt-2 text-sm">
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
<span>
|
{lib.library}
|
||||||
Total: <strong>{lib.total.toLocaleString()}</strong>
|
</Typography>
|
||||||
</span>
|
<Typography variant="body2" color="text.secondary">
|
||||||
<span>
|
Total: {lib.total.toLocaleString()} | Series:{" "}
|
||||||
Series: <strong>{lib.series.toLocaleString()}</strong>
|
{lib.series.toLocaleString()}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
</Grid>
|
||||||
</div>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Card, CardContent, Typography } from "@mui/material";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -6,14 +8,28 @@ interface Props {
|
|||||||
|
|
||||||
export function MetricCard({ label, value, subtext }: Props) {
|
export function MetricCard({ label, value, subtext }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-4">
|
<Card variant="outlined">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ textTransform: "uppercase" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
{subtext && (
|
{subtext && (
|
||||||
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ whiteSpace: "pre-line" }}
|
||||||
|
>
|
||||||
{subtext}
|
{subtext}
|
||||||
</p>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,70 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import * as d3 from "d3";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
Box,
|
||||||
Line,
|
Button,
|
||||||
XAxis,
|
Card,
|
||||||
YAxis,
|
CardContent,
|
||||||
CartesianGrid,
|
Checkbox,
|
||||||
Tooltip,
|
Chip,
|
||||||
ResponsiveContainer,
|
FormControlLabel,
|
||||||
Legend,
|
Grid,
|
||||||
} from "recharts";
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
import type { MonitoringSample } from "../types";
|
import type { MonitoringSample } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
samples: MonitoringSample[];
|
samples: MonitoringSample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts: number) {
|
type MetricKey =
|
||||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
| "cpu"
|
||||||
hour: "2-digit",
|
| "iowait"
|
||||||
minute: "2-digit",
|
| "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 {
|
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"];
|
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||||
let value = bytes;
|
let value = bytes;
|
||||||
let unitIdx = 0;
|
let unitIdx = 0;
|
||||||
@@ -33,147 +75,741 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonitoringCharts({ samples }: Props) {
|
function formatTime(ts: number) {
|
||||||
if (samples.length === 0) {
|
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 (
|
return (
|
||||||
<p className="text-sm text-gray-500">No monitoring samples available.</p>
|
<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) {
|
||||||
|
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 (
|
||||||
|
<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 (
|
return (
|
||||||
<div className="space-y-8">
|
<Box sx={{ width: "100%" }}>
|
||||||
<div>
|
{/* Toolbar */}
|
||||||
<h3 className="text-sm font-semibold mb-2">
|
<Box
|
||||||
CPU, IO Wait, and RAM - last hour
|
sx={{
|
||||||
</h3>
|
mb: 2,
|
||||||
<ResponsiveContainer width="100%" height={250}>
|
display: "flex",
|
||||||
<LineChart data={data}>
|
alignItems: "center",
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
justifyContent: "space-between",
|
||||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
flexWrap: "wrap",
|
||||||
<YAxis unit="%" domain={[0, 100]} />
|
gap: 2,
|
||||||
<Tooltip />
|
}}
|
||||||
<Legend />
|
>
|
||||||
<Line
|
<FormControlLabel
|
||||||
type="monotone"
|
control={
|
||||||
dataKey="cpu"
|
<Checkbox
|
||||||
name="CPU %"
|
checked={showAverages}
|
||||||
stroke="#2563eb"
|
onChange={(event) => setShowAverages(event.target.checked)}
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
/>
|
||||||
<Line
|
}
|
||||||
type="monotone"
|
label={`Show ${MOVING_AVG_WINDOW}-point moving average`}
|
||||||
dataKey="iowait"
|
|
||||||
name="IO Wait %"
|
|
||||||
stroke="#dc2626"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
/>
|
||||||
<Line
|
<Box
|
||||||
type="monotone"
|
sx={{
|
||||||
dataKey="mem"
|
display: "flex",
|
||||||
name="RAM %"
|
gap: 1,
|
||||||
stroke="#16a34a"
|
alignItems: "center",
|
||||||
dot={false}
|
flexWrap: "wrap",
|
||||||
strokeWidth={1.5}
|
}}
|
||||||
/>
|
>
|
||||||
</LineChart>
|
<Chip size="small" label={selectionLabel} variant="outlined" />
|
||||||
</ResponsiveContainer>
|
{zoomOptions.map((option) => (
|
||||||
</div>
|
<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">
|
{/* Shared brush slider above all graphs */}
|
||||||
<div>
|
<MonitoringBrush
|
||||||
<h3 className="text-sm font-semibold mb-2">Network download</h3>
|
data={baseData}
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
selectionRange={selectionRange}
|
||||||
<LineChart data={data}>
|
onBrush={setSelectionRange}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
{/* Chart grid */}
|
||||||
<div>
|
<Grid container spacing={3}>
|
||||||
<h3 className="text-sm font-semibold mb-2">Disk read</h3>
|
<Grid size={12}>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<MonitoringD3Chart
|
||||||
<LineChart data={data}>
|
title="CPU, IO Wait, and RAM"
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
data={displayData}
|
||||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
metrics={[
|
||||||
<YAxis tickFormatter={(v) => formatBytes(v)} />
|
{ key: "cpu", label: "CPU %", color: "#2563eb" },
|
||||||
<Tooltip formatter={(v) => formatBytes(Number(v))} />
|
{ key: "iowait", label: "IO Wait %", color: "#dc2626" },
|
||||||
<Line
|
{ key: "mem", label: "RAM %", color: "#16a34a" },
|
||||||
type="monotone"
|
]}
|
||||||
dataKey="disk_read"
|
showAverages={showAverages}
|
||||||
name="Read"
|
averages={averages}
|
||||||
stroke="#ea580c"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</Grid>
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<div>
|
<MonitoringD3Chart
|
||||||
<h3 className="text-sm font-semibold mb-2">Disk write</h3>
|
title="Network traffic"
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
data={displayData}
|
||||||
<LineChart data={data}>
|
metrics={[
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
{ key: "netDown", label: "Download", color: "#2563eb" },
|
||||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
{ key: "netUp", label: "Upload", color: "#9333ea" },
|
||||||
<YAxis tickFormatter={(v) => formatBytes(v)} />
|
]}
|
||||||
<Tooltip formatter={(v) => formatBytes(Number(v))} />
|
showAverages={showAverages}
|
||||||
<Line
|
averages={averages}
|
||||||
type="monotone"
|
yFormatter={formatBytes}
|
||||||
dataKey="disk_write"
|
|
||||||
name="Write"
|
|
||||||
stroke="#0891b2"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</Grid>
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
</div>
|
<MonitoringD3Chart
|
||||||
</div>
|
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 type { NowPlayingSession } from "../types";
|
||||||
|
import { SessionActivityPanel } from "./SessionActivityPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: NowPlayingSession[];
|
sessions: NowPlayingSession[];
|
||||||
|
onSelectSession?: (session: NowPlayingSession) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NowPlaying({ sessions }: Props) {
|
export function NowPlaying({ sessions, onSelectSession }: Props) {
|
||||||
if (sessions.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-gray-500">
|
<Card variant="outlined">
|
||||||
No active playback sessions right now.
|
<CardContent>
|
||||||
</p>
|
<SessionActivityPanel
|
||||||
);
|
sessions={sessions}
|
||||||
}
|
onSelectSession={onSelectSession}
|
||||||
|
emptyMessage="No recent user activity sessions right now."
|
||||||
return (
|
/>
|
||||||
<div className="overflow-x-auto">
|
</CardContent>
|
||||||
<table className="w-full text-sm border-collapse">
|
</Card>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client";
|
import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client";
|
||||||
|
|
||||||
export function useCounts() {
|
export function useCounts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -17,10 +17,13 @@ export function useLibraries() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNowPlaying() {
|
export function useActivity() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "now-playing"],
|
queryKey: ["dashboard", "activity"],
|
||||||
queryFn: fetchNowPlaying,
|
queryFn: fetchActivity,
|
||||||
refetchInterval: 15_000,
|
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 { 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() {
|
export function useMediaStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["media", "status"],
|
queryKey: ["media", "status"],
|
||||||
queryFn: fetchMediaStatus,
|
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;
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { enabled = true, ...queryParams } = params;
|
const { enabled = true, ...queryParams } = params;
|
||||||
|
|
||||||
|
// Feature: Sync file browser with selected media path
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["media", "query", queryParams],
|
queryKey: ["media", "query", queryParams],
|
||||||
queryFn: () => queryMedia(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() {
|
export function useBuildIndex() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: buildMediaIndex,
|
mutationFn: buildMediaIndex,
|
||||||
onSuccess: () => {
|
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({
|
return useQuery({
|
||||||
queryKey: ["monitoring", "metrics", lastSeconds],
|
queryKey: ["monitoring", "metrics"],
|
||||||
queryFn: () => fetchMonitoringMetrics(lastSeconds),
|
queryFn: () => fetchMonitoringMetrics(),
|
||||||
refetchInterval: 15_000,
|
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 { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
|
||||||
import { NowPlaying } from "../components/NowPlaying";
|
import { NowPlaying } from "../components/NowPlaying";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
@@ -20,78 +23,192 @@ function formatRate(bytes: number): string {
|
|||||||
return `${formatBytes(bytes)}/s`;
|
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() {
|
export function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: counts } = useCounts();
|
const { data: counts } = useCounts();
|
||||||
const { data: libraries } = useLibraries();
|
const { data: libraries } = useLibraries();
|
||||||
const { data: nowPlaying } = useNowPlaying();
|
const { data: activity } = useActivity();
|
||||||
const { data: metrics } = useMonitoringMetrics();
|
const { data: metrics } = useMonitoringMetrics();
|
||||||
const { data: disk } = useDiskSpace();
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<Stack spacing={3}>
|
||||||
{/* Now Playing */}
|
<Box>
|
||||||
<section>
|
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||||
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
|
Activity
|
||||||
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
|
</Typography>
|
||||||
</section>
|
{activity && (
|
||||||
|
<NowPlaying
|
||||||
|
sessions={activity}
|
||||||
|
onSelectSession={(session) =>
|
||||||
|
navigate(`/users?user=${encodeURIComponent(session.user)}`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<hr />
|
<Divider />
|
||||||
|
|
||||||
{/* Server Overview */}
|
<Box>
|
||||||
<section>
|
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||||
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
|
Monitoring Overview
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
</Typography>
|
||||||
|
<Grid container spacing={1.5}>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="CPU"
|
label="CPU (10m avg)"
|
||||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
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
|
<MetricCard
|
||||||
label="IO Wait"
|
label="IO Wait (10m avg)"
|
||||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
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
|
<MetricCard
|
||||||
label="RAM"
|
label="RAM (10m avg)"
|
||||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
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
|
<MetricCard
|
||||||
label="Net down"
|
label="Net down (10m avg)"
|
||||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
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
|
<MetricCard
|
||||||
label="Net up"
|
label="Net up (10m avg)"
|
||||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
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
|
<MetricCard
|
||||||
label="Disk read"
|
label="Disk read (10m avg)"
|
||||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
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
|
<MetricCard
|
||||||
label="Disk write"
|
label="Disk write (10m avg)"
|
||||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
|
||||||
|
subtext={
|
||||||
|
diskWriteSummary
|
||||||
|
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
{disk && (
|
{disk && (
|
||||||
<div className="mt-3 grid grid-cols-4 gap-3">
|
<Grid container spacing={1.5} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Disk available"
|
label="Disk available"
|
||||||
value={formatBytes(disk.available)}
|
value={formatBytes(disk.available)}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard label="Used %" value={disk.used_pct} />
|
<MetricCard label="Used %" value={disk.used_pct} />
|
||||||
</div>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Box>
|
||||||
|
|
||||||
<hr />
|
<Divider />
|
||||||
|
|
||||||
{/* Media Library Overview */}
|
<Box>
|
||||||
<section>
|
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||||
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
|
Library Stats
|
||||||
|
</Typography>
|
||||||
{counts && (
|
{counts && (
|
||||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
<Grid container spacing={1.5} sx={{ mb: 2 }}>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Total"
|
label="Total"
|
||||||
value={(
|
value={(
|
||||||
@@ -100,16 +217,29 @@ export function Dashboard() {
|
|||||||
counts.episodes
|
counts.episodes
|
||||||
).toLocaleString()}
|
).toLocaleString()}
|
||||||
/>
|
/>
|
||||||
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
|
</Grid>
|
||||||
<MetricCard label="Series" value={counts.series.toLocaleString()} />
|
<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
|
<MetricCard
|
||||||
label="Episodes"
|
label="Episodes"
|
||||||
value={counts.episodes.toLocaleString()}
|
value={counts.episodes.toLocaleString()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{libraries && <LibraryOverview libraries={libraries} />}
|
{libraries && <LibraryOverview libraries={libraries} />}
|
||||||
</section>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+670
-112
@@ -1,5 +1,23 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState } from "react";
|
||||||
import { AgGridReact } from "ag-grid-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 {
|
import {
|
||||||
useDirectoryListing,
|
useDirectoryListing,
|
||||||
useFfprobe,
|
useFfprobe,
|
||||||
@@ -8,6 +26,7 @@ import {
|
|||||||
} from "../hooks/useFiles";
|
} from "../hooks/useFiles";
|
||||||
|
|
||||||
interface DisplayRow {
|
interface DisplayRow {
|
||||||
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
ext: string;
|
ext: string;
|
||||||
@@ -16,6 +35,46 @@ interface DisplayRow {
|
|||||||
path: string;
|
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 {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return "-";
|
if (bytes === 0) return "-";
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
@@ -33,6 +92,45 @@ function formatTime(epoch: number): string {
|
|||||||
return new Date(epoch * 1000).toLocaleString();
|
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 {
|
function isVideoFile(name: string): boolean {
|
||||||
const exts = [
|
const exts = [
|
||||||
".mkv",
|
".mkv",
|
||||||
@@ -48,38 +146,446 @@ function isVideoFile(name: string): boolean {
|
|||||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileBrowser() {
|
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||||
const [currentDir, setCurrentDir] = useState("/");
|
const format = data.format ?? {};
|
||||||
const [pathInput, setPathInput] = useState("/");
|
const streams = data.streams ?? [];
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
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);
|
return (
|
||||||
const { data: ffprobeData } = useFfprobe(
|
<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 ?? "",
|
||||||
!!selectedPath && isVideoFile(selectedPath),
|
!!selectedPath && isVideoFile(selectedPath),
|
||||||
);
|
);
|
||||||
const { data: templates } = useJobTemplates();
|
const { data: templates } = useJobTemplates();
|
||||||
const runJob = useRunJob();
|
const runJob = useRunJob();
|
||||||
|
|
||||||
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
|
const navigate = (path: string) => {
|
||||||
|
|
||||||
const navigate = useCallback((path: string) => {
|
|
||||||
setCurrentDir(path);
|
setCurrentDir(path);
|
||||||
setPathInput(path);
|
setPathInput(path);
|
||||||
setSelectedPath(null);
|
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[] = [];
|
const rows: DisplayRow[] = [];
|
||||||
if (currentDir !== "/") {
|
if (currentDir !== "/") {
|
||||||
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
||||||
rows.push({
|
rows.push({
|
||||||
|
id: `up-${parent}`,
|
||||||
type: "up",
|
type: "up",
|
||||||
name: "..",
|
name: "..",
|
||||||
ext: "",
|
ext: "",
|
||||||
@@ -92,131 +598,183 @@ export function FileBrowser() {
|
|||||||
for (const entry of listing.entries) {
|
for (const entry of listing.entries) {
|
||||||
const kind = entry.type === "d" ? "dir" : "file";
|
const kind = entry.type === "d" ? "dir" : "file";
|
||||||
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
|
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
|
||||||
|
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
|
||||||
rows.push({
|
rows.push({
|
||||||
|
id: path,
|
||||||
type: kind,
|
type: kind,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
ext,
|
ext,
|
||||||
size: kind === "dir" ? "-" : formatSize(entry.size),
|
size: kind === "dir" ? "-" : formatSize(entry.size),
|
||||||
modified: formatTime(entry.mtime),
|
modified: formatTime(entry.mtime),
|
||||||
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
|
path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnDefs = [
|
const columns: GridColDef<DisplayRow>[] = [
|
||||||
{ field: "type" as const, headerName: "Type", width: 80 },
|
{ field: "type", headerName: "Type", width: 90 },
|
||||||
{ field: "name" as const, headerName: "Name", flex: 2 },
|
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
|
||||||
{ field: "ext" as const, headerName: "Ext", width: 80 },
|
{ field: "ext", headerName: "Ext", width: 90 },
|
||||||
{ field: "size" as const, headerName: "Size", width: 110 },
|
{ field: "size", headerName: "Size", width: 120 },
|
||||||
{ field: "modified" as const, headerName: "Modified", width: 180 },
|
{ field: "modified", headerName: "Modified", width: 190 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const onRowClicked = useCallback(
|
const rowSelectionModel: GridRowSelectionModel = selectedPath
|
||||||
(event: { data?: DisplayRow }) => {
|
? { type: "include", ids: new Set([selectedPath]) }
|
||||||
const row = event.data;
|
: { type: "include", ids: new Set() };
|
||||||
if (!row) return;
|
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||||
if (row.type === "dir" || row.type === "up") {
|
|
||||||
navigate(row.path);
|
|
||||||
} else {
|
|
||||||
setSelectedPath(row.path);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigate],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Stack spacing={2}>
|
||||||
{/* Path input */}
|
<Typography variant="h5">File Browser</Typography>
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
<Stack direction="row" spacing={1}>
|
||||||
type="text"
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Remote path"
|
||||||
value={pathInput}
|
value={pathInput}
|
||||||
onChange={(e) => setPathInput(e.target.value)}
|
onChange={(e) => setPathInput(e.target.value)}
|
||||||
onKeyDown={handlePathSubmit}
|
onKeyDown={handlePathSubmit}
|
||||||
className="border rounded px-3 py-1 text-sm flex-1"
|
|
||||||
placeholder="Remote path (press Enter to navigate)"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}>
|
||||||
onClick={() => navigate(pathInput || "/")}
|
Open
|
||||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
</Button>
|
||||||
>
|
<Button variant="outlined" onClick={() => refetch()}>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
{/* Status */}
|
<Typography variant="caption" color="text.secondary">
|
||||||
<div className="flex gap-6 text-xs text-gray-500">
|
Current: {currentDir}{" "}
|
||||||
<span>
|
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
|
||||||
Current: <code>{currentDir}</code>
|
{listing ? `| Entries: ${listing.count}` : ""}
|
||||||
</span>
|
</Typography>
|
||||||
{selectedPath && (
|
|
||||||
<span>
|
|
||||||
Selected: <code>{selectedPath}</code>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{listing && <span>Entries: {listing.count}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
|
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||||
|
|
||||||
{/* File listing grid */}
|
<Box
|
||||||
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
|
sx={{
|
||||||
<AgGridReact<DisplayRow>
|
height: 420,
|
||||||
ref={gridRef}
|
bgcolor: "background.paper",
|
||||||
rowData={rows}
|
border: 1,
|
||||||
columnDefs={columnDefs}
|
borderColor: "divider",
|
||||||
rowSelection="single"
|
borderRadius: 2,
|
||||||
onRowClicked={onRowClicked}
|
}}
|
||||||
loading={isLoading}
|
|
||||||
suppressCellFocus
|
|
||||||
animateRows={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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}
|
|
||||||
>
|
>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{selectedPath && isVideoFile(selectedPath) && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedPath && templates && templates.length > 0 && (
|
||||||
|
<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}
|
{tpl.name}
|
||||||
</button>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{runJob.data && (
|
||||||
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 260,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Exit: {runJob.data.exit_status}
|
Exit: {runJob.data.exit_status}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
{runJob.data.stdout}
|
{runJob.data.stdout}
|
||||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||||
</pre>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+317
-123
@@ -1,15 +1,49 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { AgGridReact } from "ag-grid-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 {
|
import {
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useBuildIndex,
|
useBuildIndex,
|
||||||
|
useStopBuildIndex,
|
||||||
|
useForceStopBuildIndex,
|
||||||
} from "../hooks/useMedia";
|
} from "../hooks/useMedia";
|
||||||
import type { MediaItem } from "../types";
|
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() {
|
export function Media() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: status } = useMediaStatus();
|
const { data: status } = useMediaStatus();
|
||||||
const buildIndex = useBuildIndex();
|
const buildIndex = useBuildIndex();
|
||||||
|
const stopBuildIndex = useStopBuildIndex();
|
||||||
|
const forceStopBuildIndex = useForceStopBuildIndex();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [types, setTypes] = useState("Movie,Episode");
|
const [types, setTypes] = useState("Movie,Episode");
|
||||||
@@ -30,181 +64,341 @@ export function Media() {
|
|||||||
enabled: status?.exists ?? false,
|
enabled: status?.exists ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const gridRef = useRef<AgGridReact<MediaItem>>(null);
|
const columns: GridColDef<MediaItem>[] = [
|
||||||
|
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
|
||||||
const columnDefs = [
|
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
|
||||||
{ field: "title" as const, headerName: "Title", minWidth: 150 },
|
{ field: "season", headerName: "Season", width: 90 },
|
||||||
{ field: "series" as const, headerName: "Series", minWidth: 120 },
|
{ field: "episode", headerName: "Episode", width: 100 },
|
||||||
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
|
{ field: "type", headerName: "Type", width: 100 },
|
||||||
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
|
{ field: "year", headerName: "Year", width: 90 },
|
||||||
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
|
{ field: "runtime_min", headerName: "Runtime", width: 110 },
|
||||||
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
|
{ field: "size", headerName: "Size", width: 120 },
|
||||||
{
|
{ field: "bitrate", headerName: "Bitrate", width: 130 },
|
||||||
field: "runtime_min" as const,
|
{ field: "hdr", headerName: "HDR", width: 80 },
|
||||||
headerName: "Runtime (min)",
|
{ field: "video", headerName: "Video codec", width: 130 },
|
||||||
maxWidth: 125,
|
{ field: "resolution", headerName: "Resolution", width: 120 },
|
||||||
},
|
{ field: "date_added", headerName: "Date added", width: 120 },
|
||||||
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
|
{ field: "library", headerName: "Library", width: 140 },
|
||||||
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
|
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
|
||||||
{ 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 onGridReady = useCallback(() => {
|
const rows = useMemo(
|
||||||
gridRef.current?.api?.sizeColumnsToFit();
|
() =>
|
||||||
}, []);
|
(queryResult?.items ?? []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: item.id || item.path,
|
||||||
|
})),
|
||||||
|
[queryResult],
|
||||||
|
);
|
||||||
|
|
||||||
const page = Math.floor(offset / limit) + 1;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<Stack spacing={2}>
|
||||||
{/* Status and controls */}
|
<Stack
|
||||||
<div className="flex items-center gap-4">
|
direction="row"
|
||||||
{status?.exists ? (
|
spacing={1.5}
|
||||||
<span className="text-sm text-gray-600">
|
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||||
Index: {status.item_count.toLocaleString()} items
|
|
||||||
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-amber-600">No index built yet.</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => buildIndex.mutate()}
|
|
||||||
disabled={buildIndex.isPending}
|
|
||||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{buildIndex.isPending ? "Building..." : "Build index"}
|
<Typography variant="h5">Media</Typography>
|
||||||
</button>
|
{status?.exists ? (
|
||||||
</div>
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Index: {status.item_count.toLocaleString()} items
|
||||||
|
{status.updated_at_label
|
||||||
|
? ` | updated ${status.updated_at_label}`
|
||||||
|
: ""}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Alert severity="warning" sx={{ py: 0 }}>
|
||||||
|
No index built yet.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => buildIndex.mutate()}
|
||||||
|
disabled={
|
||||||
|
buildIndex.isPending || buildRunning || buildCancelRequested
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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 */}
|
<Stack spacing={0.35}>
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
<Typography variant="caption" color="text.secondary">
|
||||||
<div>
|
Overall:{" "}
|
||||||
<label className="text-xs text-gray-500 block">Search</label>
|
{buildProgress != null
|
||||||
<input
|
? `${Math.round(buildProgress * 100)}%`
|
||||||
type="text"
|
: "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}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
}}
|
}}
|
||||||
className="border rounded px-2 py-1 text-sm w-48"
|
|
||||||
placeholder="Search title, series, path..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Grid>
|
||||||
<div>
|
<Grid size={{ xs: 6, md: 2 }}>
|
||||||
<label className="text-xs text-gray-500 block">Types</label>
|
<FormControl fullWidth size="small">
|
||||||
<select
|
<InputLabel>Types</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Types"
|
||||||
value={types}
|
value={types}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTypes(e.target.value);
|
setTypes(e.target.value);
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
}}
|
}}
|
||||||
className="border rounded px-2 py-1 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="Movie,Episode">Movies + Episodes</option>
|
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
|
||||||
<option value="Movie">Movies only</option>
|
<MenuItem value="Movie">Movies only</MenuItem>
|
||||||
<option value="Episode">Episodes only</option>
|
<MenuItem value="Episode">Episodes only</MenuItem>
|
||||||
<option value="Movie,Episode,Video">All video</option>
|
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</FormControl>
|
||||||
<div>
|
</Grid>
|
||||||
<label className="text-xs text-gray-500 block">HDR</label>
|
<Grid size={{ xs: 6, md: 2 }}>
|
||||||
<select
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>HDR</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="HDR"
|
||||||
value={hdrFilter}
|
value={hdrFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setHdrFilter(e.target.value);
|
setHdrFilter(e.target.value);
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
}}
|
}}
|
||||||
className="border rounded px-2 py-1 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="All">All</option>
|
<MenuItem value="All">All</MenuItem>
|
||||||
<option value="HDR only">HDR only</option>
|
<MenuItem value="HDR only">HDR only</MenuItem>
|
||||||
<option value="SDR/unknown only">SDR/unknown only</option>
|
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</FormControl>
|
||||||
<div>
|
</Grid>
|
||||||
<label className="text-xs text-gray-500 block">Sort</label>
|
<Grid size={{ xs: 6, md: 2 }}>
|
||||||
<select
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Sort</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Sort"
|
||||||
value={sortKey}
|
value={sortKey}
|
||||||
onChange={(e) => setSortKey(e.target.value)}
|
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>
|
["title", "Title"],
|
||||||
<option value="size">Size</option>
|
["series", "Series"],
|
||||||
<option value="bitrate">Bitrate</option>
|
["size", "Size"],
|
||||||
<option value="runtime">Runtime</option>
|
["bitrate", "Bitrate"],
|
||||||
<option value="year">Year</option>
|
["runtime", "Runtime"],
|
||||||
<option value="date_added">Date added</option>
|
["year", "Year"],
|
||||||
<option value="resolution">Resolution</option>
|
["date_added", "Date added"],
|
||||||
</select>
|
["resolution", "Resolution"],
|
||||||
</div>
|
].map(([k, l]) => (
|
||||||
<div>
|
<MenuItem key={k} value={k}>
|
||||||
<label className="text-xs text-gray-500 block">Order</label>
|
{l}
|
||||||
<select
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 2 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Order</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Order"
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(e.target.value)}
|
onChange={(e) => setSortOrder(e.target.value)}
|
||||||
className="border rounded px-2 py-1 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="Ascending">Ascending</option>
|
<MenuItem value="Ascending">Ascending</MenuItem>
|
||||||
<option value="Descending">Descending</option>
|
<MenuItem value="Descending">Descending</MenuItem>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</FormControl>
|
||||||
</div>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Results info */}
|
|
||||||
{queryResult && (
|
{queryResult && (
|
||||||
<p className="text-xs text-gray-500">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Showing {queryResult.items.length} of{" "}
|
Showing {queryResult.items.length} of{" "}
|
||||||
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
|
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
|
||||||
{totalPages}
|
{totalPages}
|
||||||
</p>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AG Grid table */}
|
|
||||||
{status?.exists && (
|
{status?.exists && (
|
||||||
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
|
<Box
|
||||||
<AgGridReact<MediaItem>
|
sx={{
|
||||||
ref={gridRef}
|
height: 640,
|
||||||
rowData={queryResult?.items ?? []}
|
bgcolor: "background.paper",
|
||||||
columnDefs={columnDefs}
|
border: 1,
|
||||||
rowSelection="single"
|
borderColor: "divider",
|
||||||
onGridReady={onGridReady}
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
suppressCellFocus
|
checkboxSelection={false}
|
||||||
animateRows={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 && (
|
{queryResult && totalPages > 1 && (
|
||||||
<div className="flex gap-2 items-center">
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</Button>
|
||||||
<span className="text-sm">
|
<Typography variant="body2">
|
||||||
Page {page} / {totalPages}
|
Page {page} / {totalPages}
|
||||||
</span>
|
</Typography>
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
onClick={() => setOffset(offset + limit)}
|
onClick={() => setOffset(offset + limit)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
useMonitoringStatus,
|
useMonitoringStatus,
|
||||||
useMonitoringMetrics,
|
useMonitoringMetrics,
|
||||||
@@ -32,7 +41,6 @@ export function Monitoring() {
|
|||||||
const samples = metrics?.samples ?? [];
|
const samples = metrics?.samples ?? [];
|
||||||
const latest = samples.at(-1);
|
const latest = samples.at(-1);
|
||||||
|
|
||||||
// Compute averages and peaks
|
|
||||||
const avg = (arr: number[]) =>
|
const avg = (arr: number[]) =>
|
||||||
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||||
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 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);
|
const diskWriteArr = samples.map((s) => s.disk_write_bps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<Stack spacing={3}>
|
||||||
{/* Controls */}
|
<Stack
|
||||||
<section className="flex items-center gap-4">
|
direction="row"
|
||||||
<span className="text-sm text-gray-600">
|
spacing={1.5}
|
||||||
Collector:{" "}
|
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||||
<code className="bg-gray-100 px-1 rounded">
|
>
|
||||||
{status?.status ?? "unknown"}
|
<Typography variant="h5">Monitoring</Typography>
|
||||||
</code>
|
<Chip
|
||||||
</span>
|
label={status?.status ?? "unknown"}
|
||||||
<button
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
onClick={() => start.mutate()}
|
onClick={() => start.mutate()}
|
||||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
disabled={start.isPending}
|
||||||
>
|
>
|
||||||
Start
|
Start
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
onClick={() => restart.mutate()}
|
onClick={() => restart.mutate()}
|
||||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
disabled={restart.isPending}
|
||||||
>
|
>
|
||||||
Restart
|
Restart
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
onClick={() => stop.mutate()}
|
onClick={() => stop.mutate()}
|
||||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
disabled={stop.isPending}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</Button>
|
||||||
</section>
|
</Stack>
|
||||||
|
|
||||||
{/* Metrics summary */}
|
<Grid container spacing={1.5}>
|
||||||
<section>
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="CPU now"
|
label="CPU now"
|
||||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||||
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="IO Wait"
|
label="IO Wait"
|
||||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||||
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="RAM now"
|
label="RAM now"
|
||||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||||
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Net down"
|
label="Net down"
|
||||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||||
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Net up"
|
label="Net up"
|
||||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||||
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Disk read"
|
label="Disk read"
|
||||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||||
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Disk write"
|
label="Disk write"
|
||||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||||
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Grid>
|
||||||
</section>
|
</Grid>
|
||||||
|
|
||||||
{/* Disk space */}
|
|
||||||
{disk && (
|
{disk && (
|
||||||
<section>
|
<Grid container spacing={1.5}>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Disk available"
|
label="Disk available"
|
||||||
value={formatBytes(disk.available)}
|
value={formatBytes(disk.available)}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<MetricCard label="Used %" value={disk.used_pct} />
|
<MetricCard label="Used %" value={disk.used_pct} />
|
||||||
</div>
|
</Grid>
|
||||||
</section>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Charts */}
|
<Divider />
|
||||||
<section>
|
<Box>
|
||||||
<MonitoringCharts samples={samples} />
|
<MonitoringCharts samples={samples} />
|
||||||
</section>
|
</Box>
|
||||||
</div>
|
</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;
|
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 {
|
export interface NowPlayingSession {
|
||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -65,6 +158,48 @@ export interface MediaIndexStatus {
|
|||||||
updated_at: number | null;
|
updated_at: number | null;
|
||||||
updated_at_label: string;
|
updated_at_label: string;
|
||||||
build_duration_seconds: number | null;
|
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 {
|
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);
|
||||||
|
});
|
||||||
+16
-5
@@ -1,12 +1,23 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react(), tailwindcss()],
|
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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8000",
|
"/api": {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user