diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb3652f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/dist +**/.vite +**/__pycache__ +**/*.pyc +.git +.env diff --git a/.env.example b/.env.example index 76548d5..6818241 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,24 @@ JELLYFIN_API_KEY=your-api-key # Optional if /Users works with your API key. Otherwise set the id of the Jellyfin user whose library views should be shown. JELLYFIN_USER_ID= +# Optional Jellyseerr enrichment for the Users tab. +JELLYSEERR_URL=https://requests.example.com +JELLYSEERR_API_KEY=your-jellyseerr-api-key + +# Optional logging level for backend diagnostics. +LOG_LEVEL=INFO + +# Optional SMTP settings for the Users -> message popup. +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=your-smtp-username +SMTP_PASSWORD=your-smtp-password +SMTP_FROM_ADDRESS=no-reply@example.com +SMTP_FROM_NAME=Media Library Viewer +SMTP_USE_TLS=true +SMTP_USE_SSL=false +SMTP_TIMEOUT=30 + SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_PORT=22 @@ -12,3 +30,19 @@ REMOTE_MEDIA_ROOT=/mnt/media # Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough. # Example: Jellyfin gives /media/... but SSH host requires /srv/media/... REMOTE_PATH_PREFIX= + +# Authentik / OIDC +# Backend validates every API request with a Bearer JWT. +AUTH_ENABLED=true +OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/ +OIDC_AUDIENCE=media-library-viewer +OIDC_JWKS_URL= +OIDC_CLOCK_SKEW_SECONDS=30 + +# Frontend OIDC settings (Vite build/runtime env) +VITE_OIDC_ENABLED=true +VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/ +VITE_OIDC_CLIENT_ID=media-library-viewer +VITE_OIDC_SCOPE=openid profile email +VITE_OIDC_REDIRECT_URI=http://localhost:8080/ +VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/ diff --git a/README.md b/README.md index b4a5f65..e04384e 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,32 @@ The project consists of two subprojects: - Dashboard with now-playing sessions, server monitoring overview, and per-library media counts - Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts - SQLite-indexed media table with full-library sort/filter +- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment - Remote file browser with ffprobe preview and job execution -- Jellyfin API integration for library metadata +- Jellyfin API integration for library metadata and user identity data - SSH-based file inspection and remote job templates ## Quick Start -### Backend +### Docker Compose (recommended) + +Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend: + +```bash +docker compose up --build +``` + +Open the app at http://localhost:8080. + +Local development with hot reload: + +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +Frontend runs on http://localhost:5173 and the backend on http://localhost:8000. + +### Manual backend/frontend development ```bash cd backend @@ -39,16 +58,12 @@ pip install -e '.[dev]' uvicorn media_library_viewer_api.main:app --reload --port 8000 ``` -### Frontend - ```bash cd frontend npm install npm run dev ``` -Frontend runs on http://localhost:5173 and proxies API requests to http://localhost:8000. - ## Configuration Create a `.env` file in the project root: @@ -58,6 +73,13 @@ JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_API_KEY=your-api-key JELLYFIN_USER_ID= +# Optional Jellyseerr enrichment for the Users tab +JELLYSEERR_URL=https://requests.example.com +JELLYSEERR_API_KEY=your-jellyseerr-api-key + +# Optional backend logging level +LOG_LEVEL=INFO + SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_PORT=22 @@ -66,6 +88,21 @@ SSH_PASSWORD= REMOTE_MEDIA_ROOT=/srv/media REMOTE_PATH_PREFIX= + +# Authentik / OIDC +AUTH_ENABLED=true +OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/ +OIDC_AUDIENCE=media-library-viewer +OIDC_JWKS_URL= +OIDC_CLOCK_SKEW_SECONDS=30 + +# Frontend OIDC settings +VITE_OIDC_ENABLED=true +VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/ +VITE_OIDC_CLIENT_ID=media-library-viewer +VITE_OIDC_SCOPE=openid profile email +VITE_OIDC_REDIRECT_URI=http://localhost:8080/ +VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/ ``` ## Remote server requirements @@ -98,3 +135,4 @@ cd frontend && npx tsc --noEmit && npm run build - SSH commands run through `/bin/sh -c` regardless of remote login shell. - Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`. - Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines. +- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`). diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b8a7f06 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/README.md b/backend/README.md index a5be669..0848273 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend - Media Library Viewer API -FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, and server monitoring. +FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. ## Project structure @@ -21,10 +21,12 @@ backend/ │ │ ├── dashboard.py │ │ ├── monitoring.py │ │ ├── media.py +│ │ ├── users.py │ │ ├── files.py │ │ └── jobs.py │ ├── clients/ │ │ ├── jellyfin.py +│ │ ├── jellyseerr.py │ │ ├── resources.py │ │ └── ssh.py │ ├── domain/ @@ -52,6 +54,20 @@ JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_API_KEY=your-api-key JELLYFIN_USER_ID= +# Optional Jellyseerr enrichment for the Users tab +JELLYSEERR_URL=https://requests.example.com +JELLYSEERR_API_KEY=your-jellyseerr-api-key + +# Optional backend logging level +LOG_LEVEL=INFO + +# Authentik / OIDC +AUTH_ENABLED=true +OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/ +OIDC_AUDIENCE=media-library-viewer +OIDC_JWKS_URL= +OIDC_CLOCK_SKEW_SECONDS=30 + SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_PORT=22 @@ -77,6 +93,10 @@ PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000 API docs available at: http://localhost:8000/docs +## Docker + +The repository root includes a production `docker-compose.yml` and a development `docker-compose.dev.yml`. + ## API Endpoints - `GET /api/dashboard/counts` — Movie/series/episode totals @@ -96,3 +116,4 @@ API docs available at: http://localhost:8000/docs - `GET /api/files/resolve-path?path=` — Path resolution - `GET /api/jobs/templates` — Available jobs - `POST /api/jobs/run` — Execute a job +- `GET /api/users` — Jellyfin users with optional Jellyseerr enrichment diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 405c06b..627145a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "requests>=2.28", "python-dotenv>=1.0", "pandas>=2.0", + "PyJWT[crypto]>=2.8", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/backend/src/media_library_viewer_api/auth.py b/backend/src/media_library_viewer_api/auth.py new file mode 100644 index 0000000..a4a4d93 --- /dev/null +++ b/backend/src/media_library_viewer_api/auth.py @@ -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) diff --git a/backend/src/media_library_viewer_api/clients/jellyfin.py b/backend/src/media_library_viewer_api/clients/jellyfin.py index d339389..90063a1 100644 --- a/backend/src/media_library_viewer_api/clients/jellyfin.py +++ b/backend/src/media_library_viewer_api/clients/jellyfin.py @@ -7,10 +7,13 @@ future FastAPI/React frontend can reuse the same client. from __future__ import annotations -from typing import Any +import logging +from typing import Any, cast import requests +logger = logging.getLogger(__name__) + # Jellyfin validates Fields against its ItemFields enum. Keep this list to # documented/commonly supported optional fields; invalid names cause 400s. @@ -54,9 +57,10 @@ class JellyfinClient: } ) - def get(self, path: str, **params: Any) -> dict[str, Any]: + def get(self, path: str, **params: Any) -> Any: """GET a Jellyfin endpoint and include useful response text on errors.""" clean_params = {k: v for k, v in params.items() if v is not None and v != ""} + logger.debug("Jellyfin GET %s params=%s", path, sorted(clean_params.keys())) response = self.session.get( f"{self.base_url}{path}", params=clean_params, timeout=self.timeout ) @@ -64,10 +68,12 @@ class JellyfinClient: response.raise_for_status() except requests.HTTPError as exc: detail = response.text[:500] + logger.warning("Jellyfin GET %s failed status=%s url=%s", path, response.status_code, response.url) raise requests.HTTPError( f"{response.status_code} for {response.url}: {detail}", response=response, ) from exc + logger.debug("Jellyfin GET %s ok status=%s", path, response.status_code) return response.json() def users(self) -> list[dict[str, Any]]: @@ -77,11 +83,15 @@ class JellyfinClient: /Users/Me often fails with API-key auth. The user id selected here is then used for user-scoped library endpoints. """ - return self.get("/Users") + users = self.get("/Users") + logger.info("Jellyfin returned %s visible users", len(users)) + return users def libraries(self, user_id: str) -> list[dict[str, Any]]: """Return top-level library views visible to the selected Jellyfin user.""" - return self.get(f"/Users/{user_id}/Views").get("Items", []) + items = self.get(f"/Users/{user_id}/Views").get("Items", []) + logger.info("Jellyfin returned %s libraries for user_id=%s", len(items), user_id) + return items def items( self, @@ -101,6 +111,17 @@ class JellyfinClient: builder. Keep arguments close to Jellyfin's own query parameters so the service layer can request server-side pagination and basic sorting. """ + logger.debug( + "Jellyfin items user_id=%s parent_id=%s start=%s limit=%s types=%s search=%s sort=%s/%s", + user_id, + parent_id or "", + start_index, + limit, + include_item_types or "", + search or "", + sort_by, + sort_order, + ) return self.get( f"/Users/{user_id}/Items", ParentId=parent_id, @@ -123,7 +144,15 @@ class JellyfinClient: IncludeItemTypes=include_item_types, Limit=0, ) - return int(response.get("TotalRecordCount", 0)) + count = int(response.get("TotalRecordCount", 0)) + logger.debug( + "Jellyfin item count user_id=%s parent_id=%s types=%s count=%s", + user_id, + parent_id or "", + include_item_types, + count, + ) + return count def media_counts(self, user_id: str) -> dict[str, int]: """Return dashboard-level counts for the main media types.""" @@ -156,11 +185,20 @@ class JellyfinClient: }) return results + def sessions(self, active_within_seconds: int | None = None) -> list[dict[str, Any]]: + """Return Jellyfin sessions (playing and idle/logged-in). + + When ``active_within_seconds`` is None, no recency filter is sent and + Jellyfin decides which sessions to include. + """ + payload: Any = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds) + return cast(list[dict[str, Any]], payload) if isinstance(payload, list) else [] + def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]: - """Return currently active sessions that have a now-playing item.""" - payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds) - sessions = payload if isinstance(payload, list) else [] - return [session for session in sessions if session.get("NowPlayingItem")] + """Return sessions that currently have a now-playing item.""" + sessions = [session for session in self.sessions(active_within_seconds) if session.get("NowPlayingItem")] + logger.info("Jellyfin active sessions within %ss: %s", active_within_seconds, len(sessions)) + return sessions def image_url(self, item_id: str, image_type: str = "Primary") -> str: """Build an authenticated image URL suitable for st.image/browser use.""" diff --git a/backend/src/media_library_viewer_api/clients/jellyseerr.py b/backend/src/media_library_viewer_api/clients/jellyseerr.py new file mode 100644 index 0000000..6de945e --- /dev/null +++ b/backend/src/media_library_viewer_api/clients/jellyseerr.py @@ -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 diff --git a/backend/src/media_library_viewer_api/clients/resources.py b/backend/src/media_library_viewer_api/clients/resources.py index b3862ea..ad88fa2 100644 --- a/backend/src/media_library_viewer_api/clients/resources.py +++ b/backend/src/media_library_viewer_api/clients/resources.py @@ -9,12 +9,15 @@ Lines. This module starts/stops the collector and reads those JSONL samples. from __future__ import annotations import json +import logging import shlex from dataclasses import dataclass from typing import Any from media_library_viewer_api.clients.ssh import RemoteSSHClient +logger = logging.getLogger(__name__) + # POSIX shell script copied to the remote server by start_resource_collector(). # Keep this script bash-free because many NAS/media servers have minimal shells. COLLECTOR_SCRIPT = r'''#!/bin/sh @@ -199,9 +202,14 @@ else echo "started pid=$(cat {shlex.quote(paths.pid_file)})" fi """ + logger.info( + "Starting remote resource collector interval=%ss retention=%ss max_lines=%s", interval_seconds, retention_seconds, max_lines + ) result = ssh.run(command, timeout=20) if result.exit_status != 0: + logger.warning("Failed to start remote resource collector: %s", result.stderr or result.stdout) raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector") + logger.info("Remote resource collector start response: %s", result.stdout.strip()) return result.stdout.strip() @@ -221,9 +229,12 @@ else echo "not running" fi """ + logger.info("Stopping remote resource collector") result = ssh.run(command, timeout=20) if result.exit_status != 0: + logger.warning("Failed to stop remote resource collector: %s", result.stderr or result.stdout) raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector") + logger.info("Remote resource collector stop response: %s", result.stdout.strip()) return result.stdout.strip() @@ -234,6 +245,7 @@ def restart_resource_collector( max_lines: int = 70_000, paths: ResourceMonitorPaths = ResourceMonitorPaths(), ) -> str: + logger.info("Restarting remote resource collector") stop_message = stop_resource_collector(ssh, paths) start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths) return f"{stop_message}\n{start_message}" @@ -250,8 +262,11 @@ fi """ result = ssh.run(command, timeout=10) if result.exit_status != 0: + logger.warning("Failed to read collector status: %s", result.stderr or result.stdout) raise RuntimeError(result.stderr or result.stdout or "failed to check collector status") - return result.stdout.strip() + status = result.stdout.strip() + logger.info("Resource collector status: %s", status) + return status def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str: @@ -281,15 +296,20 @@ if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_ echo "netdev_snapshot:" cat /proc/net/dev 2>&1 || true """ + logger.info("Collecting resource collector diagnostics") result = ssh.run(command, timeout=20) - return (result.stdout or "") + (result.stderr or "") + output = (result.stdout or "") + (result.stderr or "") + logger.debug("Resource collector diagnostics length=%s", len(output)) + return output def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]: """Read recent JSONL metric samples from the remote collector file.""" command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true" + logger.debug("Reading up to %s resource metric lines", max_lines) result = ssh.run(command, timeout=20) if result.exit_status != 0: + logger.warning("Failed to read resource metrics: %s", result.stderr or result.stdout) raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics") rows = [] for line in result.stdout.splitlines(): @@ -310,7 +330,11 @@ def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]: + shlex.quote(path or "/") + " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'" ) + logger.debug("Reading disk space for path=%s", path) result = ssh.run(command, timeout=20) if result.exit_status != 0 or not result.stdout.strip(): + logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout) raise RuntimeError(result.stderr or result.stdout or "failed to read disk space") - return json.loads(result.stdout) + data = json.loads(result.stdout) + logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct")) + return data diff --git a/backend/src/media_library_viewer_api/clients/ssh.py b/backend/src/media_library_viewer_api/clients/ssh.py index 0443b14..d71458e 100644 --- a/backend/src/media_library_viewer_api/clients/ssh.py +++ b/backend/src/media_library_viewer_api/clients/ssh.py @@ -10,6 +10,7 @@ commands are shell-quoted by callers. This is important for two reasons: from __future__ import annotations import json +import logging import posixpath import shlex from dataclasses import dataclass @@ -17,6 +18,8 @@ from typing import Any import paramiko +logger = logging.getLogger(__name__) + @dataclass class CommandResult: @@ -88,14 +91,25 @@ class RemoteSSHClient: """ client = self.connect() shell_command = f"/bin/sh -c {shlex.quote(command)}" + logger.debug("SSH run host=%s timeout=%s command=%s", self.host, timeout or self.timeout, command) stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout) exit_status = stdout.channel.recv_exit_status() - return CommandResult( + result = CommandResult( command=command, exit_status=exit_status, stdout=stdout.read().decode(errors="replace"), stderr=stderr.read().decode(errors="replace"), ) + if result.exit_status == 0: + logger.debug("SSH command ok host=%s exit_status=%s", self.host, result.exit_status) + else: + logger.warning( + "SSH command failed host=%s exit_status=%s stderr=%s", + self.host, + result.exit_status, + result.stderr.strip() or result.stdout.strip(), + ) + return result def list_dir(self, path: str) -> CommandResult: """List one remote directory as JSON. @@ -123,12 +137,16 @@ class RemoteSSHClient: "print(json.dumps(rows))" ) ) - return self.run(command) + result = self.run(command) + logger.info("SSH list_dir path=%s exit_status=%s", path, result.exit_status) + return result def stat_path(self, path: str) -> CommandResult: """Run stat for a remote file or directory path.""" quoted = shlex.quote(path) - return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}") + result = self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}") + logger.info("SSH stat path=%s exit_status=%s", path, result.exit_status) + return result def ffprobe_json(self, path: str) -> dict[str, Any]: """Run ffprobe and parse JSON output for a remote media file.""" @@ -137,6 +155,7 @@ class RemoteSSHClient: "ffprobe -v error -show_format -show_streams -print_format json " + quoted, timeout=60, ) + logger.info("SSH ffprobe path=%s exit_status=%s", path, result.exit_status) if result.exit_status != 0: raise RuntimeError(result.stderr or result.stdout or "ffprobe failed") return json.loads(result.stdout) diff --git a/backend/src/media_library_viewer_api/config.py b/backend/src/media_library_viewer_api/config.py index 01539e3..66940c3 100644 --- a/backend/src/media_library_viewer_api/config.py +++ b/backend/src/media_library_viewer_api/config.py @@ -7,11 +7,16 @@ JELLYFIN_URL etc. are picked up directly without nested-model complications. from __future__ import annotations +import logging from functools import lru_cache from pathlib import Path from pydantic_settings import BaseSettings +from media_library_viewer_api.logging_utils import describe_settings + +logger = logging.getLogger(__name__) + class Settings(BaseSettings): """Flat application settings read from env vars / .env file.""" @@ -21,6 +26,31 @@ class Settings(BaseSettings): jellyfin_api_key: str = "" jellyfin_user_id: str = "" + # Jellyseerr (optional) + jellyseerr_url: str = "" + jellyseerr_api_key: str = "" + + # Logging + log_level: str = "INFO" + + # Auth / OIDC (Authentik-compatible JWT validation) + auth_enabled: bool = False + oidc_issuer_url: str = "" + oidc_audience: str = "" + oidc_jwks_url: str = "" + oidc_clock_skew_seconds: int = 30 + + # SMTP (optional, used for Users -> message popup) + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_from_address: str = "" + smtp_from_name: str = "Media Library Viewer" + smtp_use_tls: bool = True + smtp_use_ssl: bool = False + smtp_timeout: int = 30 + # SSH ssh_host: str = "" ssh_username: str = "" @@ -61,6 +91,10 @@ def _find_env_file() -> str | None: def get_settings() -> Settings: """Return a cached Settings instance.""" env_file = _find_env_file() - if env_file: - return Settings(_env_file=env_file) - return Settings() + settings = Settings(_env_file=env_file) if env_file else Settings() + logger.info( + "Loaded backend settings from %s: %s", + env_file or "environment/defaults", + describe_settings(settings), + ) + return settings diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index 8e723f2..c9ab52b 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -6,24 +6,53 @@ dependency system. Uses lru_cache so connections are reused across requests. from __future__ import annotations +import logging from functools import lru_cache from media_library_viewer_api.clients.jellyfin import JellyfinClient +from media_library_viewer_api.clients.jellyseerr import JellyseerrClient from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.config import get_settings +from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue + +logger = logging.getLogger(__name__) @lru_cache def get_jellyfin_client() -> JellyfinClient: """Return a cached Jellyfin client.""" settings = get_settings() + logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "") 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 "") + return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key) + + @lru_cache def get_ssh_client() -> RemoteSSHClient: """Return a cached SSH client (connects on first use).""" settings = get_settings() + logger.info( + "Creating SSH client host=%s user=%s port=%s key=%s password=%s", + settings.ssh_host or "", + settings.ssh_username or "", + settings.ssh_port, + settings.ssh_key_filename or "", + "set" if settings.ssh_password else "missing", + ) client = RemoteSSHClient( host=settings.ssh_host, username=settings.ssh_username, @@ -31,10 +60,19 @@ def get_ssh_client() -> RemoteSSHClient: key_filename=settings.ssh_key_filename or None, password=settings.ssh_password or None, ) - client.connect() + try: + client.connect() + except Exception: + logger.exception("Failed to establish SSH connection to %s", settings.ssh_host or "") + raise return client +def get_mail_queue() -> MailQueue: + """Return the singleton background email queue.""" + return _get_mail_queue() + + def get_user_id() -> str: """Return the configured Jellyfin user ID, or discover the first available user.""" settings = get_settings() diff --git a/backend/src/media_library_viewer_api/jobs.py b/backend/src/media_library_viewer_api/jobs.py index d9c411b..aad7e3c 100644 --- a/backend/src/media_library_viewer_api/jobs.py +++ b/backend/src/media_library_viewer_api/jobs.py @@ -7,12 +7,15 @@ confirmations/dry-runs. from __future__ import annotations +import logging import shlex from dataclasses import dataclass from typing import Mapping from media_library_viewer_api.clients.ssh import CommandResult, RemoteSSHClient +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class JobTemplate: @@ -56,4 +59,5 @@ def run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout: int = 600) - """Render and execute a configured job template for a selected remote path.""" template = JOB_TEMPLATES[job_key] command = template.render({"path": path}) + logger.info("Executing job template key=%s path=%s timeout=%s", job_key, path, timeout) return ssh.run(command, timeout=timeout) diff --git a/backend/src/media_library_viewer_api/logging_utils.py b/backend/src/media_library_viewer_api/logging_utils.py new file mode 100644 index 0000000..42eeed9 --- /dev/null +++ b/backend/src/media_library_viewer_api/logging_utils.py @@ -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 "" + 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 "", + "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 "", + "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 "", + "ssh_username": getattr(settings, "ssh_username", "") or "", + "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 "", + "smtp_from_name": getattr(settings, "smtp_from_name", "") or "", + "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 "", + "remote_path_prefix": getattr(settings, "remote_path_prefix", "") or "", + } diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 0171b79..376015e 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -2,25 +2,41 @@ from __future__ import annotations +import logging +import time from contextlib import asynccontextmanager import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs +from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings +from media_library_viewer_api.config import get_settings +from media_library_viewer_api.logging_utils import configure_logging, describe_settings +from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users +from media_library_viewer_api.dependencies import get_mail_queue + +logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan — startup/shutdown.""" + settings = get_settings() + configure_logging(settings.log_level) + validate_auth_settings(settings) + logger.info("Backend startup complete: %s", describe_settings(settings)) + mail_queue = get_mail_queue() + mail_queue.start() yield + mail_queue.stop() + logger.info("Backend shutdown complete") app = FastAPI( title="Media Library Viewer API", version="0.1.0", - description="Backend API for Jellyfin media browsing, SSH file inspection, and server monitoring.", + description="Backend API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access.", lifespan=lifespan, ) @@ -38,17 +54,45 @@ app.add_middleware( allow_headers=["*"], ) +@app.middleware("http") +async def enforce_jwt_auth(request: Request, call_next): + return await require_jwt_auth(request, call_next) + +@app.middleware("http") +async def log_requests(request: Request, call_next): + """Log every API request with timing and outcome.""" + start = time.perf_counter() + client_host = request.client.host if request.client else "unknown" + logger.info("request start %s %s client=%s", request.method, request.url.path, client_host) + try: + response = await call_next(request) + except Exception: + logger.exception("request error %s %s client=%s", request.method, request.url.path, client_host) + raise + elapsed_ms = (time.perf_counter() - start) * 1000.0 + logger.info( + "request end %s %s status=%s elapsed_ms=%.1f", + request.method, + request.url.path, + response.status_code, + elapsed_ms, + ) + return response + + # Register routers app.include_router(dashboard.router) app.include_router(monitoring.router) app.include_router(media.router) app.include_router(files.router) app.include_router(jobs.router) +app.include_router(users.router) @app.get("/api/health") def health_check() -> dict[str, str]: """Simple health check endpoint.""" + logger.debug("health check requested") return {"status": "ok"} diff --git a/backend/src/media_library_viewer_api/path_utils.py b/backend/src/media_library_viewer_api/path_utils.py index 3986f3b..f864fee 100644 --- a/backend/src/media_library_viewer_api/path_utils.py +++ b/backend/src/media_library_viewer_api/path_utils.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import posixpath +logger = logging.getLogger(__name__) + def apply_remote_path_prefix(path: str, prefix: str) -> str: """Apply an optional fallback prefix for Jellyfin->SSH path handoff.""" @@ -14,10 +17,16 @@ def apply_remote_path_prefix(path: str, prefix: str) -> str: return path normalized_prefix = normalized_prefix.rstrip("/") if path == normalized_prefix or path.startswith(normalized_prefix + "/"): - return posixpath.normpath(path) + resolved = posixpath.normpath(path) + logger.debug("Path prefix already applied path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved) + return resolved if path.startswith("/"): - return posixpath.normpath(normalized_prefix + path) - return posixpath.normpath(posixpath.join(normalized_prefix, path)) + resolved = posixpath.normpath(normalized_prefix + path) + logger.debug("Applied path prefix path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved) + return resolved + resolved = posixpath.normpath(posixpath.join(normalized_prefix, path)) + logger.debug("Joined path prefix path=%s prefix=%s resolved=%s", path, normalized_prefix, resolved) + return resolved def map_path_to_media_root(path: str, media_root: str) -> str: @@ -39,6 +48,7 @@ def map_path_to_media_root(path: str, media_root: str) -> str: path_absolute = "/" + "/".join(raw_parts) if path_absolute == normalized_root or path_absolute.startswith(normalized_root + "/"): + logger.debug("Path already under media root path=%s media_root=%s", path, normalized_root) return path_absolute root_anchor = posixpath.basename(normalized_root) @@ -48,7 +58,9 @@ def map_path_to_media_root(path: str, media_root: str) -> str: if root_anchor in raw_parts: anchor_index = raw_parts.index(root_anchor) remainder_parts = raw_parts[anchor_index + 1:] - return posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root + resolved = posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root + logger.debug("Mapped path to media root path=%s media_root=%s resolved=%s", path, normalized_root, resolved) + return resolved return path @@ -64,5 +76,8 @@ def resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) return path mapped = map_path_to_media_root(path, media_root) if mapped and mapped != path: + logger.debug("Resolved media path via media root original=%s resolved=%s", path, mapped) return mapped - return apply_remote_path_prefix(mapped or path, fallback_prefix) + resolved = apply_remote_path_prefix(mapped or path, fallback_prefix) + logger.debug("Resolved media path via fallback original=%s resolved=%s", path, resolved) + return resolved diff --git a/backend/src/media_library_viewer_api/routers/dashboard.py b/backend/src/media_library_viewer_api/routers/dashboard.py index 17c038f..802eb32 100644 --- a/backend/src/media_library_viewer_api/routers/dashboard.py +++ b/backend/src/media_library_viewer_api/routers/dashboard.py @@ -1,7 +1,8 @@ -"""Dashboard router — media counts, per-library breakdown, now-playing.""" +"""Dashboard router — media counts, per-library breakdown, activity.""" from __future__ import annotations +import logging from typing import Any from fastapi import APIRouter, Depends @@ -9,6 +10,8 @@ from fastapi import APIRouter, Depends from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id from media_library_viewer_api.clients.jellyfin import JellyfinClient +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) @@ -18,7 +21,9 @@ def get_counts( user_id: str = Depends(get_user_id), ) -> dict[str, int]: """Return total movie/series/episode counts.""" - return client.media_counts(user_id) + counts = client.media_counts(user_id) + logger.info("Dashboard counts user_id=%s counts=%s", user_id, counts) + return counts @router.get("/libraries") @@ -28,26 +33,31 @@ def get_library_counts( ) -> list[dict[str, Any]]: """Return per-library item counts broken down by type.""" libraries = client.libraries(user_id) + logger.info("Dashboard libraries user_id=%s count=%s", user_id, len(libraries)) return client.library_item_counts(user_id, libraries) -@router.get("/now-playing") -def get_now_playing( - client: JellyfinClient = Depends(get_jellyfin_client), -) -> list[dict[str, Any]]: - """Return currently active playback sessions with transcode info.""" - sessions = client.active_sessions() - results = [] +def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Normalize Jellyfin sessions into dashboard activity rows.""" + results: list[dict[str, Any]] = [] for session in sessions: item = session.get("NowPlayingItem") or {} play_state = session.get("PlayState") or {} transcoding = session.get("TranscodingInfo") or {} + has_item = bool(item) series = item.get("SeriesName") or "" - title = f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown") + title = ( + f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown") + ) if has_item else "(idle)" + + if not has_item: + state_label = "idle" + else: + state_label = "paused" if play_state.get("IsPaused") else "playing" is_transcoding = bool(transcoding) - transcode_type = [] + transcode_type: list[str] = [] if is_transcoding: if transcoding.get("IsVideoDirect") is False: transcode_type.append("video") @@ -59,11 +69,32 @@ def get_now_playing( results.append({ "user": session.get("UserName") or "Unknown", "title": title, - "type": item.get("Type", ""), - "state": "paused" if play_state.get("IsPaused") else "playing", + "type": item.get("Type", "") if has_item else "", + "state": state_label, "transcoding": "yes" if is_transcoding else "no", "transcoding_type": ", ".join(transcode_type), "device": session.get("DeviceName") or session.get("Client") or "", "session_id": session.get("Id") or "", }) return results + + +@router.get("/activity") +def get_activity( + client: JellyfinClient = Depends(get_jellyfin_client), +) -> list[dict[str, Any]]: + """Return activity rows for active sessions (playing and idle/logged-in).""" + sessions = client.sessions() + rows = _map_sessions_to_activity_rows(sessions) + state_rank = {"playing": 0, "paused": 1, "idle": 2} + rows.sort(key=lambda r: (state_rank.get(str(r.get("state")), 9), str(r.get("user", "")).lower())) + logger.info("Dashboard activity sessions=%s rows=%s", len(sessions), len(rows)) + return rows + + +@router.get("/now-playing") +def get_now_playing( + client: JellyfinClient = Depends(get_jellyfin_client), +) -> list[dict[str, Any]]: + """Backward-compatible alias; returns full activity rows.""" + return get_activity(client) diff --git a/backend/src/media_library_viewer_api/routers/files.py b/backend/src/media_library_viewer_api/routers/files.py index d87f254..0efb7ec 100644 --- a/backend/src/media_library_viewer_api/routers/files.py +++ b/backend/src/media_library_viewer_api/routers/files.py @@ -3,15 +3,18 @@ from __future__ import annotations import json +import logging from typing import Any -from fastapi import APIRouter, Depends, Query, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query -from media_library_viewer_api.dependencies import get_ssh_client from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.config import get_settings +from media_library_viewer_api.dependencies import get_ssh_client from media_library_viewer_api.path_utils import resolve_remote_media_path +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/files", tags=["files"]) @@ -23,8 +26,10 @@ def list_directory( """List a remote directory.""" result = ssh.list_dir(path) if result.exit_status != 0: + logger.warning("Files list failed path=%s exit_status=%s", path, result.exit_status) raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory") entries = json.loads(result.stdout) + logger.info("Files list path=%s entries=%s", path, len(entries)) return { "path": path, "entries": entries, @@ -41,7 +46,9 @@ def get_ffprobe( try: data = ssh.ffprobe_json(path) except RuntimeError as exc: + logger.warning("Files ffprobe failed path=%s error=%s", path, exc) raise HTTPException(status_code=400, detail=str(exc)) + logger.info("Files ffprobe path=%s", path) return data @@ -53,7 +60,9 @@ def get_stat( """Run stat on a remote path.""" result = ssh.stat_path(path) if result.exit_status != 0: + logger.warning("Files stat failed path=%s exit_status=%s", path, result.exit_status) raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "stat failed") + logger.info("Files stat path=%s", path) return {"path": path, "output": result.stdout} @@ -64,4 +73,5 @@ def resolve_path( """Resolve a Jellyfin path to its SSH-visible equivalent.""" settings = get_settings() resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix) + logger.info("Files resolve path original=%s resolved=%s", path, resolved) return {"original": path, "resolved": resolved} diff --git a/backend/src/media_library_viewer_api/routers/jobs.py b/backend/src/media_library_viewer_api/routers/jobs.py index 1be669c..c86f626 100644 --- a/backend/src/media_library_viewer_api/routers/jobs.py +++ b/backend/src/media_library_viewer_api/routers/jobs.py @@ -2,15 +2,18 @@ from __future__ import annotations +import logging from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from media_library_viewer_api.dependencies import get_ssh_client from media_library_viewer_api.clients.ssh import RemoteSSHClient +from media_library_viewer_api.dependencies import get_ssh_client from media_library_viewer_api.jobs import JOB_TEMPLATES, run_job +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/jobs", tags=["jobs"]) @@ -22,7 +25,7 @@ class RunJobRequest(BaseModel): @router.get("/templates") def get_templates() -> list[dict[str, str]]: """Return available job templates.""" - return [ + templates = [ { "key": key, "name": template.name, @@ -30,6 +33,8 @@ def get_templates() -> list[dict[str, str]]: } for key, template in JOB_TEMPLATES.items() ] + logger.info("Jobs templates requested count=%s", len(templates)) + return templates @router.post("/run") @@ -39,9 +44,12 @@ def post_run_job( ) -> dict[str, Any]: """Run a job template on a remote path.""" if request.job_key not in JOB_TEMPLATES: + logger.warning("Unknown job requested key=%s path=%s", request.job_key, request.path) raise HTTPException(status_code=400, detail=f"Unknown job key: {request.job_key}") + logger.info("Running job key=%s path=%s", request.job_key, request.path) result = run_job(ssh, request.job_key, request.path) + logger.info("Job finished key=%s exit_status=%s path=%s", request.job_key, result.exit_status, request.path) return { "job_key": request.job_key, "path": request.path, diff --git a/backend/src/media_library_viewer_api/routers/media.py b/backend/src/media_library_viewer_api/routers/media.py index 1262cfc..2d1e840 100644 --- a/backend/src/media_library_viewer_api/routers/media.py +++ b/backend/src/media_library_viewer_api/routers/media.py @@ -1,45 +1,262 @@ -"""Media router — index status, build, and query.""" +"""Media router — index status, build, stop, and query.""" from __future__ import annotations +import logging +import os +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status -from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id from media_library_viewer_api.clients.jellyfin import JellyfinClient -from media_library_viewer_api.services.media_index import MediaIndex, build_media_index +from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id +from media_library_viewer_api.services.media_index import MediaIndex + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/media", tags=["media"]) +_build_lock = threading.Lock() def get_media_index() -> MediaIndex: return MediaIndex() -@router.get("/status") -def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]: - """Return media index status (exists, count, last updated, build duration).""" +def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None: + for key, value in state.items(): + index.set_metadata(key, "" if value is None else value) + + +def _staging_db_path(index: MediaIndex) -> Path: + return index.db_path.with_name(f"{index.db_path.stem}.building{index.db_path.suffix}") + + +def _pid_is_alive(pid: int | None) -> bool: + if not pid: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def _clean_stale_build_state(index: MediaIndex) -> Any: status = index.status() + if status.build_running and not _pid_is_alive(status.build_pid): + logger.warning("Detected stale media build state pid=%s", status.build_pid) + _set_build_metadata( + index, + { + "build_running": False, + "build_stage": "stale", + "build_message": "Previous media index build stopped unexpectedly", + "build_cancel_requested": False, + "build_pid": "", + "build_error": "Worker process is no longer running", + }, + ) + status = index.status() + return status + + +def _serialize_status(status: Any) -> dict[str, Any]: return { "exists": status.exists, "item_count": status.item_count, "updated_at": status.updated_at, "updated_at_label": status.updated_at_label, "build_duration_seconds": status.build_duration_seconds, + "build_running": status.build_running, + "build_stage": status.build_stage, + "build_message": status.build_message, + "build_progress": status.build_progress, + "build_items_processed": status.build_items_processed, + "build_items_total": status.build_items_total, + "build_current_library": status.build_current_library, + "build_library_index": status.build_library_index, + "build_libraries_total": status.build_libraries_total, + "build_library_progress": status.build_library_progress, + "build_library_items_processed": status.build_library_items_processed, + "build_library_items_total": status.build_library_items_total, + "build_elapsed_seconds": status.build_elapsed_seconds, + "build_eta_seconds": status.build_eta_seconds, + "build_library_elapsed_seconds": status.build_library_elapsed_seconds, + "build_library_eta_seconds": status.build_library_eta_seconds, + "build_cancel_requested": status.build_cancel_requested, + "build_pid": status.build_pid, + "build_error": status.build_error, } -@router.post("/build") +def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]: + return [ + sys.executable, + "-m", + "media_library_viewer_api.workers.media_index_worker", + "--index-path", + str(final_db_path), + "--staging-path", + str(staging_db_path), + ] + + +def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]: + staging_path = _staging_db_path(index) + staging_path.unlink(missing_ok=True) + return subprocess.Popen( + _worker_command(index.db_path, staging_path), + start_new_session=True, + env=os.environ.copy(), + ) + + +@router.get("/status") +def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]: + """Return media index status (exists, count, build progress, and errors).""" + with _build_lock: + media_status = _clean_stale_build_state(index) + logger.info("Media status requested running=%s stage=%s", media_status.build_running, media_status.build_stage) + return _serialize_status(media_status) + + +@router.post("/build", status_code=status.HTTP_202_ACCEPTED) def post_build_index( client: JellyfinClient = Depends(get_jellyfin_client), user_id: str = Depends(get_user_id), index: MediaIndex = Depends(get_media_index), ) -> dict[str, Any]: - """Rebuild the media index from Jellyfin.""" - libraries = client.libraries(user_id) - count = build_media_index(client, user_id, libraries, index) - return {"indexed_items": count} + """Start a media index build in a subprocess worker.""" + with _build_lock: + current_status = _clean_stale_build_state(index) + if current_status.build_running and _pid_is_alive(current_status.build_pid): + logger.warning("Media build already running pid=%s", current_status.build_pid) + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress") + + libraries = client.libraries(user_id) + logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries)) + process = _start_worker(index) + _set_build_metadata( + index, + { + "build_running": True, + "build_stage": "queued", + "build_message": "Media index build queued", + "build_progress": None, + "build_items_processed": 0, + "build_items_total": 0, + "build_current_library": "", + "build_library_index": 0, + "build_libraries_total": len(libraries), + "build_library_progress": None, + "build_library_items_processed": 0, + "build_library_items_total": 0, + "build_elapsed_seconds": None, + "build_eta_seconds": None, + "build_library_elapsed_seconds": None, + "build_library_eta_seconds": None, + "build_cancel_requested": False, + "build_pid": process.pid, + "build_error": "", + }, + ) + media_status = index.status() + logger.info("Media index build started pid=%s", process.pid) + return {"status": "started", **_serialize_status(media_status)} + + +@router.post("/stop", status_code=status.HTTP_202_ACCEPTED) +def stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]: + """Request cooperative cancellation of a running media index build.""" + with _build_lock: + media_status = _clean_stale_build_state(index) + if not media_status.build_running: + logger.warning("Stop requested but no media build is running") + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running") + + logger.info("Cooperative stop requested for media build pid=%s", media_status.build_pid) + _set_build_metadata( + index, + { + "build_running": True, + "build_stage": "canceling", + "build_message": "Stopping media index build...", + "build_cancel_requested": True, + "build_error": "", + }, + ) + media_status = index.status() + logger.info("Media stop requested acknowledged") + return {"status": "stop_requested", **_serialize_status(media_status)} + + +@router.post("/force-stop", status_code=status.HTTP_202_ACCEPTED) +def force_stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]: + """Terminate the media index worker process if it is stuck.""" + with _build_lock: + media_status = _clean_stale_build_state(index) + if not media_status.build_running: + logger.warning("Force stop requested but no media build is running") + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running") + + pid = media_status.build_pid + if not pid or not _pid_is_alive(pid): + logger.warning("Force stop requested but build worker is not alive pid=%s", pid) + _set_build_metadata( + index, + { + "build_running": False, + "build_stage": "stale", + "build_message": "Media index worker is not running", + "build_cancel_requested": False, + "build_pid": "", + "build_error": "Worker process is not running", + }, + ) + media_status = index.status() + return {"status": "already_stopped", **_serialize_status(media_status)} + + logger.info("Force stopping media build pid=%s", pid) + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc + + deadline = time.time() + 3.0 + while time.time() < deadline and _pid_is_alive(pid): + time.sleep(0.1) + + if _pid_is_alive(pid): + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc + + _set_build_metadata( + index, + { + "build_running": False, + "build_stage": "force-stopped", + "build_message": "Media index build force stopped", + "build_cancel_requested": False, + "build_pid": "", + "build_error": "", + }, + ) + media_status = index.status() + return {"status": "force_stopped", **_serialize_status(media_status)} @router.get("/query") @@ -57,13 +274,25 @@ def query_media( index: MediaIndex = Depends(get_media_index), ) -> dict[str, Any]: """Query the media index with filters, sorting, and pagination.""" - # If no library IDs provided, use all libraries + # If no library IDs provided, use all libraries. library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None if not library_ids: all_libs = client.libraries(user_id) library_ids = [lib["Id"] for lib in all_libs] media_types = [t.strip() for t in types.split(",") if t.strip()] + logger.info( + "Media query user_id=%s libraries=%s types=%s search=%s hdr=%s sort=%s/%s limit=%s offset=%s", + user_id, + len(library_ids or []), + ",".join(media_types), + search or "", + hdr_filter, + sort_key, + sort_order, + limit, + offset, + ) rows, total = index.query( library_ids=library_ids, @@ -76,6 +305,7 @@ def query_media( offset=offset, ) + logger.info("Media query returned total=%s rows=%s", total, len(rows)) return { "items": rows, "total": total, diff --git a/backend/src/media_library_viewer_api/routers/monitoring.py b/backend/src/media_library_viewer_api/routers/monitoring.py index 97fa687..f0741ca 100644 --- a/backend/src/media_library_viewer_api/routers/monitoring.py +++ b/backend/src/media_library_viewer_api/routers/monitoring.py @@ -2,13 +2,16 @@ from __future__ import annotations +import logging import time from typing import Any from fastapi import APIRouter, Depends -from media_library_viewer_api.dependencies import get_ssh_client from media_library_viewer_api.clients.ssh import RemoteSSHClient +from media_library_viewer_api.dependencies import get_ssh_client + +logger = logging.getLogger(__name__) from media_library_viewer_api.clients.resources import ( disk_space, read_resource_metrics, @@ -26,19 +29,26 @@ router = APIRouter(prefix="/api/monitoring", tags=["monitoring"]) @router.get("/status") def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: """Return collector running status.""" - return {"status": resource_collector_status(ssh)} + status = resource_collector_status(ssh) + logger.info("Monitoring status requested: %s", status) + return {"status": status} @router.get("/metrics") def get_metrics( - max_lines: int = 1000, - last_seconds: int = 3600, + max_lines: int = 70_000, + last_seconds: int | None = None, ssh: RemoteSSHClient = Depends(get_ssh_client), ) -> dict[str, Any]: """Return resource metric samples from the remote collector.""" rows = read_resource_metrics(ssh, max_lines=max_lines) - cutoff_ts = time.time() - last_seconds - filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts] + if last_seconds is None: + filtered = rows + cutoff_ts = 0.0 + else: + cutoff_ts = time.time() - last_seconds + filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts] + logger.info("Monitoring metrics requested total=%s filtered=%s last_seconds=%s", len(rows), len(filtered), last_seconds) return { "samples": filtered, "total_samples": len(rows), @@ -52,6 +62,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, """Return disk space for the configured media root.""" settings = get_settings() path = settings.media_root or "/" + logger.info("Monitoring disk requested path=%s", path) return disk_space(ssh, path) @@ -59,6 +70,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: """Start the remote resource collector.""" message = start_resource_collector(ssh) + logger.info("Monitoring collector start result: %s", message) return {"message": message} @@ -66,6 +78,7 @@ def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str] def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: """Stop the remote resource collector.""" message = stop_resource_collector(ssh) + logger.info("Monitoring collector stop result: %s", message) return {"message": message} @@ -73,10 +86,13 @@ def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: """Restart the remote resource collector.""" message = restart_resource_collector(ssh) + logger.info("Monitoring collector restart result: %s", message) return {"message": message} @router.get("/diagnostics") def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: """Return collector debug info for troubleshooting.""" - return {"diagnostics": resource_collector_debug_info(ssh)} + diagnostics = resource_collector_debug_info(ssh) + logger.info("Monitoring diagnostics requested") + return {"diagnostics": diagnostics} diff --git a/backend/src/media_library_viewer_api/routers/users.py b/backend/src/media_library_viewer_api/routers/users.py new file mode 100644 index 0000000..bf57e11 --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/users.py @@ -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, + } diff --git a/backend/src/media_library_viewer_api/services/mail_queue.py b/backend/src/media_library_viewer_api/services/mail_queue.py new file mode 100644 index 0000000..d40db73 --- /dev/null +++ b/backend/src/media_library_viewer_api/services/mail_queue.py @@ -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", ""), + result.get("authenticated_as") or "", + 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 diff --git a/backend/src/media_library_viewer_api/services/mailer.py b/backend/src/media_library_viewer_api/services/mailer.py new file mode 100644 index 0000000..e1e4eae --- /dev/null +++ b/backend/src/media_library_viewer_api/services/mailer.py @@ -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 "", + } + + +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 "", + ) + + 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 "", + 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") diff --git a/backend/src/media_library_viewer_api/services/media_index.py b/backend/src/media_library_viewer_api/services/media_index.py index 75319e5..1bf5728 100644 --- a/backend/src/media_library_viewer_api/services/media_index.py +++ b/backend/src/media_library_viewer_api/services/media_index.py @@ -7,14 +7,18 @@ through FastAPI to a React frontend without rewriting Jellyfin indexing logic. from __future__ import annotations +import logging import sqlite3 import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable +from typing import Any, Callable, Iterable from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.domain.media import display_media_row, normalize_media_item +from media_library_viewer_api.path_utils import resolve_remote_media_path + +logger = logging.getLogger(__name__) # Local generated database. It is ignored by git and can be rebuilt from # Jellyfin metadata whenever needed. @@ -42,6 +46,19 @@ SORT_COLUMNS = { } +def _estimate_remaining_seconds(elapsed_seconds: float, progress: float | None) -> float | None: + if progress is None: + return None + progress = max(0.0, min(1.0, progress)) + if progress <= 0.0: + return None + return max(0.0, elapsed_seconds * (1.0 - progress) / progress) + + +class MediaIndexBuildCancelled(Exception): + """Raised when a media index build is requested to stop.""" + + @dataclass(frozen=True) class MediaIndexStatus: """Lightweight status object displayed by the Media tab.""" @@ -51,6 +68,25 @@ class MediaIndexStatus: updated_at: int | None = None updated_at_label: str = "" build_duration_seconds: float | None = None + build_running: bool = False + build_stage: str = "" + build_message: str = "" + build_progress: float | None = None + build_items_processed: int = 0 + build_items_total: int = 0 + build_current_library: str = "" + build_library_index: int = 0 + build_libraries_total: int = 0 + build_library_progress: float | None = None + build_library_items_processed: int = 0 + build_library_items_total: int = 0 + build_elapsed_seconds: float | None = None + build_eta_seconds: float | None = None + build_library_elapsed_seconds: float | None = None + build_library_eta_seconds: float | None = None + build_cancel_requested: bool = False + build_pid: int | None = None + build_error: str = "" class MediaIndex: @@ -66,8 +102,10 @@ class MediaIndex: def connect(self) -> sqlite3.Connection: """Open a sqlite connection configured to return Row objects.""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path, timeout=30) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") return conn def init_schema(self) -> None: @@ -170,24 +208,68 @@ class MediaIndex: try: with self.connect() as conn: item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0]) - updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone() - duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone() + meta = { + row[0]: row[1] + for row in conn.execute("SELECT key, value FROM index_metadata").fetchall() + } except sqlite3.Error: return MediaIndexStatus(exists=False) - updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None + updated_at_raw = meta.get("updated_at", "") + updated_at = int(updated_at_raw) if str(updated_at_raw).isdigit() else None label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else "" + duration_raw = meta.get("build_duration_seconds") build_duration = None - if duration_row: + if duration_raw is not None: try: - build_duration = float(duration_row[0]) + build_duration = float(duration_raw) except (TypeError, ValueError): build_duration = None + + def _bool(key: str, default: bool = False) -> bool: + value = str(meta.get(key, str(default))).strip().lower() + return value in {"1", "true", "yes", "on"} + + def _int(key: str, default: int = 0) -> int: + value = meta.get(key, default) + try: + return int(value) + except (TypeError, ValueError): + return default + + def _float(key: str) -> float | None: + value = meta.get(key) + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + return MediaIndexStatus( exists=True, item_count=item_count, updated_at=updated_at, updated_at_label=label, build_duration_seconds=build_duration, + build_running=_bool("build_running"), + build_stage=str(meta.get("build_stage", "")), + build_message=str(meta.get("build_message", "")), + build_progress=_float("build_progress"), + build_items_processed=_int("build_items_processed"), + build_items_total=_int("build_items_total"), + build_current_library=str(meta.get("build_current_library", "")), + build_library_index=_int("build_library_index"), + build_libraries_total=_int("build_libraries_total"), + build_library_progress=_float("build_library_progress"), + build_library_items_processed=_int("build_library_items_processed"), + build_library_items_total=_int("build_library_items_total"), + build_elapsed_seconds=_float("build_elapsed_seconds"), + build_eta_seconds=_float("build_eta_seconds"), + build_library_elapsed_seconds=_float("build_library_elapsed_seconds"), + build_library_eta_seconds=_float("build_library_eta_seconds"), + build_cancel_requested=_bool("build_cancel_requested"), + build_pid=_int("build_pid") or None, + build_error=str(meta.get("build_error", "")), ) def query( @@ -245,18 +327,79 @@ def build_media_index( libraries: list[dict[str, Any]], index: MediaIndex | None = None, page_size: int = 500, + media_root: str = "", + fallback_prefix: str = "", + progress_callback: Callable[[dict[str, Any]], None] | None = None, + should_cancel: Callable[[], bool] | None = None, ) -> int: """Fetch Jellyfin pages for all selected libraries and rebuild the index.""" index = index or MediaIndex() started_at = time.perf_counter() normalized_rows: list[dict[str, Any]] = [] - for library in libraries: + processed_total = 0 + expected_total = 0 + current_library_name = "" + current_library_index = 0 + current_library_processed = 0 + current_library_total = 0 + current_library_started_at = started_at + + def ensure_not_cancelled() -> None: + if should_cancel and should_cancel(): + raise MediaIndexBuildCancelled() + + def emit(stage: str, message: str) -> None: + if not progress_callback: + return + elapsed_seconds = time.perf_counter() - started_at + library_elapsed_seconds = time.perf_counter() - current_library_started_at + overall_progress = (processed_total / expected_total) if expected_total else None + library_progress = (current_library_processed / current_library_total) if current_library_total else None + progress_callback( + { + "stage": stage, + "message": message, + "processed": processed_total, + "total": expected_total, + "progress": overall_progress, + "elapsed_seconds": elapsed_seconds, + "eta_seconds": _estimate_remaining_seconds(elapsed_seconds, overall_progress), + "library": current_library_name, + "library_index": current_library_index, + "libraries_total": len(libraries), + "library_processed": current_library_processed, + "library_total": current_library_total, + "library_progress": library_progress, + "library_elapsed_seconds": library_elapsed_seconds if current_library_total else None, + "library_eta_seconds": _estimate_remaining_seconds(library_elapsed_seconds, library_progress), + } + ) + + ensure_not_cancelled() + logger.info("Media index build starting libraries=%s page_size=%s", len(libraries), page_size) + emit("starting", "Starting media index build") + for library_index, library in enumerate(libraries, start=1): library_id = library.get("Id") - library_name = library.get("Name", "") + current_library_name = library.get("Name", "") + current_library_index = library_index + current_library_processed = 0 + current_library_total = 0 + current_library_started_at = time.perf_counter() if not library_id: continue + ensure_not_cancelled() + logger.info( + "Media index scanning library index=%s/%s name=%s id=%s", + library_index, + len(libraries), + current_library_name or "Library", + library_id, + ) + emit("library-starting", f"Scanning {current_library_name or 'Library'}") start = 0 + discovered_library_total = None while True: + ensure_not_cancelled() response = client.items( user_id=user_id, parent_id=library_id, @@ -267,12 +410,49 @@ def build_media_index( sort_by="SortName", sort_order="Ascending", ) + ensure_not_cancelled() items = response.get("Items", []) - normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items) + if discovered_library_total is None: + discovered_library_total = int(response.get("TotalRecordCount", len(items))) + current_library_total = max(discovered_library_total, 0) + expected_total += current_library_total + normalized_rows.extend( + { + **row, + "path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix), + } + for row in ( + normalize_media_item(item, library_id, current_library_name) + for item in items + ) + ) + processed_total += len(items) + current_library_processed += len(items) start += len(items) + ensure_not_cancelled() + emit( + "building", + f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items", + ) + logger.debug( + "Media index progress library=%s processed=%s/%s total_processed=%s", + current_library_name or "Library", + current_library_processed, + current_library_total, + processed_total, + ) total = int(response.get("TotalRecordCount", start)) if not items or start >= total: break + ensure_not_cancelled() + logger.info("Media index finalizing rows=%s", len(normalized_rows)) + emit("finalizing", "Writing index to disk") + ensure_not_cancelled() count = index.replace_items(normalized_rows) - index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}") + duration = time.perf_counter() - started_at + index.set_metadata("build_duration_seconds", f"{duration:.3f}") + processed_total = count + current_library_processed = current_library_total + emit("completed", f"Indexed {count} items in {duration:.1f}s") + logger.info("Media index build completed count=%s duration=%.2fs", count, duration) return count diff --git a/backend/src/media_library_viewer_api/workers/__init__.py b/backend/src/media_library_viewer_api/workers/__init__.py new file mode 100644 index 0000000..a164927 --- /dev/null +++ b/backend/src/media_library_viewer_api/workers/__init__.py @@ -0,0 +1 @@ +"""Worker entrypoints for background tasks.""" diff --git a/backend/src/media_library_viewer_api/workers/media_index_worker.py b/backend/src/media_library_viewer_api/workers/media_index_worker.py new file mode 100644 index 0000000..fdcfd01 --- /dev/null +++ b/backend/src/media_library_viewer_api/workers/media_index_worker.py @@ -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()) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fcc5906..d2767d4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -5,14 +5,23 @@ without requiring real remote connections. """ import json +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from media_library_viewer_api.main import app -from media_library_viewer_api.dependencies import get_ssh_client, get_jellyfin_client, get_user_id +from media_library_viewer_api.dependencies import ( + get_ssh_client, + get_jellyfin_client, + get_jellyseerr_client, + get_mail_queue, + get_user_id, +) from media_library_viewer_api.clients.ssh import CommandResult +from media_library_viewer_api.routers.media import get_media_index +from media_library_viewer_api.services.media_index import MediaIndex # --- Fixtures --- @@ -26,11 +35,15 @@ def mock_jellyfin(): {"Id": "lib1", "Name": "Movies", "CollectionType": "movies"}, {"Id": "lib2", "Name": "TV Shows", "CollectionType": "tvshows"}, ] + client.users.return_value = [ + {"Id": "jf1", "Name": "alex"}, + {"Id": "jf2", "Name": "sam"}, + ] client.library_item_counts.return_value = [ {"library": "Movies", "type": "movies", "movies": 100, "series": 0, "episodes": 0, "total": 100}, {"library": "TV Shows", "type": "tvshows", "movies": 0, "series": 20, "episodes": 500, "total": 520}, ] - client.active_sessions.return_value = [ + client.sessions.return_value = [ { "Id": "sess1", "UserName": "alex", @@ -38,11 +51,51 @@ def mock_jellyfin(): "NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"}, "PlayState": {"IsPaused": False}, "TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False}, - } + }, + { + "Id": "sess2", + "UserName": "sam", + "DeviceName": "Android", + "NowPlayingItem": None, + "PlayState": {}, + "TranscodingInfo": None, + }, ] return client +@pytest.fixture +def mock_jellyseerr(): + """Mock Jellyseerr client.""" + client = MagicMock() + client.jellyfin_users.return_value = [ + {"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"}, + {"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"}, + ] + client.users.return_value = [ + { + "id": 7, + "username": "alex", + "email": "alex@example.com", + "avatar": "/avatarproxy/alex", + "userType": 3, + "permissions": 10, + "requestCount": 3, + }, + { + "id": 8, + "username": "sam", + "email": "sam@example.com", + "avatar": "/avatarproxy/sam", + "userType": 2, + "permissions": 32, + "requestCount": 1, + }, + ] + client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}" + return client + + @pytest.fixture def mock_ssh(): """Mock SSH client.""" @@ -73,9 +126,10 @@ def mock_ssh(): @pytest.fixture -def test_client(mock_jellyfin, mock_ssh): +def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh): """FastAPI test client with mocked dependencies.""" app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin + app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr app.dependency_overrides[get_ssh_client] = lambda: mock_ssh app.dependency_overrides[get_user_id] = lambda: "user123" client = TestClient(app) @@ -111,17 +165,244 @@ class TestDashboard: assert data[0]["library"] == "Movies" assert data[1]["library"] == "TV Shows" - def test_now_playing(self, test_client): + def test_activity(self, test_client): + response = test_client.get("/api/dashboard/activity") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + playing_row = next(row for row in data if row["user"] == "alex") + assert playing_row["title"] == "Test Movie" + assert playing_row["state"] == "playing" + assert playing_row["transcoding"] == "yes" + assert "audio" in playing_row["transcoding_type"] + + idle_row = next(row for row in data if row["user"] == "sam") + assert idle_row["state"] == "idle" + assert idle_row["title"] == "(idle)" + + def test_now_playing_alias(self, test_client): response = test_client.get("/api/dashboard/now-playing") assert response.status_code == 200 data = response.json() - assert len(data) == 1 - assert data[0]["user"] == "alex" - assert data[0]["title"] == "Test Movie" - assert data[0]["transcoding"] == "yes" - assert "audio" in data[0]["transcoding_type"] + assert len(data) == 2 +# --- Users --- + +class TestUsers: + def test_users_list_enriched(self, test_client): + response = test_client.get("/api/users") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert data["jellyseerr_configured"] is True + assert data["jellyseerr_available"] is True + assert data["jellyseerr_error"] == "" + + alex = next(item for item in data["items"] if item["username"] == "alex") + assert alex["email"] == "alex@example.com" + assert alex["email_source"] == "jellyseerr:user" + assert alex["contactable"] is True + assert alex["avatar"].startswith("https://requests.example.com/") + assert alex["avatar_source"] == "jellyseerr:user" + assert alex["permissions"] == 10 + assert alex["permissions_label"] == "admin, manage_users" + assert alex["role"] == "admin" + assert alex["user_type_label"] == "jellyfin" + assert alex["request_count"] == 3 + assert "name=jellyfin" in alex["source_summary"] + assert "email=jellyseerr:user" in alex["source_summary"] + + sam = next(item for item in data["items"] if item["username"] == "sam") + assert sam["role"] == "requester" + assert sam["user_type_label"] == "local" + assert sam["email"] == "sam@example.com" + + def test_users_message_status(self, test_client): + mail_queue = MagicMock() + mail_queue.status.return_value = { + "state": "idle", + "worker_running": True, + "stop_requested": False, + "pending_count": 0, + "active_request_id": None, + "last_request_id": None, + "last_result": None, + "last_error": "", + "last_error_at": None, + "last_success_at": None, + "last_activity_at": None, + "sent_count": 0, + "failed_count": 0, + } + app.dependency_overrides[get_mail_queue] = lambda: mail_queue + try: + response = test_client.get("/api/users/message/status") + finally: + app.dependency_overrides.pop(get_mail_queue, None) + assert response.status_code == 200 + assert response.json()["state"] == "idle" + assert response.json()["pending_count"] == 0 + + def test_users_message_test_smtp(self, test_client): + settings = SimpleNamespace( + smtp_host="smtp.fastmail.com", + smtp_port=587, + smtp_username="main@fastmail.com", + smtp_password="app-password", + smtp_from_address="alias@example.com", + smtp_from_name="Media Library Viewer", + smtp_use_tls=True, + smtp_use_ssl=False, + smtp_timeout=15, + ) + app.dependency_overrides[get_mail_queue] = lambda: MagicMock() + try: + with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings), patch( + "media_library_viewer_api.routers.users.test_smtp_connection", + return_value={ + "status": "ok", + "message": "SMTP connection successful using Fastmail STARTTLS 587", + "from_address": "alias@example.com", + "from_name": "Media Library Viewer", + "smtp_host": "smtp.fastmail.com", + "smtp_port": 587, + "use_tls": True, + "use_ssl": False, + "authenticated": True, + "selected_mode": { + "label": "Fastmail STARTTLS 587", + "smtp_host": "smtp.fastmail.com", + "smtp_port": 587, + "use_tls": True, + "use_ssl": False, + }, + "attempts": [ + { + "label": "Fastmail STARTTLS 587", + "smtp_host": "smtp.fastmail.com", + "smtp_port": 587, + "use_tls": True, + "use_ssl": False, + "status": "ok", + } + ], + }, + ): + response = test_client.post("/api/users/message/test-smtp") + finally: + app.dependency_overrides.pop(get_mail_queue, None) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["smtp_host"] == "smtp.fastmail.com" + assert data["selected_mode"]["label"] == "Fastmail STARTTLS 587" + + def test_users_message_test_smtp_timeout_message(self, test_client): + settings = SimpleNamespace( + smtp_host="smtp.fastmail.com", + smtp_port=587, + smtp_username="main@fastmail.com", + smtp_password="app-password", + smtp_from_address="alias@example.com", + smtp_from_name="Media Library Viewer", + smtp_use_tls=True, + smtp_use_ssl=False, + smtp_timeout=15, + ) + app.dependency_overrides[get_mail_queue] = lambda: MagicMock() + try: + with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings), patch( + "media_library_viewer_api.routers.users.test_smtp_connection", + return_value={ + "status": "error", + "message": "SMTP connection timed out while waiting for the server greeting. Check host, port, network access, and SMTP_TIMEOUT.", + "from_address": "alias@example.com", + "from_name": "Media Library Viewer", + "smtp_host": "smtp.fastmail.com", + "smtp_port": 587, + "use_tls": True, + "use_ssl": False, + "authenticated": True, + "selected_mode": None, + "attempts": [ + { + "label": "configured", + "smtp_host": "smtp.fastmail.com", + "smtp_port": 587, + "use_tls": True, + "use_ssl": False, + "status": "failed", + "error": "SMTP connection timed out while waiting for the server greeting. Check host, port, network access, and SMTP_TIMEOUT.", + } + ], + }, + ): + response = test_client.post("/api/users/message/test-smtp") + finally: + app.dependency_overrides.pop(get_mail_queue, None) + assert response.status_code == 200 + assert response.json()["status"] == "error" + assert "timed out" in response.json()["message"].lower() + + def test_users_message_is_queued(self, test_client): + mail_queue = MagicMock() + mail_queue.status.return_value = { + "state": "idle", + "worker_running": True, + "stop_requested": False, + "pending_count": 0, + "active_request_id": None, + "last_request_id": None, + "last_result": None, + "last_error": "", + "last_error_at": None, + "last_success_at": None, + "last_activity_at": None, + "sent_count": 0, + "failed_count": 0, + } + mail_queue.enqueue.return_value = "mail-123456" + app.dependency_overrides[get_mail_queue] = lambda: mail_queue + settings = SimpleNamespace( + smtp_host="smtp.example.com", + smtp_port=587, + smtp_username="mailer@example.com", + smtp_password="secret", + smtp_from_address="mailer@example.com", + smtp_from_name="Media Library Viewer", + smtp_use_tls=True, + smtp_use_ssl=False, + smtp_timeout=15, + ) + + try: + with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings): + response = test_client.post( + "/api/users/message", + data={ + "recipient_ids": json.dumps(["jf1", "jf2"]), + "subject": "Hello team", + "html_body": "

Hi there

", + "text_body": "Hi there", + }, + ) + finally: + app.dependency_overrides.pop(get_mail_queue, None) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "queued" + assert data["request_id"] == "mail-123456" + assert data["recipient_count"] == 2 + assert data["attachment_count"] == 0 + mail_queue.enqueue.assert_called_once() + kwargs = mail_queue.enqueue.call_args.kwargs + assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"] + assert kwargs["subject"] == "Hello team" + assert kwargs["settings"] is settings + # --- Files --- class TestFiles: @@ -163,6 +444,135 @@ class TestFiles: assert "Permission denied" in response.text +# --- Media index build --- + +class TestMediaIndexApi: + def test_status_includes_build_progress(self, test_client, tmp_path): + index = MediaIndex(tmp_path / "index.sqlite") + index.init_schema() + index.set_metadata("build_running", "true") + index.set_metadata("build_stage", "building") + index.set_metadata("build_message", "3 / 10 items") + index.set_metadata("build_progress", "0.3") + index.set_metadata("build_items_processed", "3") + index.set_metadata("build_items_total", "10") + index.set_metadata("build_current_library", "Movies") + index.set_metadata("build_library_index", "1") + index.set_metadata("build_libraries_total", "2") + index.set_metadata("build_library_progress", "0.5") + index.set_metadata("build_library_items_processed", "1") + index.set_metadata("build_library_items_total", "2") + index.set_metadata("build_elapsed_seconds", "12.0") + index.set_metadata("build_eta_seconds", "28.0") + index.set_metadata("build_library_elapsed_seconds", "4.0") + index.set_metadata("build_library_eta_seconds", "4.0") + index.set_metadata("build_pid", "4321") + app.dependency_overrides[get_media_index] = lambda: index + with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True): + response = test_client.get("/api/media/status") + assert response.status_code == 200 + data = response.json() + assert data["build_running"] is True + assert data["build_stage"] == "building" + assert data["build_message"] == "3 / 10 items" + assert data["build_progress"] == 0.3 + assert data["build_items_processed"] == 3 + assert data["build_items_total"] == 10 + assert data["build_current_library"] == "Movies" + assert data["build_library_index"] == 1 + assert data["build_libraries_total"] == 2 + assert data["build_library_progress"] == 0.5 + assert data["build_library_items_processed"] == 1 + assert data["build_library_items_total"] == 2 + assert data["build_elapsed_seconds"] == 12.0 + assert data["build_eta_seconds"] == 28.0 + assert data["build_library_elapsed_seconds"] == 4.0 + assert data["build_library_eta_seconds"] == 4.0 + assert data["build_pid"] == 4321 + + def test_build_returns_started_when_background_build_is_queued(self, test_client, tmp_path, mock_jellyfin): + index = MediaIndex(tmp_path / "index.sqlite") + app.dependency_overrides[get_media_index] = lambda: index + + class FakeProcess: + pid = 4321 + + with patch("media_library_viewer_api.routers.media._start_worker", return_value=FakeProcess()) as start_worker: + try: + response = test_client.post("/api/media/build") + assert response.status_code == 202 + data = response.json() + assert data["status"] == "started" + assert data["build_running"] is True + assert data["build_stage"] == "queued" + assert data["build_libraries_total"] == len(mock_jellyfin.libraries.return_value) + assert data["build_pid"] == 4321 + start_worker.assert_called_once() + finally: + app.dependency_overrides.pop(get_media_index, None) + + def test_stop_requests_cancel(self, test_client, tmp_path): + index = MediaIndex(tmp_path / "index.sqlite") + index.init_schema() + index.set_metadata("build_running", "true") + index.set_metadata("build_stage", "building") + index.set_metadata("build_pid", "4321") + app.dependency_overrides[get_media_index] = lambda: index + + with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True): + try: + response = test_client.post("/api/media/stop") + assert response.status_code == 202 + data = response.json() + assert data["status"] == "stop_requested" + assert data["build_cancel_requested"] is True + status = test_client.get("/api/media/status").json() + assert status["build_cancel_requested"] is True + assert status["build_stage"] == "canceling" + finally: + app.dependency_overrides.pop(get_media_index, None) + + def test_force_stop_terminates_worker(self, test_client, tmp_path): + index = MediaIndex(tmp_path / "index.sqlite") + index.init_schema() + index.set_metadata("build_running", "true") + index.set_metadata("build_stage", "building") + index.set_metadata("build_pid", "4321") + app.dependency_overrides[get_media_index] = lambda: index + + alive_calls = {"count": 0} + + def fake_pid_is_alive(pid): + alive_calls["count"] += 1 + return alive_calls["count"] <= 2 + + with patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive), patch( + "media_library_viewer_api.routers.media.os.killpg" + ) as killpg, patch("media_library_viewer_api.routers.media.time.sleep", return_value=None): + try: + response = test_client.post("/api/media/force-stop") + assert response.status_code == 202 + data = response.json() + assert data["status"] == "force_stopped" + killpg.assert_called() + status = test_client.get("/api/media/status").json() + assert status["build_running"] is False + assert status["build_stage"] == "force-stopped" + finally: + app.dependency_overrides.pop(get_media_index, None) + + def test_force_stop_returns_conflict_when_idle(self, test_client, tmp_path): + index = MediaIndex(tmp_path / "index.sqlite") + index.init_schema() + app.dependency_overrides[get_media_index] = lambda: index + + try: + response = test_client.post("/api/media/force-stop") + assert response.status_code == 409 + finally: + app.dependency_overrides.pop(get_media_index, None) + + # --- Jobs --- class TestJobs: diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index c55f073..e178b61 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -12,12 +12,17 @@ class TestSettings: assert settings.ssh_host == "" assert settings.ssh_port == 22 assert settings.jellyfin_url == "" + assert settings.jellyseerr_url == "" + assert settings.log_level == "INFO" assert settings.remote_media_root == "" def test_from_env(self): env = { "JELLYFIN_URL": "https://test.example.com", "JELLYFIN_API_KEY": "key123", + "JELLYSEERR_URL": "https://requests.example.com", + "JELLYSEERR_API_KEY": "seerr123", + "LOG_LEVEL": "DEBUG", "SSH_HOST": "192.168.1.1", "SSH_USERNAME": "testuser", "SSH_PORT": "2222", @@ -27,6 +32,9 @@ class TestSettings: settings = Settings(_env_file=None) assert settings.jellyfin_url == "https://test.example.com" assert settings.jellyfin_api_key == "key123" + assert settings.jellyseerr_url == "https://requests.example.com" + assert settings.jellyseerr_api_key == "seerr123" + assert settings.log_level == "DEBUG" assert settings.ssh_host == "192.168.1.1" assert settings.ssh_username == "testuser" assert settings.ssh_port == 2222 diff --git a/backend/tests/test_jellyseerr_client.py b/backend/tests/test_jellyseerr_client.py new file mode 100644 index 0000000..c16aa5c --- /dev/null +++ b/backend/tests/test_jellyseerr_client.py @@ -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() diff --git a/backend/tests/test_mailer.py b/backend/tests/test_mailer.py new file mode 100644 index 0000000..3c231fa --- /dev/null +++ b/backend/tests/test_mailer.py @@ -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("

Hello world

Line 2

") + 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="

Hi there

", + 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 ") + 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="

Hello

", + ) + + 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="

Hello

", + ) + + 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() diff --git a/backend/tests/test_media_index.py b/backend/tests/test_media_index.py index 9d9d83e..c555d6d 100644 --- a/backend/tests/test_media_index.py +++ b/backend/tests/test_media_index.py @@ -3,7 +3,12 @@ import pytest import tempfile from pathlib import Path -from media_library_viewer_api.services.media_index import MediaIndex +from typing import Any +from media_library_viewer_api.services.media_index import ( + MediaIndex, + MediaIndexBuildCancelled, + build_media_index, +) @pytest.fixture @@ -242,3 +247,126 @@ class TestMediaIndexMetadata: index.set_metadata("build_duration_seconds", "12.5") status = index.status() assert status.build_duration_seconds == 12.5 + + +class TestMediaIndexBuildPaths: + def test_build_media_index_patches_remote_media_root(self, tmp_path): + class FakeClient: + def items(self, **kwargs): + return { + "Items": [ + { + "Id": "m1", + "Name": "Movie One", + "Type": "Movie", + "Path": "/media/movies/Movie One/file.mkv", + } + ], + "TotalRecordCount": 1, + } + + index = MediaIndex(tmp_path / "index.sqlite") + count = build_media_index( + FakeClient(), + "user1", + [{"Id": "lib1", "Name": "Movies"}], + index, + media_root="/srv/media", + fallback_prefix="", + ) + assert count == 1 + rows, total = index.query(library_ids=["lib1"], media_types=["Movie"]) + assert total == 1 + assert rows[0]["path"] == "/srv/media/movies/Movie One/file.mkv" + + def test_build_media_index_reports_progress(self, tmp_path): + class FakeClient: + def __init__(self): + self.calls = [] + + def items(self, **kwargs): + self.calls.append(kwargs.get("start_index", 0)) + if kwargs.get("start_index", 0) == 0: + return { + "Items": [ + {"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"} + ], + "TotalRecordCount": 2, + } + return { + "Items": [ + {"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"} + ], + "TotalRecordCount": 2, + } + + events: list[dict[str, Any]] = [] + index = MediaIndex(tmp_path / "index.sqlite") + count = build_media_index( + FakeClient(), + "user1", + [{"Id": "lib1", "Name": "Movies"}], + index, + page_size=1, + progress_callback=events.append, + ) + assert count == 2 + assert events[0]["stage"] == "starting" + assert events[0]["progress"] is None + assert any(event["stage"] == "building" for event in events) + building_event = next(event for event in events if event["stage"] == "building") + assert building_event["library"] == "Movies" + assert building_event["library_progress"] in (0.5, 1.0) + assert "elapsed_seconds" in building_event + assert "eta_seconds" in building_event + assert events[-1]["stage"] == "completed" + assert events[-1]["progress"] == 1.0 + assert events[-1]["library_progress"] == 1.0 + + def test_build_media_index_can_be_cancelled(self, tmp_path): + class FakeClient: + def __init__(self): + self.calls = [] + + def items(self, **kwargs): + self.calls.append(kwargs.get("start_index", 0)) + if kwargs.get("start_index", 0) == 0: + return { + "Items": [ + {"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"} + ], + "TotalRecordCount": 2, + } + return { + "Items": [ + {"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"} + ], + "TotalRecordCount": 2, + } + + events: list[dict[str, Any]] = [] + cancel_after_building = [False] + + def progress_callback(state: dict[str, Any]) -> None: + events.append(state) + if state["stage"] == "building": + cancel_after_building[0] = True + + def should_cancel() -> bool: + return cancel_after_building[0] + + index = MediaIndex(tmp_path / "index.sqlite") + client = FakeClient() + with pytest.raises(MediaIndexBuildCancelled): + build_media_index( + client, + "user1", + [{"Id": "lib1", "Name": "Movies"}], + index, + page_size=1, + progress_callback=progress_callback, + should_cancel=should_cancel, + ) + assert any(event["stage"] == "building" for event in events) + assert events[-1]["stage"] == "building" + assert client.calls == [0] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..267efac --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6687395 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index a88aa03..185133b 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -34,12 +34,42 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - File browser interaction should stay explicit and simple: read-only listing plus explicit Open/Select actions rather than another row-selection grid. - Use valid Jellyfin `Fields` query values only, because invalid field names can cause `400 Bad Request` responses. +### Users & Communication + +- Provide a Users tab that lists all available users the system knows about. +- Use Jellyfin as the base source of truth for the user list. +- Optionally enrich Jellyfin users with Jellyseerr data when Jellyseerr is configured and reachable. +- Be tolerant of Jellyseerr response-shape differences across versions; for example, some endpoints may return a wrapped `{ users: [...] }` payload instead of a raw list. +- Surface whatever contact/identity fields are available from the configured source(s), such as email, avatar/thumb, role/permissions, and notification/contact eligibility. +- Email should only render actual email addresses; usernames or other non-email identifiers should be suppressed instead of shown as email. +- Keep communication actions separate from listing/identity data so the UI can support future email/notification workflows without redesigning the user list. +- SMTP-backed user messages should be queued asynchronously and return immediately; delivery must not block the rest of the API request path. +- The Users tab should expose a live queue status indicator so users can see when the outbound email queue is idle, busy, stopped, or failing. +- The queue status indicator should clearly show the current queue item count. +- The Users tab should include a one-click SMTP test action that validates connectivity/authentication without sending a real message. +- The SMTP test action should visibly show when it is running. +- The SMTP test should surface the chosen protocol/port and, for Fastmail, try both 465/SSL and 587/STARTTLS so configuration mismatches are easier to diagnose. +- Rework the Users data into an internal merged state so user identity can be combined with related now-playing/session data. +- Clicking a now-playing row should navigate to the Users tab and open the matching user detail drawer, keeping the selection deep-linkable. +- Provide an explicit "Open in Users" action in now-playing rows in addition to row-click navigation. +- Provide a compact per-field source summary for the Users detail drawer so it is obvious which backend source supplied name, email, avatar, and access data. +- Jellyseerr user list pagination must use `take`/`skip`, not `page`/`pageSize`. +- The Users tab table should stay compact and readable: center the avatar and email cells, keep backend source diagnostics out of the table itself, and prefer a simpler hand-built row layout when a dense grid makes text positioning awkward. +- The Users table activity column should stay compact and show only a brief status badge for playing/paused/idle/no-session state instead of a multi-line activity summary. +- The Dashboard activity panel should reuse the same compact session-table styling as the Users activity details so the two views feel consistent. +- In the shared session activity table, the user column should come before state, title/type, and device because the user is the most relevant identifier. +- The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts. +- The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter. +- The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented. +- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys. + ### Remote Filesystem over SSH - Connect to a remote media server via SSH. - Use strict SSH host key behavior; users should connect manually once to populate `known_hosts`. - Browse remote directories and files rooted at a configurable default media path. - File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`). +- Media index paths should be stored in the SSH-visible form by default, using the same Jellyfin-to-SSH mapping so the Media tab and file browser agree on paths. - Support a configurable Jellyfin-to-SSH fallback path prefix for cases where `REMOTE_MEDIA_ROOT` mapping alone is not sufficient. - Support manual path entry and refresh. - Remote file listing must be compact, structured, and navigable. @@ -94,22 +124,31 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo ### Dashboard / Server Monitoring - Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview. +- Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests. +- Provide Docker Compose deployment files at the repository root for production and local development. - Show Jellyfin media counts for movies, series, and series episodes on the dashboard. -- Show currently playing Jellyfin sessions on the dashboard, including user, media title, playback state, and whether transcoding is active. +- Show dashboard session activity from Jellyfin, including both currently playing sessions and logged-in idle sessions. +- Activity rows should include user, media title (or `(idle)`), playback state (`playing`/`paused`/`idle`), and whether transcoding is active. - Provide a dashboard tab with a compact server resource overview over SSH. - Provide a separate Monitoring tab for detailed resource charts, collector controls, diagnostics, and raw samples. +- The Monitoring tab should request all retained collector samples by default, while the dashboard overview can continue to use a shorter recent window. - Show CPU and RAM usage for the last hour. - Show IO wait percentage for the last hour. +- On the dashboard overview, summarize monitoring metrics as 10-minute averages with high/low values for quick inspection. - Show average and spike/peak values for network throughput and disk I/O. - Show used, available, and total disk space for the configured media root, falling back to `/`. +- Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library. - Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`. - The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap. - The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack. - Last-hour charts require the collector to have been running long enough to collect samples. -- Network throughput should be shown split into down/download and up/upload. +- Because the collector keeps only a bounded history, the Monitoring tab can safely load all retained samples up to the retention/max-lines cap. +- Network throughput should be shown as a combined traffic chart with download and upload lines. - Network throughput should use bytes-per-second display units such as KB/s, MB/s, and GB/s to avoid bit/byte ambiguity. -- Disk throughput should be shown split into read and write. +- Disk throughput should be shown as a combined I/O chart with read and write lines. - Network and disk throughput charts should scale values into readable units such as KB/s, MB/s, and GB/s. +- Each Monitoring chart should show compact summary chips such as min/avg/max for quick inspection. +- The Monitoring toolbar should offer quick time-range buttons such as 1h, 8h, 1 day, and 7 days in addition to free brush selection. ### Remote Jobs @@ -120,202 +159,12 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - Command template values must be shell-quoted before execution. - Future destructive jobs should require explicit confirmation. -## Current Architecture - -- `app.py` - Thin root Streamlit entrypoint for `streamlit run app.py`. -- `pyproject.toml` - Authoritative package metadata, dependencies, and tool configuration. -- `requirements.txt` - Convenience install file that installs the local package editable. -- `src/media_library_viewer/app.py` - Thin Streamlit orchestration layer. -- `src/media_library_viewer/ui/` - Streamlit UI modules split by feature area (dashboard, media, file browser, library, preview/tools). -- `src/media_library_viewer/clients/jellyfin.py` - Jellyfin API wrapper. -- `src/media_library_viewer/clients/ssh.py` - SSH command execution, directory listing, `stat`, and `ffprobe` helpers. -- `src/media_library_viewer/domain/` - UI-independent normalization/domain helpers. -- `src/media_library_viewer/services/` - UI-independent application services such as the SQLite media index. -- `src/media_library_viewer/jobs.py` - Remote job template definitions and runner. -- `src/media_library_viewer/utils.py` - Formatting and media metadata summarization helpers. -- `src/media_library_viewer/config.py` - Environment variable and `.env` configuration loading. -- `docs/REQUIREMENTS.md` - Living requirements and decision log. -- `tests/` - Reserved for future test coverage. - -## Key Implementation Decisions - -- Prefer the Jellyfin API for library and server metadata. -- Prefer SSH plus `ffprobe` for disk-authoritative stream/container metadata. -- Use API-key auth for Jellyfin, but select a user explicitly for user-scoped endpoints. -- Use `streamlit-aggrid` as a required dependency for Media table row selection. Avoid optional frontend fallbacks that create multiple interaction models. -- Keep remote jobs template-based to reduce accidental destructive actions. -- Keep the Phase 1 UI compact and structured rather than using large per-row buttons. -- Use a `src/` package layout so the project can grow without accumulating many root-level modules. -- Keep root `app.py` as a compatibility/convenience wrapper for Streamlit. -- Keep clients, domain normalization, and application services independent from Streamlit so the frontend can later be replaced by React/FastAPI or another UI. -- Keep Streamlit rendering split into small UI modules so interaction bugs can be debugged in feature-local code instead of one monolithic app file. - -## Security and Safety Requirements - -- Do not hardcode secrets. -- Use `.env`, environment variables, or Streamlit secrets for credentials. -- Keep `.env` and Streamlit secrets out of version control. -- Reject unknown SSH host keys by default. -- Treat SSH jobs as potentially dangerous and keep them explicit/template-based. -- Add confirmation steps before implementing cleanup, delete, transcode-replace, or other destructive workflows. - -## Configuration Requirements - -Supported environment variables: - -```bash -JELLYFIN_URL= -JELLYFIN_API_KEY= -JELLYFIN_USER_ID= -SSH_HOST= -SSH_USERNAME= -SSH_PORT=22 -SSH_KEY_FILENAME= -SSH_PASSWORD= -REMOTE_MEDIA_ROOT= -REMOTE_PATH_PREFIX= -``` - -## Known External Requirements - -Remote server should have: - -- Linux `/proc` and `/sys/block` for resource metrics -- `/bin/sh` for POSIX command execution, even when the user's login shell is fish or another non-POSIX shell -- POSIX shell utilities including `awk`, `date`, `tail`, `df`, `kill`, and `nohup` -- `python3` -- GNU/coreutils-compatible `find` and `stat` -- `ffprobe` for media metadata inspection - -Local app dependencies are declared in `pyproject.toml`; `requirements.txt` installs the package editable for convenience. Runtime dependencies include: - -- `streamlit` -- `streamlit-aggrid` -- `requests` -- `paramiko` -- `python-dotenv` -- `pandas` - -## Backlog / Future Extensions - -- Add transcode job templates. -- Add cleanup job templates with dry-run and explicit confirmation. -- Add subtitle/audio-track diagnostics. -- Add sidecar file inspection for `.nfo`, `.srt`, images, and metadata files. -- Compare Jellyfin metadata against disk metadata and sidecars. -- Add long-running job tracking/log streaming. -- Add saved presets for common media roots and job templates. -- Add file previews for text sidecars. -- Add richer HDR/Dolby Vision/bit-depth summaries from `ffprobe`. -- Add optional integration with existing monitoring stacks such as Prometheus/node_exporter, Netdata, or sysstat/sar. - ## Decision Log -### 2026-04-30 - Initial app plan - -- Planned a Streamlit app that uses the Jellyfin API as the primary metadata source. -- Decided SSH should be used for disk inspection and future maintenance jobs. - -### 2026-04-30 - Phase 1 implementation - -- Created the initial app structure with Jellyfin, SSH, jobs, config, and utility modules. -- Added safe/read-only remote job templates. -- Added `ffprobe` and `stat` inspection. - -### 2026-04-30 - Jellyfin API fixes - -- Replaced `/Users/Me` usage with `GET /Users` plus user selection. -- Added `JELLYFIN_USER_ID` override. -- Cleaned Jellyfin `Fields` values to avoid 400 responses. -- Added defensive stripping of trailing `/web` from Jellyfin URLs. - -### 2026-04-30 - Remote file browser evolution - -- Added interactive remote listing. -- Removed emoji and hard-to-render characters. -- Added search, filtering, sorting, pagination, and compact listing summary. -- Reworked listing from large button rows into a compact table. -- Switched to `streamlit-aggrid` for file-browser-like row click behavior. -- Removed visible checkbox/selection column behavior. -- Added `[UP] ..` top row for parent directory navigation. - -### 2026-04-30 - Selected-file metadata preview - -- Added a requirement for automatic `ffprobe` preview when known video files are selected. -- Initially explored asynchronous/non-blocking preview, then changed to a blocking call with a spinner because it is more streamlined for this app. -- Decided to cache preview results briefly and provide a manual reload action. -- Decided `ffprobe` output should be separated into container, video, audio, and subtitle sections to avoid sparse mixed-stream tables. - -### 2026-04-30 - Process requirement - -- Added this living requirements and decision log document. -- Added a global agent skill to encourage maintaining such a document for future projects. - -### 2026-04-30 - Repository restructuring - -- Restructured the project into a larger-project-ready `src/media_library_viewer/` package layout. -- Kept a thin root `app.py` entrypoint so `streamlit run app.py` remains the primary launch command. -- Moved service clients into `src/media_library_viewer/clients/`. -- Added `pyproject.toml` with runtime dependencies, development extras, Ruff configuration, and pytest configuration. -- Simplified `requirements.txt` to install the local project editable. -- Expanded `.gitignore` for Python caches, build artifacts, virtual environments, local secrets, editor files, and logs. - -### 2026-04-30 - Resource dashboard - -- Added a dashboard requirement for CPU, RAM, network, disk I/O, and disk space overview. -- Decided that true last-hour metrics require collection over time; implemented a lightweight SSH-started remote collector instead of requiring Prometheus, Netdata, or sysstat. -- The collector stores JSONL samples in `/tmp` and can be started/stopped from the dashboard. -- Charts show the last hour of collected samples; the dashboard becomes more useful once the collector has been running for a while. -- Last-hour filtering uses epoch seconds rather than local naive datetimes to avoid timezone-offset issues between the app host and remote sample timestamps. -- Fixed SSH command execution to explicitly use `/bin/sh -c` so POSIX resource commands work even when the remote user's login shell is fish. -- Added explicit Streamlit keys to dashboard/file/tool buttons to avoid duplicate auto-generated element IDs as the UI grows. -- Changed the resource collector script from bash-specific syntax to POSIX `/bin/sh` syntax and added dashboard diagnostics/restart controls for collector troubleshooting. -- Added 7-day metrics file pruning plus a 70,000-line safety cap to prevent the JSONL file from growing without bound. -- Split network charts and metrics into download and upload, and disk charts and metrics into read and write. -- Changed network display units from bits per second to bytes per second to avoid Kbps/KB/s ambiguity; the collector still stores bit-rate compatibility fields for old/debug consumers. -- Scaled network and disk throughput charts into readable units such as KB/s, MB/s, and GB/s instead of plotting raw base units. -- Updated collector startup to remove old temporary metrics/log files when a new collector process is started after a schema/display change. -- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Monitoring tab; the Dashboard now keeps a compact overview. -- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Monitoring tab. -- Renamed the remote files tab to File browser. -- Added Jellyfin media counts for movies, series, and episodes to the Dashboard using lightweight count queries. -- Added a Dashboard now-playing section sourced from Jellyfin sessions, showing who is currently playing what and whether each session is transcoding. - -### 2026-04-30 - Media inventory tab - -- Added a paginated Media tab for file-oriented Jellyfin metadata. -- Decided not to fetch all media at once because large libraries can make API responses and Streamlit rendering slow. -- Decided to derive length, size, bitrate, HDR flag, date added, codec, and resolution from Jellyfin metadata for now. -- Added series name, season, and episode number for episode rows. -- Added server-side sort/order controls and read-only AG Grid column sorting/filtering for the loaded page. -- Reworked the Media tab to use a local SQLite media index for full-library sorting/filtering, including numeric sorting for size and bitrate. -- Added last index build duration metadata to the Media tab status line. -- Replaced single-library selection with multi-library selection so users can include/exclude multiple libraries in the indexed table. -- Changed HDR display from blank/no-value to explicit yes/no. -- Added row selection plus an Open folder action in the Media tab that sets the File browser to the containing folder. -- Restored row-based Media table selection while keeping File browser state changes limited to the explicit Open folder button. -- Updated Media tab behavior so selecting a row automatically syncs the File browser folder to that item's containing directory; removed the extra Open folder button step. -- Restored File browser table row selection with AG Grid (single-select), using a table interaction style consistent with the Media tab. -- Reintroduced open-on-select behavior in File browser: selecting a directory row (including `[UP] ..`) opens it immediately, while file rows update selected target path. -- Refined Media table column presentation with explicit user-friendly headers and null-safe display formatting to keep the grid readable and consistent. -- Renamed Resources tab to Monitoring; added IO wait (iowait) percentage to the collector script, metrics, dashboard summary, and detailed charts. -- Removed the Jellyfin library poster-grid tab and its associated cached API calls and UI module; the Media index tab now covers library browsing needs. -- Simplified File browser navigation: removed Up/Go/Select folder buttons; pressing Enter in the path text input navigates directly. -- Added broad inline/module documentation across clients, domain, services, and Streamlit adapter modules to make debugging and future frontend extraction easier. -- Simplified the File browser by removing its interactive AG Grid and using a read-only listing with explicit Open/Select controls, reducing cross-tab state interactions with the Media grid. -- Removed optional/compatibility code paths around the Media table grid and old file-browser state aliases to keep the interaction model easier to reason about during debugging. -- Split the Streamlit frontend into dedicated UI modules (dashboard, media, file browser, library, preview/tools) and reduced `app.py` to orchestration glue. -- Reviewed remote path handling and kept shell interactions routed through quoted paths (`shlex.quote`) while UI/path-parent operations use POSIX path handling, preserving paths with spaces. -- Fixed file browser handoff/navigation to reset stale search/filter/page state when changing folders, preventing old filters from hiding all entries in the newly opened folder. -- Reworked file browser state to separate current directory from selected path. Selecting a file no longer changes the directory being listed, while opening a folder updates the current directory and keeps path input synchronized. -- Made remote directory listing fail explicitly when the current path is not a directory and recover by listing the parent, preventing file paths from appearing as empty directories. -- Made File browser Refresh/Select folder apply a manually typed path if it differs from the current folder, reducing confusion when manually navigating. -- Moved media normalization into `domain/media.py` and index/query logic into `services/media_index.py` to make the project less Streamlit-specific and easier to expose through a future API/React frontend. -- Deferred full ffprobe enrichment for every item to a future cached/background scan. -- Fixed network byte parsing to split `/proc/net/dev` lines at the colon first, so interface indentation differences do not shift fields and accidentally report packet counts instead of byte counts. -- Fixed a follow-up `/proc/net/dev` parsing issue where leading whitespace after the colon could produce an empty first split field in some `awk` implementations, resulting in zero network rates. Added `/proc/net/dev` snapshots to collector diagnostics. -- Simplified File browser directory error behavior: stopped automatic parent-directory fallback and now show the direct listing error for the current path. -- Added configurable `REMOTE_PATH_PREFIX` support so Jellyfin paths can be mapped to SSH-visible paths when opening folders from Media/Library tabs (for example `/media/...` -> `/srv/media/...`). -- Updated path handoff logic to prefer mapping through `REMOTE_MEDIA_ROOT` (anchor replacement using the root basename, e.g. `media`) and use `REMOTE_PATH_PREFIX` as fallback. -- Added a public-repo readiness note in README describing what local/sensitive files must stay out of version control. -- Added `LICENSE` (MIT) and `CONTRIBUTING.md` for public-repo baseline documentation. +- 2026-05-03: Reaffirmed that the Monitoring tab charts should be rendered directly with D3 and expose brush-based time-range selection plus moving averages. +- 2026-05-03: Added hover tooltips, summary chips, a moving vertical cursor, snapped point markers, and a selected-range label to the D3 Monitoring charts for faster visual inspection. +- 2026-05-03: Combined network download/upload into one traffic chart and disk read/write into one I/O chart for clearer Monitoring layout. +- 2026-05-03: Confirmed the shared session activity table should keep the session identifier as a caption under the user name instead of a full column. +- 2026-05-03: Confirmed the shared session table should keep the compact overall status summary line above the rows. +- 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample. +- 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0f64f5c --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md index babb551..ade82bb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -48,6 +48,7 @@ Output goes to `frontend/dist/`. - **Dashboard** (`/`) — Now playing, server overview, library stats - **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls - **Media** (`/media`) — Full-library table with sort/filter/search +- **Users** (`/users`) — Read-only Jellyfin user list with optional Jellyseerr enrichment - **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs ## Environment Variables diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1543342 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4aea484..2039b0f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,18 +8,23 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@tailwindcss/vite": "^4.2.4", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.0.0", + "@mui/material": "^9.0.0", + "@mui/x-data-grid": "^9.0.4", "@tanstack/react-query": "^5.100.6", - "ag-grid-community": "^35.2.1", - "ag-grid-react": "^35.2.1", + "d3": "^7.9.0", + "oidc-client-ts": "^3.5.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-oidc-context": "^3.3.1", "react-router-dom": "^7.14.2", - "recharts": "^3.8.1", - "tailwindcss": "^4.2.4" + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/d3": "^7.4.3", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -37,7 +42,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -93,7 +97,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -127,7 +130,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -137,7 +139,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -169,7 +170,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -179,7 +179,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -213,7 +212,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -225,11 +223,19 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -244,7 +250,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -263,7 +268,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -277,6 +281,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -288,6 +293,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -298,12 +304,165 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -512,6 +671,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -543,10 +703,326 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz", + "integrity": "sha512-uwQNGkhv0lf7ufxw6QXev77BW6pWbW+7uxYjU5+rfp4lBkFtMEgJCsarTM3Tn+i0lGx6+Ol2u88JdGXr0GDskA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.0.tgz", + "integrity": "sha512-oDwyvI6LgjWRC9MBcSGvLkPud9S9ELgSBQFYxa1rYcZn6Br55dn22SyvsPDMsn0G8OndFk53iMT45W5mNqrogw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz", + "integrity": "sha512-+VP/oQCDhDR87NQQgXnNBG8dwy6GNuQLnenS1pZvkbn2dKFSxRSRMybTpH9xUxXP+316mlYDy5CSbYtusnCWtw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/core-downloads-tracker": "^9.0.0", + "@mui/system": "^9.0.0", + "@mui/types": "^9.0.0", + "@mui/utils": "^9.0.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.4", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.0.tgz", + "integrity": "sha512-JtuZoaiCqwD6vjgYu6Xp3T7DZkrxJlgtDz5yESzhI34fEX5hHMh2VJUbuL9UOg8xrfIFMrq6dcYoH/7Zi4G0RA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz", + "integrity": "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.0.0.tgz", + "integrity": "sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/private-theming": "^9.0.0", + "@mui/styled-engine": "^9.0.0", + "@mui/types": "^9.0.0", + "@mui/utils": "^9.0.0", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", + "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", + "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-9.0.4.tgz", + "integrity": "sha512-XhjewU6EGFPXDhVJ48ILA7PXYdGVbIwvy6g3SYkm7yMygb/ScA6k+Uac2zi09OMxJDCK8nRiuhj+ONY4uZZIYQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "9.0.0", + "@mui/x-internals": "^9.0.4", + "@mui/x-virtualizer": "9.0.0-alpha.2", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.4.tgz", + "integrity": "sha512-I84xcPZOEmN29syfAjgsv25kpW7GX9+F7n2xXKEX5C7VdyXITbAW2RomwXt3guud4KwGhkAGuGDPK7Wi20EoCg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "9.0.0", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-virtualizer": { + "version": "9.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-9.0.0-alpha.2.tgz", + "integrity": "sha512-L/7I81NzdQ6a+iu74bkOyVILbwVdAEeCMQLDdmBS77lXVmSZSn3E664oQVdE8uoiSjbOuF0U2LGyM7wH9Ae/nA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "9.0.0", + "@mui/x-internals": "^9.0.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -565,11 +1041,22 @@ "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -613,6 +1100,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -629,6 +1117,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -645,6 +1134,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -661,6 +1151,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -677,6 +1168,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -693,6 +1185,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -712,6 +1205,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -731,6 +1225,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "glibc" ], @@ -750,6 +1245,7 @@ "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -769,6 +1265,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -788,6 +1285,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -807,6 +1305,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -823,6 +1322,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -841,6 +1341,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -857,6 +1358,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -885,275 +1387,6 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, "node_modules/@tanstack/query-core": { "version": "5.100.6", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.6.tgz", @@ -1184,30 +1417,180 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -1223,6 +1606,27 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -1232,6 +1636,20 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -1247,12 +1665,40 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1267,6 +1713,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1278,17 +1731,28 @@ "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1304,6 +1768,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1602,35 +2075,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ag-charts-types": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.2.1.tgz", - "integrity": "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ==", - "license": "MIT" - }, - "node_modules/ag-grid-community": { - "version": "35.2.1", - "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.2.1.tgz", - "integrity": "sha512-ycmGI+1EbUT7i3eg/Kgi1owwnkdHXRufo10Xm6cfSsVPM3TMpvlbLgi28KIPt9DGHZWHq9fOBn7nxMNdv1Yaow==", - "license": "MIT", - "dependencies": { - "ag-charts-types": "13.2.1" - } - }, - "node_modules/ag-grid-react": { - "version": "35.2.1", - "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.2.1.tgz", - "integrity": "sha512-UzdU15R6fyGJB+lBKEC458xacGoZged3Ra6Plqa7LvrJ/Mg0tWn1NH01UnuKyGEKPWMEAGvdXruOtOUywsPElA==", - "license": "MIT", - "dependencies": { - "ag-grid-community": "35.2.1", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1648,6 +2092,21 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1718,6 +2177,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001791", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", @@ -1748,6 +2216,15 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1768,6 +2245,31 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1787,9 +2289,49 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -1802,6 +2344,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -1811,6 +2390,77 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -1820,6 +2470,32 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -1829,6 +2505,27 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -1850,6 +2547,33 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -1866,6 +2590,28 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -1911,11 +2657,45 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1942,15 +2722,35 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.345", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", @@ -1958,17 +2758,22 @@ "dev": true, "license": "ISC" }, - "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" } }, "node_modules/es-toolkit": { @@ -1995,7 +2800,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2217,6 +3021,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2243,6 +3048,12 @@ "node": ">=16.0.0" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2285,6 +3096,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2295,6 +3107,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2331,11 +3152,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/hermes-estree": { "version": "0.25.1", @@ -2354,6 +3181,33 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2374,6 +3228,22 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2393,6 +3263,27 @@ "node": ">=12" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2423,15 +3314,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2442,7 +3324,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -2458,6 +3339,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2485,6 +3372,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2513,6 +3409,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2545,6 +3442,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2565,6 +3463,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2585,6 +3484,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2605,6 +3505,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2625,6 +3526,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2645,6 +3547,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -2668,6 +3571,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -2691,6 +3595,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -2714,6 +3619,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -2737,6 +3643,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2757,6 +3664,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2770,6 +3678,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2808,15 +3722,6 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2837,13 +3742,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -2881,6 +3786,18 @@ "node": ">=0.10.0" } }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2931,6 +3848,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2951,6 +3898,21 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2961,6 +3923,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2973,6 +3936,7 @@ "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3059,8 +4023,20 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, + "node_modules/react-oidc-context": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz", + "integrity": "sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==", "license": "MIT", - "peer": true + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } }, "node_modules/react-redux": { "version": "9.2.0", @@ -3123,6 +4099,22 @@ "react-dom": ">=18" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -3174,10 +4166,47 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.127.0", @@ -3211,6 +4240,19 @@ "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/scheduler": { @@ -3258,32 +4300,41 @@ "node": ">=8" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/tiny-invariant": { @@ -3296,6 +4347,7 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3325,6 +4377,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -3383,7 +4436,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -3462,6 +4515,7 @@ "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/frontend/package.json b/frontend/package.json index 37a2967..9e38f7b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,18 +10,23 @@ "preview": "vite preview" }, "dependencies": { - "@tailwindcss/vite": "^4.2.4", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.0.0", + "@mui/material": "^9.0.0", + "@mui/x-data-grid": "^9.0.4", "@tanstack/react-query": "^5.100.6", - "ag-grid-community": "^35.2.1", - "ag-grid-react": "^35.2.1", + "d3": "^7.9.0", + "oidc-client-ts": "^3.5.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-oidc-context": "^3.3.1", "react-router-dom": "^7.14.2", - "recharts": "^3.8.1", - "tailwindcss": "^4.2.4" + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/d3": "^7.4.3", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8340802..61bec96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,62 +1,236 @@ -import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom"; +import { + BrowserRouter, + Routes, + Route, + NavLink, + useLocation, +} from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "@mui/material/styles"; +import { + AppBar, + Toolbar, + Typography, + Box, + Tabs, + Tab, + Container, + CssBaseline, + Chip, + useMediaQuery, + Button, + Stack, + Card, + CardContent, + CircularProgress, +} from "@mui/material"; +import { useEffect, useMemo } from "react"; +import { AuthProvider, useAuth } from "react-oidc-context"; import { Dashboard } from "./pages/Dashboard"; import { Monitoring } from "./pages/Monitoring"; import { Media } from "./pages/Media"; +import { UsersPage } from "./pages/Users"; import { FileBrowser } from "./pages/FileBrowser"; +import { getAppTheme } from "./theme"; +import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: false, - }, - }, + defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } }, }); -const navLinks = [ - { to: "/", label: "Dashboard" }, - { to: "/monitoring", label: "Monitoring" }, - { to: "/media", label: "Media" }, - { to: "/files", label: "File Browser" }, -]; +function Shell({ + darkMode, + authLabel, + onSignOut, +}: { + darkMode: boolean; + authLabel?: string; + onSignOut?: () => void; +}) { + const location = useLocation(); + const current = location.pathname; -function NavBar() { return ( - + <> + + + + + Media Library Viewer + + + + + + + + + + {authLabel && ( + + )} + + {onSignOut && ( + + )} + + + + + + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +function LoadingScreen({ label }: { label: string }) { + return ( + + + + + + {label} + + + + + ); +} + +function SignInScreen({ onSignIn }: { onSignIn: () => void }) { + return ( + + + + + Sign in required + + Use your Authentik account to access the media library viewer. + + + + + + + ); +} + +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 | undefined; + return String( + profile?.name ?? + profile?.preferred_username ?? + profile?.email ?? + auth.user?.profile?.sub ?? + "Authenticated", + ); + }, [auth.user]); + + if (auth.isLoading || auth.activeNavigator) { + return ; + } + + if (auth.error) { + return ( + + ); + } + + if (!auth.isAuthenticated) { + return void auth.signinRedirect()} />; + } + + return ( + + + void auth.signoutRedirect()} + /> + + + ); +} + +function AppInner() { + const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + const theme = useMemo( + () => getAppTheme(prefersDarkMode ? "dark" : "light"), + [prefersDarkMode], + ); + + return ( + + + {isOidcConfigured() ? ( + + + + ) : ( + + + + + + )} + + ); } export default function App() { - return ( - - -
- -
- - } /> - } /> - } /> - } /> - -
-
-
-
- ); + return ; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6077716..530a87a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,14 +2,20 @@ * Typed API client for the FastAPI backend. */ +import { getAccessToken } from "../auth"; import type { MediaCounts, LibraryCount, + UserDirectoryResponse, + UserMessageResponse, + UserMessageQueueStatus, + SmtpTestResponse, NowPlayingSession, MonitoringStatus, MonitoringMetrics, DiskSpace, MediaIndexStatus, + MediaIndexActionResponse, MediaQueryResponse, DirectoryListing, JobTemplate, @@ -17,36 +23,89 @@ import type { ResolvedPath, } from "../types"; -const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; +const BASE_URL = import.meta.env.VITE_API_URL || "/api"; + +function isAbsoluteUrl(value: string): boolean { + return /^https?:\/\//i.test(value) || value.startsWith("//"); +} + +function buildUrl(path: string, params?: Record): string { + 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( - path: string, - params?: Record, -): Promise { const url = new URL(path, BASE_URL); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== "") url.searchParams.set(key, value); }); } - const response = await fetch(url.toString()); + return url.toString(); +} + +async function readErrorDetail(response: Response): Promise { + 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( + path: string, + params?: Record, +): Promise { + const response = await fetch(buildUrl(path, params), { + headers: buildHeaders(false), + }); if (!response.ok) { - const detail = await response.text(); - throw new Error(`${response.status}: ${detail}`); + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); } return response.json(); } async function post(path: string, body?: unknown): Promise { - const url = new URL(path, BASE_URL); - const response = await fetch(url.toString(), { + const response = await fetch(buildUrl(path), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: buildHeaders(true), body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { - const detail = await response.text(); - throw new Error(`${response.status}: ${detail}`); + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} + +async function postForm(path: string, body: FormData): Promise { + const headers = buildHeaders(false); + const response = await fetch(buildUrl(path), { + method: "POST", + headers, + body, + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); } return response.json(); } @@ -55,15 +114,23 @@ async function post(path: string, body?: unknown): Promise { export const fetchCounts = () => get("/api/dashboard/counts"); export const fetchLibraries = () => get("/api/dashboard/libraries"); -export const fetchNowPlaying = () => - get("/api/dashboard/now-playing"); +export const fetchActivity = () => + get("/api/dashboard/activity"); +export const fetchUsers = () => get("/api/users"); + +// Backward-compatible alias used by older hooks/components. +export const fetchNowPlaying = fetchActivity; // Monitoring export const fetchMonitoringStatus = () => get("/api/monitoring/status"); -export const fetchMonitoringMetrics = (lastSeconds = 3600) => +export const fetchMonitoringMetrics = ( + lastSeconds?: number | null, + maxLines = 70_000, +) => get("/api/monitoring/metrics", { - last_seconds: String(lastSeconds), + ...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }), + max_lines: String(maxLines), }); export const fetchDiskSpace = () => get("/api/monitoring/disk"); export const startCollector = () => @@ -77,7 +144,11 @@ export const restartCollector = () => export const fetchMediaStatus = () => get("/api/media/status"); export const buildMediaIndex = () => - post<{ indexed_items: number }>("/api/media/build"); + post("/api/media/build"); +export const stopMediaIndexBuild = () => + post("/api/media/stop"); +export const forceStopMediaIndexBuild = () => + post("/api/media/force-stop"); export const queryMedia = (params: { libraries?: string; types?: string; @@ -114,3 +185,12 @@ export const fetchJobTemplates = () => get("/api/jobs/templates"); export const runJob = (jobKey: string, path: string) => post("/api/jobs/run", { job_key: jobKey, path }); + +export const fetchUserMessageQueueStatus = () => + get("/api/users/message/status"); + +export const testUserSmtpConnection = () => + post("/api/users/message/test-smtp"); + +export const sendUserMessage = (formData: FormData) => + postForm("/api/users/message", formData); diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..fd34f04 --- /dev/null +++ b/frontend/src/auth.ts @@ -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; +} diff --git a/frontend/src/components/LibraryOverview.tsx b/frontend/src/components/LibraryOverview.tsx index 3873590..06f4328 100644 --- a/frontend/src/components/LibraryOverview.tsx +++ b/frontend/src/components/LibraryOverview.tsx @@ -1,3 +1,4 @@ +import { Card, CardContent, Grid, Stack, Typography } from "@mui/material"; import type { LibraryCount } from "../types"; interface Props { @@ -9,47 +10,47 @@ export function LibraryOverview({ libraries }: Props) { const tvLibs = libraries.filter((l) => l.type === "tvshows"); return ( -
- {movieLibs.length > 0 && ( -
-

- Movie libraries -

+ + + + Movie libraries + + {movieLibs.map((lib) => ( -
-

{lib.library}

-
- - Total: {lib.total.toLocaleString()} - - - Movies: {lib.movies.toLocaleString()} - -
-
+ + + + {lib.library} + + + Total: {lib.total.toLocaleString()} | Movies:{" "} + {lib.movies.toLocaleString()} + + + ))} -
- )} - {tvLibs.length > 0 && ( -
-

- TV libraries -

+ + + + + TV libraries + + {tvLibs.map((lib) => ( -
-

{lib.library}

-
- - Total: {lib.total.toLocaleString()} - - - Series: {lib.series.toLocaleString()} - -
-
+ + + + {lib.library} + + + Total: {lib.total.toLocaleString()} | Series:{" "} + {lib.series.toLocaleString()} + + + ))} -
- )} -
+ + + ); } diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 6c80e02..6aba8a2 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -1,3 +1,5 @@ +import { Card, CardContent, Typography } from "@mui/material"; + interface Props { label: string; value: string; @@ -6,14 +8,28 @@ interface Props { export function MetricCard({ label, value, subtext }: Props) { return ( -
-

{label}

-

{value}

- {subtext && ( -

- {subtext} -

- )} -
+ + + + {label} + + + {value} + + {subtext && ( + + {subtext} + + )} + + ); } diff --git a/frontend/src/components/MonitoringCharts.tsx b/frontend/src/components/MonitoringCharts.tsx index e4951c7..27dde6b 100644 --- a/frontend/src/components/MonitoringCharts.tsx +++ b/frontend/src/components/MonitoringCharts.tsx @@ -1,28 +1,70 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as d3 from "d3"; import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from "recharts"; + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + FormControlLabel, + Grid, + Typography, +} from "@mui/material"; import type { MonitoringSample } from "../types"; interface Props { samples: MonitoringSample[]; } -function formatTime(ts: number) { - return new Date(ts * 1000).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); +type MetricKey = + | "cpu" + | "iowait" + | "mem" + | "netDown" + | "netUp" + | "diskRead" + | "diskWrite"; + +interface DataPoint { + ts: number; + cpu: number; + iowait: number; + mem: number; + netDown: number; + netUp: number; + diskRead: number; + diskWrite: number; } +interface MetricConfig { + key: MetricKey; + label: string; + color: string; +} + +interface ChartProps { + title: string; + data: DataPoint[]; + metrics: MetricConfig[]; + showAverages: boolean; + averages: Record; + yFormatter?: (v: number) => string; +} + +interface BrushProps { + data: DataPoint[]; + selectionRange: [number, number] | null; + onBrush: (range: [number, number] | null) => void; +} + +const MOVING_AVG_WINDOW = 10; +const CHART_HEIGHT = 280; +const BRUSH_HEIGHT = 84; +const BRUSH_LABEL_HEIGHT = 24; + function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B/s"; + if (!bytes) return "0 B/s"; const units = ["B/s", "KB/s", "MB/s", "GB/s"]; let value = bytes; let unitIdx = 0; @@ -33,147 +75,741 @@ function formatBytes(bytes: number): string { return `${value.toFixed(1)} ${units[unitIdx]}`; } +function formatTime(ts: number) { + return new Date(ts * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +function movingAverage(values: number[]): number[] { + if (values.length === 0) return []; + return values.map((_, index) => { + const start = Math.max(0, index - MOVING_AVG_WINDOW + 1); + const slice = values.slice(start, index + 1); + return slice.reduce((sum, value) => sum + value, 0) / slice.length; + }); +} + +function buildAverages(samples: DataPoint[]): Record { + 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 () +// ══════════════════════════════════════════════════════════════════════════ + +function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [width, setWidth] = useState(0); + const brushGroupRef = useRef | null>(null); + const brushRef = useRef | null>(null); + const brushXRef = useRef | 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() + .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) => { + 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) => { + 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, + 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 ( + + + Time range — drag the left/right ends or the middle + + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════ +// Parent: MonitoringCharts +// ══════════════════════════════════════════════════════════════════════════ + export function MonitoringCharts({ samples }: Props) { - if (samples.length === 0) { + const [showAverages, setShowAverages] = useState(false); + const [selectionRange, setSelectionRange] = useState<[number, number] | null>( + null, + ); + + const baseData = useMemo( + () => + 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 ( -

No monitoring samples available.

+ + No monitoring samples available. + ); } - 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 ( -
-
-

- CPU, IO Wait, and RAM - last hour -

- - - - - - - - + {/* Toolbar */} + + setShowAverages(event.target.checked)} /> - - - - -
+ } + label={`Show ${MOVING_AVG_WINDOW}-point moving average`} + /> + + + {zoomOptions.map((option) => ( + + ))} + + + -
-
-

Network download

- - - - - formatBytes(v)} /> - formatBytes(Number(v))} /> - - - -
-
-

Network upload

- - - - - formatBytes(v)} /> - formatBytes(Number(v))} /> - - - -
-
+ {/* Shared brush slider above all graphs */} + -
-
-

Disk read

- - - - - formatBytes(v)} /> - formatBytes(Number(v))} /> - - - -
-
-

Disk write

- - - - - formatBytes(v)} /> - formatBytes(Number(v))} /> - - - -
-
-
+ {/* Chart grid */} + + + + + + + + + + + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════ +// MonitoringD3Chart – single chart (lines + hover, no brush) +// ══════════════════════════════════════════════════════════════════════════ + +function MonitoringD3Chart({ + title, + data, + metrics, + showAverages, + averages, + yFormatter, +}: ChartProps) { + const containerRef = useRef(null); + const svgRef = useRef(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() + .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() + .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("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("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 ( + + + + {title} + + + + + + + + + + + {metrics.map((metric) => ( + + + {metric.label} + + ))} + {showAverages ? ( + Dashed = moving average + ) : null} + + + ); } diff --git a/frontend/src/components/NowPlaying.tsx b/frontend/src/components/NowPlaying.tsx index f3fa952..58ac31c 100644 --- a/frontend/src/components/NowPlaying.tsx +++ b/frontend/src/components/NowPlaying.tsx @@ -1,46 +1,22 @@ +import { Card, CardContent } from "@mui/material"; import type { NowPlayingSession } from "../types"; +import { SessionActivityPanel } from "./SessionActivityPanel"; interface Props { sessions: NowPlayingSession[]; + onSelectSession?: (session: NowPlayingSession) => void; } -export function NowPlaying({ sessions }: Props) { - if (sessions.length === 0) { - return ( -

- No active playback sessions right now. -

- ); - } - +export function NowPlaying({ sessions, onSelectSession }: Props) { return ( -
- - - - - - - - - - - - - - {sessions.map((s) => ( - - - - - - - - - - ))} - -
UserTitleTypeStateTranscodingTranscode typeDevice
{s.user}{s.title}{s.type}{s.state}{s.transcoding}{s.transcoding_type}{s.device}
-
+ + + + + ); } diff --git a/frontend/src/components/SessionActivityPanel.tsx b/frontend/src/components/SessionActivityPanel.tsx new file mode 100644 index 0000000..3fa4388 --- /dev/null +++ b/frontend/src/components/SessionActivityPanel.tsx @@ -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 ( + + {emptyMessage} + + ); + } + + return ( + + + + + + User + + + State + + + Title / Type + + + Device + + + Transcoding + + {onSelectSession ? ( + + Action + + ) : null} + + + + + + + {buildStatusSummary(sessions)} + + + + {sessions.map((session) => { + const state = String(session.state || "") + .trim() + .toLowerCase(); + const sessionLabel = formatStateLabel(session.state); + return ( + onSelectSession(session) : undefined + } + > + + + {session.user || userFallback} + + + {session.session_id} + + + + + + + + {session.title || "(idle)"} + + + {session.type || "—"} + + + + + {session.device || "Unknown device"} + + + + + {session.transcoding === "yes" + ? session.transcoding_type + ? `yes (${session.transcoding_type})` + : "yes" + : "no"} + + + {onSelectSession ? ( + + + + ) : null} + + ); + })} + +
+
+ ); +} diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index d2bf21c..0da8a79 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client"; +import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client"; export function useCounts() { return useQuery({ @@ -17,10 +17,13 @@ export function useLibraries() { }); } -export function useNowPlaying() { +export function useActivity() { return useQuery({ - queryKey: ["dashboard", "now-playing"], - queryFn: fetchNowPlaying, + queryKey: ["dashboard", "activity"], + queryFn: fetchActivity, refetchInterval: 15_000, }); } + +// Backward-compatible alias used by older code. +export const useNowPlaying = useActivity; diff --git a/frontend/src/hooks/useMedia.ts b/frontend/src/hooks/useMedia.ts index 5157c14..e957a4d 100644 --- a/frontend/src/hooks/useMedia.ts +++ b/frontend/src/hooks/useMedia.ts @@ -1,11 +1,20 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client"; +import { + fetchMediaStatus, + buildMediaIndex, + queryMedia, + stopMediaIndexBuild, + forceStopMediaIndexBuild, +} from "../api/client"; export function useMediaStatus() { return useQuery({ queryKey: ["media", "status"], queryFn: fetchMediaStatus, - staleTime: 60_000, + staleTime: 5_000, + refetchInterval: (query) => + query.state.data?.build_running ? 1000 : false, + refetchIntervalInBackground: true, }); } @@ -21,6 +30,8 @@ export function useMediaQuery(params: { enabled?: boolean; }) { const { enabled = true, ...queryParams } = params; + + // Feature: Sync file browser with selected media path return useQuery({ queryKey: ["media", "query", queryParams], queryFn: () => queryMedia(queryParams), @@ -29,12 +40,36 @@ export function useMediaQuery(params: { }); } +function invalidateMedia(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: ["media"] }); +} + export function useBuildIndex() { const queryClient = useQueryClient(); return useMutation({ mutationFn: buildMediaIndex, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["media"] }); + invalidateMedia(queryClient); + }, + }); +} + +export function useStopBuildIndex() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: stopMediaIndexBuild, + onSuccess: () => { + invalidateMedia(queryClient); + }, + }); +} + +export function useForceStopBuildIndex() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: forceStopMediaIndexBuild, + onSuccess: () => { + invalidateMedia(queryClient); }, }); } diff --git a/frontend/src/hooks/useMonitoring.ts b/frontend/src/hooks/useMonitoring.ts index 19ee1c9..4618aaf 100644 --- a/frontend/src/hooks/useMonitoring.ts +++ b/frontend/src/hooks/useMonitoring.ts @@ -16,10 +16,10 @@ export function useMonitoringStatus() { }); } -export function useMonitoringMetrics(lastSeconds = 3600) { +export function useMonitoringMetrics() { return useQuery({ - queryKey: ["monitoring", "metrics", lastSeconds], - queryFn: () => fetchMonitoringMetrics(lastSeconds), + queryKey: ["monitoring", "metrics"], + queryFn: () => fetchMonitoringMetrics(), refetchInterval: 15_000, }); } diff --git a/frontend/src/hooks/useSendUserMessage.ts b/frontend/src/hooks/useSendUserMessage.ts new file mode 100644 index 0000000..fd7a91a --- /dev/null +++ b/frontend/src/hooks/useSendUserMessage.ts @@ -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"] }); + }, + }); +} diff --git a/frontend/src/hooks/useTestUserSmtp.ts b/frontend/src/hooks/useTestUserSmtp.ts new file mode 100644 index 0000000..4245baf --- /dev/null +++ b/frontend/src/hooks/useTestUserSmtp.ts @@ -0,0 +1,8 @@ +import { useMutation } from "@tanstack/react-query"; +import { testUserSmtpConnection } from "../api/client"; + +export function useTestUserSmtp() { + return useMutation({ + mutationFn: testUserSmtpConnection, + }); +} diff --git a/frontend/src/hooks/useUserMessageQueueStatus.ts b/frontend/src/hooks/useUserMessageQueueStatus.ts new file mode 100644 index 0000000..2e5daea --- /dev/null +++ b/frontend/src/hooks/useUserMessageQueueStatus.ts @@ -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, + }); +} diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/useUsers.ts new file mode 100644 index 0000000..3770c9f --- /dev/null +++ b/frontend/src/hooks/useUsers.ts @@ -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, + }); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f1d8c73..30e636a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,7 @@ -@import "tailwindcss"; +html, +body, +#root { + margin: 0; + width: 100%; + min-height: 100%; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 645ce43..f1fa2a6 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,7 @@ -import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard"; +import { useMemo } from "react"; +import { Box, Divider, Grid, Stack, Typography } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard"; import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring"; import { NowPlaying } from "../components/NowPlaying"; import { MetricCard } from "../components/MetricCard"; @@ -20,96 +23,223 @@ function formatRate(bytes: number): string { return `${formatBytes(bytes)}/s`; } +function formatPct(value: number): string { + return `${value.toFixed(1)}%`; +} + +function summarize(values: number[]) { + if (values.length === 0) return null; + const total = values.reduce((sum, value) => sum + value, 0); + return { + avg: total / values.length, + min: Math.min(...values), + max: Math.max(...values), + }; +} + export function Dashboard() { + const navigate = useNavigate(); const { data: counts } = useCounts(); const { data: libraries } = useLibraries(); - const { data: nowPlaying } = useNowPlaying(); + const { data: activity } = useActivity(); const { data: metrics } = useMonitoringMetrics(); const { data: disk } = useDiskSpace(); - const latest = metrics?.samples?.at(-1); + const monitoringWindow = useMemo(() => { + const samples = metrics?.samples ?? []; + if (samples.length === 0) return []; + const latestTs = samples.at(-1)?.ts ?? 0; + const windowStart = latestTs - 10 * 60; + const windowed = samples.filter((sample) => sample.ts >= windowStart); + return windowed.length > 0 ? windowed : samples; + }, [metrics?.samples]); + + const cpuSummary = summarize( + monitoringWindow.map((sample) => sample.cpu_pct), + ); + const iowaitSummary = summarize( + monitoringWindow + .map((sample) => sample.iowait_pct) + .filter((value): value is number => value !== undefined), + ); + const memSummary = summarize( + monitoringWindow.map((sample) => sample.mem_pct), + ); + const netRxSummary = summarize( + monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec), + ); + const netTxSummary = summarize( + monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec), + ); + const diskReadSummary = summarize( + monitoringWindow.map((sample) => sample.disk_read_bps), + ); + const diskWriteSummary = summarize( + monitoringWindow.map((sample) => sample.disk_write_bps), + ); return ( -
- {/* Now Playing */} -
-

Now playing

- {nowPlaying && } -
- -
- - {/* Server Overview */} -
-

Server overview

-
- + + + Activity + + {activity && ( + + navigate(`/users?user=${encodeURIComponent(session.user)}`) + } /> - - - - - - -
- {disk && ( -
- - - - -
)} -
+ -
+ - {/* Media Library Overview */} -
-

Media library overview

+ + + Monitoring Overview + + + + + + + + + + + + + + + + + + + + + + + + + {disk && ( + + + + + + + + + + + + + + + )} + + + + + + + Library Stats + {counts && ( -
- - - - -
+ + + + + + + + + + + + + + )} {libraries && } -
-
+ + ); } diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index 665abf4..4a3198a 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -1,5 +1,23 @@ -import { useState, useCallback, useRef } from "react"; -import { AgGridReact } from "ag-grid-react"; +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { DataGrid } from "@mui/x-data-grid"; +import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; import { useDirectoryListing, useFfprobe, @@ -8,6 +26,7 @@ import { } from "../hooks/useFiles"; interface DisplayRow { + id: string; type: string; name: string; ext: string; @@ -16,6 +35,46 @@ interface DisplayRow { path: string; } +interface FfprobeStream { + index?: number; + codec_type?: string; + codec_name?: string; + codec_long_name?: string; + profile?: string; + width?: number; + height?: number; + bit_rate?: string | number; + duration?: string | number; + channels?: number; + sample_rate?: string | number; + channel_layout?: string; + pix_fmt?: string; + sample_aspect_ratio?: string; + display_aspect_ratio?: string; + field_order?: string; + level?: number | string; + color_range?: string; + color_space?: string; + color_transfer?: string; + color_primaries?: string; + tags?: Record; +} + +interface FfprobeFormat { + filename?: string; + format_name?: string; + format_long_name?: string; + duration?: string | number; + size?: string | number; + bit_rate?: string | number; + tags?: Record; +} + +interface FfprobeData { + format?: FfprobeFormat; + streams?: FfprobeStream[]; +} + function formatSize(bytes: number): string { if (bytes === 0) return "-"; const units = ["B", "KB", "MB", "GB", "TB"]; @@ -33,6 +92,45 @@ function formatTime(epoch: number): string { return new Date(epoch * 1000).toLocaleString(); } +function humanBytes(value: string | number | undefined): string { + if (value === undefined || value === null || value === "") return "-"; + const bytes = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(bytes)) return "-"; + return formatSize(bytes); +} + +function humanRate(value: string | number | undefined): string { + if (value === undefined || value === null || value === "") return "-"; + const rate = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(rate)) return "-"; + const units = ["bps", "Kbps", "Mbps", "Gbps"]; + let v = rate; + let unitIdx = 0; + while (v >= 1000 && unitIdx < units.length - 1) { + v /= 1000; + unitIdx++; + } + return `${v.toFixed(1)} ${units[unitIdx]}`; +} + +function humanDuration(value: string | number | undefined): string { + if (value === undefined || value === null || value === "") return "-"; + const seconds = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(seconds)) return "-"; + const total = Math.max(0, Math.round(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (hours > 0) + return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + return `${minutes}:${String(secs).padStart(2, "0")}`; +} + +function fieldLabel(_key: string, value: string | number | undefined): string { + if (value === undefined || value === null || value === "") return "-"; + return String(value); +} + function isVideoFile(name: string): boolean { const exts = [ ".mkv", @@ -48,38 +146,446 @@ function isVideoFile(name: string): boolean { return exts.some((ext) => name.toLowerCase().endsWith(ext)); } -export function FileBrowser() { - const [currentDir, setCurrentDir] = useState("/"); - const [pathInput, setPathInput] = useState("/"); - const [selectedPath, setSelectedPath] = useState(null); +function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) { + const format = data.format ?? {}; + const streams = data.streams ?? []; + const videoStreams = streams.filter( + (stream) => stream.codec_type === "video", + ); + const audioStreams = streams.filter( + (stream) => stream.codec_type === "audio", + ); + const subtitleStreams = streams.filter( + (stream) => stream.codec_type === "subtitle", + ); - const { data: listing, isLoading, error } = useDirectoryListing(currentDir); - const { data: ffprobeData } = useFfprobe( + return ( + + + + ffprobe details + + + {path} + + + + + + + Container / format + + + + + Format: {fieldLabel("format", format.format_name)} + + + Long name:{" "} + {fieldLabel("format_long_name", format.format_long_name)} + + + Duration: {humanDuration(format.duration)} + + + + + Size: {humanBytes(format.size)} + + + Bitrate: {humanRate(format.bit_rate)} + + + Filename: {fieldLabel("filename", format.filename)} + + + + + + + + + + Streams + + + {videoStreams.length > 0 && ( + + + Video streams + + + {videoStreams.map((stream, index) => ( + + + + + + {stream.codec_long_name && ( + + )} + {stream.profile && ( + + )} + {stream.bit_rate && ( + + )} + {stream.duration && ( + + )} + {stream.width && stream.height && ( + + )} + {stream.pix_fmt && ( + + )} + {stream.display_aspect_ratio && ( + + )} + {stream.sample_aspect_ratio && ( + + )} + {stream.level !== undefined && + stream.level !== null && ( + + )} + {stream.field_order && + stream.field_order !== "unknown" && ( + + )} + {(stream.color_range || + stream.color_space || + stream.color_transfer || + stream.color_primaries) && ( + + )} + + + {stream.tags?.language + ? `Language: ${stream.tags.language}. ` + : ""} + {stream.tags?.title + ? `Title: ${stream.tags.title}.` + : ""} + + + ))} + + + )} + + {audioStreams.length > 0 && ( + + + Audio streams + + + {audioStreams.map((stream, index) => ( + + + + + + {stream.channels && ( + + )} + {stream.sample_rate && ( + + )} + {stream.bit_rate && ( + + )} + {stream.duration && ( + + )} + + + {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}.` + : ""} + + + ))} + + + )} + + {subtitleStreams.length > 0 && ( + + + Subtitle streams + + + {subtitleStreams.map((stream, index) => ( + + + + + + {stream.tags?.language && ( + + )} + {stream.tags?.title && ( + + )} + + + ))} + + + )} + + {streams.length === 0 && ( + + No streams found. + + )} + + + + + {Object.keys(format.tags ?? {}).length > 0 && ( + + + + Tags + + + {Object.entries(format.tags ?? {}).map(([key, value]) => ( + + ))} + + + + )} + + ); +} + +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( + initialSelectedPath, + ); + const [selectedJob, setSelectedJob] = useState(""); + + const { + data: listing, + isLoading, + error, + refetch, + } = useDirectoryListing(currentDir); + const { + data: ffprobeData, + isLoading: ffprobeLoading, + error: ffprobeError, + } = useFfprobe( selectedPath ?? "", !!selectedPath && isVideoFile(selectedPath), ); const { data: templates } = useJobTemplates(); const runJob = useRunJob(); - const gridRef = useRef>(null); - - const navigate = useCallback((path: string) => { + const navigate = (path: string) => { setCurrentDir(path); setPathInput(path); setSelectedPath(null); - }, []); - - const handlePathSubmit = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - navigate(pathInput || "/"); - } }; - // Build display rows + const handlePathSubmit = (e: React.KeyboardEvent) => { + if (e.key === "Enter") navigate(pathInput || "/"); + }; + const rows: DisplayRow[] = []; if (currentDir !== "/") { const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/"; rows.push({ + id: `up-${parent}`, type: "up", name: "..", ext: "", @@ -92,131 +598,183 @@ export function FileBrowser() { for (const entry of listing.entries) { const kind = entry.type === "d" ? "dir" : "file"; const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : ""; + const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`; rows.push({ + id: path, type: kind, name: entry.name, ext, size: kind === "dir" ? "-" : formatSize(entry.size), modified: formatTime(entry.mtime), - path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`, + path, }); } } - const columnDefs = [ - { field: "type" as const, headerName: "Type", width: 80 }, - { field: "name" as const, headerName: "Name", flex: 2 }, - { field: "ext" as const, headerName: "Ext", width: 80 }, - { field: "size" as const, headerName: "Size", width: 110 }, - { field: "modified" as const, headerName: "Modified", width: 180 }, + const columns: GridColDef[] = [ + { field: "type", headerName: "Type", width: 90 }, + { field: "name", headerName: "Name", flex: 1.2, minWidth: 220 }, + { field: "ext", headerName: "Ext", width: 90 }, + { field: "size", headerName: "Size", width: 120 }, + { field: "modified", headerName: "Modified", width: 190 }, ]; - const onRowClicked = useCallback( - (event: { data?: DisplayRow }) => { - const row = event.data; - if (!row) return; - if (row.type === "dir" || row.type === "up") { - navigate(row.path); - } else { - setSelectedPath(row.path); - } - }, - [navigate], - ); + const rowSelectionModel: GridRowSelectionModel = selectedPath + ? { type: "include", ids: new Set([selectedPath]) } + : { type: "include", ids: new Set() }; + const selectedTemplate = templates?.find((t) => t.key === selectedJob); return ( -
- {/* Path input */} -
- + File Browser + + + setPathInput(e.target.value)} onKeyDown={handlePathSubmit} - className="border rounded px-3 py-1 text-sm flex-1" - placeholder="Remote path (press Enter to navigate)" /> - + -
+ + - {/* Status */} -
- - Current: {currentDir} - - {selectedPath && ( - - Selected: {selectedPath} - - )} - {listing && Entries: {listing.count}} -
+ + Current: {currentDir}{" "} + {selectedPath ? `| Selected: ${selectedPath}` : ""}{" "} + {listing ? `| Entries: ${listing.count}` : ""} + - {error &&

Error: {String(error)}

} + {error && {String(error)}} - {/* File listing grid */} -
- - ref={gridRef} - rowData={rows} - columnDefs={columnDefs} - rowSelection="single" - onRowClicked={onRowClicked} + + { + const row = params.row as DisplayRow; + if (row.type === "dir" || row.type === "up") navigate(row.path); + else setSelectedPath(row.path); + }} /> -
+ - {/* ffprobe preview */} {selectedPath && isVideoFile(selectedPath) && ( -
-

- ffprobe preview: {selectedPath} -

- {ffprobeData ? ( -
-							{JSON.stringify(ffprobeData, null, 2)}
-						
- ) : ( -

Loading ffprobe data...

- )} -
+ + + {ffprobeError ? ( + + {String(ffprobeError)} + + ) : ffprobeLoading && !ffprobeData ? ( + + Loading ffprobe data... + + ) : ffprobeData ? ( + + ) : ( + + No ffprobe data available. + + )} + + )} - {/* Jobs */} {selectedPath && templates && templates.length > 0 && ( -
-

Jobs

-
- {templates.map((tpl) => ( - + {selectedTemplate && ( + + {selectedTemplate.description} + + )} + + + + + {runJob.data && ( + - {tpl.name} - - ))} -
- {runJob.data && ( -
-							Exit: {runJob.data.exit_status}
-							{"\n"}
-							{runJob.data.stdout}
-							{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
-						
- )} -
+ Exit: {runJob.data.exit_status} + {"\n"} + {runJob.data.stdout} + {runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`} + + )} + + )} -
+ ); } diff --git a/frontend/src/pages/Media.tsx b/frontend/src/pages/Media.tsx index 06a10cf..b99d681 100644 --- a/frontend/src/pages/Media.tsx +++ b/frontend/src/pages/Media.tsx @@ -1,15 +1,49 @@ -import { useState, useCallback, useRef } from "react"; -import { AgGridReact } from "ag-grid-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { DataGrid } from "@mui/x-data-grid"; +import type { GridColDef } from "@mui/x-data-grid"; +import { + Alert, + Box, + Button, + Card, + CardContent, + LinearProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; import { useMediaStatus, useMediaQuery, useBuildIndex, + useStopBuildIndex, + useForceStopBuildIndex, } from "../hooks/useMedia"; import type { MediaItem } from "../types"; +function formatDuration(seconds: number | null | undefined): string { + if (seconds == null || Number.isNaN(seconds)) return "-"; + const total = Math.max(0, Math.round(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (hours > 0) return `${hours}h ${minutes}m ${secs}s`; + if (minutes > 0) return `${minutes}m ${secs}s`; + return `${secs}s`; +} + export function Media() { + const navigate = useNavigate(); const { data: status } = useMediaStatus(); const buildIndex = useBuildIndex(); + const stopBuildIndex = useStopBuildIndex(); + const forceStopBuildIndex = useForceStopBuildIndex(); const [search, setSearch] = useState(""); const [types, setTypes] = useState("Movie,Episode"); @@ -30,181 +64,341 @@ export function Media() { enabled: status?.exists ?? false, }); - const gridRef = useRef>(null); - - const columnDefs = [ - { field: "title" as const, headerName: "Title", minWidth: 150 }, - { field: "series" as const, headerName: "Series", minWidth: 120 }, - { field: "season" as const, headerName: "Season", maxWidth: 95 }, - { field: "episode" as const, headerName: "Episode", maxWidth: 105 }, - { field: "type" as const, headerName: "Type", maxWidth: 100 }, - { field: "year" as const, headerName: "Year", maxWidth: 90 }, - { - field: "runtime_min" as const, - headerName: "Runtime (min)", - maxWidth: 125, - }, - { field: "size" as const, headerName: "Size", maxWidth: 120 }, - { field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 }, - { field: "hdr" as const, headerName: "HDR", maxWidth: 80 }, - { field: "video" as const, headerName: "Video codec", maxWidth: 120 }, - { field: "resolution" as const, headerName: "Resolution", maxWidth: 120 }, - { field: "date_added" as const, headerName: "Date added", maxWidth: 120 }, - { field: "library" as const, headerName: "Library", maxWidth: 140 }, - { field: "path" as const, headerName: "Path", minWidth: 200 }, + const columns: GridColDef[] = [ + { field: "title", headerName: "Title", minWidth: 180, flex: 1.2 }, + { field: "series", headerName: "Series", minWidth: 140, flex: 1 }, + { field: "season", headerName: "Season", width: 90 }, + { field: "episode", headerName: "Episode", width: 100 }, + { field: "type", headerName: "Type", width: 100 }, + { field: "year", headerName: "Year", width: 90 }, + { field: "runtime_min", headerName: "Runtime", width: 110 }, + { field: "size", headerName: "Size", width: 120 }, + { field: "bitrate", headerName: "Bitrate", width: 130 }, + { field: "hdr", headerName: "HDR", width: 80 }, + { field: "video", headerName: "Video codec", width: 130 }, + { field: "resolution", headerName: "Resolution", width: 120 }, + { field: "date_added", headerName: "Date added", width: 120 }, + { field: "library", headerName: "Library", width: 140 }, + { field: "path", headerName: "Path", minWidth: 240, flex: 1.2 }, ]; - const onGridReady = useCallback(() => { - gridRef.current?.api?.sizeColumnsToFit(); - }, []); + const rows = useMemo( + () => + (queryResult?.items ?? []).map((item) => ({ + ...item, + id: item.id || item.path, + })), + [queryResult], + ); const page = Math.floor(offset / limit) + 1; - const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1; + const totalPages = queryResult + ? Math.max(1, Math.ceil(queryResult.total / limit)) + : 1; + const buildRunning = status?.build_running ?? false; + const buildProgress = status?.build_progress ?? null; + const buildLibraryProgress = status?.build_library_progress ?? null; + const buildCancelRequested = status?.build_cancel_requested ?? false; + const buildLabel = buildRunning + ? status?.build_message || "Building media index..." + : status?.build_error + ? `Build failed: ${status.build_error}` + : ""; + const elapsedLabel = formatDuration(status?.build_elapsed_seconds); + const etaLabel = + buildRunning && status?.build_eta_seconds != null + ? formatDuration(status.build_eta_seconds) + : "-"; + const libraryElapsedLabel = formatDuration( + status?.build_library_elapsed_seconds, + ); + const libraryEtaLabel = + buildRunning && status?.build_library_eta_seconds != null + ? formatDuration(status.build_library_eta_seconds) + : "-"; + const libraryLabel = + status?.build_current_library || + (status?.build_library_index && status?.build_libraries_total + ? `Library ${status.build_library_index} / ${status.build_libraries_total}` + : "Current library"); return ( -
- {/* Status and controls */} -
+ + + Media {status?.exists ? ( - + Index: {status.item_count.toLocaleString()} items - {status.updated_at_label && ` | updated ${status.updated_at_label}`} - + {status.updated_at_label + ? ` | updated ${status.updated_at_label}` + : ""} + ) : ( - No index built yet. + + No index built yet. + )} - -
+ {buildIndex.isPending || buildRunning ? "Building..." : "Build index"} + + {buildRunning && ( + <> + + + + )} + {(buildRunning || status?.build_error) && ( + + + + {buildLabel || + (buildRunning + ? "Building media index..." + : status?.build_error || "")} + - {/* Filters */} -
-
- - { - setSearch(e.target.value); - setOffset(0); - }} - className="border rounded px-2 py-1 text-sm w-48" - placeholder="Search title, series, path..." - /> -
-
- - -
-
- - -
-
- - -
-
- - -
-
+ + + Overall:{" "} + {buildProgress != null + ? `${Math.round(buildProgress * 100)}%` + : "pending"} + {buildRunning + ? ` • elapsed ${elapsedLabel} • eta ${etaLabel}` + : ""} + + + + {status?.build_items_processed?.toLocaleString() ?? 0}/ + {status?.build_items_total?.toLocaleString() ?? 0} items + + + + + + Current: {libraryLabel} + {buildRunning + ? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}` + : ""} + + + + {status?.build_library_items_processed?.toLocaleString() ?? 0} + /{status?.build_library_items_total?.toLocaleString() ?? 0}{" "} + items + + +
+
+ )} + + + + + + + { + setSearch(e.target.value); + setOffset(0); + }} + /> + + + + Types + + + + + + HDR + + + + + + Sort + + + + + + Order + + + + + + - {/* Results info */} {queryResult && ( -

+ Showing {queryResult.items.length} of{" "} {queryResult.total.toLocaleString()} items | Page {page} of{" "} {totalPages} -

+ )} - {/* AG Grid table */} {status?.exists && ( -
- - ref={gridRef} - rowData={queryResult?.items ?? []} - columnDefs={columnDefs} - rowSelection="single" - onGridReady={onGridReady} + + { + const row = params.row as MediaItem; + navigate(`/files?path=${encodeURIComponent(row.path)}`); + }} + pageSizeOptions={[100]} + hideFooter + sx={{ + "& .MuiDataGrid-columnHeaders": { + fontWeight: 700, + backgroundColor: "action.hover", + }, + }} /> -
+ )} - {/* Pagination */} {queryResult && totalPages > 1 && ( -
- - + + Page {page} / {totalPages} - - -
+ + )} -
+ ); } diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index e8eff18..9ff09b2 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -1,3 +1,12 @@ +import { + Box, + Button, + Chip, + Divider, + Grid, + Stack, + Typography, +} from "@mui/material"; import { useMonitoringStatus, useMonitoringMetrics, @@ -32,7 +41,6 @@ export function Monitoring() { const samples = metrics?.samples ?? []; const latest = samples.at(-1); - // Compute averages and peaks const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0); @@ -46,95 +54,120 @@ export function Monitoring() { const diskWriteArr = samples.map((s) => s.disk_write_bps); return ( -
- {/* Controls */} -
- - Collector:{" "} - - {status?.status ?? "unknown"} - - - - - -
+ + - {/* Metrics summary */} -
-
+ + + + + + + + + + + + + + -
-
+ + - {/* Disk space */} {disk && ( -
-
+ + + + + + + + -
-
+ + )} - {/* Charts */} -
+ + -
-
+ + ); } diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..5af0c7e --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -0,0 +1,1192 @@ +import { useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { ChangeEvent } from "react"; +import CloseIcon from "@mui/icons-material/Close"; +import AttachFileIcon from "@mui/icons-material/AttachFile"; +import FormatBoldIcon from "@mui/icons-material/FormatBold"; +import FormatItalicIcon from "@mui/icons-material/FormatItalic"; +import LinkIcon from "@mui/icons-material/Link"; +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; +import MailOutlinedIcon from "@mui/icons-material/MailOutlined"; +import SendIcon from "@mui/icons-material/Send"; +import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; +import { + Avatar, + Alert, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Drawer, + IconButton, + LinearProgress, + Paper, + CircularProgress, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { MetricCard } from "../components/MetricCard"; +import { SessionActivityPanel } from "../components/SessionActivityPanel"; +import { useUsers } from "../hooks/useUsers"; +import { useActivity } from "../hooks/useDashboard"; +import { useSendUserMessage } from "../hooks/useSendUserMessage"; +import { useTestUserSmtp } from "../hooks/useTestUserSmtp"; +import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus"; +import type { UserDirectoryItem } from "../types"; +import { buildUserDrawerModel } from "../users"; +import { + mergeUsersWithActivity, + resolveUserSelection, + type UserStateItem, +} from "../userState"; + +function userLabel(user: UserDirectoryItem) { + return user.display_name || user.username || user.jellyfin_id; +} + +const DEFAULT_HTML_BODY = + "

Hello,

Best,
Media Library Viewer

"; + +export function UsersPage() { + const { data, isError, error } = useUsers(); + const { data: activity } = useActivity(); + const queueStatusQuery = useUserMessageQueueStatus(); + const sendUserMessage = useSendUserMessage(); + const testUserSmtp = useTestUserSmtp(); + const [search, setSearch] = useState(""); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [composeOpen, setComposeOpen] = useState(false); + const [subject, setSubject] = useState(""); + const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY); + const [attachments, setAttachments] = useState([]); + const htmlBodyRef = useRef(null); + + const baseRows = data?.items ?? []; + const rows = useMemo( + () => mergeUsersWithActivity(baseRows, activity ?? []), + [baseRows, activity], + ); + const filteredRows = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) { + return rows; + } + return rows.filter((row) => { + return [ + row.username, + row.display_name, + row.email, + row.email_source, + row.avatar_source, + row.name_source, + row.access_source, + row.user_type_label, + row.role, + row.permissions_label, + row.jellyseerr_username, + row.activity_label, + row.activity_summary, + row.activity.primary_session?.title || "", + String(row.jellyseerr_user_id ?? ""), + ].some((value) => value.toLowerCase().includes(term)); + }); + }, [rows, search]); + + const metrics = useMemo(() => { + const total = baseRows.length; + const contactable = rows.filter((row) => row.contactable).length; + const enriched = rows.filter( + (row) => row.jellyseerr_user_id !== null, + ).length; + const admins = rows.filter((row) => row.role === "admin").length; + return { total, contactable, enriched, admins }; + }, [baseRows]); + + const queueStatus = queueStatusQuery.data; + const queueBanner = useMemo(() => { + if (!queueStatus) { + return null; + } + const activeCount = queueStatus.active_request_id ? 1 : 0; + const totalCount = queueStatus.pending_count + activeCount; + const countLabel = + totalCount > 0 + ? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})` + : "0 items in queue"; + if (!queueStatus.worker_running) { + return { + severity: "warning" as const, + message: + queueStatus.last_error || + "Email queue worker is not running. New messages cannot be delivered until it restarts.", + countLabel, + subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, + }; + } + if (queueStatus.state === "error") { + return { + severity: "error" as const, + message: queueStatus.last_error || "The last email delivery failed.", + countLabel, + subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, + }; + } + if (queueStatus.state === "busy") { + const active = queueStatus.active_request_id + ? `processing ${queueStatus.active_request_id.slice(0, 8)}` + : "processing a message"; + const waiting = queueStatus.pending_count + ? `${queueStatus.pending_count} waiting` + : "no backlog"; + return { + severity: "info" as const, + message: `Email queue is busy: ${active}, ${waiting}.`, + countLabel, + subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, + }; + } + return { + severity: "success" as const, + message: "Email queue is idle and empty.", + countLabel, + subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, + }; + }, [queueStatus]); + + const selectedIdSet = useMemo( + () => new Set(selectedUserIds), + [selectedUserIds], + ); + const selectedRows = useMemo( + () => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)), + [rows, selectedIdSet], + ); + const selectedDeliverableRows = useMemo( + () => selectedRows.filter((row) => row.contactable && row.email), + [selectedRows], + ); + const skippedRows = useMemo( + () => selectedRows.filter((row) => !row.contactable || !row.email), + [selectedRows], + ); + const visibleSelectedRows = useMemo( + () => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)), + [filteredRows, selectedIdSet], + ); + const allVisibleSelected = + filteredRows.length > 0 && + visibleSelectedRows.length === filteredRows.length; + const someVisibleSelected = + visibleSelectedRows.length > 0 && + visibleSelectedRows.length < filteredRows.length; + + const toggleUserSelected = (userId: string) => { + setSelectedUserIds((current) => + current.includes(userId) + ? current.filter((id) => id !== userId) + : [...current, userId], + ); + }; + + const toggleVisibleSelection = (checked: boolean) => { + setSelectedUserIds((current) => { + const next = new Set(current); + filteredRows.forEach((row) => { + if (checked) { + next.add(row.jellyfin_id); + } else { + next.delete(row.jellyfin_id); + } + }); + return Array.from(next); + }); + }; + + const selectedUserParam = searchParams.get("user") || ""; + const selectedUser = useMemo( + () => + selectedUserParam + ? (resolveUserSelection( + rows, + selectedUserParam, + ) as UserStateItem | null) + : null, + [rows, selectedUserParam], + ); + const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null; + + const openCompose = () => { + if (!selectedRows.length) { + return; + } + sendUserMessage.reset(); + if (!subject.trim()) { + setSubject( + `Media Library Viewer update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`, + ); + } + if (!htmlBody.trim()) { + setHtmlBody(DEFAULT_HTML_BODY); + } + setComposeOpen(true); + }; + + const closeCompose = () => { + setComposeOpen(false); + sendUserMessage.reset(); + }; + + const insertMarkup = (before: string, after = before) => { + const textarea = htmlBodyRef.current; + if (!textarea) { + return; + } + const start = textarea.selectionStart ?? htmlBody.length; + const end = textarea.selectionEnd ?? htmlBody.length; + const selected = htmlBody.slice(start, end) || "text"; + const next = + htmlBody.slice(0, start) + + before + + selected + + after + + htmlBody.slice(end); + setHtmlBody(next); + requestAnimationFrame(() => { + textarea.focus(); + const cursorStart = start + before.length; + const cursorEnd = cursorStart + selected.length; + textarea.setSelectionRange(cursorStart, cursorEnd); + }); + }; + + const addLink = () => { + const url = window.prompt("Link URL", "https://"); + if (!url) { + return; + } + insertMarkup(``, ""); + }; + + const handleAttachments = (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length) { + setAttachments((current) => [...current, ...files]); + } + event.target.value = ""; + }; + + const removeAttachment = (index: number) => { + setAttachments((current) => current.filter((_, idx) => idx !== index)); + }; + + const handleSend = async () => { + const allSelectedRows = selectedRows; + if (!allSelectedRows.length) { + return; + } + + const formData = new FormData(); + formData.append( + "recipient_ids", + JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)), + ); + formData.append("subject", subject); + formData.append("html_body", htmlBody); + attachments.forEach((file) => { + formData.append("attachments", file, file.name); + }); + + try { + await sendUserMessage.mutateAsync(formData); + setComposeOpen(false); + setAttachments([]); + setSubject(""); + setHtmlBody(DEFAULT_HTML_BODY); + } catch { + // Mutation state is shown inline. + } + }; + + return ( + + + + Users + + + Read-only Jellyfin users with optional Jellyseerr enrichment. + + + + {isError ? ( + + Unable to load users: {(error as Error)?.message || "Unknown error"} + + ) : null} + + {data && !data.jellyseerr_configured ? ( + + Jellyseerr is not configured in the backend yet. Check JELLYSEERR_URL + and JELLYSEERR_API_KEY, then restart the API. + + ) : null} + + {data?.jellyseerr_error ? ( + + Jellyseerr enrichment is unavailable: {data.jellyseerr_error} + + ) : null} + + {data?.jellyseerr_configured && + !data.jellyseerr_error && + data.enriched_count === 0 ? ( + + Jellyseerr is connected, but no Jellyfin users were matched yet. The + backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked + entries and {data.jellyseerr_user_count} Jellyseerr users. + + ) : null} + + {queueStatusQuery.isError ? ( + + Unable to load email queue status:{" "} + {String( + (queueStatusQuery.error as Error)?.message || "Unknown error", + )} + + ) : queueBanner ? ( + + + + {queueBanner.message} + + + + + {queueBanner.subtext} + + + ) : null} + + {testUserSmtp.isPending ? ( + + + + + Testing SMTP connection... + + + + ) : testUserSmtp.isSuccess ? ( + testUserSmtp.data.status === "ok" ? ( + + SMTP connection succeeded: {testUserSmtp.data.smtp_host}: + {testUserSmtp.data.smtp_port} using{" "} + {testUserSmtp.data.use_ssl + ? "SSL" + : testUserSmtp.data.use_tls + ? "STARTTLS" + : "plain SMTP"} + . + + {testUserSmtp.data.message} + + {testUserSmtp.data.selected_mode ? ( + + Selected mode: {testUserSmtp.data.selected_mode.label} + + ) : null} + + ) : ( + + + {testUserSmtp.data.message} + + + Tried {testUserSmtp.data.attempts.length} mode + {testUserSmtp.data.attempts.length === 1 ? "" : "s"}. + + + {testUserSmtp.data.attempts.map((attempt) => ( + + {attempt.label}: {attempt.error || "failed"} + + ))} + + + ) + ) : testUserSmtp.isError ? ( + + SMTP test failed:{" "} + {(testUserSmtp.error as Error)?.message || "Unknown error"} + + ) : null} + + + + + + + + + + + + + User list + + {filteredRows.length} visible of {rows.length} total + + + + + + + + + setSearch(event.target.value)} + sx={{ minWidth: { xs: "100%", sm: 320 } }} + /> + + + + + + + + + + toggleVisibleSelection(event.target.checked) + } + slotProps={{ + input: { "aria-label": "Select all visible users" }, + }} + /> + + + User + + + Email + + + Activity + + + Type + + + Jellyseerr + + + Role + + + Permissions + + + Reqs + + + Contact + + + + + {filteredRows.map((row) => { + const linked = + row.jellyseerr_user_id !== null && + row.jellyseerr_user_id !== undefined; + const checked = selectedIdSet.has(row.jellyfin_id); + return ( + setSearchParams({ user: row.jellyfin_id })} + > + + event.stopPropagation()} + onChange={() => toggleUserSelected(row.jellyfin_id)} + slotProps={{ + input: { "aria-label": `Select ${userLabel(row)}` }, + }} + /> + + + + + {userLabel(row).charAt(0).toUpperCase()} + + + + {userLabel(row)} + + + {row.username && row.username !== row.display_name + ? row.username + : row.jellyfin_id} + + + + + + + {row.email || "—"} + + + + + + + + + + + + + + + + + {row.permissions_label} + + + + + {row.request_count ?? "—"} + + + + + + + ); + })} + +
+
+
+
+ + setSearchParams({})} + slotProps={{ + paper: { + sx: { + width: { xs: "100%", sm: 440 }, + p: 3, + }, + }, + }} + > + {selectedUser && drawerModel ? ( + + + + {drawerModel.title.charAt(0).toUpperCase()} + + + + {drawerModel.title} + + + {drawerModel.subtitle} + + + + + + + + + + + + + + + Identity + + + {drawerModel.identity.map((field) => ( + + + {field.label} + + + {field.value} + + + ))} + + + + + + Activity + + + + + + + Contact actions + + + {drawerModel.contactState.description} + + + {drawerModel.contactActions.map((action) => ( + + ))} + + + {drawerModel.contactActions + .map((action) => action.hint) + .join(" ")} + + + + + + Permissions + + + {drawerModel.permissions.map((permission) => ( + + ))} + + + + + + + This panel is read-only for now. Communication actions will be + added later without redesigning the list. + + + ) : null} + + + + + Message selected users + + + + + {sendUserMessage.isPending ? : null} + + + {sendUserMessage.isError ? ( + + Unable to send message:{" "} + {(sendUserMessage.error as Error)?.message || "Unknown error"} + + ) : null} + {sendUserMessage.isSuccess ? ( + + Queued for {sendUserMessage.data.recipient_count} recipients + {sendUserMessage.data.attachment_count + ? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}` + : ""} + {sendUserMessage.data.request_id + ? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})` + : ""} + . + + ) : null} + + {queueBanner ? ( + + + + {queueBanner.message} + + + + + ) : null} + + + {selectedRows.length} selected, {selectedDeliverableRows.length}{" "} + deliverable. + {skippedRows.length + ? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.` + : ""} + + + + {selectedDeliverableRows.map((row) => ( + `} + size="small" + /> + ))} + + + setSubject(event.target.value)} + /> + + + + insertMarkup("", "")} + > + + + + + insertMarkup("", "")}> + + + + + + + + + + insertMarkup("
  • ", "
")} + > + +
+
+
+ + setHtmlBody(event.target.value)} + helperText="Formatting is sent as HTML; a plain-text fallback is generated automatically." + /> + + + + Preview + + +