commit 655e2f81580ebddd2f77c710af0eec04c788ff6f
Author: Zak Bearman
Date: Sat Aug 29 20:27:17 2026 +1200
Start Magent beta overhaul
diff --git a/.build_number b/.build_number
new file mode 100644
index 0000000..2862b8b
--- /dev/null
+++ b/.build_number
@@ -0,0 +1 @@
+0803262237
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..85d5f67
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.env
+*.log
+data/*
+!data/branding/
+!data/branding/**
+frontend/node_modules/
+frontend/.next/
+backend/__pycache__/
+**/__pycache__/
+**/*.pyc
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..aa6354f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,17 @@
+* text=auto eol=lf
+
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
+
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.pdf binary
+*.zip binary
+*.gz binary
+*.tgz binary
+*.woff binary
+*.woff2 binary
diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml
new file mode 100644
index 0000000..0f9102e
--- /dev/null
+++ b/.gitea/workflows/ci-cd.yml
@@ -0,0 +1,104 @@
+name: Magent CI/CD
+
+on:
+ push:
+ branches:
+ - beta
+ - prod
+ workflow_dispatch:
+
+concurrency:
+ group: magent-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install frontend dependencies
+ working-directory: frontend
+ run: npm ci
+
+ - name: Run backend quality gate
+ run: bash scripts/ci_backend_quality_gate.sh
+
+ - name: Build frontend
+ working-directory: frontend
+ run: npm run build
+
+ deploy-prod:
+ if: github.ref_name == 'prod'
+ needs: verify
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure SSH key
+ env:
+ PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
+ PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
+ run: |
+ set -euo pipefail
+ mkdir -p ~/.ssh
+ chmod 700 ~/.ssh
+ printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
+ printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
+ chmod 644 ~/.ssh/known_hosts
+ fi
+
+ - name: Deploy to AMS-DEV01
+ env:
+ DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
+ DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
+ DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
+ DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
+ run: bash scripts/deploy_ams_dev01.sh
+
+ deploy-beta:
+ if: github.ref_name == 'beta'
+ needs: verify
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure SSH key
+ env:
+ PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
+ PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
+ run: |
+ set -euo pipefail
+ mkdir -p ~/.ssh
+ chmod 700 ~/.ssh
+ printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
+ printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
+ chmod 644 ~/.ssh/known_hosts
+ fi
+
+ - name: Deploy beta to AMS-DEV01
+ env:
+ DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
+ DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
+ PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
+ DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
+ run: bash scripts/deploy_beta_ams_dev01.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..916a6bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+.env
+.venv/
+data/
+!data/branding/
+!data/branding/**
+backend/__pycache__/
+**/__pycache__/
+*.pyc
+backend/.pytest_cache/
+frontend/node_modules/
+frontend/.next/
+*.log
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ad288e0
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,53 @@
+FROM node:24-slim AS frontend-builder
+
+WORKDIR /frontend
+
+ENV NODE_ENV=production \
+ BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
+ NEXT_PUBLIC_API_BASE=/api
+
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm ci --include=dev
+
+COPY frontend/app ./app
+COPY frontend/public ./public
+COPY frontend/next-env.d.ts ./next-env.d.ts
+COPY frontend/next.config.js ./next.config.js
+COPY frontend/tsconfig.json ./tsconfig.json
+
+RUN npm run build
+
+FROM python:3.14-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ NODE_ENV=production
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends curl gnupg supervisor \
+ && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY backend/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY backend/app ./app
+COPY data/branding /app/data/branding
+
+COPY --from=frontend-builder /frontend/.next /app/frontend/.next
+COPY --from=frontend-builder /frontend/public /app/frontend/public
+COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
+COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
+COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
+COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
+COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
+
+COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
+
+EXPOSE 3000 8000
+
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7671587
--- /dev/null
+++ b/README.md
@@ -0,0 +1,189 @@
+# Magent
+
+Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
+
+## How it works
+
+1) Requests are pulled from Seerr and stored locally.
+2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
+3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
+4) The UI renders a timeline and a central status box for each request.
+5) Optional AI triage summarizes the likely cause and safest next steps.
+
+## Core features
+
+- Request search by title/year or request ID.
+- Recent requests list with posters and status.
+- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
+- Central status box with clear reason + next steps.
+- Safe action buttons (search, resume, re-add, etc.).
+- Admin settings for service URLs, API keys, profiles, and root folders.
+- Health status for each service in the pipeline.
+- Cache and sync controls (full sync, delta sync, scheduled syncs).
+- Local database for speed and audit history.
+- Users and access control (admin vs user, block access).
+- Local account password changes via "My profile".
+- Docker-first deployment for easy hosting.
+
+## Quick start (Docker - primary)
+
+Docker is the recommended way to run Magent. It includes the backend and frontend with sane defaults.
+
+```bash
+docker compose up --build
+```
+
+Then open:
+
+- Frontend: http://localhost:3000
+- Backend: http://localhost:8000
+
+### Docker setup steps
+
+1) Create `.env` with your service URLs and API keys.
+2) Run `docker compose up --build`.
+3) Log in at http://localhost:3000.
+4) Visit Settings to confirm service health.
+
+### Docker environment variables (sample)
+
+```bash
+JELLYSEERR_URL="http://localhost:5055"
+JELLYSEERR_API_KEY="..."
+SONARR_URL="http://localhost:8989"
+SONARR_API_KEY="..."
+SONARR_QUALITY_PROFILE_ID="1"
+SONARR_ROOT_FOLDER="/tv"
+RADARR_URL="http://localhost:7878"
+RADARR_API_KEY="..."
+RADARR_QUALITY_PROFILE_ID="1"
+RADARR_ROOT_FOLDER="/movies"
+PROWLARR_URL="http://localhost:9696"
+PROWLARR_API_KEY="..."
+QBIT_URL="http://localhost:8080"
+QBIT_USERNAME="..."
+QBIT_PASSWORD="..."
+SQLITE_PATH="data/magent.db"
+JWT_SECRET="replace-with-a-long-random-secret"
+JWT_EXP_MINUTES="720"
+ADMIN_USERNAME="set-a-real-admin-username"
+ADMIN_PASSWORD="set-a-long-unique-admin-password"
+```
+
+## Screenshots
+
+Add screenshots here once available:
+
+- `docs/screenshots/home.png`
+- `docs/screenshots/request-timeline.png`
+- `docs/screenshots/settings.png`
+- `docs/screenshots/profile.png`
+
+## Local development (secondary)
+
+Use this only when you need to modify code locally.
+
+### Backend (FastAPI)
+
+```bash
+cd backend
+python -m venv .venv
+.\.venv\Scripts\Activate.ps1
+pip install -r requirements.txt
+uvicorn app.main:app --reload --port 8000
+```
+
+Environment variables (sample):
+
+```bash
+$env:JELLYSEERR_URL="http://localhost:5055"
+$env:JELLYSEERR_API_KEY="..."
+$env:SONARR_URL="http://localhost:8989"
+$env:SONARR_API_KEY="..."
+$env:SONARR_QUALITY_PROFILE_ID="1"
+$env:SONARR_ROOT_FOLDER="/tv"
+$env:RADARR_URL="http://localhost:7878"
+$env:RADARR_API_KEY="..."
+$env:RADARR_QUALITY_PROFILE_ID="1"
+$env:RADARR_ROOT_FOLDER="/movies"
+$env:PROWLARR_URL="http://localhost:9696"
+$env:PROWLARR_API_KEY="..."
+$env:QBIT_URL="http://localhost:8080"
+$env:QBIT_USERNAME="..."
+$env:QBIT_PASSWORD="..."
+$env:SQLITE_PATH="data/magent.db"
+$env:JWT_SECRET="replace-with-a-long-random-secret"
+$env:JWT_EXP_MINUTES="720"
+$env:ADMIN_USERNAME="set-a-real-admin-username"
+$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
+```
+
+### Frontend (Next.js)
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Open http://localhost:3000
+
+Admin panel: http://localhost:3000/admin
+
+Login uses the admin credentials above (or any other local user you create in SQLite).
+
+## Public Hosting Notes
+
+The frontend proxies `/api/*` to the backend container. Set:
+
+- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
+- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
+
+If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
+
+## Gitea CI/CD
+
+This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
+
+- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
+- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
+
+The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
+
+- `http://127.0.0.1:8000/health`
+- `http://127.0.0.1:3000/login`
+
+Configure these Gitea Actions secrets before enabling the deploy job:
+
+- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
+- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
+- `PROD_SSH_USER`: target user, for example `zak`.
+- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
+- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
+
+## History endpoints
+
+- `GET /requests/{id}/history?limit=10` recent snapshots
+- `GET /requests/{id}/actions?limit=10` recent action logs
+
+## Troubleshooting
+
+### Login fails
+
+- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
+- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
+
+### Services show as down
+
+- Check the URLs and API keys in Settings.
+- Verify containers can reach each service (network/DNS).
+
+### No recent requests
+
+- Confirm Seerr credentials in Settings.
+- Run a full sync from Settings -> Requests.
+
+### Docker images not updating
+
+- Run `docker compose up --build` again.
+- If needed, run `docker compose down` first, then rebuild.
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..bc4f72a
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,4 @@
+__pycache__/
+*.pyc
+.venv/
+.env
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..4dfb611
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+COPY backend/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY backend/app ./app
+COPY data/branding /app/data/branding
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/ai/triage.py b/backend/app/ai/triage.py
new file mode 100644
index 0000000..ae4499c
--- /dev/null
+++ b/backend/app/ai/triage.py
@@ -0,0 +1,64 @@
+from ..models import NormalizedState, TriageRecommendation, TriageResult, Snapshot
+
+
+def triage_snapshot(snapshot: Snapshot) -> TriageResult:
+ recommendations = []
+ root_cause = "unknown"
+ summary = "No clear blocker detected yet."
+ confidence = 0.2
+
+ if snapshot.state == NormalizedState.requested:
+ root_cause = "approval"
+ summary = "The request is waiting for approval in Seerr."
+ recommendations.append(
+ TriageRecommendation(
+ action_id="wait_for_approval",
+ title="Ask an admin to approve the request",
+ reason="Seerr has not marked this request as approved.",
+ risk="low",
+ )
+ )
+ confidence = 0.6
+
+ if snapshot.state == NormalizedState.needs_add:
+ root_cause = "not_added"
+ summary = "The request is approved but not added to Sonarr/Radarr yet."
+ recommendations.append(
+ TriageRecommendation(
+ action_id="readd_to_arr",
+ title="Push to Sonarr/Radarr",
+ reason="Sonarr/Radarr has not created the entry for this request.",
+ risk="medium",
+ )
+ )
+ confidence = 0.7
+
+ if snapshot.state == NormalizedState.added_to_arr:
+ root_cause = "search"
+ summary = "The item is in Sonarr/Radarr but has not been downloaded yet."
+ recommendations.append(
+ TriageRecommendation(
+ action_id="search",
+ title="Re-run search",
+ reason="A fresh search can locate new releases.",
+ risk="low",
+ )
+ )
+ confidence = 0.55
+
+ if not recommendations:
+ recommendations.append(
+ TriageRecommendation(
+ action_id="diagnostics",
+ title="Generate diagnostics bundle",
+ reason="Collect service status and recent errors for review.",
+ risk="low",
+ )
+ )
+
+ return TriageResult(
+ summary=summary,
+ confidence=confidence,
+ root_cause=root_cause,
+ recommendations=recommendations,
+ )
diff --git a/backend/app/assets/branding/favicon.ico b/backend/app/assets/branding/favicon.ico
new file mode 100644
index 0000000..68b4d3d
Binary files /dev/null and b/backend/app/assets/branding/favicon.ico differ
diff --git a/backend/app/assets/branding/logo.png b/backend/app/assets/branding/logo.png
new file mode 100644
index 0000000..d76a78a
Binary files /dev/null and b/backend/app/assets/branding/logo.png differ
diff --git a/backend/app/auth.py b/backend/app/auth.py
new file mode 100644
index 0000000..945bc84
--- /dev/null
+++ b/backend/app/auth.py
@@ -0,0 +1,226 @@
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
+
+from fastapi import Depends, HTTPException, Request, Response, status
+from fastapi.security import OAuth2PasswordBearer
+
+from .config import settings
+from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
+from .network_security import request_trusts_forwarded_headers
+from .security import TokenError, safe_decode_token, verify_password
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
+
+
+def _is_expired(expires_at: str | None) -> bool:
+ if not isinstance(expires_at, str) or not expires_at.strip():
+ return False
+ candidate = expires_at.strip()
+ if candidate.endswith("Z"):
+ candidate = candidate[:-1] + "+00:00"
+ try:
+ parsed = datetime.fromisoformat(candidate)
+ except ValueError:
+ return False
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed <= datetime.now(timezone.utc)
+
+
+def _extract_client_ip(request: Request) -> str:
+ direct_host = request.client.host if request.client else None
+ if request_trusts_forwarded_headers(direct_host):
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ parts = [part.strip() for part in forwarded.split(",") if part.strip()]
+ if parts:
+ return parts[0]
+ real_ip = request.headers.get("x-real-ip")
+ if real_ip:
+ return real_ip.strip()
+ if direct_host:
+ return direct_host
+ return "unknown"
+
+
+def _cookie_settings() -> dict[str, Any]:
+ samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
+ if samesite not in {"lax", "strict", "none"}:
+ samesite = "lax"
+ return {
+ "secure": bool(settings.auth_cookie_secure),
+ "httponly": True,
+ "samesite": samesite,
+ "domain": settings.auth_cookie_domain or None,
+ "path": "/",
+ }
+
+
+def _state_cookie_settings() -> dict[str, Any]:
+ cookie = _cookie_settings()
+ cookie["httponly"] = False
+ return cookie
+
+
+def set_auth_cookies(response: Response, token: str) -> None:
+ max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
+ response.set_cookie(
+ settings.auth_cookie_name,
+ token,
+ max_age=max_age,
+ **_cookie_settings(),
+ )
+ response.set_cookie(
+ settings.auth_state_cookie_name,
+ "1",
+ max_age=max_age,
+ **_state_cookie_settings(),
+ )
+
+
+def clear_auth_cookies(response: Response) -> None:
+ response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
+ response.delete_cookie(
+ settings.auth_state_cookie_name,
+ path="/",
+ domain=settings.auth_cookie_domain or None,
+ )
+
+
+def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.lower().startswith("bearer "):
+ return auth_header.split(" ", 1)[1].strip()
+ if oauth_token:
+ return oauth_token
+ cookie_token = request.cookies.get(settings.auth_cookie_name)
+ if isinstance(cookie_token, str) and cookie_token.strip():
+ return cookie_token.strip()
+ return None
+
+
+def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
+ if not isinstance(user, dict):
+ return "local"
+ provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
+ if provider != "local":
+ return provider
+ password_hash = user.get("password_hash")
+ if isinstance(password_hash, str) and password_hash:
+ if verify_password("jellyfin-user", password_hash):
+ return "jellyfin"
+ if verify_password("jellyseerr-user", password_hash):
+ return "jellyseerr"
+ return provider
+
+
+def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ if not isinstance(user, dict):
+ return {}
+ resolved_provider = resolve_user_auth_provider(user)
+ stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
+ if resolved_provider != stored_provider:
+ username = str(user.get("username") or "").strip()
+ if username:
+ set_user_auth_provider(username, resolved_provider)
+ refreshed_user = get_user_by_username(username)
+ if refreshed_user:
+ user = refreshed_user
+ normalized = dict(user)
+ normalized["auth_provider"] = resolved_provider
+ normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
+ normalized["password_provider"] = (
+ resolved_provider if resolved_provider in {"local", "jellyfin"} else None
+ )
+ return normalized
+
+
+def _load_current_user_from_token(
+ token: str,
+ request: Optional[Request] = None,
+ allowed_token_types: Optional[set[str]] = None,
+) -> Dict[str, Any]:
+ try:
+ payload = safe_decode_token(token)
+ except TokenError as exc:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
+ token_type = str(payload.get("typ") or "access").strip().lower()
+ if allowed_token_types and token_type not in allowed_token_types:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
+
+ username = payload.get("sub")
+ if not username:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject")
+
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
+ if user.get("is_blocked"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
+ if _is_expired(user.get("expires_at")):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
+
+ user = normalize_user_auth_provider(user)
+
+ if request is not None:
+ ip = _extract_client_ip(request)
+ user_agent = request.headers.get("user-agent", "unknown")
+ upsert_user_activity(user["username"], ip, user_agent)
+
+ return {
+ "username": user["username"],
+ "email": user.get("email"),
+ "role": user["role"],
+ "auth_provider": user.get("auth_provider", "local"),
+ "jellyseerr_user_id": user.get("jellyseerr_user_id"),
+ "auto_search_enabled": bool(user.get("auto_search_enabled", True)),
+ "invite_management_enabled": bool(user.get("invite_management_enabled", False)),
+ "profile_id": user.get("profile_id"),
+ "expires_at": user.get("expires_at"),
+ "is_expired": bool(user.get("is_expired", False)),
+ "password_change_supported": bool(user.get("password_change_supported", False)),
+ "password_provider": user.get("password_provider"),
+ }
+
+
+def get_current_user(
+ request: Request,
+ token: Optional[str] = Depends(oauth2_scheme),
+) -> Dict[str, Any]:
+ resolved_token = _extract_access_token(request, token)
+ if not resolved_token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
+ return _load_current_user_from_token(resolved_token, request)
+
+
+def get_current_user_event_stream(
+ request: Request,
+ token: Optional[str] = Depends(oauth2_scheme),
+) -> Dict[str, Any]:
+ """EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
+ resolved_token = _extract_access_token(request, token)
+ stream_query_token = request.query_params.get("stream_token")
+ if resolved_token:
+ # Allow standard bearer tokens for non-browser EventSource clients.
+ return _load_current_user_from_token(resolved_token, None)
+ if not stream_query_token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
+ return _load_current_user_from_token(
+ str(stream_query_token),
+ None,
+ allowed_token_types={"sse"},
+ )
+
+
+def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ if user.get("role") != "admin":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
+ return user
+
+
+def require_admin_event_stream(
+ user: Dict[str, Any] = Depends(get_current_user_event_stream),
+) -> Dict[str, Any]:
+ if user.get("role") != "admin":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
+ return user
diff --git a/backend/app/build_info.py b/backend/app/build_info.py
new file mode 100644
index 0000000..5fc2598
--- /dev/null
+++ b/backend/app/build_info.py
@@ -0,0 +1,2 @@
+BUILD_NUMBER = "0803262237"
+CHANGELOG = '2026-03-08|Process 1 build 0803262229\n2026-03-08|Process 1 build 0803262216\n2026-03-08|Process 1 build 0803262038\n2026-03-07|Process 1 build 0703261729\n2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit'
diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py
new file mode 100644
index 0000000..718b65b
--- /dev/null
+++ b/backend/app/clients/base.py
@@ -0,0 +1,108 @@
+from typing import Any, Dict, Optional
+import logging
+import time
+import httpx
+
+from ..logging_config import sanitize_headers, sanitize_value
+
+
+class ApiClient:
+ def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
+ self.base_url = base_url.rstrip("/") if base_url else None
+ self.api_key = api_key
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
+
+ def configured(self) -> bool:
+ return bool(self.base_url)
+
+ def headers(self) -> Dict[str, str]:
+ return {"X-Api-Key": self.api_key} if self.api_key else {}
+
+ def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
+ if response is None:
+ return None
+ try:
+ payload = sanitize_value(response.json())
+ except ValueError:
+ payload = sanitize_value(response.text)
+ if isinstance(payload, str) and len(payload) > 500:
+ return f"{payload[:500]}..."
+ return payload
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: Optional[Dict[str, Any]] = None,
+ payload: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Any]:
+ if not self.base_url:
+ self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
+ return None
+ url = f"{self.base_url}{path}"
+ started_at = time.perf_counter()
+ self.logger.debug(
+ "outbound request started method=%s url=%s params=%s payload=%s headers=%s",
+ method,
+ url,
+ sanitize_value(params),
+ sanitize_value(payload),
+ sanitize_headers(self.headers()),
+ )
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.request(
+ method,
+ url,
+ headers=self.headers(),
+ params=params,
+ json=payload,
+ )
+ response.raise_for_status()
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ self.logger.debug(
+ "outbound request completed method=%s url=%s status=%s duration_ms=%s",
+ method,
+ url,
+ response.status_code,
+ duration_ms,
+ )
+ if not response.content:
+ return None
+ return response.json()
+ except httpx.HTTPStatusError as exc:
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ response = exc.response
+ status = response.status_code if response is not None else "unknown"
+ log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
+ log_fn(
+ "outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
+ method,
+ url,
+ status,
+ duration_ms,
+ self._response_summary(response),
+ )
+ raise
+ except Exception:
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ self.logger.exception(
+ "outbound request failed method=%s url=%s duration_ms=%s",
+ method,
+ url,
+ duration_ms,
+ )
+ raise
+
+ async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ return await self._request("GET", path, params=params)
+
+ async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ return await self._request("POST", path, payload=payload)
+
+ async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ return await self._request("PUT", path, payload=payload)
+
+ async def delete(self, path: str) -> Optional[Any]:
+ return await self._request("DELETE", path)
diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py
new file mode 100644
index 0000000..d735b41
--- /dev/null
+++ b/backend/app/clients/jellyfin.py
@@ -0,0 +1,201 @@
+from typing import Any, Dict, Optional
+import httpx
+from .base import ApiClient
+
+
+class JellyfinClient(ApiClient):
+ def __init__(self, base_url: Optional[str], api_key: Optional[str]):
+ super().__init__(base_url, api_key)
+
+ def configured(self) -> bool:
+ return bool(self.base_url and self.api_key)
+
+ def _emby_headers(self) -> Dict[str, str]:
+ return {"X-Emby-Token": self.api_key} if self.api_key else {}
+
+ @staticmethod
+ def _extract_user_id(payload: Any) -> Optional[str]:
+ if not isinstance(payload, dict):
+ return None
+ candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
+ if not isinstance(candidate, dict):
+ return None
+ for key in ("Id", "id", "UserId", "userId"):
+ value = candidate.get(key)
+ if value is None:
+ continue
+ if isinstance(value, (str, int)):
+ text = str(value).strip()
+ if text:
+ return text
+ return None
+
+ async def get_users(self) -> Optional[Dict[str, Any]]:
+ if not self.base_url:
+ return None
+ url = f"{self.base_url}/Users"
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(url, headers=headers)
+ response.raise_for_status()
+ return response.json()
+
+ async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Users/{user_id}"
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(url, headers=headers)
+ response.raise_for_status()
+ return response.json()
+
+ async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
+ users = await self.get_users()
+ if not isinstance(users, list):
+ return None
+ target = username.strip().lower()
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ name = str(user.get("Name") or "").strip().lower()
+ if name and name == target:
+ return user
+ return None
+
+ async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
+ if not self.base_url:
+ return None
+ url = f"{self.base_url}/Users/AuthenticateByName"
+ headers = self._emby_headers()
+ payload = {"Username": username, "Pw": password}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers=headers, json=payload)
+ response.raise_for_status()
+ return response.json()
+
+ async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Users/New"
+ headers = self._emby_headers()
+ payload = {"Name": username}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers=headers, json=payload)
+ response.raise_for_status()
+ if not response.content:
+ return None
+ return response.json()
+
+ async def set_user_password(self, user_id: str, password: str) -> None:
+ if not self.base_url or not self.api_key:
+ return None
+ headers = self._emby_headers()
+ payloads = [
+ {"CurrentPw": "", "NewPw": password},
+ {"CurrentPwd": "", "NewPw": password},
+ {"CurrentPw": "", "NewPw": password, "ResetPassword": False},
+ {"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
+ {"NewPw": password, "ResetPassword": False},
+ ]
+ paths = [
+ f"/Users/{user_id}/Password",
+ f"/Users/{user_id}/EasyPassword",
+ ]
+ last_error: Exception | None = None
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ for path in paths:
+ url = f"{self.base_url}{path}"
+ for payload in payloads:
+ try:
+ response = await client.post(url, headers=headers, json=payload)
+ response.raise_for_status()
+ return
+ except httpx.HTTPStatusError as exc:
+ last_error = exc
+ continue
+ except Exception as exc:
+ last_error = exc
+ continue
+ if last_error:
+ raise last_error
+
+ async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
+ if not self.base_url or not self.api_key:
+ return None
+ user = await self.get_user(user_id)
+ if not isinstance(user, dict):
+ raise RuntimeError("Jellyfin user details not available")
+ policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
+ payload = {**policy, "IsDisabled": bool(disabled)}
+ url = f"{self.base_url}/Users/{user_id}/Policy"
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers=headers, json=payload)
+ response.raise_for_status()
+
+ async def delete_user(self, user_id: str) -> None:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Users/{user_id}"
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.delete(url, headers=headers)
+ response.raise_for_status()
+
+ async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
+ created = await self.create_user(username)
+ user_id = self._extract_user_id(created)
+ if not user_id:
+ users = await self.get_users()
+ if isinstance(users, list):
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ name = str(user.get("Name") or "").strip()
+ if name.lower() == username.strip().lower():
+ created = user
+ user_id = self._extract_user_id(user)
+ break
+ if not user_id:
+ raise RuntimeError("Jellyfin user created but user ID was not returned")
+ await self.set_user_password(user_id, password)
+ return created
+
+ async def search_items(
+ self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
+ ) -> Optional[Dict[str, Any]]:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Items"
+ params = {
+ "SearchTerm": term,
+ "IncludeItemTypes": ",".join(item_types or []),
+ "Recursive": "true",
+ "Limit": limit,
+ }
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(url, headers=headers, params=params)
+ response.raise_for_status()
+ return response.json()
+
+ async def get_system_info(self) -> Optional[Dict[str, Any]]:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/System/Info"
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(url, headers=headers)
+ response.raise_for_status()
+ return response.json()
+
+ async def refresh_library(self, recursive: bool = True) -> None:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Library/Refresh"
+ headers = self._emby_headers()
+ params = {"Recursive": "true" if recursive else "false"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers=headers, params=params)
+ response.raise_for_status()
diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py
new file mode 100644
index 0000000..7201283
--- /dev/null
+++ b/backend/app/clients/jellyseerr.py
@@ -0,0 +1,79 @@
+from typing import Any, Dict, Optional
+import httpx
+from .base import ApiClient
+
+
+class JellyseerrClient(ApiClient):
+ async def get_status(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v1/status")
+
+ async def get_request(self, request_id: str) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v1/request/{request_id}")
+
+ async def get_recent_requests(self, take: int = 10, skip: int = 0) -> Optional[Dict[str, Any]]:
+ return await self.get(
+ "/api/v1/request",
+ params={
+ "take": take,
+ "skip": skip,
+ },
+ )
+
+ async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v1/movie/{tmdb_id}")
+
+ async def get_tv(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v1/tv/{tmdb_id}")
+
+ async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
+ return await self.get(
+ "/api/v1/search",
+ params={
+ "query": query,
+ "page": page,
+ },
+ )
+
+ async def create_request(
+ self,
+ *,
+ media_type: str,
+ media_id: int,
+ seasons: Optional[list[int]] = None,
+ is_4k: Optional[bool] = None,
+ ) -> Optional[Dict[str, Any]]:
+ payload: Dict[str, Any] = {
+ "mediaType": media_type,
+ "mediaId": media_id,
+ }
+ if isinstance(seasons, list) and seasons:
+ payload["seasons"] = seasons
+ if isinstance(is_4k, bool):
+ payload["is4k"] = is_4k
+ return await self.post("/api/v1/request", payload=payload)
+
+ async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
+ return await self.get(
+ "/api/v1/user",
+ params={
+ "take": take,
+ "skip": skip,
+ },
+ )
+
+ async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v1/user/{user_id}")
+
+ async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
+ return await self.delete(f"/api/v1/user/{user_id}")
+
+ async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
+ payload = {"email": email, "password": password}
+ try:
+ return await self.post("/api/v1/auth/local", payload=payload)
+ except httpx.HTTPStatusError as exc:
+ # Backward compatibility for older Seerr/Overseerr deployments
+ # that still expose /auth/login instead of /auth/local.
+ if exc.response is not None and exc.response.status_code in {404, 405}:
+ return await self.post("/api/v1/auth/login", payload=payload)
+ raise
diff --git a/backend/app/clients/prowlarr.py b/backend/app/clients/prowlarr.py
new file mode 100644
index 0000000..5d8203b
--- /dev/null
+++ b/backend/app/clients/prowlarr.py
@@ -0,0 +1,10 @@
+from typing import Any, Dict, Optional
+from .base import ApiClient
+
+
+class ProwlarrClient(ApiClient):
+ async def get_health(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v1/health")
+
+ async def search(self, query: str) -> Optional[Any]:
+ return await self.get("/api/v1/search", params={"query": query})
diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py
new file mode 100644
index 0000000..88f2823
--- /dev/null
+++ b/backend/app/clients/qbittorrent.py
@@ -0,0 +1,108 @@
+from typing import Any, Dict, Optional
+import httpx
+import logging
+from .base import ApiClient
+
+
+class QBittorrentClient(ApiClient):
+ def __init__(self, base_url: Optional[str], username: Optional[str], password: Optional[str]):
+ super().__init__(base_url, None)
+ self.username = username
+ self.password = password
+ self.logger = logging.getLogger(__name__)
+
+ def configured(self) -> bool:
+ return bool(self.base_url and self.username and self.password)
+
+ async def _login(self, client: httpx.AsyncClient) -> None:
+ if not self.base_url or not self.username or not self.password:
+ raise RuntimeError("qBittorrent not configured")
+ response = await client.post(
+ f"{self.base_url}/api/v2/auth/login",
+ data={"username": self.username, "password": self.password},
+ headers={"Referer": self.base_url},
+ )
+ response.raise_for_status()
+ text = response.text.strip().lower()
+ has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
+ if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
+ raise RuntimeError("qBittorrent login failed")
+
+ async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ if not self.base_url:
+ return None
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ await self._login(client)
+ response = await client.get(f"{self.base_url}{path}", params=params)
+ response.raise_for_status()
+ return response.json()
+
+ async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ if not self.base_url:
+ return None
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ await self._login(client)
+ response = await client.get(f"{self.base_url}{path}", params=params)
+ response.raise_for_status()
+ return response.text.strip()
+
+ async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
+ if not self.base_url:
+ return None
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ await self._login(client)
+ response = await client.post(f"{self.base_url}{path}", data=data)
+ response.raise_for_status()
+
+ async def is_webui_reachable(self) -> bool:
+ if not self.base_url:
+ return False
+ try:
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
+ response = await client.get(self.base_url)
+ response.raise_for_status()
+ return True
+ except httpx.HTTPError:
+ return False
+
+ async def get_torrents(self) -> Optional[Any]:
+ return await self._get("/api/v2/torrents/info")
+
+ async def get_torrents_by_hashes(self, hashes: str) -> Optional[Any]:
+ return await self._get("/api/v2/torrents/info", params={"hashes": hashes})
+
+ async def get_torrents_by_category(self, category: str) -> Optional[Any]:
+ return await self._get("/api/v2/torrents/info", params={"category": category})
+
+ async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
+ return await self._get("/api/v2/torrents/info", params={"tag": tag})
+
+ async def get_app_version(self) -> Optional[Any]:
+ return await self._get_text("/api/v2/app/version")
+
+ async def resume_torrents(self, hashes: str) -> None:
+ try:
+ await self._post_form("/api/v2/torrents/resume", data={"hashes": hashes})
+ except httpx.HTTPStatusError as exc:
+ if exc.response is not None and exc.response.status_code == 404:
+ await self._post_form("/api/v2/torrents/start", data={"hashes": hashes})
+ return
+ raise
+
+ async def add_torrent_url(
+ self, url: str, category: Optional[str] = None, tags: Optional[str] = None
+ ) -> None:
+ url_host = None
+ if isinstance(url, str) and "://" in url:
+ url_host = url.split("://", 1)[-1].split("/", 1)[0]
+ self.logger.warning(
+ "qBittorrent add_torrent_url invoked: category=%s host=%s",
+ category,
+ url_host or "unknown",
+ )
+ data: Dict[str, Any] = {"urls": url}
+ if category:
+ data["category"] = category
+ if tags:
+ data["tags"] = tags
+ await self._post_form("/api/v2/torrents/add", data=data)
diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py
new file mode 100644
index 0000000..83da911
--- /dev/null
+++ b/backend/app/clients/radarr.py
@@ -0,0 +1,63 @@
+from typing import Any, Dict, Optional
+from .base import ApiClient
+
+
+class RadarrClient(ApiClient):
+ async def get_system_status(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/system/status")
+
+ async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
+
+ async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v3/movie/{movie_id}")
+
+ async def get_movies(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/movie")
+
+ async def get_root_folders(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/rootfolder")
+
+ async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/qualityprofile")
+
+ async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/queue", params={"movieId": movie_id})
+
+ async def get_indexers(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/indexer")
+
+ async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
+
+ async def add_movie(
+ self,
+ tmdb_id: int,
+ quality_profile_id: int,
+ root_folder: str,
+ monitored: bool = True,
+ search_for_movie: bool = True,
+ ) -> Optional[Dict[str, Any]]:
+ payload = {
+ "tmdbId": tmdb_id,
+ "qualityProfileId": quality_profile_id,
+ "rootFolderPath": root_folder,
+ "monitored": monitored,
+ "addOptions": {"searchForMovie": search_for_movie},
+ }
+ return await self.post("/api/v3/movie", payload=payload)
+
+ async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return await self.put("/api/v3/movie", payload=payload)
+
+ async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
+
+ async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/release/push", payload=payload)
+
+ async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post(
+ "/api/v3/command",
+ payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
+ )
diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py
new file mode 100644
index 0000000..6de81be
--- /dev/null
+++ b/backend/app/clients/sonarr.py
@@ -0,0 +1,70 @@
+from typing import Any, Dict, Optional
+from .base import ApiClient
+
+
+class SonarrClient(ApiClient):
+ async def get_system_status(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/system/status")
+
+ async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
+
+ async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v3/series/{series_id}")
+
+ async def get_root_folders(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/rootfolder")
+
+ async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/qualityprofile")
+
+ async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/queue", params={"seriesId": series_id})
+
+ async def get_indexers(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/indexer")
+
+ async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/episode", params={"seriesId": series_id})
+
+ async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
+
+ async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
+
+ async def add_series(
+ self,
+ tvdb_id: int,
+ quality_profile_id: int,
+ root_folder: str,
+ monitored: bool = True,
+ title: Optional[str] = None,
+ search_missing: bool = True,
+ ) -> Optional[Dict[str, Any]]:
+ payload = {
+ "tvdbId": tvdb_id,
+ "qualityProfileId": quality_profile_id,
+ "rootFolderPath": root_folder,
+ "monitored": monitored,
+ "seasonFolder": True,
+ "addOptions": {"searchForMissingEpisodes": search_missing},
+ }
+ if title:
+ payload["title"] = title
+ return await self.post("/api/v3/series", payload=payload)
+
+ async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return await self.put("/api/v3/series", payload=payload)
+
+ async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
+
+ async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/release/push", payload=payload)
+
+ async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post(
+ "/api/v3/command",
+ payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
+ )
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..c234a47
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,322 @@
+from typing import Optional
+from pydantic import AliasChoices, Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from .build_info import BUILD_NUMBER, CHANGELOG
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_prefix="")
+ app_name: str = "Magent"
+ cors_allow_origin: str = "http://localhost:3000"
+ sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
+ sqlite_journal_mode: str = Field(
+ default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
+ )
+ jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
+ jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
+ api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
+ auth_rate_limit_window_seconds: int = Field(
+ default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
+ )
+ auth_rate_limit_max_attempts_ip: int = Field(
+ default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
+ )
+ auth_rate_limit_max_attempts_user: int = Field(
+ default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
+ )
+ password_reset_rate_limit_window_seconds: int = Field(
+ default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
+ )
+ password_reset_rate_limit_max_attempts_ip: int = Field(
+ default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
+ )
+ password_reset_rate_limit_max_attempts_identifier: int = Field(
+ default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
+ )
+ admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
+ admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
+ auth_cookie_name: str = Field(
+ default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
+ )
+ auth_cookie_secure: bool = Field(
+ default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
+ )
+ auth_cookie_samesite: str = Field(
+ default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
+ )
+ auth_cookie_domain: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
+ )
+ auth_state_cookie_name: str = Field(
+ default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
+ )
+ log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
+ log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
+ log_file_max_bytes: int = Field(
+ default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
+ )
+ log_file_backup_count: int = Field(
+ default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
+ )
+ log_http_client_level: str = Field(
+ default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
+ )
+ log_background_sync_level: str = Field(
+ default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
+ )
+ requests_sync_ttl_minutes: int = Field(
+ default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
+ )
+ requests_poll_interval_seconds: int = Field(
+ default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
+ )
+ requests_delta_sync_interval_minutes: int = Field(
+ default=5, validation_alias=AliasChoices("REQUESTS_DELTA_SYNC_INTERVAL_MINUTES")
+ )
+ requests_full_sync_time: str = Field(
+ default="00:00", validation_alias=AliasChoices("REQUESTS_FULL_SYNC_TIME")
+ )
+ requests_cleanup_time: str = Field(
+ default="02:00", validation_alias=AliasChoices("REQUESTS_CLEANUP_TIME")
+ )
+ requests_cleanup_days: int = Field(
+ default=90, validation_alias=AliasChoices("REQUESTS_CLEANUP_DAYS")
+ )
+ requests_data_source: str = Field(
+ default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
+ )
+ artwork_cache_mode: str = Field(
+ default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
+ )
+ site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
+ site_banner_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
+ )
+ site_banner_message: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
+ )
+ site_banner_tone: str = Field(
+ default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
+ )
+ site_login_show_jellyfin_login: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
+ )
+ site_login_show_local_login: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
+ )
+ site_login_show_forgot_password: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
+ )
+ site_login_show_signup_link: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
+ )
+ site_nav_show_requests: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
+ )
+ site_changelog: Optional[str] = Field(default=CHANGELOG)
+
+ magent_application_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
+ )
+ magent_application_port: int = Field(
+ default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
+ )
+ magent_api_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_API_URL")
+ )
+ magent_api_port: int = Field(
+ default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
+ )
+ magent_bind_host: str = Field(
+ default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
+ )
+ magent_proxy_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
+ )
+ magent_proxy_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
+ )
+ magent_proxy_trust_forwarded_headers: bool = Field(
+ default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
+ )
+ magent_proxy_trusted_proxies: str = Field(
+ default="127.0.0.1,::1",
+ validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
+ )
+ magent_proxy_forwarded_prefix: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
+ )
+ magent_ssl_bind_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
+ )
+ magent_ssl_certificate_path: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
+ )
+ magent_ssl_private_key_path: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
+ )
+ magent_ssl_certificate_pem: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
+ )
+ magent_ssl_private_key_pem: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
+ )
+
+ magent_notify_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
+ )
+ magent_notify_email_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
+ )
+ magent_notify_email_smtp_host: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
+ )
+ magent_notify_email_smtp_port: int = Field(
+ default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
+ )
+ magent_notify_email_smtp_username: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
+ )
+ magent_notify_email_smtp_password: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
+ )
+ magent_notify_email_from_address: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
+ )
+ magent_notify_email_from_name: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
+ )
+ magent_notify_email_use_tls: bool = Field(
+ default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
+ )
+ magent_notify_email_use_ssl: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
+ )
+
+ magent_notify_discord_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
+ )
+ magent_notify_discord_webhook_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
+ )
+
+ magent_notify_telegram_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
+ )
+ magent_notify_telegram_bot_token: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
+ )
+ magent_notify_telegram_chat_id: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
+ )
+
+ magent_notify_push_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
+ )
+ magent_notify_push_provider: Optional[str] = Field(
+ default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
+ )
+ magent_notify_push_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
+ )
+ magent_notify_push_topic: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
+ )
+ magent_notify_push_token: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
+ )
+ magent_notify_push_user_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
+ )
+ magent_notify_push_device: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
+ )
+
+ magent_notify_webhook_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
+ )
+ magent_notify_webhook_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
+ )
+ magent_allow_private_notification_targets: bool = Field(
+ default=False,
+ validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
+ )
+
+ jellyseerr_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
+ )
+ jellyseerr_api_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
+ )
+ jellyfin_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
+ )
+ jellyfin_api_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("JELLYFIN_API_KEY", "JELLYFIN_KEY")
+ )
+ jellyfin_public_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("JELLYFIN_PUBLIC_URL")
+ )
+ jellyfin_sync_to_arr: bool = Field(
+ default=True, validation_alias=AliasChoices("JELLYFIN_SYNC_TO_ARR")
+ )
+
+ sonarr_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SONARR_URL", "SONARR_BASE_URL")
+ )
+ sonarr_api_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SONARR_API_KEY", "SONARR_KEY")
+ )
+ sonarr_quality_profile_id: Optional[int] = Field(
+ default=None, validation_alias=AliasChoices("SONARR_QUALITY_PROFILE_ID")
+ )
+ sonarr_root_folder: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
+ )
+ sonarr_qbittorrent_category: Optional[str] = Field(
+ default="sonarr",
+ validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
+ )
+
+ radarr_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
+ )
+ radarr_api_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("RADARR_API_KEY", "RADARR_KEY")
+ )
+ radarr_quality_profile_id: Optional[int] = Field(
+ default=None, validation_alias=AliasChoices("RADARR_QUALITY_PROFILE_ID")
+ )
+ radarr_root_folder: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
+ )
+ radarr_qbittorrent_category: Optional[str] = Field(
+ default="radarr",
+ validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
+ )
+
+ prowlarr_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
+ )
+ prowlarr_api_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("PROWLARR_API_KEY", "PROWLARR_KEY")
+ )
+
+ qbittorrent_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("QBIT_URL", "QBITTORRENT_URL", "QBITTORRENT_BASE_URL")
+ )
+ qbittorrent_username: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("QBIT_USERNAME", "QBITTORRENT_USERNAME")
+ )
+ qbittorrent_password: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD")
+ )
+
+ discord_webhook_url: Optional[str] = Field(
+ default=None,
+ validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
+ )
+
+
+settings = Settings()
diff --git a/backend/app/db.py b/backend/app/db.py
new file mode 100644
index 0000000..7d9ef33
--- /dev/null
+++ b/backend/app/db.py
@@ -0,0 +1,3758 @@
+import json
+import os
+import sqlite3
+import logging
+from hashlib import sha256
+from datetime import datetime, timezone, timedelta
+from time import perf_counter
+from typing import Any, Dict, Optional
+
+from .config import settings
+from .models import Snapshot
+from .security import hash_password, verify_password
+
+logger = logging.getLogger(__name__)
+
+SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS = 6
+SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS = 24
+SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS = 30
+SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
+SQLITE_BUSY_TIMEOUT_MS = 5_000
+SQLITE_CACHE_SIZE_KIB = 32_768
+SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
+_DB_UNSET = object()
+_DEFAULT_JWT_SECRET = "change-me"
+_DEFAULT_ADMIN_PASSWORD = "adminadmin"
+
+
+def _db_path() -> str:
+ path = settings.sqlite_path or "data/magent.db"
+ if not os.path.isabs(path):
+ app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+ path = os.path.join(app_root, path)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ return path
+
+
+def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
+ journal_mode = str(getattr(settings, "sqlite_journal_mode", "DELETE") or "DELETE").strip().upper()
+ if journal_mode not in {"DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"}:
+ journal_mode = "DELETE"
+ pragmas = (
+ ("journal_mode", journal_mode),
+ ("synchronous", "NORMAL"),
+ ("temp_store", "MEMORY"),
+ ("cache_size", -SQLITE_CACHE_SIZE_KIB),
+ ("mmap_size", SQLITE_MMAP_SIZE_BYTES),
+ ("busy_timeout", SQLITE_BUSY_TIMEOUT_MS),
+ )
+ for pragma, value in pragmas:
+ try:
+ conn.execute(f"PRAGMA {pragma} = {value}")
+ except sqlite3.DatabaseError:
+ logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True)
+
+
+def _connect() -> sqlite3.Connection:
+ conn = sqlite3.connect(
+ _db_path(),
+ timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
+ cached_statements=512,
+ )
+ _apply_connection_pragmas(conn)
+ return conn
+
+
+def _parse_datetime_value(value: Optional[str]) -> Optional[datetime]:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ candidate = value.strip()
+ if candidate.endswith("Z"):
+ candidate = candidate[:-1] + "+00:00"
+ try:
+ parsed = datetime.fromisoformat(candidate)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _is_datetime_in_past(value: Optional[str]) -> bool:
+ parsed = _parse_datetime_value(value)
+ if parsed is None:
+ return False
+ return parsed <= datetime.now(timezone.utc)
+
+
+def _normalize_title_value(title: Optional[str]) -> Optional[str]:
+ if not isinstance(title, str):
+ return None
+ trimmed = title.strip()
+ return trimmed if trimmed else None
+
+
+def _normalize_year_value(year: Optional[Any]) -> Optional[int]:
+ if isinstance(year, int):
+ return year
+ if isinstance(year, str):
+ trimmed = year.strip()
+ if trimmed.isdigit():
+ return int(trimmed)
+ return None
+
+
+def _is_placeholder_title(title: Optional[str], request_id: Optional[int]) -> bool:
+ if not isinstance(title, str):
+ return True
+ normalized = title.strip().lower()
+ if not normalized:
+ return True
+ if normalized == "untitled":
+ return True
+ if request_id and normalized == f"request {request_id}":
+ return True
+ return False
+
+
+def _extract_title_year_from_payload(payload_json: Optional[str]) -> tuple[Optional[str], Optional[int]]:
+ if not payload_json:
+ return None, None
+ try:
+ payload = json.loads(payload_json)
+ except json.JSONDecodeError:
+ return None, None
+ if not isinstance(payload, dict):
+ return None, None
+ media = payload.get("media") or {}
+ title = None
+ year = None
+ if isinstance(media, dict):
+ title = media.get("title") or media.get("name")
+ year = media.get("year")
+ if not title:
+ title = payload.get("title") or payload.get("name")
+ if year is None:
+ year = payload.get("year")
+ return _normalize_title_value(title), _normalize_year_value(year)
+
+
+def _extract_tmdb_from_payload(payload_json: Optional[str]) -> tuple[Optional[int], Optional[str]]:
+ if not payload_json:
+ return None, None
+ try:
+ payload = json.loads(payload_json)
+ except (TypeError, json.JSONDecodeError):
+ return None, None
+ if not isinstance(payload, dict):
+ return None, None
+ media = payload.get("media") or {}
+ if not isinstance(media, dict):
+ media = {}
+ tmdb_id = (
+ media.get("tmdbId")
+ or payload.get("tmdbId")
+ or payload.get("tmdb_id")
+ or media.get("externalServiceId")
+ or payload.get("externalServiceId")
+ )
+ media_type = (
+ media.get("mediaType")
+ or payload.get("mediaType")
+ or payload.get("media_type")
+ or payload.get("type")
+ )
+ try:
+ tmdb_id = int(tmdb_id) if tmdb_id is not None else None
+ except (TypeError, ValueError):
+ tmdb_id = None
+ if isinstance(media_type, str):
+ media_type = media_type.strip().lower() or None
+ return tmdb_id, media_type
+
+
+def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ candidate = value.strip()
+ if not candidate or "@" not in candidate:
+ return None
+ return candidate
+
+
+def _has_secure_bootstrap_admin_credentials() -> bool:
+ password = str(settings.admin_password or "")
+ return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
+
+
+def init_db() -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ request_id TEXT NOT NULL,
+ state TEXT NOT NULL,
+ state_reason TEXT,
+ created_at TEXT NOT NULL,
+ payload_json TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ request_id TEXT NOT NULL,
+ action_id TEXT NOT NULL,
+ label TEXT NOT NULL,
+ status TEXT NOT NULL,
+ message TEXT,
+ created_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ email TEXT,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL,
+ auth_provider TEXT NOT NULL DEFAULT 'local',
+ jellyseerr_user_id INTEGER,
+ created_at TEXT NOT NULL,
+ last_login_at TEXT,
+ is_blocked INTEGER NOT NULL DEFAULT 0,
+ auto_search_enabled INTEGER NOT NULL DEFAULT 1,
+ invite_management_enabled INTEGER NOT NULL DEFAULT 0,
+ profile_id INTEGER,
+ expires_at TEXT,
+ invited_by_code TEXT,
+ invited_at TEXT,
+ jellyfin_password_hash TEXT,
+ last_jellyfin_auth_at TEXT
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_profiles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ role TEXT NOT NULL DEFAULT 'user',
+ auto_search_enabled INTEGER NOT NULL DEFAULT 1,
+ account_expires_days INTEGER,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS signup_invites (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT NOT NULL UNIQUE,
+ label TEXT,
+ description TEXT,
+ profile_id INTEGER,
+ role TEXT,
+ max_uses INTEGER,
+ use_count INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ expires_at TEXT,
+ recipient_email TEXT,
+ created_by TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_signup_invites_enabled
+ ON signup_invites (enabled)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_signup_invites_expires_at
+ ON signup_invites (expires_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS requests_cache (
+ request_id INTEGER PRIMARY KEY,
+ media_id INTEGER,
+ media_type TEXT,
+ status INTEGER,
+ title TEXT,
+ year INTEGER,
+ requested_by TEXT,
+ requested_by_norm TEXT,
+ requested_by_id INTEGER,
+ created_at TEXT,
+ updated_at TEXT,
+ payload_json TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS artwork_cache_status (
+ request_id INTEGER PRIMARY KEY,
+ tmdb_id INTEGER,
+ media_type TEXT,
+ poster_path TEXT,
+ backdrop_path TEXT,
+ has_tmdb INTEGER NOT NULL DEFAULT 0,
+ poster_cached INTEGER NOT NULL DEFAULT 0,
+ backdrop_cached INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS seerr_media_failures (
+ media_type TEXT NOT NULL,
+ tmdb_id INTEGER NOT NULL,
+ status_code INTEGER,
+ error_message TEXT,
+ failure_count INTEGER NOT NULL DEFAULT 1,
+ first_failed_at TEXT NOT NULL,
+ last_failed_at TEXT NOT NULL,
+ suppress_until TEXT NOT NULL,
+ is_persistent INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (media_type, tmdb_id)
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ token_hash TEXT NOT NULL UNIQUE,
+ username TEXT NOT NULL,
+ recipient_email TEXT NOT NULL,
+ auth_provider TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ used_at TEXT,
+ requested_by_ip TEXT,
+ requested_user_agent TEXT
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS portal_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ media_type TEXT,
+ year INTEGER,
+ external_ref TEXT,
+ source_system TEXT,
+ source_request_id INTEGER,
+ related_item_id INTEGER,
+ status TEXT NOT NULL,
+ workflow_request_status TEXT,
+ workflow_media_status TEXT,
+ issue_type TEXT,
+ issue_resolved_at TEXT,
+ metadata_json TEXT,
+ priority TEXT NOT NULL,
+ created_by_username TEXT NOT NULL,
+ created_by_id INTEGER,
+ assignee_username TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ last_activity_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS portal_comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ item_id INTEGER NOT NULL,
+ author_username TEXT NOT NULL,
+ author_role TEXT NOT NULL,
+ message TEXT NOT NULL,
+ is_internal INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
+ ON requests_cache (created_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm
+ ON requests_cache (requested_by_norm)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_updated_at
+ ON requests_cache (updated_at DESC, request_id DESC)
+ """
+ )
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
+ ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ # Older databases may not have requested_by_id until later migrations run.
+ pass
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
+ ON requests_cache (requested_by_norm, created_at DESC, request_id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_status_created_at
+ ON requests_cache (status, created_at DESC, request_id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_artwork_cache_status_updated_at
+ ON artwork_cache_status (updated_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_seerr_media_failures_suppress_until
+ ON seerr_media_failures (suppress_until)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_username
+ ON password_reset_tokens (username)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
+ ON password_reset_tokens (expires_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_kind_status
+ ON portal_items (kind, status, updated_at DESC, id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_creator
+ ON portal_items (created_by_username, updated_at DESC, id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_status
+ ON portal_items (status, updated_at DESC, id DESC)
+ """
+ )
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
+ ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
+ ON portal_items (related_item_id, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
+ ON portal_comments (item_id, created_at DESC, id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_activity (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL,
+ ip TEXT NOT NULL,
+ user_agent TEXT NOT NULL,
+ first_seen_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL,
+ hit_count INTEGER NOT NULL DEFAULT 1,
+ UNIQUE(username, ip, user_agent)
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_user_activity_username
+ ON user_activity (username)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen
+ ON user_activity (last_seen_at)
+ """
+ )
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
+ ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
+ ON portal_items (related_item_id, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_profile_id
+ ON users (profile_id)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_expires_at
+ ON users (expires_at)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_username_nocase
+ ON users (username COLLATE NOCASE)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_email_nocase
+ ON users (email COLLATE NOCASE)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
+ ON requests_cache (requested_by_id)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("PRAGMA optimize")
+ except sqlite3.OperationalError:
+ pass
+ _backfill_auth_providers()
+ ensure_admin_user()
+
+
+def save_snapshot(snapshot: Snapshot) -> None:
+ payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
+ created_at = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ latest = conn.execute(
+ """
+ SELECT state, state_reason
+ FROM snapshots
+ WHERE request_id = ?
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (snapshot.request_id,),
+ ).fetchone()
+ if latest and latest[0] == snapshot.state.value and latest[1] == snapshot.state_reason:
+ return
+ conn.execute(
+ """
+ INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (
+ snapshot.request_id,
+ snapshot.state.value,
+ snapshot.state_reason,
+ created_at,
+ payload,
+ ),
+ )
+
+
+def save_action(
+ request_id: str,
+ action_id: str,
+ label: str,
+ status: str,
+ message: Optional[str] = None,
+) -> None:
+ created_at = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO actions (request_id, action_id, label, status, message, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (request_id, action_id, label, status, message, created_at),
+ )
+
+
+def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
+ bounded_limit = max(1, min(int(limit or 10), 100))
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, state, state_reason, created_at, payload_json
+ FROM snapshots
+ WHERE request_id = ?
+ ORDER BY id DESC
+ LIMIT ?
+ """,
+ (request_id, min(bounded_limit * 20, 500)),
+ ).fetchall()
+ results = []
+ previous_signature: tuple[str, Optional[str]] | None = None
+ for row in rows:
+ signature = (row[1], row[2])
+ if signature == previous_signature:
+ continue
+ previous_signature = signature
+ results.append(
+ {
+ "request_id": row[0],
+ "state": row[1],
+ "state_reason": row[2],
+ "created_at": row[3],
+ "payload": json.loads(row[4]),
+ }
+ )
+ if len(results) >= bounded_limit:
+ break
+ return results
+
+
+def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, action_id, label, status, message, created_at
+ FROM actions
+ WHERE request_id = ?
+ ORDER BY id DESC
+ LIMIT ?
+ """,
+ (request_id, limit),
+ ).fetchall()
+ results = []
+ for row in rows:
+ results.append(
+ {
+ "request_id": row[0],
+ "action_id": row[1],
+ "label": row[2],
+ "status": row[3],
+ "message": row[4],
+ "created_at": row[5],
+ }
+ )
+ return results
+
+
+def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str, Any]:
+ """Return the most recent proof that this request reached qBittorrent.
+
+ A current qBittorrent API error is not proof that a download exists. Historical
+ snapshots are used so a torrent that has since been removed can still be
+ described honestly in the UI.
+ """
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT created_at, payload_json
+ FROM snapshots
+ WHERE request_id = ?
+ ORDER BY id DESC
+ LIMIT ?
+ """,
+ (request_id, max(1, min(int(limit or 100), 500))),
+ ).fetchall()
+
+ for created_at, payload_json in rows:
+ try:
+ payload = json.loads(payload_json)
+ except (TypeError, ValueError):
+ continue
+ timeline = payload.get("timeline") if isinstance(payload, dict) else None
+ if not isinstance(timeline, list):
+ continue
+ for hop in timeline:
+ if not isinstance(hop, dict) or hop.get("service") != "qBittorrent":
+ continue
+ details = hop.get("details") if isinstance(hop.get("details"), dict) else {}
+ torrents = details.get("torrents")
+ if isinstance(torrents, list) and torrents:
+ return {
+ "observed": True,
+ "last_seen_at": created_at,
+ "state": hop.get("status"),
+ "summary": details.get("summary"),
+ "torrents": torrents,
+ }
+ return {
+ "observed": False,
+ "last_seen_at": None,
+ "state": None,
+ "summary": None,
+ "torrents": [],
+ }
+
+
+def ensure_admin_user() -> None:
+ if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
+ return
+ existing = get_user_by_username(settings.admin_username)
+ if existing:
+ return
+ create_user(settings.admin_username, settings.admin_password, role="admin")
+
+
+def has_admin_user() -> bool:
+ with _connect() as conn:
+ row = conn.execute(
+ "SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
+ ).fetchone()
+ return bool(row)
+
+
+def create_user(
+ username: str,
+ password: str,
+ role: str = "user",
+ email: Optional[str] = None,
+ auth_provider: str = "local",
+ jellyseerr_user_id: Optional[int] = None,
+ auto_search_enabled: bool = True,
+ invite_management_enabled: bool = False,
+ profile_id: Optional[int] = None,
+ expires_at: Optional[str] = None,
+ invited_by_code: Optional[str] = None,
+) -> None:
+ created_at = datetime.now(timezone.utc).isoformat()
+ password_hash = hash_password(password)
+ normalized_email = _normalize_stored_email(email)
+ with _connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO users (
+ username,
+ email,
+ password_hash,
+ role,
+ auth_provider,
+ jellyseerr_user_id,
+ created_at,
+ auto_search_enabled,
+ invite_management_enabled,
+ profile_id,
+ expires_at,
+ invited_by_code,
+ invited_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ username,
+ normalized_email,
+ password_hash,
+ role,
+ auth_provider,
+ jellyseerr_user_id,
+ created_at,
+ 1 if auto_search_enabled else 0,
+ 1 if invite_management_enabled else 0,
+ profile_id,
+ expires_at,
+ invited_by_code,
+ created_at if invited_by_code else None,
+ ),
+ )
+
+
+def create_user_if_missing(
+ username: str,
+ password: str,
+ role: str = "user",
+ email: Optional[str] = None,
+ auth_provider: str = "local",
+ jellyseerr_user_id: Optional[int] = None,
+ auto_search_enabled: bool = True,
+ invite_management_enabled: bool = False,
+ profile_id: Optional[int] = None,
+ expires_at: Optional[str] = None,
+ invited_by_code: Optional[str] = None,
+) -> bool:
+ created_at = datetime.now(timezone.utc).isoformat()
+ password_hash = hash_password(password)
+ normalized_email = _normalize_stored_email(email)
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT OR IGNORE INTO users (
+ username,
+ email,
+ password_hash,
+ role,
+ auth_provider,
+ jellyseerr_user_id,
+ created_at,
+ auto_search_enabled,
+ invite_management_enabled,
+ profile_id,
+ expires_at,
+ invited_by_code,
+ invited_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ username,
+ normalized_email,
+ password_hash,
+ role,
+ auth_provider,
+ jellyseerr_user_id,
+ created_at,
+ 1 if auto_search_enabled else 0,
+ 1 if invite_management_enabled else 0,
+ profile_id,
+ expires_at,
+ invited_by_code,
+ created_at if invited_by_code else None,
+ ),
+ )
+ created = cursor.rowcount > 0
+ if created:
+ logger.info(
+ "user created-if-missing username=%s role=%s auth_provider=%s jellyseerr_user_id=%s profile_id=%s expires_at=%s",
+ username,
+ role,
+ auth_provider,
+ jellyseerr_user_id,
+ profile_id,
+ expires_at,
+ )
+ else:
+ logger.debug("user create-if-missing skipped existing username=%s", username)
+ return created
+
+
+def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (username,),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+
+
+def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE jellyseerr_user_id = ?
+ ORDER BY id ASC
+ LIMIT 1
+ """,
+ (jellyseerr_user_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+
+
+def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE id = ?
+ """,
+ (user_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+
+def get_all_users() -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at,
+ last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
+ profile_id, expires_at, invited_by_code, invited_at
+ FROM users
+ ORDER BY username COLLATE NOCASE
+ """
+ ).fetchall()
+ all_rows: list[Dict[str, Any]] = []
+ for row in rows:
+ all_rows.append(
+ {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "role": row[3],
+ "auth_provider": row[4],
+ "jellyseerr_user_id": row[5],
+ "created_at": row[6],
+ "last_login_at": row[7],
+ "is_blocked": bool(row[8]),
+ "auto_search_enabled": bool(row[9]),
+ "invite_management_enabled": bool(row[10]),
+ "profile_id": row[11],
+ "expires_at": row[12],
+ "invited_by_code": row[13],
+ "invited_at": row[14],
+ "is_expired": _is_datetime_in_past(row[12]),
+ }
+ )
+ # Admin user management uses Jellyfin as the source of truth for non-admin
+ # user objects. Seerr rows are treated as enrichment-only and hidden
+ # from admin/user-management views to avoid duplicate accounts in the UI.
+ def _provider_rank(user: Dict[str, Any]) -> int:
+ provider = str(user.get("auth_provider") or "local").strip().lower()
+ if provider == "jellyfin":
+ return 0
+ if provider == "local":
+ return 1
+ if provider == "jellyseerr":
+ return 2
+ return 2
+
+ visible_candidates = [
+ user
+ for user in all_rows
+ if not (
+ str(user.get("auth_provider") or "local").strip().lower() == "jellyseerr"
+ and str(user.get("role") or "user").strip().lower() != "admin"
+ )
+ ]
+
+ visible_candidates.sort(
+ key=lambda user: (
+ 0 if str(user.get("role") or "user").strip().lower() == "admin" else 1,
+ 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
+ _provider_rank(user),
+ 0 if user.get("last_login_at") else 1,
+ int(user.get("id") or 0),
+ )
+ )
+ seen_usernames: set[str] = set()
+ seen_jellyseerr_ids: set[int] = set()
+ results: list[Dict[str, Any]] = []
+ for user in visible_candidates:
+ username = str(user.get("username") or "").strip()
+ if not username:
+ continue
+ username_key = username.lower()
+ jellyseerr_user_id = user.get("jellyseerr_user_id")
+ if isinstance(jellyseerr_user_id, int) and jellyseerr_user_id in seen_jellyseerr_ids:
+ continue
+ if username_key in seen_usernames:
+ continue
+ results.append(user)
+ seen_usernames.add(username_key)
+ if isinstance(jellyseerr_user_id, int):
+ seen_jellyseerr_ids.add(jellyseerr_user_id)
+ results.sort(key=lambda user: str(user.get("username") or "").lower())
+ return results
+
+
+def delete_non_admin_users() -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM users WHERE role != 'admin'
+ """
+ )
+ return cursor.rowcount
+
+
+def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (jellyseerr_user_id, username),
+ )
+
+
+def set_user_auth_provider(username: str, auth_provider: str) -> None:
+ provider = (auth_provider or "local").strip().lower() or "local"
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET auth_provider = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (provider, username),
+ )
+
+
+def set_last_login(username: str) -> None:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET last_login_at = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (timestamp, username),
+ )
+
+
+def set_user_blocked(username: str, blocked: bool) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET is_blocked = ? WHERE username = ?
+ """,
+ (1 if blocked else 0, username),
+ )
+ logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
+
+
+def delete_user_by_username(username: str) -> bool:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM users WHERE username = ? COLLATE NOCASE
+ """,
+ (username,),
+ )
+ deleted = cursor.rowcount > 0
+ logger.warning("user delete username=%s deleted=%s", username, deleted)
+ return deleted
+
+
+def delete_user_activity_by_username(username: str) -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM user_activity WHERE username = ? COLLATE NOCASE
+ """,
+ (username,),
+ )
+ return cursor.rowcount
+
+
+def disable_signup_invites_by_creator(username: str) -> int:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET enabled = 0, updated_at = ?
+ WHERE created_by = ? COLLATE NOCASE AND enabled != 0
+ """,
+ (timestamp, username),
+ )
+ return cursor.rowcount
+
+
+def set_user_role(username: str, role: str) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (role, username),
+ )
+ logger.info("user role updated username=%s role=%s", username, role)
+
+
+def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET auto_search_enabled = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (1 if enabled else 0, username),
+ )
+ logger.info("user auto-search updated username=%s enabled=%s", username, enabled)
+
+
+def set_user_invite_management_enabled(username: str, enabled: bool) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET invite_management_enabled = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (1 if enabled else 0, username),
+ )
+ logger.info("user invite-management updated username=%s enabled=%s", username, enabled)
+
+
+def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE users SET auto_search_enabled = ? WHERE role != 'admin'
+ """,
+ (1 if enabled else 0,),
+ )
+ return cursor.rowcount
+
+
+def set_invite_management_enabled_for_non_admin_users(enabled: bool) -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE users SET invite_management_enabled = ? WHERE role != 'admin'
+ """,
+ (1 if enabled else 0,),
+ )
+ logger.info(
+ "bulk invite-management updated non_admin_users=%s enabled=%s",
+ cursor.rowcount,
+ enabled,
+ )
+ return cursor.rowcount
+
+
+def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET profile_id = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (profile_id, username),
+ )
+ logger.info("user profile assignment updated username=%s profile_id=%s", username, profile_id)
+
+
+def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET expires_at = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (expires_at, username),
+ )
+ logger.info("user expiry updated username=%s expires_at=%s", username, expires_at)
+
+
+def _row_to_user_profile(row: Any) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "name": row[1],
+ "description": row[2],
+ "role": row[3],
+ "auto_search_enabled": bool(row[4]),
+ "account_expires_days": row[5],
+ "is_active": bool(row[6]),
+ "created_at": row[7],
+ "updated_at": row[8],
+ }
+
+
+def list_user_profiles() -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
+ FROM user_profiles
+ ORDER BY name COLLATE NOCASE
+ """
+ ).fetchall()
+ return [_row_to_user_profile(row) for row in rows]
+
+
+def get_user_profile(profile_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
+ FROM user_profiles
+ WHERE id = ?
+ """,
+ (profile_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_user_profile(row)
+
+
+def create_user_profile(
+ name: str,
+ description: Optional[str] = None,
+ role: str = "user",
+ auto_search_enabled: bool = True,
+ account_expires_days: Optional[int] = None,
+ is_active: bool = True,
+) -> Dict[str, Any]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO user_profiles (
+ name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ name,
+ description,
+ role,
+ 1 if auto_search_enabled else 0,
+ account_expires_days,
+ 1 if is_active else 0,
+ timestamp,
+ timestamp,
+ ),
+ )
+ profile_id = int(cursor.lastrowid)
+ profile = get_user_profile(profile_id)
+ if not profile:
+ raise RuntimeError("Profile creation failed")
+ return profile
+
+
+def update_user_profile(
+ profile_id: int,
+ *,
+ name: str,
+ description: Optional[str],
+ role: str,
+ auto_search_enabled: bool,
+ account_expires_days: Optional[int],
+ is_active: bool,
+) -> Optional[Dict[str, Any]]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE user_profiles
+ SET name = ?, description = ?, role = ?, auto_search_enabled = ?,
+ account_expires_days = ?, is_active = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ name,
+ description,
+ role,
+ 1 if auto_search_enabled else 0,
+ account_expires_days,
+ 1 if is_active else 0,
+ timestamp,
+ profile_id,
+ ),
+ )
+ if cursor.rowcount <= 0:
+ return None
+ return get_user_profile(profile_id)
+
+
+def delete_user_profile(profile_id: int) -> bool:
+ with _connect() as conn:
+ users_count = conn.execute(
+ "SELECT COUNT(*) FROM users WHERE profile_id = ?",
+ (profile_id,),
+ ).fetchone()
+ invites_count = conn.execute(
+ "SELECT COUNT(*) FROM signup_invites WHERE profile_id = ?",
+ (profile_id,),
+ ).fetchone()
+ if int((users_count or [0])[0] or 0) > 0:
+ raise ValueError("Profile is assigned to existing users.")
+ if int((invites_count or [0])[0] or 0) > 0:
+ raise ValueError("Profile is assigned to existing invites.")
+ cursor = conn.execute(
+ "DELETE FROM user_profiles WHERE id = ?",
+ (profile_id,),
+ )
+ return cursor.rowcount > 0
+
+
+def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
+ max_uses = row[6]
+ use_count = int(row[7] or 0)
+ expires_at = row[9]
+ is_expired = _is_datetime_in_past(expires_at)
+ remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
+ return {
+ "id": row[0],
+ "code": row[1],
+ "label": row[2],
+ "description": row[3],
+ "profile_id": row[4],
+ "role": row[5],
+ "max_uses": max_uses,
+ "use_count": use_count,
+ "enabled": bool(row[8]),
+ "expires_at": expires_at,
+ "recipient_email": row[10],
+ "created_by": row[11],
+ "created_at": row[12],
+ "updated_at": row[13],
+ "is_expired": is_expired,
+ "remaining_uses": remaining_uses,
+ "is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
+ }
+
+
+def list_signup_invites() -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ FROM signup_invites
+ ORDER BY created_at DESC, id DESC
+ """
+ ).fetchall()
+ return [_row_to_signup_invite(row) for row in rows]
+
+
+def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ FROM signup_invites
+ WHERE id = ?
+ """,
+ (invite_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_signup_invite(row)
+
+
+def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ FROM signup_invites
+ WHERE code = ? COLLATE NOCASE
+ """,
+ (code,),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_signup_invite(row)
+
+
+def create_signup_invite(
+ *,
+ code: str,
+ label: Optional[str] = None,
+ description: Optional[str] = None,
+ profile_id: Optional[int] = None,
+ role: Optional[str] = None,
+ max_uses: Optional[int] = None,
+ enabled: bool = True,
+ expires_at: Optional[str] = None,
+ recipient_email: Optional[str] = None,
+ created_by: Optional[str] = None,
+) -> Dict[str, Any]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO signup_invites (
+ code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ code,
+ label,
+ description,
+ profile_id,
+ role,
+ max_uses,
+ 1 if enabled else 0,
+ expires_at,
+ recipient_email,
+ created_by,
+ timestamp,
+ timestamp,
+ ),
+ )
+ invite_id = int(cursor.lastrowid)
+ logger.info(
+ "signup invite created invite_id=%s code=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s recipient_email=%s created_by=%s",
+ invite_id,
+ code,
+ role,
+ profile_id,
+ max_uses,
+ enabled,
+ expires_at,
+ recipient_email,
+ created_by,
+ )
+ invite = get_signup_invite_by_id(invite_id)
+ if not invite:
+ raise RuntimeError("Invite creation failed")
+ return invite
+
+
+def update_signup_invite(
+ invite_id: int,
+ *,
+ code: str,
+ label: Optional[str],
+ description: Optional[str],
+ profile_id: Optional[int],
+ role: Optional[str],
+ max_uses: Optional[int],
+ enabled: bool,
+ expires_at: Optional[str],
+ recipient_email: Optional[str],
+) -> Optional[Dict[str, Any]]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
+ enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ code,
+ label,
+ description,
+ profile_id,
+ role,
+ max_uses,
+ 1 if enabled else 0,
+ expires_at,
+ recipient_email,
+ timestamp,
+ invite_id,
+ ),
+ )
+ if cursor.rowcount <= 0:
+ return None
+ return get_signup_invite_by_id(invite_id)
+
+
+def delete_signup_invite(invite_id: int) -> bool:
+ with _connect() as conn:
+ cursor = conn.execute(
+ "DELETE FROM signup_invites WHERE id = ?",
+ (invite_id,),
+ )
+ return cursor.rowcount > 0
+
+
+def increment_signup_invite_use(invite_id: int) -> None:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE signup_invites
+ SET use_count = use_count + 1, updated_at = ?
+ WHERE id = ?
+ """,
+ (timestamp, invite_id),
+ )
+
+
+def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
+ # Resolve case-insensitive duplicates safely by only considering local-provider rows.
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ ORDER BY
+ CASE WHEN username = ? THEN 0 ELSE 1 END,
+ id ASC
+ """,
+ (username, username),
+ ).fetchall()
+ if not rows:
+ return None
+ for row in rows:
+ provider = str(row[4] or "local").lower()
+ if provider != "local":
+ continue
+ if not verify_password(password, row[2]):
+ continue
+ return {
+ "id": row[0],
+ "username": row[1],
+ "password_hash": row[2],
+ "role": row[3],
+ "auth_provider": row[4],
+ "jellyseerr_user_id": row[5],
+ "created_at": row[6],
+ "last_login_at": row[7],
+ "is_blocked": bool(row[8]),
+ "auto_search_enabled": bool(row[9]),
+ "invite_management_enabled": bool(row[10]),
+ "profile_id": row[11],
+ "expires_at": row[12],
+ "invited_by_code": row[13],
+ "invited_at": row[14],
+ "is_expired": _is_datetime_in_past(row[12]),
+ "jellyfin_password_hash": row[15],
+ "last_jellyfin_auth_at": row[16],
+ }
+ return None
+
+
+def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ ORDER BY
+ CASE WHEN username = ? THEN 0 ELSE 1 END,
+ id ASC
+ """,
+ (username, username),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ results.append(
+ {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+ )
+ return results
+
+
+def set_user_email(username: str, email: Optional[str]) -> bool:
+ normalized_email = _normalize_stored_email(email)
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE users
+ SET email = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (normalized_email, username),
+ )
+ updated = cursor.rowcount > 0
+ if updated:
+ logger.info("user email updated username=%s email=%s", username, normalized_email)
+ else:
+ logger.debug("user email update skipped username=%s", username)
+ return updated
+
+
+def set_user_password(username: str, password: str) -> None:
+ password_hash = hash_password(password)
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (password_hash, username),
+ )
+
+
+def sync_jellyfin_password_state(username: str, password: str) -> None:
+ if not username or not password:
+ return
+ password_hash = hash_password(password)
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET password_hash = ?,
+ jellyfin_password_hash = ?,
+ last_jellyfin_auth_at = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (password_hash, password_hash, timestamp, username),
+ )
+
+
+def set_jellyfin_auth_cache(username: str, password: str) -> None:
+ if not username or not password:
+ return
+ password_hash = hash_password(password)
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET jellyfin_password_hash = ?, last_jellyfin_auth_at = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (password_hash, timestamp, username),
+ )
+
+
+def _backfill_auth_providers() -> None:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT username, password_hash, auth_provider
+ FROM users
+ """
+ ).fetchall()
+ updates: list[tuple[str, str]] = []
+ for row in rows:
+ username, password_hash, auth_provider = row
+ provider = auth_provider or "local"
+ if provider == "local":
+ if verify_password("jellyfin-user", password_hash):
+ provider = "jellyfin"
+ elif verify_password("jellyseerr-user", password_hash):
+ provider = "jellyseerr"
+ if provider != auth_provider:
+ updates.append((provider, username))
+ if not updates:
+ return
+ with _connect() as conn:
+ conn.executemany(
+ """
+ UPDATE users SET auth_provider = ? WHERE username = ?
+ """,
+ updates,
+ )
+
+
+def upsert_user_activity(username: str, ip: str, user_agent: str) -> None:
+ if not username:
+ return
+ ip_value = ip.strip() if isinstance(ip, str) and ip.strip() else "unknown"
+ agent_value = (
+ user_agent.strip() if isinstance(user_agent, str) and user_agent.strip() else "unknown"
+ )
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO user_activity (username, ip, user_agent, first_seen_at, last_seen_at, hit_count)
+ VALUES (?, ?, ?, ?, ?, 1)
+ ON CONFLICT(username, ip, user_agent)
+ DO UPDATE SET last_seen_at = excluded.last_seen_at, hit_count = hit_count + 1
+ """,
+ (username, ip_value, agent_value, timestamp, timestamp),
+ )
+
+
+def get_user_activity(username: str, limit: int = 5) -> list[Dict[str, Any]]:
+ limit = max(1, min(limit, 20))
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT ip, user_agent, first_seen_at, last_seen_at, hit_count
+ FROM user_activity
+ WHERE username = ?
+ ORDER BY last_seen_at DESC
+ LIMIT ?
+ """,
+ (username, limit),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ results.append(
+ {
+ "ip": row[0],
+ "user_agent": row[1],
+ "first_seen_at": row[2],
+ "last_seen_at": row[3],
+ "hit_count": row[4],
+ }
+ )
+ return results
+
+
+def get_user_activity_summary(username: str) -> Dict[str, Any]:
+ with _connect() as conn:
+ last_row = conn.execute(
+ """
+ SELECT ip, user_agent, last_seen_at
+ FROM user_activity
+ WHERE username = ?
+ ORDER BY last_seen_at DESC
+ LIMIT 1
+ """,
+ (username,),
+ ).fetchone()
+ count_row = conn.execute(
+ """
+ SELECT COUNT(*)
+ FROM user_activity
+ WHERE username = ?
+ """,
+ (username,),
+ ).fetchone()
+ return {
+ "last_ip": last_row[0] if last_row else None,
+ "last_user_agent": last_row[1] if last_row else None,
+ "last_seen_at": last_row[2] if last_row else None,
+ "device_count": int(count_row[0] or 0) if count_row else 0,
+ }
+
+
+def get_user_request_stats(username_norm: str, requested_by_id: Optional[int] = None) -> Dict[str, Any]:
+ if requested_by_id is None:
+ return {
+ "total": 0,
+ "ready": 0,
+ "pending": 0,
+ "approved": 0,
+ "working": 0,
+ "partial": 0,
+ "declined": 0,
+ "in_progress": 0,
+ "last_request_at": None,
+ }
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT
+ COUNT(*) AS total,
+ SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS ready,
+ SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS pending,
+ SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS approved,
+ SUM(CASE WHEN status = 5 THEN 1 ELSE 0 END) AS working,
+ SUM(CASE WHEN status = 6 THEN 1 ELSE 0 END) AS partial,
+ SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS declined,
+ MAX(created_at) AS last_request_at
+ FROM requests_cache
+ WHERE requested_by_id = ?
+ """,
+ (requested_by_id,),
+ ).fetchone()
+ if not row:
+ return {
+ "total": 0,
+ "ready": 0,
+ "pending": 0,
+ "approved": 0,
+ "working": 0,
+ "partial": 0,
+ "declined": 0,
+ "in_progress": 0,
+ "last_request_at": None,
+ }
+ total = int(row[0] or 0)
+ ready = int(row[1] or 0)
+ pending = int(row[2] or 0)
+ approved = int(row[3] or 0)
+ working = int(row[4] or 0)
+ partial = int(row[5] or 0)
+ declined = int(row[6] or 0)
+ in_progress = approved + working + partial
+ return {
+ "total": total,
+ "ready": ready,
+ "pending": pending,
+ "approved": approved,
+ "working": working,
+ "partial": partial,
+ "declined": declined,
+ "in_progress": in_progress,
+ "last_request_at": row[7],
+ }
+
+
+def get_global_request_leader() -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT requested_by_norm, MAX(requested_by) as display_name, COUNT(*) as total
+ FROM requests_cache
+ WHERE requested_by_norm IS NOT NULL AND requested_by_norm != ''
+ GROUP BY requested_by_norm
+ ORDER BY total DESC
+ LIMIT 1
+ """
+ ).fetchone()
+ if not row:
+ return None
+ return {"username": row[1] or row[0], "total": int(row[2] or 0)}
+
+
+def get_global_request_total() -> int:
+ with _connect() as conn:
+ row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone()
+ return int(row[0] or 0)
+
+
+_REQUESTS_CACHE_UPSERT_SQL = """
+ INSERT INTO requests_cache (
+ request_id,
+ media_id,
+ media_type,
+ status,
+ title,
+ year,
+ requested_by,
+ requested_by_norm,
+ requested_by_id,
+ created_at,
+ updated_at,
+ payload_json
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(request_id) DO UPDATE SET
+ media_id = excluded.media_id,
+ media_type = excluded.media_type,
+ status = excluded.status,
+ title = excluded.title,
+ year = excluded.year,
+ requested_by = excluded.requested_by,
+ requested_by_norm = excluded.requested_by_norm,
+ requested_by_id = excluded.requested_by_id,
+ created_at = excluded.created_at,
+ updated_at = excluded.updated_at,
+ payload_json = excluded.payload_json
+"""
+
+
+def get_request_cache_lookup(request_ids: list[int]) -> Dict[int, Dict[str, Any]]:
+ normalized_ids = sorted({int(request_id) for request_id in request_ids if isinstance(request_id, int)})
+ if not normalized_ids:
+ return {}
+ placeholders = ", ".join("?" for _ in normalized_ids)
+ query = f"""
+ SELECT request_id, updated_at, title, year
+ FROM requests_cache
+ WHERE request_id IN ({placeholders})
+ """
+ with _connect() as conn:
+ rows = conn.execute(query, tuple(normalized_ids)).fetchall()
+ return {
+ int(row[0]): {
+ "request_id": int(row[0]),
+ "updated_at": row[1],
+ "title": row[2],
+ "year": row[3],
+ }
+ for row in rows
+ }
+
+
+def _prepare_requests_cache_upsert_rows(
+ records: list[Dict[str, Any]], conn: sqlite3.Connection
+) -> list[tuple[Any, ...]]:
+ if not records:
+ return []
+ existing_rows: Dict[int, tuple[Optional[str], Optional[int]]] = {}
+ ids_needing_existing = [
+ int(record["request_id"])
+ for record in records
+ if isinstance(record.get("request_id"), int)
+ and (
+ not _normalize_title_value(record.get("title"))
+ or _normalize_year_value(record.get("year")) is None
+ )
+ ]
+ if ids_needing_existing:
+ placeholders = ", ".join("?" for _ in sorted(set(ids_needing_existing)))
+ query = f"""
+ SELECT request_id, title, year
+ FROM requests_cache
+ WHERE request_id IN ({placeholders})
+ """
+ for row in conn.execute(query, tuple(sorted(set(ids_needing_existing)))).fetchall():
+ existing_rows[int(row[0])] = (row[1], row[2])
+
+ prepared: list[tuple[Any, ...]] = []
+ for record in records:
+ request_id = int(record["request_id"])
+ media_id = record.get("media_id")
+ media_type = record.get("media_type")
+ status = record.get("status")
+ requested_by = record.get("requested_by")
+ requested_by_norm = record.get("requested_by_norm")
+ requested_by_id = record.get("requested_by_id")
+ created_at = record.get("created_at")
+ updated_at = record.get("updated_at")
+ payload_json = str(record.get("payload_json") or "")
+
+ normalized_title = _normalize_title_value(record.get("title"))
+ normalized_year = _normalize_year_value(record.get("year"))
+ derived_title = None
+ derived_year = None
+ if not normalized_title or normalized_year is None:
+ derived_title, derived_year = _extract_title_year_from_payload(payload_json)
+ if _is_placeholder_title(normalized_title, request_id):
+ normalized_title = None
+ if derived_title and not normalized_title:
+ normalized_title = derived_title
+ if normalized_year is None and derived_year is not None:
+ normalized_year = derived_year
+
+ existing_title = None
+ existing_year = None
+ if normalized_title is None or normalized_year is None:
+ existing = existing_rows.get(request_id)
+ if existing:
+ existing_title, existing_year = existing
+ if _is_placeholder_title(existing_title, request_id):
+ existing_title = None
+ if normalized_title is None and existing_title:
+ normalized_title = existing_title
+ if normalized_year is None and existing_year is not None:
+ normalized_year = existing_year
+
+ prepared.append(
+ (
+ request_id,
+ media_id,
+ media_type,
+ status,
+ normalized_title,
+ normalized_year,
+ requested_by,
+ requested_by_norm,
+ requested_by_id,
+ created_at,
+ updated_at,
+ payload_json,
+ )
+ )
+ return prepared
+
+
+def upsert_request_cache(
+ request_id: int,
+ media_id: Optional[int],
+ media_type: Optional[str],
+ status: Optional[int],
+ title: Optional[str],
+ year: Optional[int],
+ requested_by: Optional[str],
+ requested_by_norm: Optional[str],
+ requested_by_id: Optional[int],
+ created_at: Optional[str],
+ updated_at: Optional[str],
+ payload_json: str,
+) -> None:
+ with _connect() as conn:
+ rows = _prepare_requests_cache_upsert_rows(
+ [
+ {
+ "request_id": request_id,
+ "media_id": media_id,
+ "media_type": media_type,
+ "status": status,
+ "title": title,
+ "year": year,
+ "requested_by": requested_by,
+ "requested_by_norm": requested_by_norm,
+ "requested_by_id": requested_by_id,
+ "created_at": created_at,
+ "updated_at": updated_at,
+ "payload_json": payload_json,
+ }
+ ],
+ conn,
+ )
+ if rows:
+ conn.execute(_REQUESTS_CACHE_UPSERT_SQL, rows[0])
+ logger.debug(
+ "requests_cache upsert: request_id=%s media_id=%s status=%s updated_at=%s",
+ request_id,
+ media_id,
+ status,
+ updated_at,
+ )
+
+
+def upsert_request_cache_many(records: list[Dict[str, Any]]) -> int:
+ if not records:
+ return 0
+ with _connect() as conn:
+ rows = _prepare_requests_cache_upsert_rows(records, conn)
+ if rows:
+ conn.executemany(_REQUESTS_CACHE_UPSERT_SQL, rows)
+ logger.debug("requests_cache bulk upsert: rows=%s", len(records))
+ return len(records)
+
+
+def get_request_cache_last_updated() -> Optional[str]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT MAX(updated_at) FROM requests_cache
+ """
+ ).fetchone()
+ if not row:
+ return None
+ return row[0]
+
+
+def get_request_cache_by_id(request_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT request_id, updated_at, title
+ FROM requests_cache
+ WHERE request_id = ?
+ """,
+ (request_id,),
+ ).fetchone()
+ if not row:
+ logger.debug("requests_cache miss: request_id=%s", request_id)
+ return None
+ logger.debug("requests_cache hit: request_id=%s updated_at=%s", row[0], row[1])
+ return {"request_id": row[0], "updated_at": row[1], "title": row[2]}
+
+
+def get_request_cache_payload(request_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT payload_json
+ FROM requests_cache
+ WHERE request_id = ?
+ """,
+ (request_id,),
+ ).fetchone()
+ if not row or not row[0]:
+ logger.debug("requests_cache payload miss: request_id=%s", request_id)
+ return None
+ try:
+ payload = json.loads(row[0])
+ logger.debug("requests_cache payload hit: request_id=%s", request_id)
+ return payload
+ except json.JSONDecodeError:
+ logger.warning("requests_cache payload invalid json: request_id=%s", request_id)
+ return None
+
+
+def get_cached_requests(
+ limit: int,
+ offset: int,
+ requested_by_norm: Optional[str] = None,
+ requested_by_id: Optional[int] = None,
+ since_iso: Optional[str] = None,
+ status_codes: Optional[list[int]] = None,
+) -> list[Dict[str, Any]]:
+ query = """
+ SELECT request_id, media_id, media_type, status, title, year, requested_by,
+ requested_by_norm, requested_by_id, created_at, payload_json
+ FROM requests_cache
+ """
+ params: list[Any] = []
+ conditions = []
+ if requested_by_id is not None:
+ conditions.append("requested_by_id = ?")
+ params.append(requested_by_id)
+ elif requested_by_norm:
+ conditions.append("requested_by_norm = ?")
+ params.append(requested_by_norm)
+ if since_iso:
+ conditions.append("created_at >= ?")
+ params.append(since_iso)
+ if status_codes:
+ placeholders = ", ".join("?" for _ in status_codes)
+ conditions.append(f"status IN ({placeholders})")
+ params.extend(status_codes)
+ if conditions:
+ query += " WHERE " + " AND ".join(conditions)
+ query += " ORDER BY created_at DESC, request_id DESC LIMIT ? OFFSET ?"
+ params.extend([limit, offset])
+ with _connect() as conn:
+ rows = conn.execute(query, tuple(params)).fetchall()
+ logger.debug(
+ "requests_cache list: count=%s requested_by_norm=%s requested_by_id=%s since_iso=%s status_codes=%s",
+ len(rows),
+ requested_by_norm,
+ requested_by_id,
+ since_iso,
+ status_codes,
+ )
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ title = row[4]
+ year = row[5]
+ payload_json = row[10]
+ if (not title or not year) and payload_json:
+ derived_title, derived_year = _extract_title_year_from_payload(payload_json)
+ if not title:
+ title = derived_title
+ if not year:
+ year = derived_year
+ results.append(
+ {
+ "request_id": row[0],
+ "media_id": row[1],
+ "media_type": row[2],
+ "status": row[3],
+ "title": title,
+ "year": year,
+ "requested_by": row[6],
+ "requested_by_norm": row[7],
+ "requested_by_id": row[8],
+ "created_at": row[9],
+ }
+ )
+ return results
+
+
+def get_cached_requests_count(
+ requested_by_norm: Optional[str] = None,
+ requested_by_id: Optional[int] = None,
+ since_iso: Optional[str] = None,
+ status_codes: Optional[list[int]] = None,
+) -> int:
+ query = "SELECT COUNT(*) FROM requests_cache"
+ params: list[Any] = []
+ conditions = []
+ if requested_by_id is not None:
+ conditions.append("requested_by_id = ?")
+ params.append(requested_by_id)
+ elif requested_by_norm:
+ conditions.append("requested_by_norm = ?")
+ params.append(requested_by_norm)
+ if since_iso:
+ conditions.append("created_at >= ?")
+ params.append(since_iso)
+ if status_codes:
+ placeholders = ", ".join("?" for _ in status_codes)
+ conditions.append(f"status IN ({placeholders})")
+ params.extend(status_codes)
+ if conditions:
+ query += " WHERE " + " AND ".join(conditions)
+ with _connect() as conn:
+ row = conn.execute(query, tuple(params)).fetchone()
+ if not row:
+ return 0
+ return int(row[0])
+
+
+def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
+ limit = max(1, min(limit, 200))
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, media_id, media_type, status, title, year, requested_by,
+ requested_by_norm, requested_by_id, created_at, updated_at, payload_json
+ FROM requests_cache
+ ORDER BY updated_at DESC, request_id DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ title = row[4]
+ if not title and row[11]:
+ derived_title, _ = _extract_title_year_from_payload(row[11])
+ title = derived_title or row[4]
+ results.append(
+ {
+ "request_id": row[0],
+ "media_id": row[1],
+ "media_type": row[2],
+ "status": row[3],
+ "title": title,
+ "year": row[5],
+ "requested_by": row[6],
+ "requested_by_norm": row[7],
+ "requested_by_id": row[8],
+ "created_at": row[9],
+ "updated_at": row[10],
+ }
+ )
+ return results
+
+
+def get_request_cache_missing_titles(limit: int = 200) -> list[Dict[str, Any]]:
+ limit = max(1, min(limit, 500))
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, payload_json
+ FROM requests_cache
+ WHERE title IS NULL OR TRIM(title) = '' OR LOWER(title) = 'untitled'
+ ORDER BY updated_at DESC, request_id DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ payload_json = row[1]
+ tmdb_id, media_type = _extract_tmdb_from_payload(payload_json)
+ results.append(
+ {
+ "request_id": row[0],
+ "payload_json": payload_json,
+ "tmdb_id": tmdb_id,
+ "media_type": media_type,
+ }
+ )
+ return results
+
+
+def get_request_cache_count() -> int:
+ with _connect() as conn:
+ row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone()
+ return int(row[0] or 0)
+
+
+def upsert_artwork_cache_status(
+ request_id: int,
+ tmdb_id: Optional[int],
+ media_type: Optional[str],
+ poster_path: Optional[str],
+ backdrop_path: Optional[str],
+ has_tmdb: bool,
+ poster_cached: bool,
+ backdrop_cached: bool,
+) -> None:
+ upsert_artwork_cache_status_many(
+ [
+ {
+ "request_id": request_id,
+ "tmdb_id": tmdb_id,
+ "media_type": media_type,
+ "poster_path": poster_path,
+ "backdrop_path": backdrop_path,
+ "has_tmdb": has_tmdb,
+ "poster_cached": poster_cached,
+ "backdrop_cached": backdrop_cached,
+ }
+ ]
+ )
+
+
+def upsert_artwork_cache_status_many(records: list[Dict[str, Any]]) -> int:
+ if not records:
+ return 0
+ updated_at = datetime.now(timezone.utc).isoformat()
+ params = [
+ (
+ record["request_id"],
+ record.get("tmdb_id"),
+ record.get("media_type"),
+ record.get("poster_path"),
+ record.get("backdrop_path"),
+ 1 if record.get("has_tmdb") else 0,
+ 1 if record.get("poster_cached") else 0,
+ 1 if record.get("backdrop_cached") else 0,
+ updated_at,
+ )
+ for record in records
+ if isinstance(record.get("request_id"), int)
+ ]
+ if not params:
+ return 0
+ with _connect() as conn:
+ conn.executemany(
+ """
+ INSERT INTO artwork_cache_status (
+ request_id,
+ tmdb_id,
+ media_type,
+ poster_path,
+ backdrop_path,
+ has_tmdb,
+ poster_cached,
+ backdrop_cached,
+ updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(request_id) DO UPDATE SET
+ tmdb_id = excluded.tmdb_id,
+ media_type = excluded.media_type,
+ poster_path = excluded.poster_path,
+ backdrop_path = excluded.backdrop_path,
+ has_tmdb = excluded.has_tmdb,
+ poster_cached = excluded.poster_cached,
+ backdrop_cached = excluded.backdrop_cached,
+ updated_at = excluded.updated_at
+ """,
+ params,
+ )
+ return len(params)
+
+
+def get_artwork_cache_status_count() -> int:
+ with _connect() as conn:
+ row = conn.execute("SELECT COUNT(*) FROM artwork_cache_status").fetchone()
+ return int(row[0] or 0)
+
+
+def get_artwork_cache_missing_count() -> int:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT COUNT(*)
+ FROM artwork_cache_status
+ WHERE (
+ (poster_path IS NULL AND has_tmdb = 1)
+ OR (poster_path IS NOT NULL AND poster_cached = 0)
+ OR (backdrop_path IS NULL AND has_tmdb = 1)
+ OR (backdrop_path IS NOT NULL AND backdrop_cached = 0)
+ )
+ """
+ ).fetchone()
+ return int(row[0] or 0)
+
+
+def update_artwork_cache_stats(
+ cache_bytes: Optional[int] = None,
+ cache_files: Optional[int] = None,
+ missing_count: Optional[int] = None,
+ total_requests: Optional[int] = None,
+) -> None:
+ updated_at = datetime.now(timezone.utc).isoformat()
+ if cache_bytes is not None:
+ set_setting("artwork_cache_bytes", str(int(cache_bytes)))
+ if cache_files is not None:
+ set_setting("artwork_cache_files", str(int(cache_files)))
+ if missing_count is not None:
+ set_setting("artwork_cache_missing", str(int(missing_count)))
+ if total_requests is not None:
+ set_setting("artwork_cache_total_requests", str(int(total_requests)))
+ set_setting("artwork_cache_updated_at", updated_at)
+
+
+def get_artwork_cache_stats() -> Dict[str, Any]:
+ def _get_int(key: str) -> int:
+ value = get_setting(key)
+ if value is None:
+ return 0
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return 0
+
+ return {
+ "cache_bytes": _get_int("artwork_cache_bytes"),
+ "cache_files": _get_int("artwork_cache_files"),
+ "missing_artwork": _get_int("artwork_cache_missing"),
+ "total_requests": _get_int("artwork_cache_total_requests"),
+ "updated_at": get_setting("artwork_cache_updated_at"),
+ }
+
+
+def get_request_cache_stats() -> Dict[str, Any]:
+ return get_artwork_cache_stats()
+
+
+def update_request_cache_title(
+ request_id: int, title: str, year: Optional[int] = None
+) -> None:
+ normalized_title = _normalize_title_value(title)
+ normalized_year = _normalize_year_value(year)
+ if not normalized_title:
+ return
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE requests_cache
+ SET title = ?, year = COALESCE(?, year)
+ WHERE request_id = ?
+ """,
+ (normalized_title, normalized_year, request_id),
+ )
+
+
+def repair_request_cache_titles() -> int:
+ updated = 0
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, title, year, payload_json
+ FROM requests_cache
+ """
+ ).fetchall()
+ for row in rows:
+ request_id, title, year, payload_json = row
+ if not _is_placeholder_title(title, request_id):
+ continue
+ derived_title, derived_year = _extract_title_year_from_payload(payload_json)
+ if not derived_title:
+ continue
+ conn.execute(
+ """
+ UPDATE requests_cache
+ SET title = ?, year = COALESCE(?, year)
+ WHERE request_id = ?
+ """,
+ (derived_title, derived_year, request_id),
+ )
+ updated += 1
+ return updated
+
+
+def prune_duplicate_requests_cache() -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM requests_cache
+ WHERE media_id IS NOT NULL
+ AND request_id NOT IN (
+ SELECT MAX(request_id)
+ FROM requests_cache
+ WHERE media_id IS NOT NULL
+ GROUP BY media_id, COALESCE(requested_by_norm, '')
+ )
+ """
+ )
+ return cursor.rowcount
+
+
+def get_request_cache_payloads(limit: int = 200, offset: int = 0) -> list[Dict[str, Any]]:
+ limit = max(1, min(limit, 1000))
+ offset = max(0, offset)
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, payload_json
+ FROM requests_cache
+ ORDER BY request_id ASC
+ LIMIT ? OFFSET ?
+ """,
+ (limit, offset),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ payload = None
+ if row[1]:
+ try:
+ payload = json.loads(row[1])
+ except json.JSONDecodeError:
+ payload = None
+ results.append({"request_id": row[0], "payload": payload})
+ return results
+
+
+def get_request_cache_payloads_missing(limit: int = 200, offset: int = 0) -> list[Dict[str, Any]]:
+ limit = max(1, min(limit, 1000))
+ offset = max(0, offset)
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT rc.request_id, rc.payload_json
+ FROM requests_cache rc
+ JOIN artwork_cache_status acs
+ ON rc.request_id = acs.request_id
+ WHERE (
+ (acs.poster_path IS NULL AND acs.has_tmdb = 1)
+ OR (acs.poster_path IS NOT NULL AND acs.poster_cached = 0)
+ OR (acs.backdrop_path IS NULL AND acs.has_tmdb = 1)
+ OR (acs.backdrop_path IS NOT NULL AND acs.backdrop_cached = 0)
+ )
+ ORDER BY rc.request_id ASC
+ LIMIT ? OFFSET ?
+ """,
+ (limit, offset),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ payload = None
+ if row[1]:
+ try:
+ payload = json.loads(row[1])
+ except json.JSONDecodeError:
+ payload = None
+ results.append({"request_id": row[0], "payload": payload})
+ return results
+
+
+def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT request_id, media_id, media_type, status, title, year, requested_by,
+ requested_by_norm, requested_by_id, created_at
+ FROM requests_cache
+ WHERE created_at >= ?
+ ORDER BY created_at DESC, request_id DESC
+ """,
+ (since_iso,),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ results.append(
+ {
+ "request_id": row[0],
+ "media_id": row[1],
+ "media_type": row[2],
+ "status": row[3],
+ "title": row[4],
+ "year": row[5],
+ "requested_by": row[6],
+ "requested_by_norm": row[7],
+ "requested_by_id": row[8],
+ "created_at": row[9],
+ }
+ )
+ return results
+
+
+def get_cached_request_by_media_id(
+ media_id: int,
+ requested_by_norm: Optional[str] = None,
+ requested_by_id: Optional[int] = None,
+) -> Optional[Dict[str, Any]]:
+ query = """
+ SELECT request_id, status
+ FROM requests_cache
+ WHERE media_id = ?
+ """
+ params: list[Any] = [media_id]
+ if requested_by_id is not None:
+ query += " AND requested_by_id = ?"
+ params.append(requested_by_id)
+ elif requested_by_norm:
+ query += " AND requested_by_norm = ?"
+ params.append(requested_by_norm)
+ query += " ORDER BY created_at DESC, request_id DESC LIMIT 1"
+ with _connect() as conn:
+ row = conn.execute(query, tuple(params)).fetchone()
+ if not row:
+ return None
+ return {"request_id": row[0], "status": row[1]}
+
+
+def get_setting(key: str) -> Optional[str]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT value FROM settings WHERE key = ?
+ """,
+ (key,),
+ ).fetchone()
+ if not row:
+ return None
+ return row[0]
+
+
+def set_setting(key: str, value: Optional[str]) -> None:
+ updated_at = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO settings (key, value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+ """,
+ (key, value, updated_at),
+ )
+
+
+def delete_setting(key: str) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ DELETE FROM settings WHERE key = ?
+ """,
+ (key,),
+ )
+
+
+def get_settings_overrides() -> Dict[str, str]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT key, value FROM settings
+ """
+ ).fetchall()
+ overrides: Dict[str, str] = {}
+ for row in rows:
+ key = row[0]
+ value = row[1]
+ if key:
+ overrides[key] = value
+ return overrides
+
+
+def _hash_password_reset_token(token_value: str) -> str:
+ return sha256(str(token_value).encode("utf-8")).hexdigest()
+
+
+def _password_reset_token_row_to_dict(row: Any) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "token_hash": row[1],
+ "username": row[2],
+ "recipient_email": row[3],
+ "auth_provider": row[4],
+ "created_at": row[5],
+ "expires_at": row[6],
+ "used_at": row[7],
+ "requested_by_ip": row[8],
+ "requested_user_agent": row[9],
+ "is_expired": _is_datetime_in_past(row[6]),
+ "is_used": bool(row[7]),
+ }
+
+
+def delete_expired_password_reset_tokens() -> int:
+ now_iso = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM password_reset_tokens
+ WHERE expires_at <= ? OR used_at IS NOT NULL
+ """,
+ (now_iso,),
+ )
+ return int(cursor.rowcount or 0)
+
+
+def create_password_reset_token(
+ token_value: str,
+ username: str,
+ recipient_email: str,
+ auth_provider: str,
+ expires_at: str,
+ *,
+ requested_by_ip: Optional[str] = None,
+ requested_user_agent: Optional[str] = None,
+) -> Dict[str, Any]:
+ created_at = datetime.now(timezone.utc).isoformat()
+ token_hash = _hash_password_reset_token(token_value)
+ delete_expired_password_reset_tokens()
+ with _connect() as conn:
+ conn.execute(
+ """
+ DELETE FROM password_reset_tokens
+ WHERE username = ? AND used_at IS NULL
+ """,
+ (username,),
+ )
+ conn.execute(
+ """
+ INSERT INTO password_reset_tokens (
+ token_hash,
+ username,
+ recipient_email,
+ auth_provider,
+ created_at,
+ expires_at,
+ used_at,
+ requested_by_ip,
+ requested_user_agent
+ )
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
+ """,
+ (
+ token_hash,
+ username,
+ recipient_email,
+ auth_provider,
+ created_at,
+ expires_at,
+ requested_by_ip,
+ requested_user_agent,
+ ),
+ )
+ logger.info(
+ "password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s",
+ username,
+ auth_provider,
+ recipient_email,
+ expires_at,
+ requested_by_ip,
+ )
+ return {
+ "username": username,
+ "recipient_email": recipient_email,
+ "auth_provider": auth_provider,
+ "created_at": created_at,
+ "expires_at": expires_at,
+ "requested_by_ip": requested_by_ip,
+ "requested_user_agent": requested_user_agent,
+ }
+
+
+def get_password_reset_token(token_value: str) -> Optional[Dict[str, Any]]:
+ token_hash = _hash_password_reset_token(token_value)
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, token_hash, username, recipient_email, auth_provider, created_at,
+ expires_at, used_at, requested_by_ip, requested_user_agent
+ FROM password_reset_tokens
+ WHERE token_hash = ?
+ """,
+ (token_hash,),
+ ).fetchone()
+ if not row:
+ return None
+ return _password_reset_token_row_to_dict(row)
+
+
+def mark_password_reset_token_used(token_value: str) -> None:
+ token_hash = _hash_password_reset_token(token_value)
+ used_at = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE password_reset_tokens
+ SET used_at = ?
+ WHERE token_hash = ? AND used_at IS NULL
+ """,
+ (used_at, token_hash),
+ )
+ logger.info("password reset token marked used token_hash=%s", token_hash[:12])
+
+
+def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
+ if not media_type or not tmdb_id:
+ return None
+ normalized_media_type = str(media_type).strip().lower()
+ try:
+ normalized_tmdb_id = int(tmdb_id)
+ except (TypeError, ValueError):
+ return None
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT media_type, tmdb_id, status_code, error_message, failure_count,
+ first_failed_at, last_failed_at, suppress_until, is_persistent
+ FROM seerr_media_failures
+ WHERE media_type = ? AND tmdb_id = ?
+ """,
+ (normalized_media_type, normalized_tmdb_id),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "media_type": row[0],
+ "tmdb_id": row[1],
+ "status_code": row[2],
+ "error_message": row[3],
+ "failure_count": row[4],
+ "first_failed_at": row[5],
+ "last_failed_at": row[6],
+ "suppress_until": row[7],
+ "is_persistent": bool(row[8]),
+ }
+
+
+def is_seerr_media_failure_suppressed(media_type: Optional[str], tmdb_id: Optional[int]) -> bool:
+ record = get_seerr_media_failure(media_type, tmdb_id)
+ if not record:
+ return False
+ suppress_until = _parse_datetime_value(record.get("suppress_until"))
+ if suppress_until and suppress_until > datetime.now(timezone.utc):
+ return True
+ clear_seerr_media_failure(media_type, tmdb_id)
+ return False
+
+
+def record_seerr_media_failure(
+ media_type: Optional[str],
+ tmdb_id: Optional[int],
+ *,
+ status_code: Optional[int] = None,
+ error_message: Optional[str] = None,
+) -> Dict[str, Any]:
+ if not media_type or not tmdb_id:
+ return {}
+ normalized_media_type = str(media_type).strip().lower()
+ normalized_tmdb_id = int(tmdb_id)
+ now = datetime.now(timezone.utc)
+ existing = get_seerr_media_failure(normalized_media_type, normalized_tmdb_id)
+ failure_count = int(existing.get("failure_count", 0)) + 1 if existing else 1
+ is_persistent = failure_count >= SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD
+ if is_persistent:
+ suppress_until = now + timedelta(days=SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS)
+ elif failure_count >= 2:
+ suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS)
+ else:
+ suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS)
+ payload = {
+ "media_type": normalized_media_type,
+ "tmdb_id": normalized_tmdb_id,
+ "status_code": status_code,
+ "error_message": error_message,
+ "failure_count": failure_count,
+ "first_failed_at": existing.get("first_failed_at") if existing else now.isoformat(),
+ "last_failed_at": now.isoformat(),
+ "suppress_until": suppress_until.isoformat(),
+ "is_persistent": is_persistent,
+ }
+ with _connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO seerr_media_failures (
+ media_type,
+ tmdb_id,
+ status_code,
+ error_message,
+ failure_count,
+ first_failed_at,
+ last_failed_at,
+ suppress_until,
+ is_persistent
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(media_type, tmdb_id) DO UPDATE SET
+ status_code = excluded.status_code,
+ error_message = excluded.error_message,
+ failure_count = excluded.failure_count,
+ first_failed_at = excluded.first_failed_at,
+ last_failed_at = excluded.last_failed_at,
+ suppress_until = excluded.suppress_until,
+ is_persistent = excluded.is_persistent
+ """,
+ (
+ payload["media_type"],
+ payload["tmdb_id"],
+ payload["status_code"],
+ payload["error_message"],
+ payload["failure_count"],
+ payload["first_failed_at"],
+ payload["last_failed_at"],
+ payload["suppress_until"],
+ 1 if payload["is_persistent"] else 0,
+ ),
+ )
+ logger.warning(
+ "seerr_media_failure upsert: media_type=%s tmdb_id=%s status=%s failure_count=%s suppress_until=%s persistent=%s",
+ payload["media_type"],
+ payload["tmdb_id"],
+ payload["status_code"],
+ payload["failure_count"],
+ payload["suppress_until"],
+ payload["is_persistent"],
+ )
+ return payload
+
+
+def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> None:
+ if not media_type or not tmdb_id:
+ return
+ normalized_media_type = str(media_type).strip().lower()
+ try:
+ normalized_tmdb_id = int(tmdb_id)
+ except (TypeError, ValueError):
+ return
+ with _connect() as conn:
+ deleted = conn.execute(
+ """
+ DELETE FROM seerr_media_failures
+ WHERE media_type = ? AND tmdb_id = ?
+ """,
+ (normalized_media_type, normalized_tmdb_id),
+ ).rowcount
+ if deleted:
+ logger.info(
+ "seerr_media_failure cleared: media_type=%s tmdb_id=%s",
+ normalized_media_type,
+ normalized_tmdb_id,
+ )
+
+
+def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "kind": row[1],
+ "title": row[2],
+ "description": row[3],
+ "media_type": row[4],
+ "year": row[5],
+ "external_ref": row[6],
+ "source_system": row[7],
+ "source_request_id": row[8],
+ "related_item_id": row[9],
+ "status": row[10],
+ "workflow_request_status": row[11],
+ "workflow_media_status": row[12],
+ "issue_type": row[13],
+ "issue_resolved_at": row[14],
+ "metadata_json": row[15],
+ "priority": row[16],
+ "created_by_username": row[17],
+ "created_by_id": row[18],
+ "assignee_username": row[19],
+ "created_at": row[20],
+ "updated_at": row[21],
+ "last_activity_at": row[22],
+ }
+
+
+def _portal_comment_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "item_id": row[1],
+ "author_username": row[2],
+ "author_role": row[3],
+ "message": row[4],
+ "is_internal": bool(row[5]),
+ "created_at": row[6],
+ }
+
+
+def create_portal_item(
+ *,
+ kind: str,
+ title: str,
+ description: str,
+ created_by_username: str,
+ created_by_id: Optional[int],
+ media_type: Optional[str] = None,
+ year: Optional[int] = None,
+ external_ref: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ status: str = "new",
+ workflow_request_status: Optional[str] = None,
+ workflow_media_status: Optional[str] = None,
+ issue_type: Optional[str] = None,
+ issue_resolved_at: Optional[str] = None,
+ metadata_json: Optional[str] = None,
+ priority: str = "normal",
+ assignee_username: Optional[str] = None,
+) -> Dict[str, Any]:
+ now = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO portal_items (
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ created_at,
+ updated_at,
+ last_activity_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ now,
+ now,
+ now,
+ ),
+ )
+ item_id = cursor.lastrowid
+ created = get_portal_item(item_id)
+ if not created:
+ raise RuntimeError("Portal item could not be loaded after insert.")
+ logger.info(
+ "portal item created id=%s kind=%s status=%s priority=%s created_by=%s",
+ created["id"],
+ created["kind"],
+ created["status"],
+ created["priority"],
+ created["created_by_username"],
+ )
+ return created
+
+
+def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT
+ id,
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ created_at,
+ updated_at,
+ last_activity_at
+ FROM portal_items
+ WHERE id = ?
+ """,
+ (item_id,),
+ ).fetchone()
+ return _portal_item_from_row(row) if row else None
+
+
+def list_portal_items(
+ *,
+ kind: Optional[str] = None,
+ status: Optional[str] = None,
+ workflow_request_status: Optional[str] = None,
+ workflow_media_status: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ mine_username: Optional[str] = None,
+ search: Optional[str] = None,
+ limit: int = 100,
+ offset: int = 0,
+) -> list[Dict[str, Any]]:
+ clauses: list[str] = []
+ params: list[Any] = []
+ if isinstance(kind, str) and kind.strip():
+ clauses.append("kind = ?")
+ params.append(kind.strip().lower())
+ if isinstance(status, str) and status.strip():
+ clauses.append("status = ?")
+ params.append(status.strip().lower())
+ if isinstance(workflow_request_status, str) and workflow_request_status.strip():
+ clauses.append("workflow_request_status = ?")
+ params.append(workflow_request_status.strip().lower())
+ if isinstance(workflow_media_status, str) and workflow_media_status.strip():
+ clauses.append("workflow_media_status = ?")
+ params.append(workflow_media_status.strip().lower())
+ if isinstance(source_system, str) and source_system.strip():
+ clauses.append("source_system = ?")
+ params.append(source_system.strip().lower())
+ if isinstance(source_request_id, int):
+ clauses.append("source_request_id = ?")
+ params.append(source_request_id)
+ if isinstance(related_item_id, int):
+ clauses.append("related_item_id = ?")
+ params.append(related_item_id)
+ if isinstance(mine_username, str) and mine_username.strip():
+ clauses.append("created_by_username = ?")
+ params.append(mine_username.strip())
+ if isinstance(search, str) and search.strip():
+ token = f"%{search.strip().lower()}%"
+ clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
+ params.extend([token, token, search.strip()])
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ safe_limit = max(1, min(int(limit), 500))
+ safe_offset = max(0, int(offset))
+ params.extend([safe_limit, safe_offset])
+ with _connect() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT
+ id,
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ created_at,
+ updated_at,
+ last_activity_at
+ FROM portal_items
+ {where_sql}
+ ORDER BY last_activity_at DESC, id DESC
+ LIMIT ? OFFSET ?
+ """,
+ tuple(params),
+ ).fetchall()
+ return [_portal_item_from_row(row) for row in rows]
+
+
+def count_portal_items(
+ *,
+ kind: Optional[str] = None,
+ status: Optional[str] = None,
+ workflow_request_status: Optional[str] = None,
+ workflow_media_status: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ mine_username: Optional[str] = None,
+ search: Optional[str] = None,
+) -> int:
+ clauses: list[str] = []
+ params: list[Any] = []
+ if isinstance(kind, str) and kind.strip():
+ clauses.append("kind = ?")
+ params.append(kind.strip().lower())
+ if isinstance(status, str) and status.strip():
+ clauses.append("status = ?")
+ params.append(status.strip().lower())
+ if isinstance(workflow_request_status, str) and workflow_request_status.strip():
+ clauses.append("workflow_request_status = ?")
+ params.append(workflow_request_status.strip().lower())
+ if isinstance(workflow_media_status, str) and workflow_media_status.strip():
+ clauses.append("workflow_media_status = ?")
+ params.append(workflow_media_status.strip().lower())
+ if isinstance(source_system, str) and source_system.strip():
+ clauses.append("source_system = ?")
+ params.append(source_system.strip().lower())
+ if isinstance(source_request_id, int):
+ clauses.append("source_request_id = ?")
+ params.append(source_request_id)
+ if isinstance(related_item_id, int):
+ clauses.append("related_item_id = ?")
+ params.append(related_item_id)
+ if isinstance(mine_username, str) and mine_username.strip():
+ clauses.append("created_by_username = ?")
+ params.append(mine_username.strip())
+ if isinstance(search, str) and search.strip():
+ token = f"%{search.strip().lower()}%"
+ clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
+ params.extend([token, token, search.strip()])
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ with _connect() as conn:
+ row = conn.execute(
+ f"SELECT COUNT(*) FROM portal_items {where_sql}",
+ tuple(params),
+ ).fetchone()
+ return int(row[0] or 0) if row else 0
+
+
+def update_portal_item(
+ item_id: int,
+ *,
+ title: Any = _DB_UNSET,
+ description: Any = _DB_UNSET,
+ status: Any = _DB_UNSET,
+ priority: Any = _DB_UNSET,
+ assignee_username: Any = _DB_UNSET,
+ media_type: Any = _DB_UNSET,
+ year: Any = _DB_UNSET,
+ external_ref: Any = _DB_UNSET,
+ source_system: Any = _DB_UNSET,
+ source_request_id: Any = _DB_UNSET,
+ related_item_id: Any = _DB_UNSET,
+ workflow_request_status: Any = _DB_UNSET,
+ workflow_media_status: Any = _DB_UNSET,
+ issue_type: Any = _DB_UNSET,
+ issue_resolved_at: Any = _DB_UNSET,
+ metadata_json: Any = _DB_UNSET,
+) -> Optional[Dict[str, Any]]:
+ updates: list[str] = []
+ params: list[Any] = []
+ if title is not _DB_UNSET:
+ updates.append("title = ?")
+ params.append(title)
+ if description is not _DB_UNSET:
+ updates.append("description = ?")
+ params.append(description)
+ if status is not _DB_UNSET:
+ updates.append("status = ?")
+ params.append(status)
+ if priority is not _DB_UNSET:
+ updates.append("priority = ?")
+ params.append(priority)
+ if assignee_username is not _DB_UNSET:
+ updates.append("assignee_username = ?")
+ params.append(assignee_username)
+ if media_type is not _DB_UNSET:
+ updates.append("media_type = ?")
+ params.append(media_type)
+ if year is not _DB_UNSET:
+ updates.append("year = ?")
+ params.append(year)
+ if external_ref is not _DB_UNSET:
+ updates.append("external_ref = ?")
+ params.append(external_ref)
+ if source_system is not _DB_UNSET:
+ updates.append("source_system = ?")
+ params.append(source_system)
+ if source_request_id is not _DB_UNSET:
+ updates.append("source_request_id = ?")
+ params.append(source_request_id)
+ if related_item_id is not _DB_UNSET:
+ updates.append("related_item_id = ?")
+ params.append(related_item_id)
+ if workflow_request_status is not _DB_UNSET:
+ updates.append("workflow_request_status = ?")
+ params.append(workflow_request_status)
+ if workflow_media_status is not _DB_UNSET:
+ updates.append("workflow_media_status = ?")
+ params.append(workflow_media_status)
+ if issue_type is not _DB_UNSET:
+ updates.append("issue_type = ?")
+ params.append(issue_type)
+ if issue_resolved_at is not _DB_UNSET:
+ updates.append("issue_resolved_at = ?")
+ params.append(issue_resolved_at)
+ if metadata_json is not _DB_UNSET:
+ updates.append("metadata_json = ?")
+ params.append(metadata_json)
+ if not updates:
+ return get_portal_item(item_id)
+ now = datetime.now(timezone.utc).isoformat()
+ updates.append("updated_at = ?")
+ updates.append("last_activity_at = ?")
+ params.extend([now, now, item_id])
+ with _connect() as conn:
+ changed = conn.execute(
+ f"""
+ UPDATE portal_items
+ SET {', '.join(updates)}
+ WHERE id = ?
+ """,
+ tuple(params),
+ ).rowcount
+ if not changed:
+ return None
+ updated = get_portal_item(item_id)
+ if updated:
+ logger.info(
+ "portal item updated id=%s status=%s priority=%s assignee=%s",
+ updated["id"],
+ updated["status"],
+ updated["priority"],
+ updated["assignee_username"],
+ )
+ return updated
+
+
+def add_portal_comment(
+ item_id: int,
+ *,
+ author_username: str,
+ author_role: str,
+ message: str,
+ is_internal: bool = False,
+) -> Dict[str, Any]:
+ now = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO portal_comments (
+ item_id,
+ author_username,
+ author_role,
+ message,
+ is_internal,
+ created_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ item_id,
+ author_username,
+ author_role,
+ message,
+ 1 if is_internal else 0,
+ now,
+ ),
+ )
+ conn.execute(
+ """
+ UPDATE portal_items
+ SET last_activity_at = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (now, now, item_id),
+ )
+ comment_id = cursor.lastrowid
+ row = conn.execute(
+ """
+ SELECT id, item_id, author_username, author_role, message, is_internal, created_at
+ FROM portal_comments
+ WHERE id = ?
+ """,
+ (comment_id,),
+ ).fetchone()
+ if not row:
+ raise RuntimeError("Portal comment could not be loaded after insert.")
+ comment = _portal_comment_from_row(row)
+ logger.info(
+ "portal comment created id=%s item_id=%s author=%s internal=%s",
+ comment["id"],
+ comment["item_id"],
+ comment["author_username"],
+ comment["is_internal"],
+ )
+ return comment
+
+
+def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: int = 200) -> list[Dict[str, Any]]:
+ clauses = ["item_id = ?"]
+ params: list[Any] = [item_id]
+ if not include_internal:
+ clauses.append("is_internal = 0")
+ safe_limit = max(1, min(int(limit), 500))
+ params.append(safe_limit)
+ with _connect() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT id, item_id, author_username, author_role, message, is_internal, created_at
+ FROM portal_comments
+ WHERE {' AND '.join(clauses)}
+ ORDER BY created_at ASC, id ASC
+ LIMIT ?
+ """,
+ tuple(params),
+ ).fetchall()
+ return [_portal_comment_from_row(row) for row in rows]
+
+
+def get_portal_overview() -> Dict[str, Any]:
+ with _connect() as conn:
+ kind_rows = conn.execute(
+ """
+ SELECT kind, COUNT(*)
+ FROM portal_items
+ GROUP BY kind
+ """
+ ).fetchall()
+ status_rows = conn.execute(
+ """
+ SELECT status, COUNT(*)
+ FROM portal_items
+ GROUP BY status
+ """
+ ).fetchall()
+ request_workflow_rows = conn.execute(
+ """
+ SELECT
+ COALESCE(workflow_request_status, ''),
+ COALESCE(workflow_media_status, ''),
+ COUNT(*)
+ FROM portal_items
+ WHERE kind = 'request'
+ GROUP BY workflow_request_status, workflow_media_status
+ """
+ ).fetchall()
+ total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
+ total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
+ request_workflow: Dict[str, Dict[str, int]] = {}
+ for row in request_workflow_rows:
+ request_status = str(row[0] or "")
+ media_status = str(row[1] or "")
+ request_workflow.setdefault(request_status, {})
+ request_workflow[request_status][media_status] = int(row[2] or 0)
+ return {
+ "total_items": int(total_items_row[0] or 0) if total_items_row else 0,
+ "total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0,
+ "by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows},
+ "by_status": {str(row[0]): int(row[1] or 0) for row in status_rows},
+ "request_workflow": request_workflow,
+ }
+
+
+def run_integrity_check() -> str:
+ with _connect() as conn:
+ row = conn.execute("PRAGMA integrity_check").fetchone()
+ if not row:
+ return "unknown"
+ return str(row[0])
+
+
+def get_database_diagnostics() -> Dict[str, Any]:
+ db_path = _db_path()
+ wal_path = f"{db_path}-wal"
+ shm_path = f"{db_path}-shm"
+
+ def _size(path: str) -> int:
+ try:
+ return os.path.getsize(path)
+ except OSError:
+ return 0
+
+ started = perf_counter()
+ with _connect() as conn:
+ integrity_started = perf_counter()
+ integrity_row = conn.execute("PRAGMA integrity_check").fetchone()
+ integrity_ms = round((perf_counter() - integrity_started) * 1000, 1)
+ integrity = str(integrity_row[0]) if integrity_row else "unknown"
+
+ pragma_started = perf_counter()
+ page_size_row = conn.execute("PRAGMA page_size").fetchone()
+ page_count_row = conn.execute("PRAGMA page_count").fetchone()
+ freelist_row = conn.execute("PRAGMA freelist_count").fetchone()
+ pragma_ms = round((perf_counter() - pragma_started) * 1000, 1)
+
+ row_count_started = perf_counter()
+ table_counts = {
+ "users": int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] or 0),
+ "requests_cache": int(conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone()[0] or 0),
+ "artwork_cache_status": int(conn.execute("SELECT COUNT(*) FROM artwork_cache_status").fetchone()[0] or 0),
+ "signup_invites": int(conn.execute("SELECT COUNT(*) FROM signup_invites").fetchone()[0] or 0),
+ "settings": int(conn.execute("SELECT COUNT(*) FROM settings").fetchone()[0] or 0),
+ "actions": int(conn.execute("SELECT COUNT(*) FROM actions").fetchone()[0] or 0),
+ "snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0),
+ "seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0),
+ "password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0),
+ "portal_items": int(conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()[0] or 0),
+ "portal_comments": int(conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()[0] or 0),
+ }
+ row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)
+
+ page_size = int(page_size_row[0] or 0) if page_size_row else 0
+ page_count = int(page_count_row[0] or 0) if page_count_row else 0
+ freelist_pages = int(freelist_row[0] or 0) if freelist_row else 0
+
+ db_size_bytes = _size(db_path)
+ wal_size_bytes = _size(wal_path)
+ shm_size_bytes = _size(shm_path)
+
+ return {
+ "integrity_check": integrity,
+ "database_path": db_path,
+ "database_size_bytes": db_size_bytes,
+ "wal_size_bytes": wal_size_bytes,
+ "shm_size_bytes": shm_size_bytes,
+ "page_size_bytes": page_size,
+ "page_count": page_count,
+ "freelist_pages": freelist_pages,
+ "allocated_bytes": page_size * page_count,
+ "free_bytes": page_size * freelist_pages,
+ "row_counts": table_counts,
+ "timings_ms": {
+ "integrity_check": integrity_ms,
+ "pragmas": pragma_ms,
+ "row_counts": row_count_ms,
+ "total": round((perf_counter() - started) * 1000, 1),
+ },
+ }
+
+
+def vacuum_db() -> None:
+ with _connect() as conn:
+ conn.execute("VACUUM")
+
+
+def clear_requests_cache() -> int:
+ with _connect() as conn:
+ cursor = conn.execute("DELETE FROM requests_cache")
+ return cursor.rowcount
+
+
+def clear_history() -> Dict[str, int]:
+ with _connect() as conn:
+ actions = conn.execute("DELETE FROM actions").rowcount
+ snapshots = conn.execute("DELETE FROM snapshots").rowcount
+ return {"actions": actions, "snapshots": snapshots}
+
+
+def clear_user_objects_nuclear() -> Dict[str, int]:
+ with _connect() as conn:
+ # Preserve admin accounts, but remove invite/profile references so profile rows can be deleted safely.
+ admin_reset = conn.execute(
+ """
+ UPDATE users
+ SET profile_id = NULL,
+ invited_by_code = NULL,
+ invited_at = NULL
+ WHERE role = 'admin'
+ """
+ ).rowcount
+ users = conn.execute("DELETE FROM users WHERE role != 'admin'").rowcount
+ invites = conn.execute("DELETE FROM signup_invites").rowcount
+ profiles = conn.execute("DELETE FROM user_profiles").rowcount
+ return {
+ "users": users,
+ "invites": invites,
+ "profiles": profiles,
+ "adminsReset": admin_reset,
+ }
+
+
+def cleanup_history(days: int) -> Dict[str, int]:
+ if days <= 0:
+ return {"actions": 0, "snapshots": 0}
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+ with _connect() as conn:
+ actions = conn.execute(
+ "DELETE FROM actions WHERE created_at < ?",
+ (cutoff,),
+ ).rowcount
+ snapshots = conn.execute(
+ "DELETE FROM snapshots WHERE created_at < ?",
+ (cutoff,),
+ ).rowcount
+ return {"actions": actions, "snapshots": snapshots}
diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py
new file mode 100644
index 0000000..89cef15
--- /dev/null
+++ b/backend/app/logging_config.py
@@ -0,0 +1,190 @@
+import contextvars
+import json
+import logging
+import os
+from logging.handlers import RotatingFileHandler
+from typing import Any, Mapping, Optional
+from urllib.parse import parse_qs
+
+REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
+ "magent_request_id", default="-"
+)
+
+_SENSITIVE_KEYWORDS = (
+ "api_key",
+ "authorization",
+ "cert",
+ "cookie",
+ "jwt",
+ "key",
+ "pass",
+ "password",
+ "pem",
+ "private",
+ "secret",
+ "session",
+ "signature",
+ "token",
+)
+_MAX_BODY_BYTES = 4096
+
+
+class RequestContextFilter(logging.Filter):
+ def filter(self, record: logging.LogRecord) -> bool:
+ record.request_id = REQUEST_ID_CONTEXT.get("-")
+ return True
+
+
+def bind_request_id(request_id: str) -> contextvars.Token[str]:
+ return REQUEST_ID_CONTEXT.set(request_id or "-")
+
+
+def reset_request_id(token: contextvars.Token[str]) -> None:
+ REQUEST_ID_CONTEXT.reset(token)
+
+
+def current_request_id() -> str:
+ return REQUEST_ID_CONTEXT.get("-")
+
+
+def _is_sensitive_key(key: str) -> bool:
+ lowered = key.strip().lower()
+ return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
+
+
+def _redact_scalar(value: Any) -> Any:
+ if value is None or isinstance(value, (int, float, bool)):
+ return value
+ text = str(value)
+ if len(text) <= 4:
+ return "***"
+ return f"{text[:2]}***{text[-2:]}"
+
+
+def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
+ if key_hint and _is_sensitive_key(key_hint):
+ return _redact_scalar(value)
+ if value is None or isinstance(value, (bool, int, float)):
+ return value
+ if isinstance(value, bytes):
+ return f""
+ if isinstance(value, str):
+ return value if len(value) <= 512 else f"{value[:509]}..."
+ if depth >= 3:
+ return f"<{type(value).__name__}>"
+ if isinstance(value, Mapping):
+ return {
+ str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
+ for key, item in value.items()
+ }
+ if isinstance(value, (list, tuple, set)):
+ return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
+ if hasattr(value, "model_dump"):
+ try:
+ return sanitize_value(value.model_dump(), depth=depth + 1)
+ except Exception:
+ return f"<{type(value).__name__}>"
+ return str(value)
+
+
+def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ str(key).lower(): sanitize_value(value, key_hint=str(key))
+ for key, value in headers.items()
+ }
+
+
+def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
+ if not body:
+ return None
+ normalized = (content_type or "").split(";")[0].strip().lower()
+ if normalized == "application/json":
+ preview = body[:_MAX_BODY_BYTES]
+ try:
+ payload = json.loads(preview.decode("utf-8"))
+ summary = sanitize_value(payload)
+ if len(body) > _MAX_BODY_BYTES:
+ return {"truncated": True, "bytes": len(body), "payload": summary}
+ return summary
+ except Exception:
+ pass
+ if normalized == "application/x-www-form-urlencoded":
+ try:
+ parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
+ compact = {
+ key: value[0] if len(value) == 1 else value
+ for key, value in parsed.items()
+ }
+ return sanitize_value(compact)
+ except Exception:
+ pass
+ if normalized.startswith("multipart/"):
+ return {"content_type": normalized, "bytes": len(body)}
+ preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
+ return {
+ "content_type": normalized or "unknown",
+ "bytes": len(body),
+ "preview": preview if len(body) <= 256 else f"{preview}...",
+ }
+
+
+def _coerce_level(level_name: Optional[str], fallback: int) -> int:
+ if not level_name:
+ return fallback
+ return getattr(logging, str(level_name).upper(), fallback)
+
+
+def configure_logging(
+ log_level: Optional[str],
+ log_file: Optional[str],
+ *,
+ log_file_max_bytes: int = 20_000_000,
+ log_file_backup_count: int = 10,
+ log_http_client_level: Optional[str] = "INFO",
+ log_background_sync_level: Optional[str] = "INFO",
+) -> None:
+ level_name = (log_level or "INFO").upper()
+ level = getattr(logging, level_name, logging.INFO)
+
+ handlers: list[logging.Handler] = []
+ stream_handler = logging.StreamHandler()
+ handlers.append(stream_handler)
+
+ if log_file:
+ log_path = log_file
+ if not os.path.isabs(log_path):
+ log_path = os.path.join(os.getcwd(), log_path)
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
+ file_handler = RotatingFileHandler(
+ log_path,
+ maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
+ backupCount=max(1, int(log_file_backup_count or 10)),
+ encoding="utf-8",
+ )
+ handlers.append(file_handler)
+
+ context_filter = RequestContextFilter()
+ formatter = logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+ for handler in handlers:
+ handler.addFilter(context_filter)
+ handler.setFormatter(formatter)
+
+ root = logging.getLogger()
+ for handler in list(root.handlers):
+ root.removeHandler(handler)
+ for handler in handlers:
+ root.addHandler(handler)
+ root.setLevel(level)
+
+ logging.getLogger("uvicorn").setLevel(level)
+ logging.getLogger("uvicorn.error").setLevel(level)
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
+ http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
+ background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
+ logging.getLogger("app.clients.base").setLevel(http_client_level)
+ logging.getLogger("app.routers.requests").setLevel(background_sync_level)
+ logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..5db75d8
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,246 @@
+import asyncio
+import logging
+import time
+import uuid
+from typing import Awaitable, Callable
+
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+
+from .config import settings
+from .db import has_admin_user, init_db
+from .routers.requests import (
+ router as requests_router,
+ startup_warmup_requests_cache,
+ run_requests_delta_loop,
+ run_daily_requests_full_sync,
+ run_daily_db_cleanup,
+)
+from .routers.auth import router as auth_router
+from .routers.admin import router as admin_router, events_router as admin_events_router
+from .routers.images import router as images_router
+from .routers.branding import router as branding_router
+from .routers.status import router as status_router
+from .routers.feedback import router as feedback_router
+from .routers.site import router as site_router
+from .routers.events import router as events_router
+from .routers.portal import router as portal_router
+from .services.jellyfin_sync import run_daily_jellyfin_sync
+from .logging_config import (
+ bind_request_id,
+ configure_logging,
+ reset_request_id,
+ sanitize_headers,
+ sanitize_value,
+ summarize_http_body,
+)
+from .runtime import get_runtime_settings
+
+logger = logging.getLogger(__name__)
+_background_tasks: list[asyncio.Task[None]] = []
+
+app = FastAPI(
+ title=settings.app_name,
+ docs_url="/docs" if settings.api_docs_enabled else None,
+ redoc_url=None,
+ openapi_url="/openapi.json" if settings.api_docs_enabled else None,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[settings.cors_allow_origin],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+@app.middleware("http")
+async def log_requests_and_add_security_headers(request: Request, call_next):
+ request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
+ token = bind_request_id(request_id)
+ request.state.request_id = request_id
+ started_at = time.perf_counter()
+ body = await request.body()
+ body_summary = summarize_http_body(body, request.headers.get("content-type"))
+
+ async def receive() -> dict:
+ return {"type": "http.request", "body": body, "more_body": False}
+
+ request._receive = receive
+ logger.info(
+ "request started method=%s path=%s query=%s client=%s headers=%s body=%s",
+ request.method,
+ request.url.path,
+ sanitize_value(dict(request.query_params)),
+ request.client.host if request.client else "-",
+ sanitize_headers(
+ {
+ key: value
+ for key, value in request.headers.items()
+ if key.lower()
+ in {
+ "content-type",
+ "content-length",
+ "user-agent",
+ "x-forwarded-for",
+ "x-forwarded-proto",
+ "x-request-id",
+ }
+ }
+ ),
+ body_summary,
+ )
+ try:
+ response = await call_next(request)
+ except Exception:
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ logger.exception(
+ "request failed method=%s path=%s duration_ms=%s",
+ request.method,
+ request.url.path,
+ duration_ms,
+ )
+ reset_request_id(token)
+ raise
+
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ response.headers.setdefault("X-Request-ID", request_id)
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
+ response.headers.setdefault("X-Frame-Options", "DENY")
+ response.headers.setdefault("Referrer-Policy", "no-referrer")
+ response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+ # Keep API responses non-executable and non-embeddable by default.
+ if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
+ response.headers.setdefault(
+ "Content-Security-Policy",
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
+ )
+ logger.info(
+ "request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
+ request.method,
+ request.url.path,
+ response.status_code,
+ duration_ms,
+ sanitize_headers(
+ {
+ key: value
+ for key, value in response.headers.items()
+ if key.lower() in {"content-type", "content-length", "x-request-id"}
+ }
+ ),
+ )
+ reset_request_id(token)
+ return response
+
+
+@app.get("/health")
+async def health() -> dict:
+ return {"status": "ok"}
+
+
+async def _run_background_task(
+ name: str, coroutine_factory: Callable[[], Awaitable[None]]
+) -> None:
+ token = bind_request_id(f"task-{name}")
+ logger.info("background task started task=%s", name)
+ try:
+ await coroutine_factory()
+ logger.warning("background task exited task=%s", name)
+ except asyncio.CancelledError:
+ logger.info("background task cancelled task=%s", name)
+ raise
+ except Exception:
+ logger.exception("background task crashed task=%s", name)
+ raise
+ finally:
+ reset_request_id(token)
+
+
+def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
+ task = asyncio.create_task(
+ _run_background_task(name, coroutine_factory), name=f"magent:{name}"
+ )
+ _background_tasks.append(task)
+
+
+def _log_security_configuration_warnings() -> None:
+ jwt_secret = str(settings.jwt_secret or "").strip()
+ if not jwt_secret or jwt_secret == "change-me":
+ logger.warning(
+ "security configuration warning: JWT_SECRET is unset or still set to the default value"
+ )
+ admin_password = str(settings.admin_password or "")
+ if not admin_password or admin_password == "adminadmin":
+ logger.warning(
+ "security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
+ )
+ if bool(settings.api_docs_enabled):
+ logger.warning(
+ "security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
+ )
+
+
+def _enforce_secure_startup_configuration() -> None:
+ jwt_secret = str(settings.jwt_secret or "").strip()
+ if not jwt_secret or jwt_secret == "change-me":
+ raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
+ admin_password = str(settings.admin_password or "")
+ if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
+ raise RuntimeError(
+ "A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
+ )
+
+
+@app.on_event("startup")
+async def startup() -> None:
+ configure_logging(
+ settings.log_level,
+ settings.log_file,
+ log_file_max_bytes=settings.log_file_max_bytes,
+ log_file_backup_count=settings.log_file_backup_count,
+ log_http_client_level=settings.log_http_client_level,
+ log_background_sync_level=settings.log_background_sync_level,
+ )
+ logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
+ _log_security_configuration_warnings()
+ init_db()
+ _enforce_secure_startup_configuration()
+ runtime = get_runtime_settings()
+ configure_logging(
+ runtime.log_level,
+ runtime.log_file,
+ log_file_max_bytes=runtime.log_file_max_bytes,
+ log_file_backup_count=runtime.log_file_backup_count,
+ log_http_client_level=runtime.log_http_client_level,
+ log_background_sync_level=runtime.log_background_sync_level,
+ )
+ logger.info(
+ "runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
+ runtime.log_level,
+ runtime.log_file,
+ runtime.log_file_max_bytes,
+ runtime.log_file_backup_count,
+ runtime.log_http_client_level,
+ runtime.log_background_sync_level,
+ runtime.requests_data_source,
+ )
+ _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
+ _launch_background_task("requests-warmup", startup_warmup_requests_cache)
+ _launch_background_task("requests-delta-loop", run_requests_delta_loop)
+ _launch_background_task("requests-full-sync", run_daily_requests_full_sync)
+ _launch_background_task("db-cleanup", run_daily_db_cleanup)
+ logger.info("startup complete")
+
+
+app.include_router(requests_router)
+app.include_router(auth_router)
+app.include_router(admin_router)
+app.include_router(admin_events_router)
+app.include_router(images_router)
+app.include_router(branding_router)
+app.include_router(status_router)
+app.include_router(feedback_router)
+app.include_router(site_router)
+app.include_router(events_router)
+app.include_router(portal_router)
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..b0a2f4d
--- /dev/null
+++ b/backend/app/models.py
@@ -0,0 +1,67 @@
+from enum import Enum
+from typing import Any, Dict, List, Optional
+from pydantic import BaseModel, Field
+
+
+class RequestType(str, Enum):
+ movie = "movie"
+ tv = "tv"
+ unknown = "unknown"
+
+
+class NormalizedState(str, Enum):
+ requested = "REQUESTED"
+ approved = "APPROVED"
+ needs_add = "NEEDS_ADD"
+ added_to_arr = "ADDED_TO_ARR"
+ searching = "SEARCHING"
+ grabbed = "GRABBED"
+ downloading = "DOWNLOADING"
+ importing = "IMPORTING"
+ completed = "COMPLETED"
+ failed = "FAILED"
+ available = "AVAILABLE"
+ unknown = "UNKNOWN"
+
+
+class TimelineHop(BaseModel):
+ service: str
+ status: str
+ details: Dict[str, Any] = Field(default_factory=dict)
+ timestamp: Optional[str] = None
+
+
+class ActionOption(BaseModel):
+ id: str
+ label: str
+ risk: str
+ description: Optional[str] = None
+ requires_confirmation: bool = True
+
+
+class Snapshot(BaseModel):
+ request_id: str
+ title: str
+ year: Optional[int] = None
+ request_type: RequestType = RequestType.unknown
+ state: NormalizedState = NormalizedState.unknown
+ state_reason: Optional[str] = None
+ timeline: List[TimelineHop] = Field(default_factory=list)
+ actions: List[ActionOption] = Field(default_factory=list)
+ artwork: Dict[str, Any] = Field(default_factory=dict)
+ presentation: Dict[str, Any] = Field(default_factory=dict)
+ raw: Dict[str, Any] = Field(default_factory=dict)
+
+
+class TriageRecommendation(BaseModel):
+ action_id: str
+ title: str
+ reason: str
+ risk: str
+
+
+class TriageResult(BaseModel):
+ summary: str
+ confidence: float
+ root_cause: str
+ recommendations: List[TriageRecommendation]
diff --git a/backend/app/network_security.py b/backend/app/network_security.py
new file mode 100644
index 0000000..68219ed
--- /dev/null
+++ b/backend/app/network_security.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import ipaddress
+import socket
+from functools import lru_cache
+from typing import Iterable
+from urllib.parse import urlparse
+
+from .config import settings
+
+_METADATA_HOSTS = {
+ "169.254.169.254",
+ "metadata.google.internal",
+ "metadata.azure.internal",
+}
+
+
+def _normalize_text(value: object) -> str:
+ if value is None:
+ return ""
+ return str(value).strip()
+
+
+def _split_csv(value: object) -> list[str]:
+ raw = _normalize_text(value)
+ if not raw:
+ return []
+ return [part.strip() for part in raw.split(",") if part.strip()]
+
+
+def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
+ return bool(
+ ip_obj.is_loopback
+ or ip_obj.is_link_local
+ or ip_obj.is_multicast
+ or ip_obj.is_unspecified
+ or ip_obj.is_reserved
+ or ip_obj.is_private
+ )
+
+
+@lru_cache(maxsize=256)
+def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
+ resolved: list[ipaddress._BaseAddress] = []
+ for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
+ if family == socket.AF_INET:
+ resolved.append(ipaddress.ip_address(sockaddr[0]))
+ elif family == socket.AF_INET6:
+ resolved.append(ipaddress.ip_address(sockaddr[0]))
+ return tuple(resolved)
+
+
+def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
+ candidate = _normalize_text(host)
+ if not candidate:
+ return False
+ try:
+ host_ip = ipaddress.ip_address(candidate)
+ except ValueError:
+ return candidate.lower() in {entry.lower() for entry in trusted_proxies}
+
+ for entry in trusted_proxies:
+ raw = _normalize_text(entry)
+ if not raw:
+ continue
+ try:
+ if "/" in raw:
+ if host_ip in ipaddress.ip_network(raw, strict=False):
+ return True
+ elif host_ip == ipaddress.ip_address(raw):
+ return True
+ except ValueError:
+ continue
+ return False
+
+
+def request_trusts_forwarded_headers(client_host: str | None) -> bool:
+ if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
+ return False
+ trusted = _split_csv(settings.magent_proxy_trusted_proxies)
+ if not trusted:
+ return False
+ return _is_trusted_proxy_host(client_host or "", trusted)
+
+
+def validate_notification_target_url(
+ url: str,
+ *,
+ allow_private: bool | None = None,
+) -> str:
+ raw = _normalize_text(url)
+ if not raw:
+ raise ValueError("URL cannot be empty.")
+
+ parsed = urlparse(raw)
+ if parsed.scheme not in {"http", "https"}:
+ raise ValueError("URL must use http:// or https://.")
+ if parsed.username or parsed.password:
+ raise ValueError("URL must not embed credentials.")
+ hostname = _normalize_text(parsed.hostname).lower()
+ if not hostname:
+ raise ValueError("URL must include a valid host.")
+
+ allow_private_targets = (
+ settings.magent_allow_private_notification_targets
+ if allow_private is None
+ else bool(allow_private)
+ )
+ if hostname in _METADATA_HOSTS:
+ raise ValueError("Metadata service targets are not allowed.")
+ if hostname == "localhost" and not allow_private_targets:
+ raise ValueError("Local notification targets are not allowed.")
+
+ try:
+ host_ip = ipaddress.ip_address(hostname)
+ except ValueError:
+ host_ip = None
+
+ if host_ip is not None:
+ if _ip_is_sensitive(host_ip) and not allow_private_targets:
+ raise ValueError("Private or local notification targets are not allowed.")
+ return raw
+
+ try:
+ resolved_ips = _resolve_host_ips(hostname)
+ except socket.gaierror as exc:
+ raise ValueError("Host could not be resolved.") from exc
+ if not resolved_ips:
+ raise ValueError("Host could not be resolved.")
+ if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
+ raise ValueError("Private or local notification targets are not allowed.")
+ return raw
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
new file mode 100644
index 0000000..2333808
--- /dev/null
+++ b/backend/app/routers/admin.py
@@ -0,0 +1,2002 @@
+from typing import Any, Dict, List, Optional
+from datetime import datetime, timedelta, timezone
+import asyncio
+import ipaddress
+import json
+import os
+import secrets
+import sqlite3
+import string
+from urllib.parse import urlparse, urlunparse
+
+from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
+from fastapi.responses import StreamingResponse
+
+from ..auth import (
+ require_admin,
+ get_current_user,
+ require_admin_event_stream,
+ normalize_user_auth_provider,
+ resolve_user_auth_provider,
+)
+from ..config import settings as env_settings
+from ..network_security import validate_notification_target_url
+from ..db import (
+ delete_setting,
+ get_all_users,
+ get_cached_requests,
+ get_cached_requests_count,
+ get_setting,
+ get_request_cache_overview,
+ get_request_cache_missing_titles,
+ get_request_cache_stats,
+ get_settings_overrides,
+ get_user_by_id,
+ get_user_by_username,
+ get_user_request_stats,
+ create_user_if_missing,
+ set_user_jellyseerr_id,
+ set_setting,
+ set_user_blocked,
+ delete_user_by_username,
+ delete_user_activity_by_username,
+ set_user_auto_search_enabled,
+ set_auto_search_enabled_for_non_admin_users,
+ set_user_email,
+ set_user_invite_management_enabled,
+ set_invite_management_enabled_for_non_admin_users,
+ set_user_profile_id,
+ set_user_expires_at,
+ set_user_password,
+ sync_jellyfin_password_state,
+ set_user_role,
+ run_integrity_check,
+ vacuum_db,
+ clear_requests_cache,
+ clear_history,
+ clear_user_objects_nuclear,
+ cleanup_history,
+ update_request_cache_title,
+ repair_request_cache_titles,
+ delete_non_admin_users,
+ list_user_profiles,
+ get_user_profile,
+ create_user_profile,
+ update_user_profile,
+ delete_user_profile,
+ list_signup_invites,
+ get_signup_invite_by_id,
+ create_signup_invite,
+ update_signup_invite,
+ delete_signup_invite,
+ get_signup_invite_by_code,
+ disable_signup_invites_by_creator,
+)
+from ..runtime import get_runtime_settings
+from ..clients.sonarr import SonarrClient
+from ..clients.radarr import RadarrClient
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..services.jellyfin_sync import sync_jellyfin_users
+from ..services.user_cache import (
+ build_jellyseerr_candidate_map,
+ extract_jellyseerr_user_email,
+ find_matching_jellyseerr_user,
+ get_cached_jellyfin_users,
+ get_cached_jellyseerr_users,
+ match_jellyseerr_user_id,
+ save_jellyfin_users_cache,
+ save_jellyseerr_users_cache,
+ clear_user_import_caches,
+)
+from ..security import validate_password_policy
+from ..services.invite_email import (
+ TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
+ get_invite_email_templates,
+ normalize_delivery_email,
+ reset_invite_email_template,
+ save_invite_email_template,
+ send_test_email,
+ smtp_email_delivery_warning,
+ send_templated_email,
+ smtp_email_config_ready,
+)
+from ..services.diagnostics import get_diagnostics_catalog, run_diagnostics
+import logging
+from ..logging_config import configure_logging
+from ..routers import requests as requests_router
+from ..routers.branding import save_branding_image
+
+router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
+events_router = APIRouter(prefix="/admin/events", tags=["admin"])
+logger = logging.getLogger(__name__)
+SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
+
+
+def _require_recipient_email(value: object) -> str:
+ normalized = normalize_delivery_email(value)
+ if normalized:
+ return normalized
+ raise HTTPException(
+ status_code=400,
+ detail="recipient_email is required and must be a valid email address",
+ )
+
+SENSITIVE_KEYS = {
+ "magent_ssl_certificate_pem",
+ "magent_ssl_private_key_pem",
+ "magent_notify_email_smtp_password",
+ "magent_notify_discord_webhook_url",
+ "magent_notify_telegram_bot_token",
+ "magent_notify_push_token",
+ "magent_notify_push_user_key",
+ "magent_notify_webhook_url",
+ "jellyseerr_api_key",
+ "jellyfin_api_key",
+ "sonarr_api_key",
+ "radarr_api_key",
+ "prowlarr_api_key",
+ "qbittorrent_password",
+}
+
+URL_SETTING_KEYS = {
+ "magent_application_url",
+ "magent_api_url",
+ "magent_proxy_base_url",
+ "magent_notify_discord_webhook_url",
+ "magent_notify_push_base_url",
+ "jellyseerr_base_url",
+ "jellyfin_base_url",
+ "jellyfin_public_url",
+ "sonarr_base_url",
+ "radarr_base_url",
+ "prowlarr_base_url",
+ "qbittorrent_base_url",
+}
+
+NOTIFICATION_URL_SETTING_KEYS = {
+ "magent_notify_discord_webhook_url",
+ "magent_notify_push_base_url",
+ "magent_notify_webhook_url",
+}
+
+SETTING_KEYS: List[str] = [
+ "magent_application_url",
+ "magent_application_port",
+ "magent_api_url",
+ "magent_api_port",
+ "magent_bind_host",
+ "magent_proxy_enabled",
+ "magent_proxy_base_url",
+ "magent_proxy_trust_forwarded_headers",
+ "magent_proxy_forwarded_prefix",
+ "magent_ssl_bind_enabled",
+ "magent_ssl_certificate_path",
+ "magent_ssl_private_key_path",
+ "magent_ssl_certificate_pem",
+ "magent_ssl_private_key_pem",
+ "magent_notify_enabled",
+ "magent_notify_email_enabled",
+ "magent_notify_email_smtp_host",
+ "magent_notify_email_smtp_port",
+ "magent_notify_email_smtp_username",
+ "magent_notify_email_smtp_password",
+ "magent_notify_email_from_address",
+ "magent_notify_email_from_name",
+ "magent_notify_email_use_tls",
+ "magent_notify_email_use_ssl",
+ "magent_notify_discord_enabled",
+ "magent_notify_discord_webhook_url",
+ "magent_notify_telegram_enabled",
+ "magent_notify_telegram_bot_token",
+ "magent_notify_telegram_chat_id",
+ "magent_notify_push_enabled",
+ "magent_notify_push_provider",
+ "magent_notify_push_base_url",
+ "magent_notify_push_topic",
+ "magent_notify_push_token",
+ "magent_notify_push_user_key",
+ "magent_notify_push_device",
+ "magent_notify_webhook_enabled",
+ "magent_notify_webhook_url",
+ "jellyseerr_base_url",
+ "jellyseerr_api_key",
+ "jellyfin_base_url",
+ "jellyfin_api_key",
+ "jellyfin_public_url",
+ "jellyfin_sync_to_arr",
+ "artwork_cache_mode",
+ "sonarr_base_url",
+ "sonarr_api_key",
+ "sonarr_quality_profile_id",
+ "sonarr_root_folder",
+ "sonarr_qbittorrent_category",
+ "radarr_base_url",
+ "radarr_api_key",
+ "radarr_quality_profile_id",
+ "radarr_root_folder",
+ "radarr_qbittorrent_category",
+ "prowlarr_base_url",
+ "prowlarr_api_key",
+ "qbittorrent_base_url",
+ "qbittorrent_username",
+ "qbittorrent_password",
+ "log_level",
+ "log_file",
+ "log_file_max_bytes",
+ "log_file_backup_count",
+ "log_http_client_level",
+ "log_background_sync_level",
+ "requests_sync_ttl_minutes",
+ "requests_poll_interval_seconds",
+ "requests_delta_sync_interval_minutes",
+ "requests_full_sync_time",
+ "requests_cleanup_time",
+ "requests_cleanup_days",
+ "requests_data_source",
+ "site_banner_enabled",
+ "site_banner_message",
+ "site_banner_tone",
+ "site_login_show_jellyfin_login",
+ "site_login_show_local_login",
+ "site_login_show_forgot_password",
+ "site_login_show_signup_link",
+ "site_nav_show_requests",
+]
+
+
+def _http_error_detail(exc: Exception) -> str:
+ try:
+ import httpx # local import to avoid hard dependency in static analysis paths
+
+ if isinstance(exc, httpx.HTTPStatusError):
+ response = exc.response
+ body = ""
+ try:
+ body = response.text.strip()
+ except Exception:
+ body = ""
+ if body:
+ return f"HTTP {response.status_code}: {body}"
+ return f"HTTP {response.status_code}"
+ except Exception:
+ pass
+ return str(exc)
+
+
+def _user_inviter_details(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ if not user:
+ return None
+ invite_code = user.get("invited_by_code")
+ if not invite_code:
+ return None
+ invite = get_signup_invite_by_code(str(invite_code))
+ if not invite:
+ return {
+ "invite_code": invite_code,
+ "invited_by": None,
+ "invite": None,
+ }
+ return {
+ "invite_code": invite.get("code"),
+ "invited_by": invite.get("created_by"),
+ "invite": {
+ "id": invite.get("id"),
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "created_by": invite.get("created_by"),
+ "created_at": invite.get("created_at"),
+ "enabled": invite.get("enabled"),
+ "is_usable": invite.get("is_usable"),
+ "recipient_email": invite.get("recipient_email"),
+ },
+ }
+
+
+def _resolve_user_invite(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ if not user:
+ return None
+ invite_code = user.get("invited_by_code")
+ if not isinstance(invite_code, str) or not invite_code.strip():
+ return None
+ return get_signup_invite_by_code(invite_code.strip())
+
+
+def _build_invite_trace_payload() -> Dict[str, Any]:
+ users = get_all_users()
+ invites = list_signup_invites()
+ usernames = {str(user.get("username") or "") for user in users}
+
+ nodes: list[Dict[str, Any]] = []
+ edges: list[Dict[str, Any]] = []
+
+ for user in users:
+ username = str(user.get("username") or "")
+ inviter = _user_inviter_details(user)
+ nodes.append(
+ {
+ "id": f"user:{username}",
+ "type": "user",
+ "username": username,
+ "label": username,
+ "role": user.get("role"),
+ "auth_provider": user.get("auth_provider"),
+ "created_at": user.get("created_at"),
+ "invited_by_code": user.get("invited_by_code"),
+ "invited_by": inviter.get("invited_by") if inviter else None,
+ }
+ )
+
+ invite_codes = set()
+ for invite in invites:
+ code = str(invite.get("code") or "")
+ if not code:
+ continue
+ invite_codes.add(code)
+ nodes.append(
+ {
+ "id": f"invite:{code}",
+ "type": "invite",
+ "code": code,
+ "label": invite.get("label") or code,
+ "created_by": invite.get("created_by"),
+ "enabled": invite.get("enabled"),
+ "use_count": invite.get("use_count"),
+ "remaining_uses": invite.get("remaining_uses"),
+ "created_at": invite.get("created_at"),
+ }
+ )
+ created_by = invite.get("created_by")
+ if isinstance(created_by, str) and created_by.strip():
+ edges.append(
+ {
+ "id": f"user:{created_by}->invite:{code}",
+ "from": f"user:{created_by}",
+ "to": f"invite:{code}",
+ "kind": "created",
+ "label": "created",
+ "from_missing": created_by not in usernames,
+ }
+ )
+
+ for user in users:
+ username = str(user.get("username") or "")
+ invited_by_code = user.get("invited_by_code")
+ if not isinstance(invited_by_code, str) or not invited_by_code.strip():
+ continue
+ code = invited_by_code.strip()
+ edges.append(
+ {
+ "id": f"invite:{code}->user:{username}",
+ "from": f"invite:{code}",
+ "to": f"user:{username}",
+ "kind": "invited",
+ "label": code,
+ "from_missing": code not in invite_codes,
+ }
+ )
+
+ return {
+ "users": users,
+ "invites": invites,
+ "nodes": nodes,
+ "edges": edges,
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+def _admin_live_state_snapshot() -> Dict[str, Any]:
+ return {
+ "type": "admin_live_state",
+ "requestsSync": requests_router.get_requests_sync_state(),
+ "artworkPrefetch": requests_router.get_artwork_prefetch_state(),
+ }
+
+
+def _sse_encode(data: Dict[str, Any]) -> str:
+ payload = json.dumps(data, ensure_ascii=True, separators=(",", ":"), default=str)
+ return f"data: {payload}\n\n"
+
+
+def _read_log_tail_lines(lines: int) -> List[str]:
+ runtime = get_runtime_settings()
+ log_file = runtime.log_file
+ if not log_file:
+ raise HTTPException(status_code=400, detail="Log file not configured")
+ if not os.path.isabs(log_file):
+ log_file = os.path.join(os.getcwd(), log_file)
+ if not os.path.exists(log_file):
+ raise HTTPException(status_code=404, detail="Log file not found")
+ lines = max(1, min(lines, 1000))
+ from collections import deque
+
+ with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
+ tail = deque(handle, maxlen=lines)
+ return list(tail)
+
+def _normalize_username(value: str) -> str:
+ normalized = value.strip().lower()
+ if "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
+ return normalized
+
+
+def _is_ip_host(host: str) -> bool:
+ try:
+ ipaddress.ip_address(host)
+ return True
+ except ValueError:
+ return False
+
+
+def _normalize_service_url(value: str) -> str:
+ raw = value.strip()
+ if not raw:
+ raise ValueError("URL cannot be empty.")
+
+ candidate = raw
+ if "://" not in candidate:
+ authority = candidate.split("/", 1)[0].strip()
+ if authority.startswith("["):
+ closing = authority.find("]")
+ host = authority[1:closing] if closing > 0 else authority.strip("[]")
+ else:
+ host = authority.split(":", 1)[0]
+ host = host.strip().lower()
+ default_scheme = "http" if host in {"localhost"} or _is_ip_host(host) or "." not in host else "https"
+ candidate = f"{default_scheme}://{candidate}"
+
+ parsed = urlparse(candidate)
+ if parsed.scheme not in {"http", "https"}:
+ raise ValueError("URL must use http:// or https://.")
+ if not parsed.netloc:
+ raise ValueError("URL must include a host.")
+ if parsed.query or parsed.fragment:
+ raise ValueError("URL must not include query params or fragments.")
+ if not parsed.hostname:
+ raise ValueError("URL must include a valid host.")
+
+ normalized_path = parsed.path.rstrip("/")
+ normalized = parsed._replace(path=normalized_path, params="", query="", fragment="")
+ result = urlunparse(normalized).rstrip("/")
+ if not result:
+ raise ValueError("URL is invalid.")
+ return result
+
+def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
+ if not isinstance(folders, list):
+ return []
+ results = []
+ for folder in folders:
+ if not isinstance(folder, dict):
+ continue
+ folder_id = folder.get("id")
+ path = folder.get("path")
+ if folder_id is None or path is None:
+ continue
+ results.append({"id": folder_id, "path": path, "label": path})
+ return results
+
+
+async def _hydrate_cache_titles_from_jellyseerr(limit: int) -> int:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ return 0
+ missing = get_request_cache_missing_titles(limit)
+ if not missing:
+ return 0
+ hydrated = 0
+ for row in missing:
+ tmdb_id = row.get("tmdb_id")
+ media_type = row.get("media_type")
+ request_id = row.get("request_id")
+ if not tmdb_id or not media_type or not request_id:
+ continue
+ try:
+ title, year = await requests_router._hydrate_title_from_tmdb(
+ client, media_type, tmdb_id
+ )
+ except Exception:
+ logger.warning(
+ "Requests cache title hydrate failed: request_id=%s tmdb_id=%s",
+ request_id,
+ tmdb_id,
+ )
+ continue
+ if title:
+ update_request_cache_title(request_id, title, year)
+ hydrated += 1
+ return hydrated
+
+
+def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
+ if not isinstance(profiles, list):
+ return []
+ results = []
+ for profile in profiles:
+ if not isinstance(profile, dict):
+ continue
+ profile_id = profile.get("id")
+ name = profile.get("name")
+ if profile_id is None or name is None:
+ continue
+ results.append({"id": profile_id, "name": name, "label": name})
+ return results
+
+
+def _normalize_optional_text(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ value = str(value)
+ trimmed = value.strip()
+ return trimmed if trimmed else None
+
+
+def _parse_optional_positive_int(value: Any, field_name: str) -> Optional[int]:
+ if value is None or value == "":
+ return None
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=f"{field_name} must be a number") from exc
+ if parsed <= 0:
+ raise HTTPException(status_code=400, detail=f"{field_name} must be greater than 0")
+ return parsed
+
+
+def _parse_optional_profile_id(value: Any) -> Optional[int]:
+ if value is None or value == "":
+ return None
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
+ if parsed <= 0:
+ raise HTTPException(status_code=400, detail="profile_id must be greater than 0")
+ profile = get_user_profile(parsed)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return parsed
+
+
+def _parse_optional_expires_at(value: Any) -> Optional[str]:
+ if value is None or value == "":
+ return None
+ if not isinstance(value, str):
+ raise HTTPException(status_code=400, detail="expires_at must be an ISO datetime string")
+ candidate = value.strip()
+ if not candidate:
+ return None
+ try:
+ parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="expires_at must be a valid ISO datetime") from exc
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.isoformat()
+
+
+def _normalize_invite_code(value: Optional[str]) -> str:
+ raw = (value or "").strip().upper()
+ filtered = "".join(ch for ch in raw if ch.isalnum())
+ if len(filtered) < 6:
+ raise HTTPException(status_code=400, detail="Invite code must be at least 6 letters/numbers.")
+ return filtered
+
+
+def _generate_invite_code(length: int = 12) -> str:
+ alphabet = string.ascii_uppercase + string.digits
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+def _normalize_role_or_none(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ value = str(value)
+ role = value.strip().lower()
+ if not role:
+ return None
+ if role not in {"user", "admin"}:
+ raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
+ return role
+
+
+def _calculate_profile_expiry(profile: Dict[str, Any]) -> Optional[str]:
+ expires_days = profile.get("account_expires_days")
+ if isinstance(expires_days, int) and expires_days > 0:
+ return (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat()
+ return None
+
+
+def _apply_profile_defaults_to_user(username: str, profile: Dict[str, Any]) -> Dict[str, Any]:
+ set_user_profile_id(username, int(profile["id"]))
+ role = profile.get("role") or "user"
+ if role in {"user", "admin"}:
+ set_user_role(username, role)
+ set_user_auto_search_enabled(username, bool(profile.get("auto_search_enabled", True)))
+ set_user_expires_at(username, _calculate_profile_expiry(profile))
+ refreshed = get_user_by_username(username)
+ if not refreshed:
+ raise HTTPException(status_code=404, detail="User not found")
+ return refreshed
+
+
+@router.get("/settings")
+async def list_settings() -> Dict[str, Any]:
+ overrides = get_settings_overrides()
+ results = []
+ for key in SETTING_KEYS:
+ override_present = key in overrides
+ value = overrides.get(key) if override_present else getattr(env_settings, key)
+ is_set = value is not None and str(value).strip() != ""
+ sensitive = key in SENSITIVE_KEYS
+ results.append(
+ {
+ "key": key,
+ "value": None if sensitive else value,
+ "isSet": is_set,
+ "source": "db" if override_present else ("env" if is_set else "unset"),
+ "sensitive": sensitive,
+ }
+ )
+ return {"settings": results}
+
+
+@router.put("/settings")
+async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
+ updates = 0
+ touched_logging = False
+ changed_keys: List[str] = []
+ for key, value in payload.items():
+ if key not in SETTING_KEYS:
+ raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
+ if value is None:
+ continue
+ if isinstance(value, str) and value.strip() == "":
+ delete_setting(key)
+ updates += 1
+ changed_keys.append(key)
+ continue
+ value_to_store = str(value).strip() if isinstance(value, str) else str(value)
+ if key in URL_SETTING_KEYS and value_to_store:
+ try:
+ value_to_store = _normalize_service_url(value_to_store)
+ except ValueError as exc:
+ friendly_key = key.replace("_", " ")
+ raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
+ if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
+ try:
+ value_to_store = validate_notification_target_url(value_to_store)
+ except ValueError as exc:
+ friendly_key = key.replace("_", " ")
+ raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
+ set_setting(key, value_to_store)
+ updates += 1
+ changed_keys.append(key)
+ if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
+ touched_logging = True
+ if touched_logging:
+ runtime = get_runtime_settings()
+ configure_logging(
+ runtime.log_level,
+ runtime.log_file,
+ log_file_max_bytes=runtime.log_file_max_bytes,
+ log_file_backup_count=runtime.log_file_backup_count,
+ log_http_client_level=runtime.log_http_client_level,
+ log_background_sync_level=runtime.log_background_sync_level,
+ )
+ logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
+ return {"status": "ok", "updated": updates}
+
+
+@router.post("/settings/test/email")
+async def test_email_settings(request: Request) -> Dict[str, Any]:
+ recipient_email = None
+ content_type = (request.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
+ try:
+ if content_type == "application/json":
+ payload = await request.json()
+ if isinstance(payload, dict) and isinstance(payload.get("recipient_email"), str):
+ recipient_email = payload["recipient_email"]
+ elif content_type in {
+ "application/x-www-form-urlencoded",
+ "multipart/form-data",
+ }:
+ form = await request.form()
+ candidate = form.get("recipient_email")
+ if isinstance(candidate, str):
+ recipient_email = candidate
+ except Exception:
+ recipient_email = None
+ try:
+ result = await send_test_email(recipient_email=recipient_email)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
+ return {"status": "ok", **result}
+
+
+@router.get("/diagnostics")
+async def diagnostics_catalog() -> Dict[str, Any]:
+ return {"status": "ok", **get_diagnostics_catalog()}
+
+
+@router.post("/diagnostics/run")
+async def diagnostics_run(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ keys: Optional[List[str]] = None
+ recipient_email: Optional[str] = None
+ if payload is not None:
+ raw_keys = payload.get("keys")
+ if raw_keys is not None:
+ if not isinstance(raw_keys, list):
+ raise HTTPException(status_code=400, detail="keys must be an array of diagnostic keys")
+ keys = []
+ for raw_key in raw_keys:
+ if not isinstance(raw_key, str):
+ raise HTTPException(status_code=400, detail="Each diagnostic key must be a string")
+ normalized = raw_key.strip()
+ if normalized:
+ keys.append(normalized)
+ raw_recipient_email = payload.get("recipient_email")
+ if raw_recipient_email is not None:
+ if not isinstance(raw_recipient_email, str):
+ raise HTTPException(status_code=400, detail="recipient_email must be a string")
+ recipient_email = raw_recipient_email.strip() or None
+ return {"status": "ok", **(await run_diagnostics(keys, recipient_email=recipient_email))}
+
+
+@router.get("/sonarr/options")
+async def sonarr_options() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Sonarr not configured")
+ root_folders = await client.get_root_folders()
+ profiles = await client.get_quality_profiles()
+ return {
+ "rootFolders": _normalize_root_folders(root_folders),
+ "qualityProfiles": _normalize_quality_profiles(profiles),
+ }
+
+
+@router.get("/radarr/options")
+async def radarr_options() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Radarr not configured")
+ root_folders = await client.get_root_folders()
+ profiles = await client.get_quality_profiles()
+ return {
+ "rootFolders": _normalize_root_folders(root_folders),
+ "qualityProfiles": _normalize_quality_profiles(profiles),
+ }
+
+
+@router.get("/jellyfin/users")
+async def jellyfin_users() -> Dict[str, Any]:
+ cached = get_cached_jellyfin_users()
+ if cached is not None:
+ return {"users": cached}
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Jellyfin not configured")
+ users = await client.get_users()
+ if not isinstance(users, list):
+ return {"users": []}
+ results = save_jellyfin_users_cache(users)
+ return {"users": results}
+
+
+@router.post("/jellyfin/users/sync")
+async def jellyfin_users_sync() -> Dict[str, Any]:
+ imported = await sync_jellyfin_users()
+ return {"status": "ok", "imported": imported}
+
+async def _fetch_all_jellyseerr_users(
+ client: JellyseerrClient, use_cache: bool = True
+) -> List[Dict[str, Any]]:
+ if use_cache:
+ cached = get_cached_jellyseerr_users()
+ if cached is not None:
+ return cached
+ users: List[Dict[str, Any]] = []
+ take = 100
+ skip = 0
+ while True:
+ payload = await client.get_users(take=take, skip=skip)
+ if not payload:
+ break
+ if isinstance(payload, list):
+ batch = payload
+ elif isinstance(payload, dict):
+ batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
+ else:
+ batch = None
+ if not isinstance(batch, list) or not batch:
+ break
+ users.extend([user for user in batch if isinstance(user, dict)])
+ if len(batch) < take:
+ break
+ skip += take
+ if users:
+ return save_jellyseerr_users_cache(users)
+ return users
+
+@router.post("/seerr/users/sync")
+@router.post("/jellyseerr/users/sync")
+async def jellyseerr_users_sync() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
+ if not jellyseerr_users:
+ return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
+
+ candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
+
+ updated = 0
+ skipped = 0
+ users = get_all_users()
+ for user in users:
+ if user.get("jellyseerr_user_id") is not None:
+ skipped += 1
+ continue
+ username = user.get("username") or ""
+ matched_id = match_jellyseerr_user_id(username, candidate_to_id)
+ matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
+ matched_email = extract_jellyseerr_user_email(matched_seerr_user)
+ if matched_id is not None:
+ set_user_jellyseerr_id(username, matched_id)
+ if matched_email:
+ set_user_email(username, matched_email)
+ updated += 1
+ else:
+ skipped += 1
+
+ return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
+
+def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
+ for key in ("email", "username", "displayName", "name"):
+ value = user.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+@router.post("/seerr/users/resync")
+@router.post("/jellyseerr/users/resync")
+async def jellyseerr_users_resync() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
+ if not jellyseerr_users:
+ return {"status": "ok", "imported": 0, "cleared": 0}
+
+ cleared = delete_non_admin_users()
+ imported = 0
+ for user in jellyseerr_users:
+ user_id = user.get("id") or user.get("userId") or user.get("Id")
+ try:
+ user_id = int(user_id)
+ except (TypeError, ValueError):
+ continue
+ username = _pick_jellyseerr_username(user)
+ if not username:
+ continue
+ email = extract_jellyseerr_user_email(user)
+ created = create_user_if_missing(
+ username,
+ "jellyseerr-user",
+ role="user",
+ email=email,
+ auth_provider="jellyseerr",
+ jellyseerr_user_id=user_id,
+ )
+ if created:
+ imported += 1
+ else:
+ set_user_jellyseerr_id(username, user_id)
+ if email:
+ set_user_email(username, email)
+ return {"status": "ok", "imported": imported, "cleared": cleared}
+
+@router.post("/requests/sync")
+async def requests_sync() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ state = await requests_router.start_requests_sync(
+ runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
+ )
+ logger.info("Admin triggered requests sync: status=%s", state.get("status"))
+ return {"status": "ok", "sync": state}
+
+
+@router.post("/requests/sync/delta")
+async def requests_sync_delta() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ state = await requests_router.start_requests_delta_sync(
+ runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
+ )
+ logger.info("Admin triggered delta requests sync: status=%s", state.get("status"))
+ return {"status": "ok", "sync": state}
+
+
+@router.post("/requests/artwork/prefetch")
+async def requests_artwork_prefetch(only_missing: bool = False) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ state = await requests_router.start_artwork_prefetch(
+ runtime.jellyseerr_base_url,
+ runtime.jellyseerr_api_key,
+ only_missing=only_missing,
+ )
+ logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
+ return {"status": "ok", "prefetch": state}
+
+
+@router.get("/requests/artwork/status")
+async def requests_artwork_status() -> Dict[str, Any]:
+ return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
+
+@router.get("/requests/artwork/summary")
+async def requests_artwork_summary() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ stats = get_request_cache_stats()
+ if cache_mode != "cache":
+ stats["cache_bytes"] = 0
+ stats["cache_files"] = 0
+ stats["missing_artwork"] = 0
+ summary = {
+ "cache_mode": cache_mode,
+ "cache_bytes": stats.get("cache_bytes", 0),
+ "cache_files": stats.get("cache_files", 0),
+ "missing_artwork": stats.get("missing_artwork", 0),
+ "total_requests": stats.get("total_requests", 0),
+ "updated_at": stats.get("updated_at"),
+ }
+ return {"status": "ok", "summary": summary}
+
+
+@router.get("/requests/sync/status")
+async def requests_sync_status() -> Dict[str, Any]:
+ return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
+
+
+@events_router.get("/stream")
+async def admin_events_stream(
+ request: Request,
+ include_logs: bool = False,
+ log_lines: int = 200,
+ _: Dict[str, Any] = Depends(require_admin_event_stream),
+) -> StreamingResponse:
+ async def event_generator():
+ # Advise client reconnect timing once per stream.
+ yield "retry: 2000\n\n"
+ last_snapshot: Optional[str] = None
+ heartbeat_counter = 0
+ log_refresh_counter = 5 if include_logs else 0
+ latest_logs_payload: Optional[Dict[str, Any]] = None
+ while True:
+ if await request.is_disconnected():
+ break
+ snapshot_payload = _admin_live_state_snapshot()
+ if include_logs:
+ log_refresh_counter += 1
+ if log_refresh_counter >= 5:
+ log_refresh_counter = 0
+ try:
+ latest_logs_payload = {
+ "lines": _read_log_tail_lines(log_lines),
+ "count": max(1, min(int(log_lines or 200), 1000)),
+ }
+ except HTTPException as exc:
+ latest_logs_payload = {
+ "error": str(exc.detail) if exc.detail else "Could not read logs",
+ }
+ except Exception as exc:
+ latest_logs_payload = {"error": str(exc)}
+ snapshot_payload["logs"] = latest_logs_payload
+
+ snapshot = _sse_encode(snapshot_payload)
+ if snapshot != last_snapshot:
+ last_snapshot = snapshot
+ yield snapshot
+ heartbeat_counter = 0
+ else:
+ heartbeat_counter += 1
+ # Keep the stream alive through proxies even when state is unchanged.
+ if heartbeat_counter >= 15:
+ yield ": ping\n\n"
+ heartbeat_counter = 0
+ await asyncio.sleep(1.0)
+
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+
+
+@router.get("/logs")
+async def read_logs(lines: int = 200) -> Dict[str, Any]:
+ return {"lines": _read_log_tail_lines(lines)}
+
+
+@router.get("/requests/cache")
+async def requests_cache(limit: int = 50) -> Dict[str, Any]:
+ repaired = repair_request_cache_titles()
+ if repaired:
+ logger.info("Requests cache titles repaired via settings view: %s", repaired)
+ hydrated = await _hydrate_cache_titles_from_jellyseerr(limit)
+ if hydrated:
+ logger.info("Requests cache titles hydrated via Seerr: %s", hydrated)
+ rows = get_request_cache_overview(limit)
+ return {"rows": rows}
+
+
+@router.get("/requests/all")
+async def requests_all(
+ take: int = 50,
+ skip: int = 0,
+ days: Optional[int] = None,
+ stage: str = "all",
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if user.get("role") != "admin":
+ raise HTTPException(status_code=403, detail="Forbidden")
+ take = max(1, min(int(take or 50), 200))
+ skip = max(0, int(skip or 0))
+ since_iso = None
+ if days is not None and int(days) > 0:
+ since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
+ status_codes = requests_router.request_stage_filter_codes(stage)
+ rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso, status_codes=status_codes)
+ total = get_cached_requests_count(since_iso=since_iso, status_codes=status_codes)
+ results = []
+ for row in rows:
+ status = row.get("status")
+ results.append(
+ {
+ "id": row.get("request_id"),
+ "title": row.get("title"),
+ "year": row.get("year"),
+ "type": row.get("media_type"),
+ "status": status,
+ "statusLabel": requests_router._status_label(status),
+ "requestedBy": row.get("requested_by"),
+ "createdAt": row.get("created_at"),
+ }
+ )
+ return {"results": results, "total": total, "take": take, "skip": skip}
+
+
+@router.post("/branding/logo")
+async def upload_branding_logo(file: UploadFile = File(...)) -> Dict[str, Any]:
+ return await save_branding_image(file)
+
+
+@router.post("/maintenance/repair")
+async def repair_database() -> Dict[str, Any]:
+ result = run_integrity_check()
+ vacuum_db()
+ logger.info("Database repair executed: integrity_check=%s", result)
+ return {"status": "ok", "integrity": result}
+
+
+@router.post("/maintenance/flush")
+async def flush_database() -> Dict[str, Any]:
+ cleared = clear_requests_cache()
+ history = clear_history()
+ user_objects = clear_user_objects_nuclear()
+ user_caches = clear_user_import_caches()
+ delete_setting("requests_sync_last_at")
+ logger.warning(
+ "Database flush executed: requests_cache=%s history=%s user_objects=%s user_caches=%s",
+ cleared,
+ history,
+ user_objects,
+ user_caches,
+ )
+ return {
+ "status": "ok",
+ "requestsCleared": cleared,
+ "historyCleared": history,
+ "userObjectsCleared": user_objects,
+ "userCachesCleared": user_caches,
+ }
+
+
+@router.post("/maintenance/cleanup")
+async def cleanup_database(days: int = 90) -> Dict[str, Any]:
+ result = cleanup_history(days)
+ logger.info("Database cleanup executed: days=%s result=%s", days, result)
+ return {"status": "ok", "days": days, "cleared": result}
+
+
+@router.post("/maintenance/logs/clear")
+async def clear_logs() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ log_file = runtime.log_file
+ if not log_file:
+ raise HTTPException(status_code=400, detail="Log file not configured")
+ if not os.path.isabs(log_file):
+ log_file = os.path.join(os.getcwd(), log_file)
+ try:
+ os.makedirs(os.path.dirname(log_file), exist_ok=True)
+ with open(log_file, "w", encoding="utf-8"):
+ pass
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ logger.info("Log file cleared")
+ return {"status": "ok"}
+
+
+@router.get("/users")
+async def list_users() -> Dict[str, Any]:
+ users = get_all_users()
+ return {"users": users}
+
+@router.get("/users/summary")
+async def list_users_summary() -> Dict[str, Any]:
+ users = get_all_users()
+ results: list[Dict[str, Any]] = []
+ for user in users:
+ username = user.get("username") or ""
+ username_norm = _normalize_username(username) if username else ""
+ stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
+ results.append({**user, "stats": stats})
+ return {"users": results}
+
+@router.get("/users/{username}")
+async def get_user_summary(username: str) -> Dict[str, Any]:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ username_norm = _normalize_username(user.get("username") or "")
+ stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
+ return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
+
+
+@router.get("/users/id/{user_id}")
+async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
+ user = get_user_by_id(user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ username_norm = _normalize_username(user.get("username") or "")
+ stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
+ return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
+
+
+@router.post("/users/{username}/block")
+async def block_user(username: str) -> Dict[str, Any]:
+ set_user_blocked(username, True)
+ logger.warning("Admin blocked user: username=%s", username)
+ return {"status": "ok", "username": username, "blocked": True}
+
+
+@router.post("/users/{username}/unblock")
+async def unblock_user(username: str) -> Dict[str, Any]:
+ set_user_blocked(username, False)
+ logger.info("Admin unblocked user: username=%s", username)
+ return {"status": "ok", "username": username, "blocked": False}
+
+
+@router.post("/users/{username}/system-action")
+async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ action = str(payload.get("action") or "").strip().lower()
+ if action not in {"ban", "unban", "remove"}:
+ raise HTTPException(status_code=400, detail="action must be ban, unban, or remove")
+
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.get("role") == "admin":
+ raise HTTPException(status_code=400, detail="Cross-system actions are not allowed for admin users")
+
+ runtime = get_runtime_settings()
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ result: Dict[str, Any] = {
+ "status": "ok",
+ "action": action,
+ "username": user.get("username"),
+ "local": {"status": "pending"},
+ "jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"},
+ "jellyseerr": {"status": "skipped", "detail": "Seerr not configured or no linked user ID"},
+ "invites": {"status": "pending", "disabled": 0},
+ "email": {"status": "skipped", "detail": "No email action required"},
+ }
+
+ if action == "ban":
+ set_user_blocked(username, True)
+ result["local"] = {"status": "ok", "blocked": True}
+ elif action == "unban":
+ set_user_blocked(username, False)
+ result["local"] = {"status": "ok", "blocked": False}
+ else:
+ result["local"] = {"status": "pending-delete"}
+
+ if action in {"ban", "remove"}:
+ result["invites"] = {"status": "ok", "disabled": disable_signup_invites_by_creator(username)}
+ else:
+ result["invites"] = {"status": "ok", "disabled": 0}
+
+ if action in {"ban", "remove"}:
+ try:
+ invite = _resolve_user_invite(user)
+ email_result = await send_templated_email(
+ "banned",
+ invite=invite,
+ user=user,
+ reason="Account banned" if action == "ban" else "Account removed",
+ )
+ result["email"] = {"status": "ok", **email_result}
+ except Exception as exc:
+ result["email"] = {"status": "error", "detail": str(exc)}
+
+ if jellyfin.configured():
+ try:
+ jellyfin_user = await jellyfin.find_user_by_name(username)
+ if not jellyfin_user:
+ result["jellyfin"] = {"status": "not_found"}
+ else:
+ jellyfin_user_id = jellyfin._extract_user_id(jellyfin_user) # type: ignore[attr-defined]
+ if not jellyfin_user_id:
+ raise RuntimeError("Could not determine Jellyfin user ID")
+ if action == "ban":
+ await jellyfin.set_user_disabled(jellyfin_user_id, True)
+ result["jellyfin"] = {"status": "ok", "action": "disabled", "user_id": jellyfin_user_id}
+ elif action == "unban":
+ await jellyfin.set_user_disabled(jellyfin_user_id, False)
+ result["jellyfin"] = {"status": "ok", "action": "enabled", "user_id": jellyfin_user_id}
+ else:
+ await jellyfin.delete_user(jellyfin_user_id)
+ result["jellyfin"] = {"status": "ok", "action": "deleted", "user_id": jellyfin_user_id}
+ except Exception as exc:
+ result["jellyfin"] = {"status": "error", "detail": _http_error_detail(exc)}
+
+ jellyseerr_user_id = user.get("jellyseerr_user_id")
+ if jellyseerr.configured() and jellyseerr_user_id is not None:
+ try:
+ if action == "remove":
+ await jellyseerr.delete_user(int(jellyseerr_user_id))
+ result["jellyseerr"] = {"status": "ok", "action": "deleted", "user_id": int(jellyseerr_user_id)}
+ elif action == "ban":
+ result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-disable", "user_id": int(jellyseerr_user_id)}
+ else:
+ result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-enable", "user_id": int(jellyseerr_user_id)}
+ except Exception as exc:
+ result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
+
+ if action == "remove":
+ deleted = delete_user_by_username(username)
+ activity_deleted = delete_user_activity_by_username(username)
+ result["local"] = {
+ "status": "ok" if deleted else "not_found",
+ "deleted": bool(deleted),
+ "activity_deleted": activity_deleted,
+ }
+
+ if any(
+ isinstance(system, dict) and system.get("status") == "error"
+ for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email"))
+ ):
+ result["status"] = "partial"
+ logger.info(
+ "Admin system action completed: username=%s action=%s overall=%s local=%s jellyfin=%s jellyseerr=%s invites=%s email=%s",
+ username,
+ action,
+ result.get("status"),
+ result.get("local", {}).get("status"),
+ result.get("jellyfin", {}).get("status"),
+ result.get("jellyseerr", {}).get("status"),
+ result.get("invites", {}).get("status"),
+ result.get("email", {}).get("status"),
+ )
+ return result
+
+
+@router.post("/users/{username}/role")
+async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ role = payload.get("role")
+ if role not in {"admin", "user"}:
+ raise HTTPException(status_code=400, detail="Invalid role")
+ set_user_role(username, role)
+ return {"status": "ok", "username": username, "role": role}
+
+
+@router.post("/users/{username}/auto-search")
+async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ set_user_auto_search_enabled(username, enabled)
+ return {"status": "ok", "username": username, "auto_search_enabled": enabled}
+
+
+@router.post("/users/{username}/invite-access")
+async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ set_user_invite_management_enabled(username, enabled)
+ refreshed = get_user_by_username(username)
+ return {
+ "status": "ok",
+ "username": username,
+ "invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled,
+ "user": refreshed,
+ }
+
+
+@router.post("/users/{username}/profile")
+async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ profile_id = payload.get("profile_id")
+ if profile_id in (None, ""):
+ set_user_profile_id(username, None)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+ try:
+ parsed_profile_id = int(profile_id)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
+ profile = get_user_profile(parsed_profile_id)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if not profile.get("is_active", True):
+ raise HTTPException(status_code=400, detail="Profile is disabled")
+ refreshed = _apply_profile_defaults_to_user(username, profile)
+ return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id}
+
+
+@router.post("/users/{username}/expiry")
+async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ clear = payload.get("clear")
+ if clear is True:
+ set_user_expires_at(username, None)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+ if "days" in payload and payload.get("days") not in (None, ""):
+ days = _parse_optional_positive_int(payload.get("days"), "days")
+ expires_at = None
+ if days is not None:
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
+ set_user_expires_at(username, expires_at)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ set_user_expires_at(username, expires_at)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+
+
+@router.post("/users/auto-search/bulk")
+async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ updated = set_auto_search_enabled_for_non_admin_users(enabled)
+ return {
+ "status": "ok",
+ "enabled": enabled,
+ "updated": updated,
+ "scope": "non-admin-users",
+ }
+
+
+@router.post("/users/invite-access/bulk")
+async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ updated = set_invite_management_enabled_for_non_admin_users(enabled)
+ return {
+ "status": "ok",
+ "enabled": enabled,
+ "updated": updated,
+ "scope": "non-admin-users",
+ }
+
+
+@router.post("/users/profile/bulk")
+async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ scope = str(payload.get("scope") or "non-admin-users").strip().lower()
+ if scope not in {"non-admin-users", "all-users"}:
+ raise HTTPException(status_code=400, detail="Invalid scope")
+ profile_id_value = payload.get("profile_id")
+ if profile_id_value in (None, ""):
+ users = get_all_users()
+ updated = 0
+ for user in users:
+ if scope == "non-admin-users" and user.get("role") == "admin":
+ continue
+ set_user_profile_id(user["username"], None)
+ updated += 1
+ return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None}
+ try:
+ profile_id = int(profile_id_value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
+ profile = get_user_profile(profile_id)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if not profile.get("is_active", True):
+ raise HTTPException(status_code=400, detail="Profile is disabled")
+ users = get_all_users()
+ updated = 0
+ for user in users:
+ if scope == "non-admin-users" and user.get("role") == "admin":
+ continue
+ _apply_profile_defaults_to_user(user["username"], profile)
+ updated += 1
+ return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id}
+
+
+@router.post("/users/expiry/bulk")
+async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ scope = str(payload.get("scope") or "non-admin-users").strip().lower()
+ if scope not in {"non-admin-users", "all-users"}:
+ raise HTTPException(status_code=400, detail="Invalid scope")
+ clear = payload.get("clear")
+ expires_at: Optional[str] = None
+ if clear is True:
+ expires_at = None
+ elif "days" in payload and payload.get("days") not in (None, ""):
+ days = _parse_optional_positive_int(payload.get("days"), "days")
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None
+ else:
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ users = get_all_users()
+ updated = 0
+ for user in users:
+ if scope == "non-admin-users" and user.get("role") == "admin":
+ continue
+ set_user_expires_at(user["username"], expires_at)
+ updated += 1
+ return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at}
+
+
+@router.post("/users/{username}/password")
+async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ new_password = payload.get("password") if isinstance(payload, dict) else None
+ if not isinstance(new_password, str):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ try:
+ new_password_clean = validate_password_policy(new_password)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ user = normalize_user_auth_provider(user)
+ auth_provider = resolve_user_auth_provider(user)
+ if auth_provider == "local":
+ set_user_password(username, new_password_clean)
+ return {"status": "ok", "username": username, "provider": "local"}
+ if auth_provider == "jellyfin":
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.")
+ try:
+ jf_user = await client.find_user_by_name(username)
+ user_id = client._extract_user_id(jf_user)
+ if not user_id:
+ raise RuntimeError("Jellyfin user ID not found")
+ await client.set_user_password(user_id, new_password_clean)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
+ sync_jellyfin_password_state(username, new_password_clean)
+ return {"status": "ok", "username": username, "provider": "jellyfin"}
+ raise HTTPException(
+ status_code=400,
+ detail="Password changes are not available for this sign-in provider.",
+ )
+
+
+@router.get("/profiles")
+async def get_profiles() -> Dict[str, Any]:
+ profiles = list_user_profiles()
+ users = get_all_users()
+ invites = list_signup_invites()
+ user_counts: Dict[int, int] = {}
+ invite_counts: Dict[int, int] = {}
+ for user in users:
+ profile_id = user.get("profile_id")
+ if isinstance(profile_id, int):
+ user_counts[profile_id] = user_counts.get(profile_id, 0) + 1
+ for invite in invites:
+ profile_id = invite.get("profile_id")
+ if isinstance(profile_id, int):
+ invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1
+ enriched = []
+ for profile in profiles:
+ pid = int(profile["id"])
+ enriched.append(
+ {
+ **profile,
+ "assigned_users": user_counts.get(pid, 0),
+ "assigned_invites": invite_counts.get(pid, 0),
+ }
+ )
+ return {"profiles": enriched}
+
+
+@router.post("/profiles")
+async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ name = _normalize_optional_text(payload.get("name"))
+ if not name:
+ raise HTTPException(status_code=400, detail="Profile name is required")
+ role = _normalize_role_or_none(payload.get("role")) or "user"
+ auto_search_enabled = payload.get("auto_search_enabled")
+ if auto_search_enabled is None:
+ auto_search_enabled = True
+ if not isinstance(auto_search_enabled, bool):
+ raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
+ is_active = payload.get("is_active")
+ if is_active is None:
+ is_active = True
+ if not isinstance(is_active, bool):
+ raise HTTPException(status_code=400, detail="is_active must be true or false")
+ account_expires_days = _parse_optional_positive_int(
+ payload.get("account_expires_days"), "account_expires_days"
+ )
+ try:
+ profile = create_user_profile(
+ name=name,
+ description=_normalize_optional_text(payload.get("description")),
+ role=role,
+ auto_search_enabled=auto_search_enabled,
+ account_expires_days=account_expires_days,
+ is_active=is_active,
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
+ logger.info(
+ "Admin created profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
+ profile.get("id"),
+ profile.get("name"),
+ profile.get("role"),
+ profile.get("is_active"),
+ profile.get("auto_search_enabled"),
+ profile.get("account_expires_days"),
+ )
+ return {"status": "ok", "profile": profile}
+
+
+@router.put("/profiles/{profile_id}")
+async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ existing = get_user_profile(profile_id)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ name = _normalize_optional_text(payload.get("name"))
+ if not name:
+ raise HTTPException(status_code=400, detail="Profile name is required")
+ role = _normalize_role_or_none(payload.get("role")) or "user"
+ auto_search_enabled = payload.get("auto_search_enabled")
+ if not isinstance(auto_search_enabled, bool):
+ raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
+ is_active = payload.get("is_active")
+ if not isinstance(is_active, bool):
+ raise HTTPException(status_code=400, detail="is_active must be true or false")
+ account_expires_days = _parse_optional_positive_int(
+ payload.get("account_expires_days"), "account_expires_days"
+ )
+ try:
+ profile = update_user_profile(
+ profile_id,
+ name=name,
+ description=_normalize_optional_text(payload.get("description")),
+ role=role,
+ auto_search_enabled=auto_search_enabled,
+ account_expires_days=account_expires_days,
+ is_active=is_active,
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ logger.info(
+ "Admin updated profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
+ profile.get("id"),
+ profile.get("name"),
+ profile.get("role"),
+ profile.get("is_active"),
+ profile.get("auto_search_enabled"),
+ profile.get("account_expires_days"),
+ )
+ return {"status": "ok", "profile": profile}
+
+
+@router.delete("/profiles/{profile_id}")
+async def remove_profile(profile_id: int) -> Dict[str, Any]:
+ try:
+ deleted = delete_user_profile(profile_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ logger.warning("Admin deleted profile: profile_id=%s", profile_id)
+ return {"status": "ok", "deleted": True, "profile_id": profile_id}
+
+
+@router.get("/invites")
+async def get_invites() -> Dict[str, Any]:
+ invites = list_signup_invites()
+ profiles = {profile["id"]: profile for profile in list_user_profiles()}
+ results = []
+ for invite in invites:
+ profile = profiles.get(invite.get("profile_id"))
+ results.append(
+ {
+ **invite,
+ "profile": (
+ {
+ "id": profile.get("id"),
+ "name": profile.get("name"),
+ }
+ if profile
+ else None
+ ),
+ }
+ )
+ return {"invites": results}
+
+
+@router.get("/invites/policy")
+async def get_invite_policy() -> Dict[str, Any]:
+ users = get_all_users()
+ non_admin_users = [user for user in users if user.get("role") != "admin"]
+ invite_access_enabled_count = sum(
+ 1 for user in non_admin_users if bool(user.get("invite_management_enabled", False))
+ )
+ raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
+ master_invite_id: Optional[int] = None
+ master_invite: Optional[Dict[str, Any]] = None
+ if raw_master_invite_id not in (None, ""):
+ try:
+ candidate = int(str(raw_master_invite_id).strip())
+ if candidate > 0:
+ master_invite_id = candidate
+ master_invite = get_signup_invite_by_id(candidate)
+ except (TypeError, ValueError):
+ master_invite_id = None
+ master_invite = None
+ return {
+ "status": "ok",
+ "policy": {
+ "master_invite_id": master_invite_id if master_invite is not None else None,
+ "master_invite": master_invite,
+ "non_admin_users": len(non_admin_users),
+ "invite_access_enabled_users": invite_access_enabled_count,
+ },
+ }
+
+
+@router.post("/invites/policy")
+async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ master_invite_value = payload.get("master_invite_id")
+ if master_invite_value in (None, "", 0, "0"):
+ set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
+ logger.info("Admin cleared invite policy master invite")
+ return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
+ try:
+ master_invite_id = int(master_invite_value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc
+ if master_invite_id <= 0:
+ raise HTTPException(status_code=400, detail="master_invite_id must be a positive number")
+ invite = get_signup_invite_by_id(master_invite_id)
+ if not invite:
+ raise HTTPException(status_code=404, detail="Master invite not found")
+ set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
+ logger.info("Admin updated invite policy: master_invite_id=%s", master_invite_id)
+ return {
+ "status": "ok",
+ "policy": {
+ "master_invite_id": master_invite_id,
+ "master_invite": invite,
+ },
+ }
+
+
+@router.get("/invites/email/templates")
+async def get_invite_email_template_settings() -> Dict[str, Any]:
+ ready, detail = smtp_email_config_ready()
+ warning = smtp_email_delivery_warning()
+ return {
+ "status": "ok",
+ "email": {
+ "configured": ready,
+ "detail": warning or detail,
+ },
+ "templates": list(get_invite_email_templates().values()),
+ }
+
+
+@router.put("/invites/email/templates/{template_key}")
+async def update_invite_email_template_settings(template_key: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
+ raise HTTPException(status_code=404, detail="Email template not found")
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ subject = _normalize_optional_text(payload.get("subject"))
+ body_text = _normalize_optional_text(payload.get("body_text"))
+ body_html = _normalize_optional_text(payload.get("body_html"))
+ if not subject:
+ raise HTTPException(status_code=400, detail="subject is required")
+ if not body_text and not body_html:
+ raise HTTPException(status_code=400, detail="At least one email body is required")
+ template = save_invite_email_template(
+ template_key,
+ subject=subject,
+ body_text=body_text or "",
+ body_html=body_html or "",
+ )
+ logger.info("Admin updated invite email template: template=%s", template_key)
+ return {"status": "ok", "template": template}
+
+
+@router.delete("/invites/email/templates/{template_key}")
+async def reset_invite_email_template_settings(template_key: str) -> Dict[str, Any]:
+ if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
+ raise HTTPException(status_code=404, detail="Email template not found")
+ template = reset_invite_email_template(template_key)
+ logger.info("Admin reset invite email template: template=%s", template_key)
+ return {"status": "ok", "template": template}
+
+
+@router.post("/invites/email/send")
+async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ template_key = str(payload.get("template_key") or "").strip().lower()
+ if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
+ raise HTTPException(status_code=400, detail="template_key is invalid")
+
+ invite: Optional[Dict[str, Any]] = None
+ invite_id = payload.get("invite_id")
+ if invite_id not in (None, ""):
+ try:
+ invite = get_signup_invite_by_id(int(invite_id))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="invite_id must be a number") from exc
+ if not invite:
+ raise HTTPException(status_code=404, detail="Invite not found")
+
+ user: Optional[Dict[str, Any]] = None
+ username = _normalize_optional_text(payload.get("username"))
+ if username:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if invite is None:
+ invite = _resolve_user_invite(user)
+
+ recipient_email = _require_recipient_email(payload.get("recipient_email"))
+ message = _normalize_optional_text(payload.get("message"))
+ reason = _normalize_optional_text(payload.get("reason"))
+
+ try:
+ result = await send_templated_email(
+ template_key,
+ invite=invite,
+ user=user,
+ recipient_email=recipient_email,
+ message=message,
+ reason=reason,
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ logger.info(
+ "Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
+ template_key,
+ result.get("recipient_email"),
+ invite.get("id") if invite else None,
+ user.get("username") if user else None,
+ )
+
+ return {
+ "status": "ok",
+ "template_key": template_key,
+ **result,
+ }
+
+
+@router.get("/invites/trace")
+async def get_invite_trace() -> Dict[str, Any]:
+ return {"status": "ok", "trace": _build_invite_trace_payload()}
+
+
+@router.post("/invites")
+async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ raw_code = _normalize_optional_text(payload.get("code"))
+ code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code()
+ profile_id = _parse_optional_profile_id(payload.get("profile_id"))
+ enabled = payload.get("enabled")
+ if enabled is None:
+ enabled = True
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ role = _normalize_role_or_none(payload.get("role"))
+ max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ recipient_email = _require_recipient_email(payload.get("recipient_email"))
+ send_email = bool(payload.get("send_email"))
+ delivery_message = _normalize_optional_text(payload.get("message"))
+ try:
+ invite = create_signup_invite(
+ code=code,
+ label=_normalize_optional_text(payload.get("label")),
+ description=_normalize_optional_text(payload.get("description")),
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ created_by=current_user.get("username"),
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ user=current_user,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ logger.info(
+ "Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
+ invite.get("id"),
+ invite.get("code"),
+ invite.get("label"),
+ invite.get("profile_id"),
+ invite.get("role"),
+ invite.get("max_uses"),
+ invite.get("enabled"),
+ invite.get("recipient_email"),
+ send_email,
+ )
+ return {
+ "status": "partial" if email_error else "ok",
+ "invite": invite,
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.put("/invites/{invite_id}")
+async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ existing = get_signup_invite_by_id(invite_id)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
+ profile_id = _parse_optional_profile_id(payload.get("profile_id"))
+ enabled = payload.get("enabled")
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ role = _normalize_role_or_none(payload.get("role"))
+ max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ recipient_email = _normalize_optional_text(payload.get("recipient_email"))
+ send_email = bool(payload.get("send_email"))
+ delivery_message = _normalize_optional_text(payload.get("message"))
+ try:
+ invite = update_signup_invite(
+ invite_id,
+ code=code,
+ label=_normalize_optional_text(payload.get("label")),
+ description=_normalize_optional_text(payload.get("description")),
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
+ if not invite:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ logger.info(
+ "Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
+ invite.get("id"),
+ invite.get("code"),
+ invite.get("label"),
+ invite.get("profile_id"),
+ invite.get("role"),
+ invite.get("max_uses"),
+ invite.get("enabled"),
+ invite.get("recipient_email"),
+ send_email,
+ )
+ return {
+ "status": "partial" if email_error else "ok",
+ "invite": invite,
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.delete("/invites/{invite_id}")
+async def remove_invite(invite_id: int) -> Dict[str, Any]:
+ deleted = delete_signup_invite(invite_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ logger.warning("Admin deleted invite: invite_id=%s", invite_id)
+ return {"status": "ok", "deleted": True, "invite_id": invite_id}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..018636e
--- /dev/null
+++ b/backend/app/routers/auth.py
@@ -0,0 +1,1449 @@
+from datetime import datetime, timedelta, timezone
+from collections import defaultdict, deque
+import logging
+import secrets
+import string
+import time
+from threading import Lock
+
+import httpx
+from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
+from fastapi.security import OAuth2PasswordRequestForm
+
+from ..db import (
+ verify_user_password,
+ create_user,
+ create_user_if_missing,
+ set_last_login,
+ get_user_by_username,
+ get_users_by_username_ci,
+ set_user_password,
+ set_user_jellyseerr_id,
+ set_user_email,
+ set_user_auth_provider,
+ get_signup_invite_by_code,
+ get_signup_invite_by_id,
+ list_signup_invites,
+ create_signup_invite,
+ update_signup_invite,
+ delete_signup_invite,
+ increment_signup_invite_use,
+ get_user_profile,
+ get_user_activity,
+ get_user_activity_summary,
+ get_user_request_stats,
+ get_global_request_leader,
+ get_global_request_total,
+ get_setting,
+ sync_jellyfin_password_state,
+)
+from ..runtime import get_runtime_settings
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..security import (
+ PASSWORD_POLICY_MESSAGE,
+ create_access_token,
+ validate_password_policy,
+ verify_password,
+)
+from ..security import create_stream_token
+from ..auth import (
+ clear_auth_cookies,
+ get_current_user,
+ normalize_user_auth_provider,
+ resolve_user_auth_provider,
+ set_auth_cookies,
+)
+from ..config import settings
+from ..network_security import request_trusts_forwarded_headers
+from ..services.user_cache import (
+ build_jellyseerr_candidate_map,
+ extract_jellyseerr_user_email,
+ find_matching_jellyseerr_user,
+ get_cached_jellyseerr_users,
+ match_jellyseerr_user_id,
+ save_jellyfin_users_cache,
+)
+from ..services.invite_email import (
+ normalize_delivery_email,
+ send_templated_email,
+ smtp_email_config_ready,
+)
+from ..services.password_reset import (
+ PasswordResetUnavailableError,
+ apply_password_reset,
+ request_password_reset,
+ verify_password_reset_token,
+)
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+logger = logging.getLogger(__name__)
+SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
+STREAM_TOKEN_TTL_SECONDS = 120
+PASSWORD_RESET_GENERIC_MESSAGE = (
+ "If an account exists for that username or email, a password reset link has been sent."
+)
+
+_LOGIN_RATE_LOCK = Lock()
+_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
+_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
+_RESET_RATE_LOCK = Lock()
+_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
+_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
+
+
+def _require_recipient_email(value: object) -> str:
+ normalized = normalize_delivery_email(value)
+ if normalized:
+ return normalized
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="recipient_email is required and must be a valid email address.",
+ )
+
+
+def _auth_client_ip(request: Request) -> str:
+ direct_host = request.client.host if request.client else None
+ if request_trusts_forwarded_headers(direct_host):
+ forwarded = request.headers.get("x-forwarded-for")
+ if isinstance(forwarded, str) and forwarded.strip():
+ return forwarded.split(",", 1)[0].strip()
+ real = request.headers.get("x-real-ip")
+ if isinstance(real, str) and real.strip():
+ return real.strip()
+ if request.client and request.client.host:
+ return str(request.client.host)
+ return "unknown"
+
+
+def _login_rate_key_user(username: str) -> str:
+ return (username or "").strip().lower()[:256] or ""
+
+
+def _password_reset_rate_key_identifier(identifier: str) -> str:
+ return (identifier or "").strip().lower()[:256] or ""
+
+
+def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
+ cutoff = now - window_seconds
+ while bucket and bucket[0] < cutoff:
+ bucket.popleft()
+
+
+def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
+ if not users:
+ return None
+ requested = (requested_username or "").strip()
+ requested_lower = requested.lower()
+
+ def _rank(user: dict) -> tuple[int, int, int, int]:
+ provider = str(user.get("auth_provider") or "local").strip().lower()
+ role = str(user.get("role") or "user").strip().lower()
+ username = str(user.get("username") or "")
+ return (
+ 0 if role == "admin" else 1,
+ 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
+ 0 if provider == "jellyfin" else (1 if provider == "local" else (2 if provider == "jellyseerr" else 3)),
+ 0 if username.lower() == requested_lower else 1,
+ )
+
+ return sorted(users, key=_rank)[0]
+
+
+def _record_login_failure(request: Request, username: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ with _LOGIN_RATE_LOCK:
+ ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
+ user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(user_bucket, now, window)
+ ip_bucket.append(now)
+ user_bucket.append(now)
+ logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
+
+
+def _clear_login_failures(request: Request, username: str) -> None:
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ with _LOGIN_RATE_LOCK:
+ _LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
+ _LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
+
+
+def _enforce_login_rate_limit(request: Request, username: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
+ max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
+ max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ with _LOGIN_RATE_LOCK:
+ ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
+ user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(user_bucket, now, window)
+ exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
+ retry_after = 1
+ if exceeded:
+ retry_candidates = []
+ if ip_bucket:
+ retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
+ if user_bucket:
+ retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
+ if retry_candidates:
+ retry_after = max(retry_candidates)
+ if exceeded:
+ logger.warning(
+ "login rate limit exceeded username=%s client=%s retry_after=%s",
+ user_key,
+ ip_key,
+ retry_after,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail="Too many login attempts. Try again shortly.",
+ headers={"Retry-After": str(retry_after)},
+ )
+
+
+def _record_password_reset_attempt(request: Request, identifier: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
+ ip_key = _auth_client_ip(request)
+ identifier_key = _password_reset_rate_key_identifier(identifier)
+ with _RESET_RATE_LOCK:
+ ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
+ identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(identifier_bucket, now, window)
+ ip_bucket.append(now)
+ identifier_bucket.append(now)
+ logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
+
+
+def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
+ max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
+ max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
+ ip_key = _auth_client_ip(request)
+ identifier_key = _password_reset_rate_key_identifier(identifier)
+ with _RESET_RATE_LOCK:
+ ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
+ identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(identifier_bucket, now, window)
+ exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
+ retry_after = 1
+ if exceeded:
+ retry_candidates = []
+ if ip_bucket:
+ retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
+ if identifier_bucket:
+ retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
+ if retry_candidates:
+ retry_after = max(retry_candidates)
+ if exceeded:
+ logger.warning(
+ "password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
+ identifier_key,
+ ip_key,
+ retry_after,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail="Too many password reset attempts. Try again shortly.",
+ headers={"Retry-After": str(retry_after)},
+ )
+
+
+def _normalize_username(value: str) -> str:
+ normalized = value.strip().lower()
+ if "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
+ return normalized
+
+
+def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
+ if not last_auth_at:
+ return False
+ try:
+ parsed = datetime.fromisoformat(last_auth_at)
+ except ValueError:
+ return False
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ age = datetime.now(timezone.utc) - parsed
+ return age <= timedelta(days=7)
+
+
+def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
+ if not user or not password:
+ return False
+ cached_hash = user.get("jellyfin_password_hash")
+ last_auth_at = user.get("last_jellyfin_auth_at")
+ if not cached_hash or not last_auth_at:
+ return False
+ if not verify_password(password, cached_hash):
+ return False
+ return _is_recent_jellyfin_auth(last_auth_at)
+
+def _extract_jellyseerr_user_id(response: dict) -> int | None:
+ if not isinstance(response, dict):
+ return None
+ candidate = response
+ if isinstance(response.get("user"), dict):
+ candidate = response.get("user")
+ for key in ("id", "userId", "Id"):
+ value = candidate.get(key) if isinstance(candidate, dict) else None
+ if value is None:
+ continue
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def _extract_jellyseerr_response_email(response: dict) -> str | None:
+ if not isinstance(response, dict):
+ return None
+ user_payload = response.get("user") if isinstance(response.get("user"), dict) else response
+ return extract_jellyseerr_user_email(user_payload)
+
+
+def _extract_http_error_detail(exc: Exception) -> str:
+ if isinstance(exc, httpx.HTTPStatusError):
+ response = exc.response
+ try:
+ text = response.text.strip()
+ except Exception:
+ text = ""
+ if text:
+ return text
+ return f"HTTP {response.status_code}"
+ return str(exc)
+
+
+def _requested_user_agent(request: Request) -> str:
+ user_agent = request.headers.get("user-agent", "")
+ return user_agent[:512]
+
+
+async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
+ try:
+ users = await client.get_users()
+ if isinstance(users, list):
+ save_jellyfin_users_cache(users)
+ except Exception:
+ # Cache refresh is best-effort and should not block auth/signup.
+ return
+
+
+def _is_user_expired(user: dict | None) -> bool:
+ if not user:
+ return False
+ expires_at = user.get("expires_at")
+ if not expires_at:
+ return False
+ try:
+ parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
+ except ValueError:
+ return False
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed <= datetime.now(timezone.utc)
+
+
+def _assert_user_can_login(user: dict | None) -> None:
+ if not user:
+ return
+ if user.get("is_blocked"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
+ if _is_user_expired(user):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
+
+
+def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
+ set_auth_cookies(response, token)
+ return {
+ "authenticated": True,
+ "token_type": "cookie",
+ "user": user_payload,
+ }
+
+
+def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
+ return {
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "description": invite.get("description"),
+ "enabled": bool(invite.get("enabled")),
+ "expires_at": invite.get("expires_at"),
+ "max_uses": invite.get("max_uses"),
+ "use_count": invite.get("use_count", 0),
+ "remaining_uses": invite.get("remaining_uses"),
+ "is_expired": bool(invite.get("is_expired")),
+ "is_usable": bool(invite.get("is_usable")),
+ "profile": (
+ {
+ "id": profile.get("id"),
+ "name": profile.get("name"),
+ "description": profile.get("description"),
+ }
+ if profile
+ else None
+ ),
+ }
+
+
+def _parse_optional_positive_int(value: object, field_name: str) -> int | None:
+ if value is None or value == "":
+ return None
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc
+ if parsed <= 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"{field_name} must be greater than 0",
+ )
+ return parsed
+
+
+def _parse_optional_expires_at(value: object) -> str | None:
+ if value is None or value == "":
+ return None
+ if not isinstance(value, str):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="expires_at must be an ISO datetime string",
+ )
+ candidate = value.strip()
+ if not candidate:
+ return None
+ try:
+ parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="expires_at must be a valid ISO datetime",
+ ) from exc
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.isoformat()
+
+
+def _normalize_invite_code(value: str | None) -> str:
+ raw = (value or "").strip().upper()
+ filtered = "".join(ch for ch in raw if ch.isalnum())
+ if len(filtered) < 6:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invite code must be at least 6 letters/numbers.",
+ )
+ return filtered
+
+
+def _generate_invite_code(length: int = 12) -> str:
+ alphabet = string.ascii_uppercase + string.digits
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+def _same_username(a: object, b: object) -> bool:
+ if not isinstance(a, str) or not isinstance(b, str):
+ return False
+ return a.strip().lower() == b.strip().lower()
+
+
+def _serialize_self_invite(invite: dict) -> dict:
+ profile = None
+ profile_id = invite.get("profile_id")
+ if profile_id is not None:
+ try:
+ profile = get_user_profile(int(profile_id))
+ except Exception:
+ profile = None
+ return {
+ "id": invite.get("id"),
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "description": invite.get("description"),
+ "profile_id": invite.get("profile_id"),
+ "profile": (
+ {"id": profile.get("id"), "name": profile.get("name")}
+ if isinstance(profile, dict)
+ else None
+ ),
+ "role": invite.get("role"),
+ "max_uses": invite.get("max_uses"),
+ "use_count": invite.get("use_count", 0),
+ "remaining_uses": invite.get("remaining_uses"),
+ "enabled": bool(invite.get("enabled")),
+ "expires_at": invite.get("expires_at"),
+ "recipient_email": invite.get("recipient_email"),
+ "is_expired": bool(invite.get("is_expired")),
+ "is_usable": bool(invite.get("is_usable")),
+ "created_at": invite.get("created_at"),
+ "updated_at": invite.get("updated_at"),
+ "created_by": invite.get("created_by"),
+ }
+
+
+def _current_user_invites(username: str) -> list[dict]:
+ owned = [
+ invite
+ for invite in list_signup_invites()
+ if _same_username(invite.get("created_by"), username)
+ ]
+ owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True)
+ return owned
+
+
+def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
+ invite = get_signup_invite_by_id(invite_id)
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ if not _same_username(invite.get("created_by"), current_user.get("username")):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites")
+ return invite
+
+
+def _self_service_invite_access_enabled(current_user: dict) -> bool:
+ if str(current_user.get("role") or "").lower() == "admin":
+ return True
+ return bool(current_user.get("invite_management_enabled", False))
+
+
+def _require_self_service_invite_access(current_user: dict) -> None:
+ if _self_service_invite_access_enabled(current_user):
+ return
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Invite management is not enabled for your account.",
+ )
+
+
+def _get_self_service_master_invite() -> dict | None:
+ raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
+ if raw_value is None:
+ return None
+ candidate = str(raw_value).strip()
+ if not candidate:
+ return None
+ try:
+ invite_id = int(candidate)
+ except (TypeError, ValueError):
+ return None
+ if invite_id <= 0:
+ return None
+ return get_signup_invite_by_id(invite_id)
+
+
+def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
+ if not isinstance(invite, dict):
+ return None
+ profile = None
+ profile_id = invite.get("profile_id")
+ if isinstance(profile_id, int):
+ profile = get_user_profile(profile_id)
+ return {
+ "id": invite.get("id"),
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "description": invite.get("description"),
+ "profile_id": invite.get("profile_id"),
+ "recipient_email": invite.get("recipient_email"),
+ "profile": (
+ {"id": profile.get("id"), "name": profile.get("name")}
+ if isinstance(profile, dict)
+ else None
+ ),
+ "role": invite.get("role"),
+ "max_uses": invite.get("max_uses"),
+ "enabled": bool(invite.get("enabled")),
+ "expires_at": invite.get("expires_at"),
+ "is_expired": bool(invite.get("is_expired")),
+ "is_usable": bool(invite.get("is_usable")),
+ "created_at": invite.get("created_at"),
+ "updated_at": invite.get("updated_at"),
+ }
+
+
+def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]:
+ profile_id_raw = master_invite.get("profile_id")
+ profile_id: int | None = None
+ if isinstance(profile_id_raw, int):
+ profile_id = profile_id_raw
+ elif profile_id_raw not in (None, ""):
+ try:
+ profile_id = int(profile_id_raw)
+ except (TypeError, ValueError):
+ profile_id = None
+ role_value = str(master_invite.get("role") or "").strip().lower()
+ role = role_value if role_value in {"user", "admin"} else "user"
+ max_uses_raw = master_invite.get("max_uses")
+ try:
+ max_uses = int(max_uses_raw) if max_uses_raw is not None else None
+ except (TypeError, ValueError):
+ max_uses = None
+ enabled = bool(master_invite.get("enabled", True))
+ expires_at_value = master_invite.get("expires_at")
+ expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None
+ return profile_id, role, max_uses, enabled, expires_at
+
+
+@router.post("/login")
+async def login(
+ request: Request,
+ response: Response,
+ form_data: OAuth2PasswordRequestForm = Depends(),
+) -> dict:
+ _enforce_login_rate_limit(request, form_data.username)
+ logger.info(
+ "login attempt provider=local username=%s client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ # Provider placeholder passwords must never be accepted by the local-login endpoint.
+ if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
+ _record_login_failure(request, form_data.username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ matching_users = get_users_by_username_ci(form_data.username)
+ has_external_match = any(
+ str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
+ )
+ if has_external_match:
+ logger.warning(
+ "login rejected provider=local username=%s reason=external-account client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This account uses external sign-in. Use the external sign-in option.",
+ )
+ user = verify_user_password(form_data.username, form_data.password)
+ if not user:
+ _record_login_failure(request, form_data.username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ if user.get("auth_provider") != "local":
+ logger.warning(
+ "login rejected provider=local username=%s reason=wrong-provider client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This account uses external sign-in. Use the external sign-in option.",
+ )
+ _assert_user_can_login(user)
+ token = create_access_token(user["username"], user["role"])
+ _clear_login_failures(request, form_data.username)
+ set_last_login(user["username"])
+ logger.info(
+ "login success provider=local username=%s role=%s client=%s",
+ user["username"],
+ user["role"],
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": user["username"], "role": user["role"]},
+ )
+
+
+@router.post("/jellyfin/login")
+async def jellyfin_login(
+ request: Request,
+ response: Response,
+ form_data: OAuth2PasswordRequestForm = Depends(),
+) -> dict:
+ _enforce_login_rate_limit(request, form_data.username)
+ logger.info(
+ "login attempt provider=jellyfin username=%s client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
+ jellyseerr_users = get_cached_jellyseerr_users()
+ candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
+ username = form_data.username
+ password = form_data.password
+ ci_matches = get_users_by_username_ci(username)
+ preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
+ canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
+ user = preferred_match or get_user_by_username(username)
+ matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or [])
+ matched_email = extract_jellyseerr_user_email(matched_seerr_user)
+ _assert_user_can_login(user)
+ if user and _has_valid_jellyfin_cache(user, password):
+ token = create_access_token(canonical_username, "user")
+ _clear_login_failures(request, username)
+ set_last_login(canonical_username)
+ logger.info(
+ "login success provider=jellyfin username=%s source=cache client=%s",
+ canonical_username,
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": canonical_username, "role": "user"},
+ )
+ try:
+ auth_response = await client.authenticate_by_name(username, password)
+ except Exception as exc:
+ logger.exception(
+ "login upstream error provider=jellyfin username=%s client=%s",
+ _login_rate_key_user(username),
+ _auth_client_ip(request),
+ )
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
+ if not isinstance(auth_response, dict) or not auth_response.get("User"):
+ _record_login_failure(request, username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
+ if not preferred_match:
+ create_user_if_missing(
+ canonical_username,
+ "jellyfin-user",
+ role="user",
+ email=matched_email,
+ auth_provider="jellyfin",
+ )
+ elif (
+ user
+ and str(user.get("role") or "user").strip().lower() != "admin"
+ and str(user.get("auth_provider") or "local").strip().lower() != "jellyfin"
+ ):
+ set_user_auth_provider(canonical_username, "jellyfin")
+ user = get_user_by_username(canonical_username)
+ if matched_email:
+ set_user_email(canonical_username, matched_email)
+ user = get_user_by_username(canonical_username)
+ _assert_user_can_login(user)
+ try:
+ users = await client.get_users()
+ if isinstance(users, list):
+ save_jellyfin_users_cache(users)
+ except Exception:
+ pass
+ sync_jellyfin_password_state(canonical_username, password)
+ if user and user.get("jellyseerr_user_id") is None and candidate_map:
+ matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
+ if matched_id is not None:
+ set_user_jellyseerr_id(canonical_username, matched_id)
+ token = create_access_token(canonical_username, "user")
+ _clear_login_failures(request, username)
+ set_last_login(canonical_username)
+ logger.info(
+ "login success provider=jellyfin username=%s linked_seerr_id=%s client=%s",
+ canonical_username,
+ get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": canonical_username, "role": "user"},
+ )
+
+
+@router.post("/seerr/login")
+@router.post("/jellyseerr/login")
+async def jellyseerr_login(
+ request: Request,
+ response: Response,
+ form_data: OAuth2PasswordRequestForm = Depends(),
+) -> dict:
+ _enforce_login_rate_limit(request, form_data.username)
+ logger.info(
+ "login attempt provider=seerr username=%s client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
+ try:
+ auth_response = await client.login_local(form_data.username, form_data.password)
+ except Exception as exc:
+ logger.exception(
+ "login upstream error provider=seerr username=%s client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
+ if not isinstance(auth_response, dict):
+ _record_login_failure(request, form_data.username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
+ jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
+ jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
+ ci_matches = get_users_by_username_ci(form_data.username)
+ preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
+ canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
+ if not preferred_match:
+ create_user_if_missing(
+ canonical_username,
+ "jellyseerr-user",
+ role="user",
+ email=jellyseerr_email,
+ auth_provider="jellyseerr",
+ jellyseerr_user_id=jellyseerr_user_id,
+ )
+ elif (
+ preferred_match
+ and str(preferred_match.get("role") or "user").strip().lower() != "admin"
+ and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"}
+ ):
+ set_user_auth_provider(canonical_username, "jellyseerr")
+ user = get_user_by_username(canonical_username)
+ _assert_user_can_login(user)
+ if jellyseerr_user_id is not None:
+ set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
+ if jellyseerr_email:
+ set_user_email(canonical_username, jellyseerr_email)
+ token = create_access_token(canonical_username, "user")
+ _clear_login_failures(request, form_data.username)
+ set_last_login(canonical_username)
+ logger.info(
+ "login success provider=seerr username=%s seerr_user_id=%s client=%s",
+ canonical_username,
+ jellyseerr_user_id,
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": canonical_username, "role": "user"},
+ )
+
+
+@router.get("/me")
+async def me(current_user: dict = Depends(get_current_user)) -> dict:
+ return current_user
+
+
+@router.post("/logout")
+async def logout(response: Response) -> dict:
+ clear_auth_cookies(response)
+ return {"status": "ok"}
+
+
+@router.get("/stream-token")
+async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
+ token = create_stream_token(
+ current_user["username"],
+ current_user["role"],
+ expires_seconds=STREAM_TOKEN_TTL_SECONDS,
+ )
+ return {
+ "stream_token": token,
+ "token_type": "bearer",
+ "expires_in": STREAM_TOKEN_TTL_SECONDS,
+ }
+
+
+@router.get("/invites/{code}")
+async def invite_details(code: str) -> dict:
+ invite = get_signup_invite_by_code(code.strip())
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ profile = None
+ profile_id = invite.get("profile_id")
+ if profile_id is not None:
+ profile = get_user_profile(int(profile_id))
+ if profile and not profile.get("is_active", True):
+ invite = {**invite, "is_usable": False}
+ return {"invite": _public_invite_payload(invite, profile)}
+
+
+@router.post("/signup")
+async def signup(payload: dict, response: Response) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ invite_code = str(payload.get("invite_code") or "").strip()
+ username = str(payload.get("username") or "").strip()
+ password = str(payload.get("password") or "")
+ if not invite_code:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
+ try:
+ password_value = validate_password_policy(password)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ if get_user_by_username(username):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
+ logger.info(
+ "signup attempt username=%s invite_code=%s",
+ username,
+ invite_code,
+ )
+
+ invite = get_signup_invite_by_code(invite_code)
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ if not invite.get("enabled"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
+ if invite.get("is_expired"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
+ remaining_uses = invite.get("remaining_uses")
+ if remaining_uses is not None and int(remaining_uses) <= 0:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
+
+ profile = None
+ profile_id = invite.get("profile_id")
+ if profile_id is not None:
+ profile = get_user_profile(int(profile_id))
+ if not profile:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
+ if not profile.get("is_active", True):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
+
+ invite_role = invite.get("role")
+ profile_role = profile.get("role") if profile else None
+ role = invite_role if invite_role in {"user", "admin"} else profile_role
+ if role not in {"user", "admin"}:
+ role = "user"
+
+ auto_search_enabled = (
+ bool(profile.get("auto_search_enabled", True))
+ if profile is not None
+ else True
+ )
+
+ expires_at = None
+ account_expires_days = profile.get("account_expires_days") if profile else None
+ if isinstance(account_expires_days, int) and account_expires_days > 0:
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
+
+ runtime = get_runtime_settings()
+ auth_provider = "local"
+ local_password_value = password_value
+ matched_jellyseerr_user_id: int | None = None
+
+ jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if jellyfin_client.configured():
+ logger.info("signup provisioning jellyfin username=%s", username)
+ auth_provider = "jellyfin"
+ local_password_value = password_value
+ try:
+ await jellyfin_client.create_user_with_password(username, password_value)
+ except httpx.HTTPStatusError as exc:
+ status_code = exc.response.status_code if exc.response is not None else None
+ duplicate_like = status_code in {400, 409}
+ if duplicate_like:
+ try:
+ auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
+ except Exception as auth_exc:
+ detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
+ ) from exc
+ if not isinstance(auth_response, dict) or not auth_response.get("User"):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Jellyfin account already exists for that username.",
+ ) from exc
+ else:
+ detail = _extract_http_error_detail(exc)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin account provisioning failed: {detail}",
+ ) from exc
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin account provisioning failed: {detail}",
+ ) from exc
+
+ await _refresh_jellyfin_user_cache(jellyfin_client)
+ jellyseerr_users = get_cached_jellyseerr_users()
+ candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
+ if candidate_map:
+ matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
+
+ try:
+ create_user(
+ username,
+ local_password_value,
+ role=role,
+ email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
+ auth_provider=auth_provider,
+ jellyseerr_user_id=matched_jellyseerr_user_id,
+ auto_search_enabled=auto_search_enabled,
+ profile_id=int(profile_id) if profile_id is not None else None,
+ expires_at=expires_at,
+ invited_by_code=invite.get("code"),
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+ increment_signup_invite_use(int(invite["id"]))
+ created_user = get_user_by_username(username)
+ if auth_provider == "jellyfin":
+ sync_jellyfin_password_state(username, password_value)
+ if (
+ created_user
+ and created_user.get("jellyseerr_user_id") is None
+ and matched_jellyseerr_user_id is not None
+ ):
+ set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
+ created_user = get_user_by_username(username)
+ if created_user:
+ try:
+ await send_templated_email(
+ "welcome",
+ invite=invite,
+ user=created_user,
+ )
+ except Exception as exc:
+ # Welcome email delivery is best-effort and must not break signup.
+ logger.warning("Welcome email send skipped for %s: %s", username, exc)
+ _assert_user_can_login(created_user)
+ token = create_access_token(username, role)
+ set_last_login(username)
+ logger.info(
+ "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
+ username,
+ role,
+ created_user.get("auth_provider") if created_user else auth_provider,
+ created_user.get("profile_id") if created_user else None,
+ invite.get("code"),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {
+ "username": username,
+ "role": role,
+ "auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
+ "profile_id": created_user.get("profile_id") if created_user else None,
+ "expires_at": created_user.get("expires_at") if created_user else None,
+ },
+ )
+
+
+@router.post("/password/forgot")
+async def forgot_password(payload: dict, request: Request) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
+ if not isinstance(identifier, str) or not identifier.strip():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
+ _enforce_password_reset_rate_limit(request, identifier)
+ _record_password_reset_attempt(request, identifier)
+
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=f"Password reset email is unavailable: {detail}",
+ )
+
+ client_ip = _auth_client_ip(request)
+ safe_identifier = identifier.strip().lower()[:256]
+ logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
+ try:
+ reset_result = await request_password_reset(
+ identifier,
+ requested_by_ip=client_ip,
+ requested_user_agent=_requested_user_agent(request),
+ )
+ if reset_result.get("issued"):
+ logger.info(
+ "password reset issued username=%s provider=%s recipient=%s client=%s",
+ reset_result.get("username"),
+ reset_result.get("auth_provider"),
+ reset_result.get("recipient_email"),
+ client_ip,
+ )
+ else:
+ logger.info(
+ "password reset request completed with no eligible account identifier=%s client=%s",
+ safe_identifier,
+ client_ip,
+ )
+ except Exception as exc:
+ logger.warning(
+ "password reset email dispatch failed identifier=%s client=%s detail=%s",
+ safe_identifier,
+ client_ip,
+ str(exc),
+ )
+ return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
+
+
+@router.get("/password/reset/verify")
+async def password_reset_verify(token: str) -> dict:
+ if not isinstance(token, str) or not token.strip():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
+ try:
+ return verify_password_reset_token(token.strip())
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+
+@router.post("/password/reset")
+async def password_reset(payload: dict) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ token = payload.get("token")
+ new_password = payload.get("new_password")
+ if not isinstance(token, str) or not token.strip():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
+ if not isinstance(new_password, str):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE)
+ try:
+ new_password_clean = validate_password_policy(new_password)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+ try:
+ result = await apply_password_reset(token.strip(), new_password_clean)
+ except PasswordResetUnavailableError as exc:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ logger.warning("password reset failed token_present=%s detail=%s", bool(token), detail)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Password reset failed: {detail}",
+ ) from exc
+
+ logger.info(
+ "password reset completed username=%s provider=%s",
+ result.get("username"),
+ result.get("provider"),
+ )
+ return result
+
+
+@router.get("/profile")
+async def profile(current_user: dict = Depends(get_current_user)) -> dict:
+ username = current_user.get("username") or ""
+ username_norm = _normalize_username(username) if username else ""
+ stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id"))
+ global_total = get_global_request_total()
+ share = (stats.get("total", 0) / global_total) if global_total else 0
+ activity_summary = get_user_activity_summary(username) if username else {}
+ activity_recent = get_user_activity(username, limit=5) if username else []
+ stats_payload = {
+ **stats,
+ "share": share,
+ "global_total": global_total,
+ }
+ if current_user.get("role") == "admin":
+ stats_payload["most_active_user"] = get_global_request_leader()
+ return {
+ "user": current_user,
+ "stats": stats_payload,
+ "activity": {
+ **activity_summary,
+ "recent": activity_recent,
+ },
+ }
+
+
+@router.get("/profile/invites")
+async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+ master_invite = _get_self_service_master_invite()
+ invite_access_enabled = _self_service_invite_access_enabled(current_user)
+ invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
+ return {
+ "invites": invites,
+ "count": len(invites),
+ "invite_access": {
+ "enabled": invite_access_enabled,
+ "managed_by_master": bool(master_invite),
+ },
+ "master_invite": _serialize_self_service_master_invite(master_invite),
+ }
+
+
+@router.post("/profile/invites")
+async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ _require_self_service_invite_access(current_user)
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+
+ requested_code = payload.get("code")
+ if isinstance(requested_code, str) and requested_code.strip():
+ code = _normalize_invite_code(requested_code)
+ existing = get_signup_invite_by_code(code)
+ if existing:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
+ else:
+ code = ""
+ for _ in range(20):
+ candidate = _generate_invite_code()
+ if not get_signup_invite_by_code(candidate):
+ code = candidate
+ break
+ if not code:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code")
+
+ label = payload.get("label")
+ description = payload.get("description")
+ recipient_email = payload.get("recipient_email")
+ if label is not None:
+ label = str(label).strip() or None
+ if description is not None:
+ description = str(description).strip() or None
+ recipient_email = _require_recipient_email(recipient_email)
+ send_email = bool(payload.get("send_email"))
+ delivery_message = str(payload.get("message") or "").strip() or None
+
+ master_invite = _get_self_service_master_invite()
+ if master_invite:
+ if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).",
+ )
+ profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
+ if profile_id is not None and not get_user_profile(profile_id):
+ profile_id = None
+ role = "user"
+ else:
+ max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ enabled = bool(payload.get("enabled", True))
+ profile_id = current_user.get("profile_id")
+ if not isinstance(profile_id, int) or profile_id <= 0:
+ profile_id = None
+ role = "user"
+
+ invite = create_signup_invite(
+ code=code,
+ label=label,
+ description=description,
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ created_by=username,
+ )
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ user=current_user,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ status_value = "partial" if email_error else "ok"
+ return {
+ "status": status_value,
+ "invite": _serialize_self_invite(invite),
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.put("/profile/invites/{invite_id}")
+async def update_profile_invite(
+ invite_id: int, payload: dict, current_user: dict = Depends(get_current_user)
+) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ _require_self_service_invite_access(current_user)
+ existing = _get_owned_invite(invite_id, current_user)
+
+ requested_code = payload.get("code", existing.get("code"))
+ if isinstance(requested_code, str) and requested_code.strip():
+ code = _normalize_invite_code(requested_code)
+ else:
+ code = str(existing.get("code") or "").strip()
+ if not code:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
+ duplicate = get_signup_invite_by_code(code)
+ if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
+
+ label = payload.get("label", existing.get("label"))
+ description = payload.get("description", existing.get("description"))
+ recipient_email = payload.get("recipient_email", existing.get("recipient_email"))
+ if label is not None:
+ label = str(label).strip() or None
+ if description is not None:
+ description = str(description).strip() or None
+ recipient_email = _require_recipient_email(recipient_email)
+ send_email = bool(payload.get("send_email"))
+ delivery_message = str(payload.get("message") or "").strip() or None
+
+ master_invite = _get_self_service_master_invite()
+ if master_invite:
+ profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
+ if profile_id is not None and not get_user_profile(profile_id):
+ profile_id = None
+ role = "user"
+ else:
+ max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
+ enabled_raw = payload.get("enabled", existing.get("enabled"))
+ enabled = bool(enabled_raw)
+ profile_id = existing.get("profile_id")
+ role = existing.get("role")
+
+ invite = update_signup_invite(
+ invite_id,
+ code=code,
+ label=label,
+ description=description,
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ )
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ user=current_user,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ status_value = "partial" if email_error else "ok"
+ return {
+ "status": status_value,
+ "invite": _serialize_self_invite(invite),
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.delete("/profile/invites/{invite_id}")
+async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
+ _require_self_service_invite_access(current_user)
+ _get_owned_invite(invite_id, current_user)
+ deleted = delete_signup_invite(invite_id)
+ if not deleted:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ return {"status": "ok"}
+
+
+@router.post("/password")
+async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
+ current_password = payload.get("current_password") if isinstance(payload, dict) else None
+ new_password = payload.get("new_password") if isinstance(payload, dict) else None
+ if not isinstance(current_password, str) or not isinstance(new_password, str):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ try:
+ new_password_clean = validate_password_policy(new_password)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+ stored_user = normalize_user_auth_provider(get_user_by_username(username))
+ auth_provider = resolve_user_auth_provider(stored_user or current_user)
+ logger.info("password change requested username=%s provider=%s", username, auth_provider)
+
+ if auth_provider == "local":
+ user = verify_user_password(username, current_password)
+ if not user:
+ logger.warning("password change rejected username=%s provider=local reason=invalid-current-password", username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
+ set_user_password(username, new_password_clean)
+ logger.info("password change completed username=%s provider=local", username)
+ return {"status": "ok", "provider": "local"}
+
+ if auth_provider == "jellyfin":
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Jellyfin is not configured for password passthrough.",
+ )
+ try:
+ auth_result = await client.authenticate_by_name(username, current_password)
+ if not isinstance(auth_result, dict) or not auth_result.get("User"):
+ logger.warning("password change rejected username=%s provider=jellyfin reason=invalid-current-password", username)
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ logger.warning("password change validation failed username=%s provider=jellyfin detail=%s", username, detail)
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
+ ) from exc
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin password validation failed: {detail}",
+ ) from exc
+
+ try:
+ jf_user = await client.find_user_by_name(username)
+ user_id = client._extract_user_id(jf_user)
+ if not user_id:
+ raise RuntimeError("Jellyfin user ID not found")
+ await client.set_user_password(user_id, new_password_clean)
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ logger.warning("password change update failed username=%s provider=jellyfin detail=%s", username, detail)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin password update failed: {detail}",
+ ) from exc
+
+ # Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
+ sync_jellyfin_password_state(username, new_password_clean)
+ logger.info("password change completed username=%s provider=jellyfin", username)
+ return {"status": "ok", "provider": "jellyfin"}
+
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Password changes are not available for this sign-in provider.",
+ )
diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py
new file mode 100644
index 0000000..a01ae85
--- /dev/null
+++ b/backend/app/routers/branding.py
@@ -0,0 +1,134 @@
+import os
+from io import BytesIO
+from typing import Any, Dict
+
+from fastapi import APIRouter, HTTPException, UploadFile, File
+from fastapi.responses import FileResponse
+from PIL import Image, ImageDraw, ImageFont
+
+router = APIRouter(prefix="/branding", tags=["branding"])
+
+_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
+_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
+_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
+_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
+_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
+_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
+_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
+
+
+def _ensure_branding_dir() -> None:
+ os.makedirs(_BRANDING_DIR, exist_ok=True)
+
+
+def _resize_image(image: Image.Image, max_size: int = 300) -> Image.Image:
+ image = image.convert("RGBA")
+ image.thumbnail((max_size, max_size))
+ return image
+
+
+def _load_font(size: int) -> ImageFont.ImageFont:
+ candidates = [
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ ]
+ for path in candidates:
+ if os.path.exists(path):
+ try:
+ return ImageFont.truetype(path, size)
+ except OSError:
+ continue
+ return ImageFont.load_default()
+
+
+def _ensure_default_branding() -> None:
+ if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
+ return
+ _ensure_branding_dir()
+ if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
+ try:
+ with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
+ target.write(source.read())
+ except OSError:
+ pass
+ if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
+ try:
+ with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
+ target.write(source.read())
+ except OSError:
+ pass
+ if not os.path.exists(_LOGO_PATH):
+ image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
+ draw = ImageDraw.Draw(image)
+ font = _load_font(160)
+ text = "M"
+ box = draw.textbbox((0, 0), text, font=font)
+ text_w = box[2] - box[0]
+ text_h = box[3] - box[1]
+ draw.text(
+ ((300 - text_w) / 2, (300 - text_h) / 2 - 6),
+ text,
+ font=font,
+ fill=(255, 255, 255, 255),
+ )
+ image.save(_LOGO_PATH, format="PNG")
+ if not os.path.exists(_FAVICON_PATH):
+ favicon = Image.open(_LOGO_PATH).copy()
+ favicon.thumbnail((64, 64))
+ try:
+ favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
+ except OSError:
+ favicon.save(_FAVICON_PATH, format="ICO")
+
+
+def _resolve_branding_paths() -> tuple[str, str]:
+ if _BRANDING_SOURCE == "data":
+ _ensure_default_branding()
+ return _LOGO_PATH, _FAVICON_PATH
+ if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
+ return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
+ _ensure_default_branding()
+ return _LOGO_PATH, _FAVICON_PATH
+
+
+@router.get("/logo.png")
+async def branding_logo() -> FileResponse:
+ logo_path, _ = _resolve_branding_paths()
+ if not os.path.exists(logo_path):
+ raise HTTPException(status_code=404, detail="Logo not found")
+ headers = {"Cache-Control": "no-store"}
+ return FileResponse(logo_path, media_type="image/png", headers=headers)
+
+
+@router.get("/favicon.ico")
+async def branding_favicon() -> FileResponse:
+ _, favicon_path = _resolve_branding_paths()
+ if not os.path.exists(favicon_path):
+ raise HTTPException(status_code=404, detail="Favicon not found")
+ headers = {"Cache-Control": "no-store"}
+ return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
+
+
+async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
+ if not file.content_type or not file.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="Please upload an image file.")
+ content = await file.read()
+ if not content:
+ raise HTTPException(status_code=400, detail="Uploaded file is empty.")
+ try:
+ image = Image.open(BytesIO(content))
+ except OSError as exc:
+ raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
+
+ _ensure_branding_dir()
+ image = _resize_image(image, 300)
+ image.save(_LOGO_PATH, format="PNG")
+
+ favicon = image.copy()
+ favicon.thumbnail((64, 64))
+ try:
+ favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
+ except OSError:
+ favicon.save(_FAVICON_PATH, format="ICO")
+
+ return {"status": "ok", "width": image.width, "height": image.height}
diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py
new file mode 100644
index 0000000..d90a601
--- /dev/null
+++ b/backend/app/routers/events.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+
+from ..auth import get_current_user_event_stream
+from . import requests as requests_router
+from .status import services_status
+
+router = APIRouter(prefix="/events", tags=["events"])
+
+
+def _sse_json(payload: Dict[str, Any]) -> str:
+ return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
+
+
+def _jsonable(value: Any) -> Any:
+ if hasattr(value, "model_dump"):
+ try:
+ return value.model_dump(mode="json")
+ except TypeError:
+ return value.model_dump()
+ if hasattr(value, "dict"):
+ try:
+ return value.dict()
+ except TypeError:
+ return value
+ return value
+
+
+def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
+ if not isinstance(entries, list):
+ return []
+ items: list[dict[str, Any]] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ items.append(
+ {
+ "request_id": entry.get("request_id"),
+ "state": entry.get("state"),
+ "state_reason": entry.get("state_reason"),
+ "created_at": entry.get("created_at"),
+ }
+ )
+ return items
+
+
+def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
+ if not isinstance(entries, list):
+ return []
+ items: list[dict[str, Any]] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ items.append(
+ {
+ "request_id": entry.get("request_id"),
+ "action_id": entry.get("action_id"),
+ "label": entry.get("label"),
+ "status": entry.get("status"),
+ "message": entry.get("message"),
+ "created_at": entry.get("created_at"),
+ }
+ )
+ return items
+
+
+@router.get("/stream")
+async def events_stream(
+ request: Request,
+ recent_days: int = 90,
+ recent_stage: str = "all",
+ user: Dict[str, Any] = Depends(get_current_user_event_stream),
+) -> StreamingResponse:
+ recent_days = max(0, min(int(recent_days or 90), 3650))
+ recent_take = 50 if user.get("role") == "admin" else 6
+
+ async def event_generator():
+ yield "retry: 2000\n\n"
+ last_recent_signature: Optional[str] = None
+ last_services_signature: Optional[str] = None
+ next_recent_at = 0.0
+ next_services_at = 0.0
+ heartbeat_counter = 0
+
+ while True:
+ if await request.is_disconnected():
+ break
+
+ now = time.monotonic()
+ sent_any = False
+
+ if now >= next_recent_at:
+ next_recent_at = now + 15.0
+ try:
+ recent_payload = await requests_router.recent_requests(
+ take=recent_take,
+ skip=0,
+ days=recent_days,
+ stage=recent_stage,
+ user=user,
+ )
+ results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
+ payload = {
+ "type": "home_recent",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "days": recent_days,
+ "stage": recent_stage,
+ "results": results if isinstance(results, list) else [],
+ }
+ except Exception as exc:
+ payload = {
+ "type": "home_recent",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "days": recent_days,
+ "stage": recent_stage,
+ "error": str(exc),
+ }
+ signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+ if signature != last_recent_signature:
+ last_recent_signature = signature
+ yield _sse_json(payload)
+ sent_any = True
+
+ if now >= next_services_at:
+ next_services_at = now + 30.0
+ try:
+ status_payload = await services_status()
+ payload = {
+ "type": "home_services",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "status": status_payload,
+ }
+ except Exception as exc:
+ payload = {
+ "type": "home_services",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ }
+ signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+ if signature != last_services_signature:
+ last_services_signature = signature
+ yield _sse_json(payload)
+ sent_any = True
+
+ if sent_any:
+ heartbeat_counter = 0
+ else:
+ heartbeat_counter += 1
+ if heartbeat_counter >= 15:
+ yield ": ping\n\n"
+ heartbeat_counter = 0
+
+ await asyncio.sleep(1.0)
+
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+
+
+@router.get("/requests/{request_id}/stream")
+async def request_events_stream(
+ request_id: str,
+ request: Request,
+ user: Dict[str, Any] = Depends(get_current_user_event_stream),
+) -> StreamingResponse:
+ request_id = str(request_id).strip()
+ if not request_id:
+ raise HTTPException(status_code=400, detail="Missing request id")
+
+ async def event_generator():
+ yield "retry: 2000\n\n"
+ last_signature: Optional[str] = None
+ next_refresh_at = 0.0
+ heartbeat_counter = 0
+
+ while True:
+ if await request.is_disconnected():
+ break
+
+ now = time.monotonic()
+ sent_any = False
+
+ if now >= next_refresh_at:
+ next_refresh_at = now + 2.0
+ try:
+ snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
+ history_payload = await requests_router.request_history(
+ request_id=request_id, limit=5, user=user
+ )
+ actions_payload = await requests_router.request_actions(
+ request_id=request_id, limit=5, user=user
+ )
+ payload = {
+ "type": "request_live",
+ "request_id": request_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "snapshot": _jsonable(snapshot),
+ "history": _request_history_brief(
+ history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
+ ),
+ "actions": _request_actions_brief(
+ actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
+ ),
+ }
+ except HTTPException as exc:
+ payload = {
+ "type": "request_live",
+ "request_id": request_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc.detail),
+ "status_code": int(exc.status_code),
+ }
+ except Exception as exc:
+ payload = {
+ "type": "request_live",
+ "request_id": request_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ }
+
+ signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+ if signature != last_signature:
+ last_signature = signature
+ yield _sse_json(payload)
+ sent_any = True
+
+ if sent_any:
+ heartbeat_counter = 0
+ else:
+ heartbeat_counter += 1
+ if heartbeat_counter >= 15:
+ yield ": ping\n\n"
+ heartbeat_counter = 0
+
+ await asyncio.sleep(1.0)
+
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
diff --git a/backend/app/routers/feedback.py b/backend/app/routers/feedback.py
new file mode 100644
index 0000000..b42f066
--- /dev/null
+++ b/backend/app/routers/feedback.py
@@ -0,0 +1,46 @@
+from typing import Any, Dict
+import httpx
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..auth import get_current_user
+from ..network_security import validate_notification_target_url
+from ..runtime import get_runtime_settings
+
+router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
+
+
+@router.post("")
+async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ runtime = get_runtime_settings()
+ webhook_url = (
+ getattr(runtime, "magent_notify_discord_webhook_url", None)
+ or runtime.discord_webhook_url
+ )
+ if not webhook_url:
+ raise HTTPException(status_code=400, detail="Discord webhook not configured")
+ try:
+ webhook_url = validate_notification_target_url(webhook_url)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ feedback_type = str(payload.get("type") or "").strip().lower()
+ if feedback_type not in {"bug", "feature"}:
+ raise HTTPException(status_code=400, detail="Invalid feedback type")
+
+ message = str(payload.get("message") or "").strip()
+ if not message:
+ raise HTTPException(status_code=400, detail="Message is required")
+ if len(message) > 2000:
+ raise HTTPException(status_code=400, detail="Message is too long")
+
+ username = user.get("username") or "unknown"
+ content = f"**{feedback_type.title()}** from **{username}**\n{message}"
+
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(webhook_url, json={"content": content})
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ return {"status": "ok"}
diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py
new file mode 100644
index 0000000..f7c149d
--- /dev/null
+++ b/backend/app/routers/images.py
@@ -0,0 +1,100 @@
+import os
+import re
+import mimetypes
+import logging
+from typing import Optional
+from fastapi import APIRouter, HTTPException, Response
+from fastapi.responses import FileResponse, RedirectResponse
+import httpx
+
+from ..runtime import get_runtime_settings
+
+router = APIRouter(prefix="/images", tags=["images"])
+
+_TMDB_BASE = "https://image.tmdb.org/t/p"
+_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
+logger = logging.getLogger(__name__)
+
+
+def _safe_filename(path: str) -> str:
+ trimmed = path.strip("/")
+ trimmed = trimmed.replace("/", "_")
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
+ return safe or "image"
+
+def tmdb_cache_path(path: str, size: str) -> Optional[str]:
+ if not path or "://" in path or ".." in path:
+ return None
+ if not path.startswith("/"):
+ path = f"/{path}"
+ if size not in _ALLOWED_SIZES:
+ return None
+ cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
+ return os.path.join(cache_dir, _safe_filename(path))
+
+
+def is_tmdb_cached(path: str, size: str) -> bool:
+ file_path = tmdb_cache_path(path, size)
+ return bool(file_path and os.path.exists(file_path))
+
+
+async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
+ if not path or "://" in path or ".." in path:
+ return False
+
+ runtime = get_runtime_settings()
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ if cache_mode != "cache":
+ return False
+
+ file_path = tmdb_cache_path(path, size)
+ if not file_path:
+ return False
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+ if os.path.exists(file_path):
+ return True
+
+ url = f"{_TMDB_BASE}/{size}{path}"
+ async with httpx.AsyncClient(timeout=20) as client:
+ response = await client.get(url)
+ response.raise_for_status()
+ content = response.content
+ with open(file_path, "wb") as handle:
+ handle.write(content)
+ return True
+
+
+@router.get("/tmdb")
+async def tmdb_image(path: str, size: str = "w342"):
+ if not path or "://" in path or ".." in path:
+ raise HTTPException(status_code=400, detail="Invalid image path")
+ if not path.startswith("/"):
+ path = f"/{path}"
+ if size not in _ALLOWED_SIZES:
+ raise HTTPException(status_code=400, detail="Invalid size")
+
+ runtime = get_runtime_settings()
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ url = f"{_TMDB_BASE}/{size}{path}"
+ if cache_mode != "cache":
+ return RedirectResponse(url=url)
+
+ file_path = tmdb_cache_path(path, size)
+ if not file_path:
+ raise HTTPException(status_code=400, detail="Invalid image path")
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+ headers = {"Cache-Control": "public, max-age=86400"}
+ if os.path.exists(file_path):
+ media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
+ return FileResponse(file_path, media_type=media_type, headers=headers)
+
+ try:
+ await cache_tmdb_image(path, size)
+ if os.path.exists(file_path):
+ media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
+ return FileResponse(file_path, media_type=media_type, headers=headers)
+ logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
+ except (httpx.HTTPError, OSError) as exc:
+ logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
+
+ return RedirectResponse(url=url)
diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py
new file mode 100644
index 0000000..2b95ff3
--- /dev/null
+++ b/backend/app/routers/portal.py
@@ -0,0 +1,1056 @@
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional, Tuple
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from ..auth import get_current_user
+from ..db import (
+ add_portal_comment,
+ count_portal_items,
+ create_portal_item,
+ get_portal_item,
+ get_portal_overview,
+ list_portal_comments,
+ list_portal_items,
+ update_portal_item,
+)
+from ..services.notifications import send_portal_notification
+
+router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
+logger = logging.getLogger(__name__)
+
+PORTAL_KINDS = {"request", "issue", "feature"}
+PORTAL_STATUSES = {
+ # Existing generic statuses
+ "new",
+ "triaging",
+ "planned",
+ "in_progress",
+ "blocked",
+ "done",
+ "declined",
+ "closed",
+ # Seerr-style request pipeline statuses
+ "pending",
+ "approved",
+ "processing",
+ "partially_available",
+ "available",
+ "failed",
+}
+PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"}
+PORTAL_MEDIA_TYPES = {"movie", "tv"}
+PORTAL_REQUEST_STATUSES = {"pending", "approved", "declined"}
+PORTAL_MEDIA_STATUSES = {
+ "unknown",
+ "pending",
+ "processing",
+ "partially_available",
+ "available",
+ "failed",
+}
+PORTAL_ISSUE_TYPES = {
+ "general",
+ "playback",
+ "subtitle",
+ "quality",
+ "metadata",
+ "missing_content",
+ "other",
+}
+
+REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
+ "pending": {"pending", "approved", "declined"},
+ "approved": {"approved", "declined"},
+ "declined": {"declined", "pending", "approved"},
+}
+
+MEDIA_STATUS_TRANSITIONS: Dict[str, set[str]] = {
+ "unknown": {"unknown", "pending", "processing", "failed"},
+ "pending": {"pending", "processing", "partially_available", "available", "failed"},
+ "processing": {"processing", "partially_available", "available", "failed"},
+ "partially_available": {"partially_available", "processing", "available", "failed"},
+ "available": {"available", "processing"},
+ "failed": {"failed", "processing", "available"},
+}
+
+LEGACY_STATUS_TO_WORKFLOW: Dict[str, Tuple[str, str]] = {
+ "new": ("pending", "pending"),
+ "triaging": ("pending", "pending"),
+ "planned": ("approved", "pending"),
+ "in_progress": ("approved", "processing"),
+ "blocked": ("approved", "failed"),
+ "done": ("approved", "available"),
+ "closed": ("approved", "available"),
+ "pending": ("pending", "pending"),
+ "approved": ("approved", "pending"),
+ "declined": ("declined", "unknown"),
+ "processing": ("approved", "processing"),
+ "partially_available": ("approved", "partially_available"),
+ "available": ("approved", "available"),
+ "failed": ("approved", "failed"),
+}
+
+
+def _clean_text(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else None
+ return str(value)
+
+
+def _require_text(value: Any, field: str, *, max_length: int = 5000) -> str:
+ normalized = _clean_text(value)
+ if not normalized:
+ raise HTTPException(status_code=400, detail=f"{field} is required")
+ if len(normalized) > max_length:
+ raise HTTPException(
+ status_code=400,
+ detail=f"{field} is too long (max {max_length} characters)",
+ )
+ return normalized
+
+
+def _normalize_choice(
+ value: Any,
+ *,
+ field: str,
+ allowed: set[str],
+ default: Optional[str] = None,
+ allow_empty: bool = False,
+) -> Optional[str]:
+ if value is None:
+ return default
+ normalized = _clean_text(value)
+ if not normalized:
+ return None if allow_empty else default
+ candidate = normalized.lower()
+ if candidate not in allowed:
+ allowed_values = ", ".join(sorted(allowed))
+ raise HTTPException(status_code=400, detail=f"Invalid {field}. Allowed: {allowed_values}")
+ return candidate
+
+
+def _normalize_year(value: Any, *, allow_empty: bool = True) -> Optional[int]:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return None if allow_empty else 0
+ value = stripped
+ try:
+ year = int(value)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="year must be an integer") from None
+ if year < 1800 or year > 2100:
+ raise HTTPException(status_code=400, detail="year must be between 1800 and 2100")
+ return year
+
+
+def _normalize_int(value: Any, field: str, *, allow_empty: bool = True) -> Optional[int]:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return None if allow_empty else 0
+ value = stripped
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail=f"{field} must be an integer") from None
+
+
+def _normalize_bool(value: Any, *, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ candidate = value.strip().lower()
+ if candidate in {"1", "true", "yes", "on"}:
+ return True
+ if candidate in {"0", "false", "no", "off"}:
+ return False
+ raise HTTPException(status_code=400, detail="Boolean value expected")
+
+
+def _workflow_to_item_status(request_status: str, media_status: str) -> str:
+ if request_status == "declined":
+ return "declined"
+ if request_status == "pending":
+ return "pending"
+ if media_status == "available":
+ return "available"
+ if media_status == "partially_available":
+ return "partially_available"
+ if media_status == "failed":
+ return "failed"
+ if media_status == "processing":
+ return "processing"
+ return "approved"
+
+
+def _item_status_to_workflow(item: Dict[str, Any]) -> Tuple[str, str]:
+ request_status = _normalize_choice(
+ item.get("workflow_request_status"),
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ allow_empty=True,
+ )
+ media_status = _normalize_choice(
+ item.get("workflow_media_status"),
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ allow_empty=True,
+ )
+ if request_status and media_status:
+ return request_status, media_status
+
+ status = _clean_text(item.get("status"))
+ if status:
+ mapped = LEGACY_STATUS_TO_WORKFLOW.get(status.lower())
+ if mapped:
+ return mapped
+ return "pending", "pending"
+
+
+def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
+ if request_status == "declined":
+ return "Declined"
+ if request_status == "pending":
+ return "Waiting for approval"
+ if media_status == "available":
+ return "Ready to watch"
+ if media_status == "partially_available":
+ return "Partially available"
+ if media_status == "processing":
+ return "Working on it"
+ if media_status == "failed":
+ return "Needs attention"
+ return "Approved"
+
+
+def _normalize_request_pipeline(
+ request_status: Optional[str],
+ media_status: Optional[str],
+ *,
+ fallback_request_status: str = "pending",
+ fallback_media_status: str = "pending",
+) -> Tuple[str, str]:
+ normalized_request = _normalize_choice(
+ request_status,
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ default=fallback_request_status,
+ )
+ normalized_media = _normalize_choice(
+ media_status,
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ default=fallback_media_status,
+ )
+ request_value = normalized_request or fallback_request_status
+ media_value = normalized_media or fallback_media_status
+
+ if request_value == "declined":
+ return request_value, "unknown"
+ if request_value == "pending":
+ if media_value not in {"pending", "unknown"}:
+ media_value = "pending"
+ return request_value, media_value
+ if media_value == "unknown":
+ media_value = "pending"
+ return request_value, media_value
+
+
+def _validate_pipeline_transition(
+ current_request: str,
+ current_media: str,
+ requested_request: str,
+ requested_media: str,
+) -> Tuple[str, str]:
+ allowed_request = REQUEST_STATUS_TRANSITIONS.get(current_request, {current_request})
+ if requested_request not in allowed_request:
+ allowed_text = ", ".join(sorted(allowed_request))
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Invalid request_status transition: {current_request} -> {requested_request}. "
+ f"Allowed: {allowed_text}"
+ ),
+ )
+
+ normalized_request, normalized_media = _normalize_request_pipeline(
+ requested_request,
+ requested_media,
+ fallback_request_status=current_request,
+ fallback_media_status=current_media,
+ )
+ if normalized_request != "approved":
+ return normalized_request, normalized_media
+
+ if current_request != "approved":
+ allowed_media = PORTAL_MEDIA_STATUSES - {"unknown"}
+ else:
+ allowed_media = MEDIA_STATUS_TRANSITIONS.get(current_media, {current_media})
+ if normalized_media not in allowed_media:
+ allowed_text = ", ".join(sorted(allowed_media))
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Invalid media_status transition: {current_media} -> {normalized_media}. "
+ f"Allowed: {allowed_text}"
+ ),
+ )
+ return normalized_request, normalized_media
+
+
+def _ensure_item_exists(item_id: Optional[int], *, field: str = "related_item_id") -> None:
+ if item_id is None:
+ return
+ target = get_portal_item(item_id)
+ if not target:
+ raise HTTPException(status_code=400, detail=f"{field} references an unknown portal item")
+
+
+def _sanitize_metadata_json(value: Any) -> Optional[str]:
+ text = _clean_text(value)
+ if text is None:
+ return None
+ if len(text) > 50000:
+ raise HTTPException(status_code=400, detail="metadata_json is too long (max 50000 characters)")
+ return text
+
+
+def _is_admin(user: Dict[str, Any]) -> bool:
+ return str(user.get("role") or "").strip().lower() == "admin"
+
+
+def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
+ return str(user.get("username") or "") == str(item.get("created_by_username") or "")
+
+
+def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
+ is_admin = _is_admin(user)
+ is_owner = _is_owner(user, item)
+ serialized = dict(item)
+ serialized["permissions"] = {
+ "can_edit": is_admin or is_owner,
+ "can_comment": True,
+ "can_moderate": is_admin,
+ "can_raise_issue": str(item.get("kind") or "") == "request",
+ }
+ kind = str(item.get("kind") or "").strip().lower()
+ if kind == "request":
+ request_status, media_status = _item_status_to_workflow(item)
+ serialized["workflow"] = {
+ "request_status": request_status,
+ "media_status": media_status,
+ "stage_label": _stage_label_for_workflow(request_status, media_status),
+ "is_terminal": media_status in {"available", "failed"} or request_status == "declined",
+ }
+ elif kind == "issue":
+ serialized["issue"] = {
+ "issue_type": _clean_text(item.get("issue_type")) or "general",
+ "related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
+ "is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
+ "resolved_at": _clean_text(item.get("issue_resolved_at")),
+ }
+ return serialized
+
+
+async def _notify(
+ *,
+ event_type: str,
+ item: Dict[str, Any],
+ user: Dict[str, Any],
+ note: Optional[str] = None,
+) -> None:
+ try:
+ result = await send_portal_notification(
+ event_type=event_type,
+ item=item,
+ actor_username=str(user.get("username") or "unknown"),
+ actor_role=str(user.get("role") or "user"),
+ note=note,
+ )
+ logger.info(
+ "portal notification dispatched event=%s item_id=%s status=%s",
+ event_type,
+ item.get("id"),
+ result.get("status"),
+ )
+ except Exception:
+ logger.exception(
+ "portal notification failed event=%s item_id=%s",
+ event_type,
+ item.get("id"),
+ )
+
+
+@router.get("/overview")
+async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ mine = count_portal_items(mine_username=str(current_user.get("username") or ""))
+ return {
+ "overview": get_portal_overview(),
+ "my_items": mine,
+ }
+
+
+@router.get("/items")
+async def portal_list_items(
+ kind: Optional[str] = None,
+ status: Optional[str] = None,
+ request_status: Optional[str] = None,
+ media_status: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ mine: bool = False,
+ search: Optional[str] = None,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ kind_value = _normalize_choice(
+ kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True
+ )
+ status_value = _normalize_choice(
+ status, field="status", allowed=PORTAL_STATUSES, allow_empty=True
+ )
+ request_status_value = _normalize_choice(
+ request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True
+ )
+ media_status_value = _normalize_choice(
+ media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True
+ )
+ source_system_value = _clean_text(source_system)
+ if source_system_value:
+ source_system_value = source_system_value.lower()
+ mine_username = str(current_user.get("username") or "") if mine else None
+ items = list_portal_items(
+ kind=kind_value,
+ status=status_value,
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ source_system=source_system_value,
+ source_request_id=source_request_id,
+ related_item_id=related_item_id,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ limit=limit,
+ offset=offset,
+ )
+ total = count_portal_items(
+ kind=kind_value,
+ status=status_value,
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ source_system=source_system_value,
+ source_request_id=source_request_id,
+ related_item_id=related_item_id,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ )
+ return {
+ "items": [_serialize_item(item, current_user) for item in items],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "has_more": offset + len(items) < total,
+ "filters": {
+ "kind": kind_value,
+ "status": status_value,
+ "request_status": request_status_value,
+ "media_status": media_status_value,
+ "source_system": source_system_value,
+ "source_request_id": source_request_id,
+ "related_item_id": related_item_id,
+ "mine": mine,
+ "search": _clean_text(search),
+ },
+ }
+
+
+@router.get("/requests")
+async def portal_list_requests(
+ request_status: Optional[str] = None,
+ media_status: Optional[str] = None,
+ mine: bool = False,
+ search: Optional[str] = None,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ mine_username = str(current_user.get("username") or "") if mine else None
+ request_status_value = _normalize_choice(
+ request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True
+ )
+ media_status_value = _normalize_choice(
+ media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True
+ )
+ items = list_portal_items(
+ kind="request",
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ limit=limit,
+ offset=offset,
+ )
+ total = count_portal_items(
+ kind="request",
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ )
+ return {
+ "items": [_serialize_item(item, current_user) for item in items],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "has_more": offset + len(items) < total,
+ "filters": {
+ "request_status": request_status_value,
+ "media_status": media_status_value,
+ "mine": mine,
+ "search": _clean_text(search),
+ },
+ }
+
+
+@router.post("/items")
+async def portal_create_item(
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ is_admin = _is_admin(current_user)
+ kind = _normalize_choice(
+ payload.get("kind"),
+ field="kind",
+ allowed=PORTAL_KINDS,
+ default="request",
+ )
+ title = _require_text(payload.get("title"), "title", max_length=220)
+ description = _require_text(payload.get("description"), "description", max_length=10000)
+ media_type = _normalize_choice(
+ payload.get("media_type"),
+ field="media_type",
+ allowed=PORTAL_MEDIA_TYPES,
+ allow_empty=True,
+ )
+ year = _normalize_year(payload.get("year"))
+ external_ref = _clean_text(payload.get("external_ref"))
+ source_system = _clean_text(payload.get("source_system")) if is_admin else None
+ if source_system:
+ source_system = source_system.lower()
+ source_request_id = (
+ _normalize_int(payload.get("source_request_id"), "source_request_id")
+ if is_admin
+ else None
+ )
+ related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id")
+ _ensure_item_exists(related_item_id)
+ workflow_request_status: Optional[str] = None
+ workflow_media_status: Optional[str] = None
+ issue_type: Optional[str] = None
+ issue_resolved_at: Optional[str] = None
+ status: Optional[str] = None
+ if kind == "request":
+ workflow_request_status, workflow_media_status = _normalize_request_pipeline(
+ payload.get("request_status"),
+ payload.get("media_status"),
+ fallback_request_status="pending",
+ fallback_media_status="pending",
+ )
+ status = _workflow_to_item_status(workflow_request_status, workflow_media_status)
+ else:
+ status = _normalize_choice(
+ payload.get("status") if is_admin else None,
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default="new",
+ )
+ if kind == "issue":
+ issue_type = _normalize_choice(
+ payload.get("issue_type"),
+ field="issue_type",
+ allowed=PORTAL_ISSUE_TYPES,
+ default="general",
+ )
+ if related_item_id is not None and not source_system:
+ source_system = "portal_request"
+ source_request_id = related_item_id
+ priority = _normalize_choice(
+ payload.get("priority"),
+ field="priority",
+ allowed=PORTAL_PRIORITIES,
+ default="normal",
+ )
+ assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None
+ metadata_json = _sanitize_metadata_json(payload.get("metadata_json")) if is_admin else None
+
+ created = create_portal_item(
+ kind=kind or "request",
+ title=title,
+ description=description,
+ created_by_username=str(current_user.get("username") or "unknown"),
+ created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"),
+ media_type=media_type,
+ year=year,
+ external_ref=external_ref,
+ source_system=source_system,
+ source_request_id=source_request_id,
+ related_item_id=related_item_id,
+ status=status or "new",
+ workflow_request_status=workflow_request_status,
+ workflow_media_status=workflow_media_status,
+ issue_type=issue_type,
+ issue_resolved_at=issue_resolved_at,
+ metadata_json=metadata_json,
+ priority=priority or "normal",
+ assignee_username=assignee_username,
+ )
+ initial_comment = _clean_text(payload.get("comment"))
+ if initial_comment:
+ add_portal_comment(
+ int(created["id"]),
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "user"),
+ message=initial_comment,
+ is_internal=False,
+ )
+ comments = list_portal_comments(int(created["id"]), include_internal=is_admin)
+ await _notify(
+ event_type="portal_item_created",
+ item=created,
+ user=current_user,
+ note=f"kind={created.get('kind')} priority={created.get('priority')}",
+ )
+ return {
+ "item": _serialize_item(created, current_user),
+ "comments": comments,
+ }
+
+
+@router.post("/requests/{item_id}/issues")
+async def portal_create_issue_for_request(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ request_item = get_portal_item(item_id)
+ if not request_item:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+ if str(request_item.get("kind") or "").lower() != "request":
+ raise HTTPException(status_code=400, detail="Only request items can have linked issues")
+
+ title = _require_text(payload.get("title"), "title", max_length=220)
+ description = _require_text(payload.get("description"), "description", max_length=10000)
+ issue_type = _normalize_choice(
+ payload.get("issue_type"),
+ field="issue_type",
+ allowed=PORTAL_ISSUE_TYPES,
+ default="general",
+ )
+ status = _normalize_choice(
+ payload.get("status"),
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default="new",
+ )
+ priority = _normalize_choice(
+ payload.get("priority"),
+ field="priority",
+ allowed=PORTAL_PRIORITIES,
+ default="normal",
+ )
+ created = create_portal_item(
+ kind="issue",
+ title=title,
+ description=description,
+ created_by_username=str(current_user.get("username") or "unknown"),
+ created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"),
+ media_type=request_item.get("media_type"),
+ year=request_item.get("year"),
+ external_ref=_clean_text(payload.get("external_ref")),
+ source_system="portal_request",
+ source_request_id=item_id,
+ related_item_id=item_id,
+ status=status or "new",
+ issue_type=issue_type,
+ priority=priority or "normal",
+ assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
+ )
+ initial_comment = _clean_text(payload.get("comment"))
+ if initial_comment:
+ add_portal_comment(
+ int(created["id"]),
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "user"),
+ message=initial_comment,
+ is_internal=False,
+ )
+ comments = list_portal_comments(int(created["id"]), include_internal=_is_admin(current_user))
+ await _notify(
+ event_type="portal_issue_created",
+ item=created,
+ user=current_user,
+ note=f"linked_request_id={item_id}",
+ )
+ return {
+ "item": _serialize_item(created, current_user),
+ "comments": comments,
+ "linked_request_id": item_id,
+ }
+
+
+@router.get("/requests/{item_id}/issues")
+async def portal_list_request_issues(
+ item_id: int,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ request_item = get_portal_item(item_id)
+ if not request_item:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+ if str(request_item.get("kind") or "").lower() != "request":
+ raise HTTPException(status_code=400, detail="Only request items can have linked issues")
+
+ items = list_portal_items(
+ kind="issue",
+ related_item_id=item_id,
+ limit=limit,
+ offset=offset,
+ )
+ total = count_portal_items(kind="issue", related_item_id=item_id)
+ return {
+ "items": [_serialize_item(item, current_user) for item in items],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "has_more": offset + len(items) < total,
+ "linked_request_id": item_id,
+ }
+
+
+@router.patch("/requests/{item_id}/pipeline")
+async def portal_update_request_pipeline(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not _is_admin(current_user):
+ raise HTTPException(status_code=403, detail="Admin access required")
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+ if str(item.get("kind") or "").lower() != "request":
+ raise HTTPException(status_code=400, detail="Only request items support pipeline updates")
+
+ current_request_status, current_media_status = _item_status_to_workflow(item)
+ requested_request = _normalize_choice(
+ payload.get("request_status"),
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ default=current_request_status,
+ ) or current_request_status
+ requested_media = _normalize_choice(
+ payload.get("media_status"),
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ default=current_media_status,
+ ) or current_media_status
+ next_request_status, next_media_status = _validate_pipeline_transition(
+ current_request_status,
+ current_media_status,
+ requested_request,
+ requested_media,
+ )
+ next_status = _workflow_to_item_status(next_request_status, next_media_status)
+ updated = update_portal_item(
+ item_id,
+ status=next_status,
+ workflow_request_status=next_request_status,
+ workflow_media_status=next_media_status,
+ )
+ if not updated:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+
+ comment_text = _clean_text(payload.get("comment"))
+ if comment_text:
+ add_portal_comment(
+ item_id,
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "admin"),
+ message=comment_text,
+ is_internal=_normalize_bool(payload.get("is_internal"), default=False),
+ )
+
+ await _notify(
+ event_type="portal_request_pipeline_updated",
+ item=updated,
+ user=current_user,
+ note=f"{current_request_status}/{current_media_status} -> {next_request_status}/{next_media_status}",
+ )
+ comments = list_portal_comments(item_id, include_internal=True)
+ return {
+ "item": _serialize_item(updated, current_user),
+ "comments": comments,
+ }
+
+
+@router.get("/items/{item_id}")
+async def portal_get_item(
+ item_id: int,
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ comments = list_portal_comments(item_id, include_internal=_is_admin(current_user))
+ return {
+ "item": _serialize_item(item, current_user),
+ "comments": comments,
+ }
+
+
+@router.patch("/items/{item_id}")
+async def portal_update_item(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ is_admin = _is_admin(current_user)
+ is_owner = _is_owner(current_user, item)
+ if not (is_admin or is_owner):
+ raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
+
+ editable_owner_fields = {"title", "description", "media_type", "year", "external_ref"}
+ editable_admin_fields = {
+ "status",
+ "priority",
+ "assignee_username",
+ "source_system",
+ "source_request_id",
+ "related_item_id",
+ "request_status",
+ "media_status",
+ "issue_type",
+ "issue_resolved_at",
+ "metadata_json",
+ }
+ provided_fields = set(payload.keys())
+ unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields)
+ if unknown_fields:
+ unknown = ", ".join(sorted(unknown_fields))
+ raise HTTPException(status_code=400, detail=f"Unsupported fields: {unknown}")
+ if not is_admin:
+ forbidden = provided_fields - editable_owner_fields
+ if forbidden:
+ forbidden_text = ", ".join(sorted(forbidden))
+ raise HTTPException(
+ status_code=403, detail=f"Admin access required to update: {forbidden_text}"
+ )
+
+ updates: Dict[str, Any] = {}
+ if "title" in payload:
+ updates["title"] = _require_text(payload.get("title"), "title", max_length=220)
+ if "description" in payload:
+ updates["description"] = _require_text(
+ payload.get("description"), "description", max_length=10000
+ )
+ if "media_type" in payload:
+ updates["media_type"] = _normalize_choice(
+ payload.get("media_type"),
+ field="media_type",
+ allowed=PORTAL_MEDIA_TYPES,
+ allow_empty=True,
+ )
+ if "year" in payload:
+ updates["year"] = _normalize_year(payload.get("year"))
+ if "external_ref" in payload:
+ updates["external_ref"] = _clean_text(payload.get("external_ref"))
+ if is_admin:
+ kind = str(item.get("kind") or "").lower()
+ if "priority" in payload:
+ updates["priority"] = _normalize_choice(
+ payload.get("priority"),
+ field="priority",
+ allowed=PORTAL_PRIORITIES,
+ default=item.get("priority") or "normal",
+ )
+ if "assignee_username" in payload:
+ updates["assignee_username"] = _clean_text(payload.get("assignee_username"))
+ if "source_system" in payload:
+ source_system = _clean_text(payload.get("source_system"))
+ updates["source_system"] = source_system.lower() if source_system else None
+ if "source_request_id" in payload:
+ updates["source_request_id"] = _normalize_int(
+ payload.get("source_request_id"), "source_request_id"
+ )
+ if "related_item_id" in payload:
+ related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id")
+ _ensure_item_exists(related_item_id)
+ updates["related_item_id"] = related_item_id
+ if "metadata_json" in payload:
+ updates["metadata_json"] = _sanitize_metadata_json(payload.get("metadata_json"))
+
+ if kind == "request":
+ current_request_status, current_media_status = _item_status_to_workflow(item)
+ request_status_input = payload.get("request_status")
+ media_status_input = payload.get("media_status")
+ explicit_status = payload.get("status")
+ if explicit_status is not None and request_status_input is None and media_status_input is None:
+ explicit_status_normalized = _normalize_choice(
+ explicit_status,
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default=item.get("status") or "pending",
+ )
+ request_status_input, media_status_input = LEGACY_STATUS_TO_WORKFLOW.get(
+ explicit_status_normalized or "pending",
+ (current_request_status, current_media_status),
+ )
+
+ if request_status_input is not None or media_status_input is not None:
+ requested_request = _normalize_choice(
+ request_status_input,
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ default=current_request_status,
+ ) or current_request_status
+ requested_media = _normalize_choice(
+ media_status_input,
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ default=current_media_status,
+ ) or current_media_status
+ next_request_status, next_media_status = _validate_pipeline_transition(
+ current_request_status,
+ current_media_status,
+ requested_request,
+ requested_media,
+ )
+ updates["workflow_request_status"] = next_request_status
+ updates["workflow_media_status"] = next_media_status
+ updates["status"] = _workflow_to_item_status(next_request_status, next_media_status)
+ elif "status" in payload:
+ updates["status"] = _normalize_choice(
+ payload.get("status"),
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default=item.get("status") or "pending",
+ )
+ else:
+ if "status" in payload:
+ updates["status"] = _normalize_choice(
+ payload.get("status"),
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default=item.get("status") or "new",
+ )
+ if kind == "issue":
+ if "issue_type" in payload:
+ updates["issue_type"] = _normalize_choice(
+ payload.get("issue_type"),
+ field="issue_type",
+ allowed=PORTAL_ISSUE_TYPES,
+ default=item.get("issue_type") or "general",
+ )
+ if "issue_resolved_at" in payload:
+ updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
+ if "status" in payload:
+ next_status = str(updates.get("status") or item.get("status") or "").lower()
+ if next_status in {"done", "closed"}:
+ updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
+ elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
+ updates.setdefault("issue_resolved_at", None)
+
+ if not updates:
+ comments = list_portal_comments(item_id, include_internal=is_admin)
+ return {
+ "item": _serialize_item(item, current_user),
+ "comments": comments,
+ }
+
+ updated = update_portal_item(item_id, **updates)
+ if not updated:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+
+ changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
+ if changed_fields:
+ await _notify(
+ event_type="portal_item_updated",
+ item=updated,
+ user=current_user,
+ note=f"changed={','.join(sorted(changed_fields))}",
+ )
+ comments = list_portal_comments(item_id, include_internal=is_admin)
+ return {
+ "item": _serialize_item(updated, current_user),
+ "comments": comments,
+ }
+
+
+@router.get("/items/{item_id}/comments")
+async def portal_get_comments(
+ item_id: int,
+ limit: int = Query(default=200, ge=1, le=500),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ comments = list_portal_comments(
+ item_id,
+ include_internal=_is_admin(current_user),
+ limit=limit,
+ )
+ return {"comments": comments}
+
+
+@router.post("/items/{item_id}/comments")
+async def portal_create_comment(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ is_admin = _is_admin(current_user)
+ message = _require_text(payload.get("message"), "message", max_length=10000)
+ is_internal = _normalize_bool(payload.get("is_internal"), default=False)
+ if is_internal and not is_admin:
+ raise HTTPException(status_code=403, detail="Only admins can add internal comments")
+ comment = add_portal_comment(
+ item_id,
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "user"),
+ message=message,
+ is_internal=is_internal,
+ )
+ updated_item = get_portal_item(item_id)
+ if updated_item:
+ await _notify(
+ event_type="portal_comment_added",
+ item=updated_item,
+ user=current_user,
+ note=f"internal={is_internal}",
+ )
+ return {"comment": comment}
diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py
new file mode 100644
index 0000000..67e84fc
--- /dev/null
+++ b/backend/app/routers/requests.py
@@ -0,0 +1,2437 @@
+from typing import Any, Dict, List, Optional, Tuple
+import asyncio
+import httpx
+import json
+import logging
+import os
+import time
+from urllib.parse import quote
+from datetime import datetime, timezone, timedelta
+from fastapi import APIRouter, HTTPException, Depends
+
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.jellyfin import JellyfinClient
+from ..clients.qbittorrent import QBittorrentClient
+from ..clients.radarr import RadarrClient
+from ..clients.sonarr import SonarrClient
+from ..clients.prowlarr import ProwlarrClient
+from ..ai.triage import triage_snapshot
+from ..auth import get_current_user
+from ..runtime import get_runtime_settings
+from .images import cache_tmdb_image, is_tmdb_cached
+from ..db import (
+ save_action,
+ get_recent_actions,
+ get_recent_snapshots,
+ get_cached_requests,
+ get_cached_requests_since,
+ get_cached_request_by_media_id,
+ get_request_cache_lookup,
+ get_request_cache_payload,
+ get_request_cache_last_updated,
+ get_request_cache_count,
+ get_request_cache_payloads,
+ get_request_cache_payloads_missing,
+ repair_request_cache_titles,
+ prune_duplicate_requests_cache,
+ upsert_request_cache,
+ upsert_request_cache_many,
+ upsert_artwork_cache_status,
+ upsert_artwork_cache_status_many,
+ get_artwork_cache_missing_count,
+ get_artwork_cache_status_count,
+ get_setting,
+ set_setting,
+ update_artwork_cache_stats,
+ cleanup_history,
+ is_seerr_media_failure_suppressed,
+ record_seerr_media_failure,
+ clear_seerr_media_failure,
+)
+from ..models import Snapshot, TriageResult, RequestType
+from ..services.snapshot import build_snapshot, jellyfin_item_matches_request
+
+router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
+
+CACHE_TTL_SECONDS = 600
+_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
+FAILED_DETAIL_CACHE_TTL_SECONDS = 3600
+_failed_detail_cache: Dict[str, float] = {}
+REQUEST_CACHE_TTL_SECONDS = 600
+logger = logging.getLogger(__name__)
+_sync_state: Dict[str, Any] = {
+ "status": "idle",
+ "stored": 0,
+ "total": None,
+ "skip": 0,
+ "message": None,
+ "started_at": None,
+ "finished_at": None,
+}
+_sync_task: Optional[asyncio.Task] = None
+_sync_last_key = "requests_sync_last_at"
+RECENT_CACHE_MAX_DAYS = 180
+RECENT_CACHE_TTL_SECONDS = 300
+_recent_cache: Dict[str, Any] = {"items": [], "updated_at": None}
+_artwork_prefetch_state: Dict[str, Any] = {
+ "status": "idle",
+ "processed": 0,
+ "total": 0,
+ "message": "",
+ "only_missing": False,
+ "started_at": None,
+ "finished_at": None,
+}
+_artwork_prefetch_task: Optional[asyncio.Task] = None
+
+STATUS_LABELS = {
+ 1: "Waiting for approval",
+ 2: "Approved",
+ 3: "Declined",
+ 4: "Ready to watch",
+ 5: "Working on it",
+ 6: "Partially ready",
+}
+
+REQUEST_STAGE_CODES = {
+ "all": None,
+ "pending": [1],
+ "approved": [2],
+ "declined": [3],
+ "ready": [4],
+ "working": [5],
+ "partial": [6],
+ "in_progress": [2, 5, 6],
+}
+
+
+def _cache_get(key: str) -> Optional[Dict[str, Any]]:
+ cached = _detail_cache.get(key)
+ if not cached:
+ return None
+ expires_at, payload = cached
+ if expires_at < time.time():
+ _detail_cache.pop(key, None)
+ return None
+ return payload
+
+
+def _cache_set(key: str, payload: Dict[str, Any]) -> None:
+ _detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
+ _failed_detail_cache.pop(key, None)
+
+
+def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
+ if not jellyfin_available:
+ return _status_label(current_status)
+ try:
+ status_code = int(current_status)
+ except (TypeError, ValueError):
+ status_code = None
+ if status_code == 6:
+ return STATUS_LABELS[6]
+ return STATUS_LABELS[4]
+
+
+async def _request_is_available_in_jellyfin(
+ jellyfin: JellyfinClient,
+ title: Optional[str],
+ year: Optional[int],
+ media_type: Optional[str],
+ request_payload: Optional[Dict[str, Any]],
+ availability_cache: Dict[str, bool],
+) -> bool:
+ if not jellyfin.configured() or not title:
+ return False
+ cache_key = f"{media_type or ''}:{title.lower()}:{year or ''}:{request_payload.get('id') if isinstance(request_payload, dict) else ''}"
+ cached_value = availability_cache.get(cache_key)
+ if cached_value is not None:
+ return cached_value
+ types = ["Movie"] if media_type == "movie" else ["Series"]
+ try:
+ search = await jellyfin.search_items(title, types, limit=50)
+ except Exception:
+ availability_cache[cache_key] = False
+ return False
+ if isinstance(search, dict):
+ items = search.get("Items") or search.get("items") or []
+ request_type = RequestType.movie if media_type == "movie" else RequestType.tv
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ if jellyfin_item_matches_request(
+ item,
+ title=title,
+ year=year,
+ request_type=request_type,
+ request_payload=request_payload,
+ ):
+ availability_cache[cache_key] = True
+ return True
+ availability_cache[cache_key] = False
+ return False
+
+
+def _failure_cache_has(key: str) -> bool:
+ expires_at = _failed_detail_cache.get(key)
+ if not expires_at:
+ return False
+ if expires_at < time.time():
+ _failed_detail_cache.pop(key, None)
+ return False
+ return True
+
+
+def _failure_cache_set(key: str, ttl_seconds: int = FAILED_DETAIL_CACHE_TTL_SECONDS) -> None:
+ _failed_detail_cache[key] = time.time() + ttl_seconds
+
+
+def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
+ response = exc.response
+ if response is None:
+ return None
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = response.text
+ if isinstance(payload, dict):
+ message = payload.get("message") or payload.get("error")
+ return str(message).strip() if message else json.dumps(payload, ensure_ascii=True)
+ if isinstance(payload, str):
+ trimmed = payload.strip()
+ return trimmed or None
+ return str(payload)
+
+
+def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
+ response = exc.response
+ if response is None:
+ return False
+ return response.status_code == 404 or response.status_code >= 500
+
+
+def _status_label(value: Any) -> str:
+ if isinstance(value, int):
+ return STATUS_LABELS.get(value, f"Status {value}")
+ return "Unknown"
+
+
+def normalize_request_stage_filter(value: Optional[str]) -> str:
+ if not isinstance(value, str):
+ return "all"
+ normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
+ if not normalized:
+ return "all"
+ if normalized in {"processing", "inprogress"}:
+ normalized = "in_progress"
+ return normalized if normalized in REQUEST_STAGE_CODES else "all"
+
+
+def request_stage_filter_codes(value: Optional[str]) -> Optional[list[int]]:
+ normalized = normalize_request_stage_filter(value)
+ codes = REQUEST_STAGE_CODES.get(normalized)
+ return list(codes) if codes else None
+
+
+def _normalize_username(value: Any) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ normalized = value.strip().lower()
+ if not normalized:
+ return None
+ if "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
+ return normalized if normalized else None
+
+
+def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
+ if user.get("role") == "admin":
+ return True
+ return bool(user.get("auto_search_enabled", True))
+
+
+def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
+ if _user_can_use_search_auto(user):
+ return snapshot
+ snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
+ return snapshot
+
+
+def _quality_profile_id(value: Any) -> Optional[int]:
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str) and value.strip().isdigit():
+ return int(value.strip())
+ return None
+
+
+def _request_matches_user(request_data: Any, username: str) -> bool:
+ requested_by = None
+ if isinstance(request_data, dict):
+ requested_by = request_data.get("requestedBy") or request_data.get("requestedByUser")
+ if requested_by is None:
+ requested_by = request_data.get("requestedByName") or request_data.get("requestedByUsername")
+ if isinstance(requested_by, dict):
+ candidates = [
+ requested_by.get("username"),
+ requested_by.get("displayName"),
+ requested_by.get("name"),
+ requested_by.get("email"),
+ ]
+ else:
+ candidates = [requested_by]
+
+ username_norm = _normalize_username(username)
+ if not username_norm:
+ return False
+
+ for candidate in candidates:
+ candidate_norm = _normalize_username(candidate)
+ if not candidate_norm:
+ continue
+ if "@" in candidate_norm:
+ candidate_norm = candidate_norm.split("@", 1)[0]
+ if candidate_norm == username_norm:
+ return True
+ return False
+
+
+def _normalize_requested_by(request_data: Any) -> Optional[str]:
+ if not isinstance(request_data, dict):
+ return None
+ requested_by = request_data.get("requestedBy")
+ if isinstance(requested_by, dict):
+ for key in ("username", "displayName", "name", "email"):
+ value = requested_by.get(key)
+ normalized = _normalize_username(value)
+ if normalized and "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
+ if normalized:
+ return normalized
+ normalized = _normalize_username(requested_by)
+ if normalized and "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
+ return normalized
+
+def _extract_requested_by_id(request_data: Any) -> Optional[int]:
+ if not isinstance(request_data, dict):
+ return None
+ requested_by = request_data.get("requestedBy") or request_data.get("requestedByUser")
+ if isinstance(requested_by, dict):
+ for key in ("id", "userId", "Id"):
+ value = requested_by.get(key)
+ if value is None:
+ continue
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str:
+ response = exc.response
+ status = response.status_code if response is not None else "unknown"
+ body = ""
+ if response is not None:
+ try:
+ payload = response.json()
+ body = json.dumps(payload, ensure_ascii=True)
+ except ValueError:
+ body = response.text
+ body = body.strip() if body else ""
+ if body:
+ return f"{service} error {status}: {body}"
+ return f"{service} error {status}."
+
+
+def _request_display_name(request_data: Any) -> Optional[str]:
+ if not isinstance(request_data, dict):
+ return None
+ requested_by = request_data.get("requestedBy")
+ if isinstance(requested_by, dict):
+ for key in ("displayName", "username", "name", "email"):
+ value = requested_by.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ if isinstance(requested_by, str) and requested_by.strip():
+ return requested_by.strip()
+ return None
+
+
+def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
+ media = item.get("media") or {}
+ media_id = media.get("id") or item.get("mediaId")
+ media_type = media.get("mediaType") or item.get("type")
+ tmdb_id = media.get("tmdbId") or item.get("tmdbId")
+ title = media.get("title") or media.get("name") or item.get("title") or item.get("name")
+ year = media.get("year") or item.get("year")
+ created_at = item.get("createdAt") or item.get("addedAt") or item.get("updatedAt")
+ updated_at = item.get("updatedAt") or created_at
+ requested_by = _request_display_name(item)
+ requested_by_norm = _normalize_requested_by(item)
+ requested_by_id = _extract_requested_by_id(item)
+ return {
+ "request_id": item.get("id"),
+ "media_id": media_id,
+ "media_type": media_type,
+ "tmdb_id": tmdb_id,
+ "status": item.get("status"),
+ "title": title,
+ "year": year,
+ "requested_by": requested_by,
+ "requested_by_norm": requested_by_norm,
+ "requested_by_id": requested_by_id,
+ "created_at": created_at,
+ "updated_at": updated_at,
+ }
+
+
+def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
+ media = item.get("media") or {}
+ poster_path = None
+ backdrop_path = None
+ if isinstance(media, dict):
+ poster_path = media.get("posterPath") or media.get("poster_path")
+ backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
+ if not poster_path:
+ poster_path = item.get("posterPath") or item.get("poster_path")
+ if not backdrop_path:
+ backdrop_path = item.get("backdropPath") or item.get("backdrop_path")
+ return poster_path, backdrop_path
+
+def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Optional[str]]:
+ media = payload.get("media") or {}
+ if not isinstance(media, dict):
+ media = {}
+ tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
+ media_type = (
+ media.get("mediaType")
+ or payload.get("mediaType")
+ or payload.get("type")
+ )
+ try:
+ tmdb_id = int(tmdb_id) if tmdb_id is not None else None
+ except (TypeError, ValueError):
+ tmdb_id = None
+ if isinstance(media_type, str):
+ media_type = media_type.strip().lower() or None
+ else:
+ media_type = None
+ return tmdb_id, media_type
+
+
+def _normalize_media_type(value: Any) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ normalized = value.strip().lower()
+ if normalized in {"movie", "tv"}:
+ return normalized
+ return None
+
+
+def _normalize_seasons(value: Any) -> list[int]:
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise HTTPException(status_code=400, detail="seasons must be an array of positive integers")
+ normalized: list[int] = []
+ for raw in value:
+ try:
+ season = int(raw)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(
+ status_code=400, detail="seasons must contain only positive integers"
+ ) from exc
+ if season <= 0:
+ raise HTTPException(status_code=400, detail="seasons must contain only positive integers")
+ normalized.append(season)
+ return sorted(set(normalized))
+
+
+def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
+ poster_path, backdrop_path = _extract_artwork_paths(payload)
+ tmdb_id, media_type = _extract_tmdb_lookup(payload)
+ can_hydrate = bool(tmdb_id and media_type)
+ if poster_path:
+ if not is_tmdb_cached(poster_path, "w185") or not is_tmdb_cached(poster_path, "w342"):
+ return True
+ elif can_hydrate:
+ return True
+ if backdrop_path:
+ if not is_tmdb_cached(backdrop_path, "w780"):
+ return True
+ elif can_hydrate:
+ return True
+ return False
+
+
+def _compute_cached_flags(
+ poster_path: Optional[str],
+ backdrop_path: Optional[str],
+ cache_mode: str,
+ poster_cached: Optional[bool] = None,
+ backdrop_cached: Optional[bool] = None,
+) -> tuple[bool, bool]:
+ if cache_mode != "cache":
+ return True, True
+ poster = poster_cached
+ backdrop = backdrop_cached
+ if poster is None:
+ poster = bool(poster_path) and is_tmdb_cached(poster_path, "w185") and is_tmdb_cached(
+ poster_path, "w342"
+ )
+ if backdrop is None:
+ backdrop = bool(backdrop_path) and is_tmdb_cached(backdrop_path, "w780")
+ return bool(poster), bool(backdrop)
+
+
+def _upsert_artwork_status(
+ payload: Dict[str, Any],
+ cache_mode: str,
+ poster_cached: Optional[bool] = None,
+ backdrop_cached: Optional[bool] = None,
+) -> None:
+ record = _build_artwork_status_record(payload, cache_mode, poster_cached, backdrop_cached)
+ if not record:
+ return
+ upsert_artwork_cache_status(**record)
+
+
+def _build_request_cache_record(payload: Dict[str, Any], request_payload: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "request_id": payload.get("request_id"),
+ "media_id": payload.get("media_id"),
+ "media_type": payload.get("media_type"),
+ "status": payload.get("status"),
+ "title": payload.get("title"),
+ "year": payload.get("year"),
+ "requested_by": payload.get("requested_by"),
+ "requested_by_norm": payload.get("requested_by_norm"),
+ "requested_by_id": payload.get("requested_by_id"),
+ "created_at": payload.get("created_at"),
+ "updated_at": payload.get("updated_at"),
+ "payload_json": json.dumps(request_payload, ensure_ascii=True),
+ }
+
+
+def _build_artwork_status_record(
+ payload: Dict[str, Any],
+ cache_mode: str,
+ poster_cached: Optional[bool] = None,
+ backdrop_cached: Optional[bool] = None,
+) -> Optional[Dict[str, Any]]:
+ parsed = _parse_request_payload(payload)
+ request_id = parsed.get("request_id")
+ if not isinstance(request_id, int):
+ return None
+ tmdb_id, media_type = _extract_tmdb_lookup(payload)
+ poster_path, backdrop_path = _extract_artwork_paths(payload)
+ has_tmdb = bool(tmdb_id and media_type)
+ poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
+ poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
+ )
+ return {
+ "request_id": request_id,
+ "tmdb_id": tmdb_id,
+ "media_type": media_type,
+ "poster_path": poster_path,
+ "backdrop_path": backdrop_path,
+ "has_tmdb": has_tmdb,
+ "poster_cached": poster_cached_flag,
+ "backdrop_cached": backdrop_cached_flag,
+ }
+
+
+def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
+ cache_root = os.path.join(os.getcwd(), "data", "artwork")
+ total_bytes = 0
+ total_files = 0
+ if not os.path.isdir(cache_root):
+ return 0, 0
+ for root, _, files in os.walk(cache_root):
+ for name in files:
+ path = os.path.join(root, name)
+ try:
+ total_bytes += os.path.getsize(path)
+ total_files += 1
+ except OSError:
+ continue
+ return total_bytes, total_files
+
+
+async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]:
+ cache_key = f"request:{request_id}"
+ cached = _cache_get(cache_key)
+ if isinstance(cached, dict):
+ return cached
+ if _failure_cache_has(cache_key):
+ return None
+ try:
+ fetched = await client.get_request(str(request_id))
+ except httpx.HTTPStatusError:
+ _failure_cache_set(cache_key)
+ return None
+ if isinstance(fetched, dict):
+ _cache_set(cache_key, fetched)
+ return fetched
+ return None
+
+
+async def _get_media_details(
+ client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
+) -> Optional[Dict[str, Any]]:
+ if not tmdb_id or not media_type:
+ return None
+ normalized_media_type = str(media_type).strip().lower()
+ if normalized_media_type not in {"movie", "tv"}:
+ return None
+ cache_key = f"media:{normalized_media_type}:{int(tmdb_id)}"
+ cached = _cache_get(cache_key)
+ if isinstance(cached, dict):
+ return cached
+ if is_seerr_media_failure_suppressed(normalized_media_type, int(tmdb_id)):
+ logger.debug(
+ "Seerr media hydration suppressed from db: media_type=%s tmdb_id=%s",
+ normalized_media_type,
+ tmdb_id,
+ )
+ _failure_cache_set(cache_key, ttl_seconds=FAILED_DETAIL_CACHE_TTL_SECONDS)
+ return None
+ if _failure_cache_has(cache_key):
+ return None
+ try:
+ if normalized_media_type == "movie":
+ fetched = await client.get_movie(int(tmdb_id))
+ else:
+ fetched = await client.get_tv(int(tmdb_id))
+ except httpx.HTTPStatusError as exc:
+ _failure_cache_set(cache_key)
+ if _should_persist_seerr_media_failure(exc):
+ record_seerr_media_failure(
+ normalized_media_type,
+ int(tmdb_id),
+ status_code=exc.response.status_code if exc.response is not None else None,
+ error_message=_extract_http_error_message(exc),
+ )
+ return None
+ if isinstance(fetched, dict):
+ clear_seerr_media_failure(normalized_media_type, int(tmdb_id))
+ _cache_set(cache_key, fetched)
+ return fetched
+ return None
+
+
+async def _hydrate_title_from_tmdb(
+ client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
+) -> tuple[Optional[str], Optional[int]]:
+ details = await _get_media_details(client, media_type, tmdb_id)
+ if not isinstance(details, dict):
+ return None, None
+ normalized_media_type = str(media_type).strip().lower() if media_type else None
+ if normalized_media_type == "movie":
+ title = details.get("title")
+ release_date = details.get("releaseDate")
+ year = int(release_date[:4]) if release_date else None
+ return title, year
+ if normalized_media_type == "tv":
+ title = details.get("name") or details.get("title")
+ first_air = details.get("firstAirDate")
+ year = int(first_air[:4]) if first_air else None
+ return title, year
+ return None, None
+
+
+async def _hydrate_artwork_from_tmdb(
+ client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
+) -> tuple[Optional[str], Optional[str]]:
+ details = await _get_media_details(client, media_type, tmdb_id)
+ if not isinstance(details, dict):
+ return None, None
+ return (
+ details.get("posterPath") or details.get("poster_path"),
+ details.get("backdropPath") or details.get("backdrop_path"),
+ )
+
+
+def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
+ if not path:
+ return None
+ if not path.startswith("/"):
+ path = f"/{path}"
+ if cache_mode == "cache":
+ return f"/images/tmdb?path={quote(path)}&size={size}"
+ return f"https://image.tmdb.org/t/p/{size}{path}"
+
+
+def _cache_is_stale(last_updated: Optional[str]) -> bool:
+ if not last_updated:
+ return True
+ runtime = get_runtime_settings()
+ ttl_seconds = max(60, int(runtime.requests_sync_ttl_minutes or 1440) * 60)
+ try:
+ parsed = datetime.fromisoformat(last_updated.replace("Z", "+00:00"))
+ now = datetime.now(timezone.utc)
+ return (now - parsed).total_seconds() > ttl_seconds
+ except ValueError:
+ return True
+
+
+def _parse_time(value: Optional[str], fallback_hour: int, fallback_minute: int) -> tuple[int, int]:
+ if isinstance(value, str) and ":" in value:
+ parts = value.strip().split(":")
+ if len(parts) == 2:
+ try:
+ hour = int(parts[0])
+ minute = int(parts[1])
+ if 0 <= hour <= 23 and 0 <= minute <= 59:
+ return hour, minute
+ except ValueError:
+ pass
+ return fallback_hour, fallback_minute
+
+
+def _seconds_until(hour: int, minute: int) -> int:
+ now = datetime.now(timezone.utc).astimezone()
+ target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
+ if target <= now:
+ target = target + timedelta(days=1)
+ return int((target - now).total_seconds())
+
+
+async def _sync_all_requests(client: JellyseerrClient) -> int:
+ take = 50
+ skip = 0
+ stored = 0
+ cache_mode = (get_runtime_settings().artwork_cache_mode or "remote").lower()
+ logger.info("Seerr sync starting: take=%s", take)
+ _sync_state.update(
+ {
+ "status": "running",
+ "stored": 0,
+ "total": None,
+ "skip": 0,
+ "message": "Starting sync",
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ }
+ )
+ while True:
+ try:
+ response = await client.get_recent_requests(take=take, skip=skip)
+ except httpx.HTTPError as exc:
+ logger.warning("Seerr sync failed at skip=%s: %s", skip, exc)
+ _sync_state.update({"status": "failed", "message": f"Sync failed: {exc}"})
+ break
+ if not isinstance(response, dict):
+ logger.warning("Seerr sync stopped: non-dict response at skip=%s", skip)
+ _sync_state.update({"status": "failed", "message": "Invalid response"})
+ break
+ if _sync_state["total"] is None:
+ page_info = response.get("pageInfo") or {}
+ total = (
+ page_info.get("totalResults")
+ or page_info.get("total")
+ or response.get("totalResults")
+ or response.get("total")
+ )
+ if isinstance(total, int):
+ _sync_state["total"] = total
+ items = response.get("results") or []
+ if not isinstance(items, list) or not items:
+ logger.info("Seerr sync completed: no more results at skip=%s", skip)
+ break
+ page_request_ids = [
+ payload.get("request_id")
+ for item in items
+ if isinstance(item, dict)
+ for payload in [_parse_request_payload(item)]
+ if isinstance(payload.get("request_id"), int)
+ ]
+ cached_by_request_id = get_request_cache_lookup(page_request_ids)
+ page_cache_records: list[Dict[str, Any]] = []
+ page_artwork_records: list[Dict[str, Any]] = []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ payload = _parse_request_payload(item)
+ request_id = payload.get("request_id")
+ cached_title = None
+ if isinstance(request_id, int):
+ cached = cached_by_request_id.get(request_id)
+ if not payload.get("title") and cached and cached.get("title"):
+ cached_title = cached.get("title")
+ needs_details = (
+ not payload.get("title")
+ or not payload.get("media_id")
+ or not payload.get("tmdb_id")
+ or not payload.get("media_type")
+ )
+ if needs_details:
+ logger.debug("Seerr sync hydrate request_id=%s", request_id)
+ details = await _get_request_details(client, request_id)
+ if isinstance(details, dict):
+ payload = _parse_request_payload(details)
+ item = details
+ poster_path, backdrop_path = _extract_artwork_paths(item)
+ if cache_mode == "cache" and not (poster_path or backdrop_path):
+ details = await _get_request_details(client, request_id)
+ if isinstance(details, dict):
+ item = details
+ payload = _parse_request_payload(details)
+ if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"):
+ hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
+ client, payload.get("media_type"), payload.get("tmdb_id")
+ )
+ if hydrated_title:
+ payload["title"] = hydrated_title
+ if hydrated_year:
+ payload["year"] = hydrated_year
+ if not payload.get("title") and cached_title:
+ payload["title"] = cached_title
+ if not isinstance(payload.get("request_id"), int):
+ continue
+ page_cache_records.append(_build_request_cache_record(payload, item))
+ if isinstance(item, dict):
+ artwork_record = _build_artwork_status_record(item, cache_mode)
+ if artwork_record:
+ page_artwork_records.append(artwork_record)
+ stored += 1
+ _sync_state["stored"] = stored
+ if page_cache_records:
+ upsert_request_cache_many(page_cache_records)
+ if page_artwork_records:
+ upsert_artwork_cache_status_many(page_artwork_records)
+ if len(items) < take:
+ logger.info("Seerr sync completed: stored=%s", stored)
+ break
+ skip += take
+ _sync_state["skip"] = skip
+ _sync_state["message"] = f"Synced {stored} requests"
+ logger.debug("Seerr sync progress: stored=%s skip=%s", stored, skip)
+ _sync_state.update(
+ {
+ "status": "completed",
+ "stored": stored,
+ "message": f"Sync complete: {stored} requests",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
+ _refresh_recent_cache_from_db()
+ if cache_mode == "cache":
+ update_artwork_cache_stats(
+ missing_count=get_artwork_cache_missing_count(),
+ total_requests=get_request_cache_count(),
+ )
+ return stored
+
+
+async def _sync_delta_requests(client: JellyseerrClient) -> int:
+ take = 50
+ skip = 0
+ stored = 0
+ unchanged_pages = 0
+ cache_mode = (get_runtime_settings().artwork_cache_mode or "remote").lower()
+ logger.info("Seerr delta sync starting: take=%s", take)
+ _sync_state.update(
+ {
+ "status": "running",
+ "stored": 0,
+ "total": None,
+ "skip": 0,
+ "message": "Starting delta sync",
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ }
+ )
+ while True:
+ try:
+ response = await client.get_recent_requests(take=take, skip=skip)
+ except httpx.HTTPError as exc:
+ logger.warning("Seerr delta sync failed at skip=%s: %s", skip, exc)
+ _sync_state.update({"status": "failed", "message": f"Delta sync failed: {exc}"})
+ break
+ if not isinstance(response, dict):
+ logger.warning("Seerr delta sync stopped: non-dict response at skip=%s", skip)
+ _sync_state.update({"status": "failed", "message": "Invalid response"})
+ break
+ items = response.get("results") or []
+ if not isinstance(items, list) or not items:
+ logger.info("Seerr delta sync completed: no more results at skip=%s", skip)
+ break
+ page_request_ids = [
+ payload.get("request_id")
+ for item in items
+ if isinstance(item, dict)
+ for payload in [_parse_request_payload(item)]
+ if isinstance(payload.get("request_id"), int)
+ ]
+ cached_by_request_id = get_request_cache_lookup(page_request_ids)
+ page_cache_records: list[Dict[str, Any]] = []
+ page_artwork_records: list[Dict[str, Any]] = []
+ page_changed = False
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ payload = _parse_request_payload(item)
+ request_id = payload.get("request_id")
+ if isinstance(request_id, int):
+ cached = cached_by_request_id.get(request_id)
+ incoming_updated = payload.get("updated_at")
+ cached_title = cached.get("title") if cached else None
+ if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
+ continue
+ needs_details = (
+ not payload.get("title")
+ or not payload.get("media_id")
+ or not payload.get("tmdb_id")
+ or not payload.get("media_type")
+ )
+ if needs_details:
+ details = await _get_request_details(client, request_id)
+ if isinstance(details, dict):
+ payload = _parse_request_payload(details)
+ item = details
+ poster_path, backdrop_path = _extract_artwork_paths(item)
+ if cache_mode == "cache" and not (poster_path or backdrop_path):
+ details = await _get_request_details(client, request_id)
+ if isinstance(details, dict):
+ payload = _parse_request_payload(details)
+ item = details
+ if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"):
+ hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
+ client, payload.get("media_type"), payload.get("tmdb_id")
+ )
+ if hydrated_title:
+ payload["title"] = hydrated_title
+ if hydrated_year:
+ payload["year"] = hydrated_year
+ if not payload.get("title") and cached_title:
+ payload["title"] = cached_title
+ if not isinstance(payload.get("request_id"), int):
+ continue
+ page_cache_records.append(_build_request_cache_record(payload, item))
+ if isinstance(item, dict):
+ artwork_record = _build_artwork_status_record(item, cache_mode)
+ if artwork_record:
+ page_artwork_records.append(artwork_record)
+ stored += 1
+ page_changed = True
+ _sync_state["stored"] = stored
+ if page_cache_records:
+ upsert_request_cache_many(page_cache_records)
+ if page_artwork_records:
+ upsert_artwork_cache_status_many(page_artwork_records)
+ if not page_changed:
+ unchanged_pages += 1
+ else:
+ unchanged_pages = 0
+ if len(items) < take or unchanged_pages >= 2:
+ logger.info("Seerr delta sync completed: stored=%s", stored)
+ break
+ skip += take
+ _sync_state["skip"] = skip
+ _sync_state["message"] = f"Delta synced {stored} requests"
+ logger.debug("Seerr delta sync progress: stored=%s skip=%s", stored, skip)
+ deduped = prune_duplicate_requests_cache()
+ if deduped:
+ logger.info("Seerr delta sync removed duplicate rows: %s", deduped)
+ _sync_state.update(
+ {
+ "status": "completed",
+ "stored": stored,
+ "message": f"Delta sync complete: {stored} updated",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
+ _refresh_recent_cache_from_db()
+ if cache_mode == "cache":
+ update_artwork_cache_stats(
+ missing_count=get_artwork_cache_missing_count(),
+ total_requests=get_request_cache_count(),
+ )
+ return stored
+
+
+async def _prefetch_artwork_cache(
+ client: JellyseerrClient,
+ only_missing: bool = False,
+ total: Optional[int] = None,
+ use_missing_query: bool = False,
+) -> None:
+ runtime = get_runtime_settings()
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ if cache_mode != "cache":
+ _artwork_prefetch_state.update(
+ {
+ "status": "failed",
+ "message": "Artwork cache mode is not set to cache.",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ return
+
+ total = total if total is not None else get_request_cache_count()
+ _artwork_prefetch_state.update(
+ {
+ "status": "running",
+ "processed": 0,
+ "total": total,
+ "message": "Starting missing artwork prefetch"
+ if only_missing
+ else "Starting artwork prefetch",
+ "only_missing": only_missing,
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ }
+ )
+ if only_missing and total == 0:
+ _artwork_prefetch_state.update(
+ {
+ "status": "completed",
+ "processed": 0,
+ "message": "No missing artwork to cache.",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ return
+ offset = 0
+ limit = 200
+ processed = 0
+ while True:
+ if use_missing_query:
+ batch = get_request_cache_payloads_missing(limit=limit, offset=offset)
+ else:
+ batch = get_request_cache_payloads(limit=limit, offset=offset)
+ if not batch:
+ break
+ page_cache_records: list[Dict[str, Any]] = []
+ page_artwork_records: list[Dict[str, Any]] = []
+ for row in batch:
+ payload = row.get("payload")
+ if not isinstance(payload, dict):
+ if not only_missing:
+ processed += 1
+ continue
+ if only_missing and not use_missing_query and not _artwork_missing_for_payload(payload):
+ continue
+ poster_path, backdrop_path = _extract_artwork_paths(payload)
+ tmdb_id, media_type = _extract_tmdb_lookup(payload)
+ if (not poster_path or not backdrop_path) and client.configured() and tmdb_id and media_type:
+ media = payload.get("media") or {}
+ hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb(
+ client, media_type, tmdb_id
+ )
+ poster_path = poster_path or hydrated_poster
+ backdrop_path = backdrop_path or hydrated_backdrop
+ if hydrated_poster or hydrated_backdrop:
+ media = dict(media) if isinstance(media, dict) else {}
+ if hydrated_poster:
+ media["posterPath"] = hydrated_poster
+ if hydrated_backdrop:
+ media["backdropPath"] = hydrated_backdrop
+ payload["media"] = media
+ parsed = _parse_request_payload(payload)
+ request_id = parsed.get("request_id")
+ if isinstance(request_id, int):
+ page_cache_records.append(_build_request_cache_record(parsed, payload))
+ poster_cached_flag = False
+ backdrop_cached_flag = False
+ if poster_path:
+ try:
+ poster_cached_flag = bool(
+ await cache_tmdb_image(poster_path, "w185")
+ ) and bool(await cache_tmdb_image(poster_path, "w342"))
+ except httpx.HTTPError:
+ poster_cached_flag = False
+ if backdrop_path:
+ try:
+ backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
+ except httpx.HTTPError:
+ backdrop_cached_flag = False
+ artwork_record = _build_artwork_status_record(
+ payload,
+ cache_mode,
+ poster_cached=poster_cached_flag if poster_path else None,
+ backdrop_cached=backdrop_cached_flag if backdrop_path else None,
+ )
+ if artwork_record:
+ page_artwork_records.append(artwork_record)
+ processed += 1
+ if processed % 25 == 0:
+ _artwork_prefetch_state.update(
+ {"processed": processed, "message": f"Cached artwork for {processed} requests"}
+ )
+ if page_cache_records:
+ upsert_request_cache_many(page_cache_records)
+ if page_artwork_records:
+ upsert_artwork_cache_status_many(page_artwork_records)
+ offset += limit
+
+ total_requests = get_request_cache_count()
+ missing_count = get_artwork_cache_missing_count()
+ cache_bytes, cache_files = _collect_artwork_cache_disk_stats()
+ update_artwork_cache_stats(
+ cache_bytes=cache_bytes,
+ cache_files=cache_files,
+ missing_count=missing_count,
+ total_requests=total_requests,
+ )
+ _artwork_prefetch_state.update(
+ {
+ "status": "completed",
+ "processed": processed,
+ "message": f"Artwork cached for {processed} requests",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+
+
+async def start_artwork_prefetch(
+ base_url: Optional[str], api_key: Optional[str], only_missing: bool = False
+) -> Dict[str, Any]:
+ global _artwork_prefetch_task
+ if _artwork_prefetch_task and not _artwork_prefetch_task.done():
+ return dict(_artwork_prefetch_state)
+ client = JellyseerrClient(base_url, api_key)
+ status_count = get_artwork_cache_status_count()
+ total_requests = get_request_cache_count()
+ use_missing_query = only_missing and status_count >= total_requests and total_requests > 0
+ if only_missing and use_missing_query:
+ total = get_artwork_cache_missing_count()
+ else:
+ total = total_requests
+ _artwork_prefetch_state.update(
+ {
+ "status": "running",
+ "processed": 0,
+ "total": total,
+ "message": "Seeding artwork cache status"
+ if only_missing and not use_missing_query
+ else ("Starting missing artwork prefetch" if only_missing else "Starting artwork prefetch"),
+ "only_missing": only_missing,
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ }
+ )
+ if only_missing and total == 0:
+ _artwork_prefetch_state.update(
+ {
+ "status": "completed",
+ "processed": 0,
+ "message": "No missing artwork to cache.",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ return dict(_artwork_prefetch_state)
+
+ async def _runner() -> None:
+ try:
+ await _prefetch_artwork_cache(
+ client,
+ only_missing=only_missing,
+ total=total,
+ use_missing_query=use_missing_query,
+ )
+ except Exception:
+ logger.exception("Artwork prefetch failed")
+ _artwork_prefetch_state.update(
+ {
+ "status": "failed",
+ "message": "Artwork prefetch failed.",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+
+ _artwork_prefetch_task = asyncio.create_task(_runner())
+ return dict(_artwork_prefetch_state)
+
+
+def get_artwork_prefetch_state() -> Dict[str, Any]:
+ return dict(_artwork_prefetch_state)
+
+
+async def _ensure_requests_cache(client: JellyseerrClient) -> None:
+ last_sync = get_setting(_sync_last_key)
+ last_updated = last_sync or get_request_cache_last_updated()
+ if _cache_is_stale(last_updated):
+ logger.info("Requests cache stale or empty, starting sync.")
+ await _sync_all_requests(client)
+ else:
+ logger.debug("Requests cache fresh: last_sync=%s", last_updated)
+
+
+def _refresh_recent_cache_from_db() -> None:
+ since_iso = (datetime.now(timezone.utc) - timedelta(days=RECENT_CACHE_MAX_DAYS)).isoformat()
+ items = get_cached_requests_since(since_iso)
+ _recent_cache["items"] = items
+ _recent_cache["updated_at"] = datetime.now(timezone.utc).isoformat()
+
+
+def _recent_cache_stale() -> bool:
+ updated_at = _recent_cache.get("updated_at")
+ if not updated_at:
+ return True
+ try:
+ parsed = datetime.fromisoformat(updated_at)
+ except ValueError:
+ return True
+ return (datetime.now(timezone.utc) - parsed).total_seconds() > RECENT_CACHE_TTL_SECONDS
+
+
+def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ return parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _get_recent_from_cache(
+ requested_by_norm: Optional[str],
+ requested_by_id: Optional[int],
+ limit: int,
+ offset: int,
+ since_iso: Optional[str],
+ status_codes: Optional[list[int]] = None,
+) -> List[Dict[str, Any]]:
+ items = _recent_cache.get("items") or []
+ results = []
+ since_dt = _parse_iso_datetime(since_iso)
+ for item in items:
+ if requested_by_id is not None:
+ if item.get("requested_by_id") != requested_by_id:
+ continue
+ elif requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
+ continue
+ if since_dt:
+ candidate = item.get("created_at") or item.get("updated_at")
+ item_dt = _parse_iso_datetime(candidate)
+ if not item_dt or item_dt < since_dt:
+ continue
+ if status_codes and item.get("status") not in status_codes:
+ continue
+ results.append(item)
+ return results[offset : offset + limit]
+
+
+async def startup_warmup_requests_cache() -> None:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ try:
+ await _ensure_requests_cache(client)
+ except httpx.HTTPError as exc:
+ logger.warning("Requests warmup skipped: %s", exc)
+ repaired = repair_request_cache_titles()
+ if repaired:
+ logger.info("Requests cache titles repaired: %s", repaired)
+ _refresh_recent_cache_from_db()
+
+
+async def run_requests_poll_loop() -> None:
+ while True:
+ runtime = get_runtime_settings()
+ interval = max(60, int(runtime.requests_poll_interval_seconds or 300))
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ try:
+ await _ensure_requests_cache(client)
+ except httpx.HTTPError as exc:
+ logger.debug("Requests poll skipped: %s", exc)
+ await asyncio.sleep(interval)
+
+
+async def run_requests_delta_loop() -> None:
+ while True:
+ runtime = get_runtime_settings()
+ interval = max(60, int(runtime.requests_delta_sync_interval_minutes or 5) * 60)
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ if _sync_task and not _sync_task.done():
+ logger.debug("Delta sync skipped: another sync is running.")
+ else:
+ try:
+ await _sync_delta_requests(client)
+ except httpx.HTTPError as exc:
+ logger.debug("Delta sync skipped: %s", exc)
+ await asyncio.sleep(interval)
+
+
+async def run_daily_requests_full_sync() -> None:
+ while True:
+ runtime = get_runtime_settings()
+ hour, minute = _parse_time(runtime.requests_full_sync_time, 0, 0)
+ await asyncio.sleep(_seconds_until(hour, minute))
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ logger.info("Daily full sync skipped: Seerr not configured.")
+ continue
+ if _sync_task and not _sync_task.done():
+ logger.info("Daily full sync skipped: another sync is running.")
+ continue
+ try:
+ await _sync_all_requests(client)
+ except httpx.HTTPError as exc:
+ logger.warning("Daily full sync failed: %s", exc)
+
+
+async def run_daily_db_cleanup() -> None:
+ while True:
+ runtime = get_runtime_settings()
+ hour, minute = _parse_time(runtime.requests_cleanup_time, 2, 0)
+ await asyncio.sleep(_seconds_until(hour, minute))
+ runtime = get_runtime_settings()
+ result = cleanup_history(int(runtime.requests_cleanup_days or 90))
+ logger.info("Daily cleanup complete: %s", result)
+
+
+async def start_requests_sync(base_url: Optional[str], api_key: Optional[str]) -> Dict[str, Any]:
+ global _sync_task
+ if _sync_task and not _sync_task.done():
+ return dict(_sync_state)
+ if not base_url:
+ _sync_state.update({"status": "failed", "message": "Seerr not configured"})
+ return dict(_sync_state)
+ client = JellyseerrClient(base_url, api_key)
+ _sync_state.update(
+ {
+ "status": "running",
+ "stored": 0,
+ "total": None,
+ "skip": 0,
+ "message": "Starting sync",
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ }
+ )
+
+ async def _runner() -> None:
+ try:
+ await _sync_all_requests(client)
+ except Exception as exc:
+ logger.exception("Seerr sync failed")
+ _sync_state.update(
+ {
+ "status": "failed",
+ "message": f"Sync failed: {exc}",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+
+ _sync_task = asyncio.create_task(_runner())
+ return dict(_sync_state)
+
+
+async def start_requests_delta_sync(base_url: Optional[str], api_key: Optional[str]) -> Dict[str, Any]:
+ global _sync_task
+ if _sync_task and not _sync_task.done():
+ return dict(_sync_state)
+ if not base_url:
+ _sync_state.update({"status": "failed", "message": "Seerr not configured"})
+ return dict(_sync_state)
+ client = JellyseerrClient(base_url, api_key)
+ _sync_state.update(
+ {
+ "status": "running",
+ "stored": 0,
+ "total": None,
+ "skip": 0,
+ "message": "Starting delta sync",
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ }
+ )
+
+ async def _runner() -> None:
+ try:
+ await _sync_delta_requests(client)
+ except Exception as exc:
+ logger.exception("Seerr delta sync failed")
+ _sync_state.update(
+ {
+ "status": "failed",
+ "message": f"Delta sync failed: {exc}",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+
+ _sync_task = asyncio.create_task(_runner())
+ return dict(_sync_state)
+
+
+def get_requests_sync_state() -> Dict[str, Any]:
+ return dict(_sync_state)
+
+
+async def _ensure_request_access(
+ client: JellyseerrClient, request_id: int, user: Dict[str, str]
+) -> None:
+ if user.get("role") == "admin" or user.get("username"):
+ return
+ raise HTTPException(status_code=403, detail="Request not accessible for this user")
+
+
+def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
+ mapping: Dict[int, Dict[str, Any]] = {}
+ for item in response.get("results", []):
+ media = item.get("media") or {}
+ media_id = media.get("id") or item.get("mediaId")
+ request_id = item.get("id")
+ status = item.get("status")
+ if isinstance(media_id, int) and isinstance(request_id, int):
+ mapping[media_id] = {
+ "requestId": request_id,
+ "status": status,
+ "statusLabel": _status_label(status),
+ }
+ return mapping
+
+
+def _queue_records(queue: Any) -> List[Dict[str, Any]]:
+ if isinstance(queue, dict):
+ records = queue.get("records")
+ if isinstance(records, list):
+ return records
+ if isinstance(queue, list):
+ return queue
+ return []
+
+
+def _download_ids(records: List[Dict[str, Any]]) -> List[str]:
+ ids = []
+ for record in records:
+ download_id = record.get("downloadId") or record.get("download_id")
+ if isinstance(download_id, str) and download_id:
+ ids.append(download_id)
+ return ids
+
+
+def _normalize_categories(categories: Any) -> List[str]:
+ names = []
+ if isinstance(categories, list):
+ for cat in categories:
+ if isinstance(cat, dict):
+ name = cat.get("name")
+ if isinstance(name, str):
+ names.append(name.lower())
+ return names
+
+
+def _normalize_indexer_name(value: Optional[str]) -> str:
+ if not isinstance(value, str):
+ return ""
+ return "".join(ch for ch in value.lower().strip() if ch.isalnum())
+
+
+def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
+ if exc.response is None:
+ logger.warning("%s %s failed: %s", service_label, action, exc)
+ return
+ status = exc.response.status_code
+ body = exc.response.text
+ if isinstance(body, str):
+ body = body.strip()
+ if len(body) > 800:
+ body = f"{body[:800]}...(truncated)"
+ logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body)
+
+
+def _format_rejections(rejections: Any) -> Optional[str]:
+ if isinstance(rejections, str):
+ return rejections.strip() or None
+ if isinstance(rejections, list):
+ reasons = []
+ for item in rejections:
+ reason = None
+ if isinstance(item, dict):
+ reason = (
+ item.get("reason")
+ or item.get("message")
+ or item.get("errorMessage")
+ )
+ if not reason and item is not None:
+ reason = str(item)
+ if isinstance(reason, str) and reason.strip():
+ reasons.append(reason.strip())
+ if reasons:
+ return "; ".join(reasons)
+ return None
+
+
+def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
+ if not isinstance(response, dict):
+ return True, None
+ rejections = response.get("rejections") or response.get("rejectionReasons")
+ reason = _format_rejections(rejections)
+ if reason:
+ return False, reason
+ if response.get("rejected") is True:
+ return False, "rejected"
+ if response.get("downloadAllowed") is False:
+ return False, "download not allowed"
+ if response.get("approved") is False:
+ return False, "not approved"
+ return True, None
+
+
+def _resolve_arr_indexer_id(
+ indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
+) -> Optional[int]:
+ if not isinstance(indexers, list):
+ return None
+ if not indexer_name:
+ if indexer_id is None:
+ return None
+ by_id = next(
+ (item for item in indexers if isinstance(item, dict) and item.get("id") == indexer_id),
+ None,
+ )
+ if by_id and by_id.get("id") is not None:
+ logger.debug("%s indexer id match: %s", service_label, by_id.get("id"))
+ return int(by_id["id"])
+ return None
+ target = indexer_name.lower().strip()
+ target_compact = _normalize_indexer_name(indexer_name)
+ exact = next(
+ (
+ item
+ for item in indexers
+ if isinstance(item, dict)
+ and str(item.get("name", "")).lower().strip() == target
+ ),
+ None,
+ )
+ if exact and exact.get("id") is not None:
+ logger.debug("%s indexer match: '%s' -> %s", service_label, indexer_name, exact.get("id"))
+ return int(exact["id"])
+ compact = next(
+ (
+ item
+ for item in indexers
+ if isinstance(item, dict)
+ and _normalize_indexer_name(str(item.get("name", ""))) == target_compact
+ ),
+ None,
+ )
+ if compact and compact.get("id") is not None:
+ logger.debug("%s indexer compact match: '%s' -> %s", service_label, indexer_name, compact.get("id"))
+ return int(compact["id"])
+ contains = next(
+ (
+ item
+ for item in indexers
+ if isinstance(item, dict)
+ and target in str(item.get("name", "")).lower()
+ ),
+ None,
+ )
+ if contains and contains.get("id") is not None:
+ logger.debug("%s indexer contains match: '%s' -> %s", service_label, indexer_name, contains.get("id"))
+ return int(contains["id"])
+ logger.warning(
+ "%s indexer not found for name '%s'. Check indexer names in the Arr app.",
+ service_label,
+ indexer_name,
+ )
+ return None
+
+
+async def _fallback_qbittorrent_download(
+ download_url: Optional[str], category: str, request_id: Optional[str] = None
+) -> bool:
+ if not download_url:
+ return False
+ runtime = get_runtime_settings()
+ client = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+ if not client.configured():
+ return False
+ request_tag = f"magent-{request_id}" if request_id else None
+ await client.add_torrent_url(download_url, category=category, tags=request_tag)
+ return True
+
+
+def _resolve_qbittorrent_category(value: Optional[str], default: str) -> str:
+ if isinstance(value, str):
+ cleaned = value.strip()
+ if cleaned:
+ return cleaned
+ return default
+
+
+def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]:
+ if not isinstance(results, list):
+ return []
+ keep = []
+ for item in results:
+ if not isinstance(item, dict):
+ continue
+ categories = _normalize_categories(item.get("categories"))
+ if request_type == RequestType.movie:
+ if not any("movies" in name for name in categories):
+ continue
+ elif request_type == RequestType.tv:
+ if not any(name.startswith("tv") or "tv/" in name for name in categories):
+ continue
+ keep.append(
+ {
+ "title": item.get("title"),
+ "indexer": item.get("indexer"),
+ "indexerId": item.get("indexerId"),
+ "guid": item.get("guid"),
+ "size": item.get("size"),
+ "seeders": item.get("seeders"),
+ "leechers": item.get("leechers"),
+ "publishDate": item.get("publishDate"),
+ "infoUrl": item.get("infoUrl"),
+ "downloadUrl": item.get("downloadUrl"),
+ "protocol": item.get("protocol"),
+ }
+ )
+ keep.sort(key=lambda item: (item.get("seeders") or 0), reverse=True)
+ return keep[:10]
+
+
+def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
+ if not isinstance(episodes, list):
+ return {}
+ grouped: Dict[int, List[int]] = {}
+ for episode in episodes:
+ if not isinstance(episode, dict):
+ continue
+ if not episode.get("monitored", True):
+ continue
+ if episode.get("hasFile"):
+ continue
+ season_number = episode.get("seasonNumber")
+ episode_id = episode.get("id")
+ if isinstance(season_number, int) and isinstance(episode_id, int):
+ grouped.setdefault(season_number, []).append(episode_id)
+ return grouped
+
+
+async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
+ if root_folder.isdigit():
+ folders = await client.get_root_folders()
+ if isinstance(folders, list):
+ for folder in folders:
+ if folder.get("id") == int(root_folder):
+ path = folder.get("path")
+ if isinstance(path, str) and path:
+ return path
+ raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found")
+ return root_folder
+
+
+@router.get("/{request_id}/snapshot", response_model=Snapshot)
+async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ return _filter_snapshot_actions_for_user(snapshot, user)
+
+
+@router.get("/recent")
+async def recent_requests(
+ take: int = 6,
+ skip: int = 0,
+ days: int = 90,
+ stage: str = "all",
+ user: Dict[str, str] = Depends(get_current_user),
+) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ mode = (runtime.requests_data_source or "prefer_cache").lower()
+ allow_remote = mode == "always_js"
+ if allow_remote:
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ try:
+ await _ensure_requests_cache(client)
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ username_norm = _normalize_username(user.get("username", ""))
+ requested_by_id = user.get("jellyseerr_user_id")
+ requested_by = None if user.get("role") == "admin" else username_norm
+ requested_by_id = None if user.get("role") == "admin" else requested_by_id
+ since_iso = None
+ if days > 0:
+ since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+ status_codes = request_stage_filter_codes(stage)
+ if _recent_cache_stale():
+ _refresh_recent_cache_from_db()
+ rows = _get_recent_from_cache(
+ requested_by,
+ requested_by_id,
+ take,
+ skip,
+ since_iso,
+ status_codes=status_codes,
+ )
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ allow_title_hydrate = False
+ allow_artwork_hydrate = client.configured()
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ jellyfin_cache: Dict[str, bool] = {}
+ results = []
+ for row in rows:
+ status = row.get("status")
+ title = row.get("title")
+ title_is_placeholder = (
+ isinstance(title, str)
+ and row.get("request_id") is not None
+ and title.strip().lower() == f"request {row.get('request_id')}"
+ )
+ year = row.get("year")
+ details = None
+ if row.get("request_id") and mode != "always_js":
+ cached_payload = get_request_cache_payload(int(row["request_id"]))
+ if isinstance(cached_payload, dict):
+ details = cached_payload
+ if (not title or title_is_placeholder) and row.get("request_id"):
+ if details is None and (allow_remote or allow_title_hydrate):
+ details = await _get_request_details(client, int(row["request_id"]))
+ if isinstance(details, dict):
+ payload = _parse_request_payload(details)
+ title = payload.get("title") or title
+ year = payload.get("year") or year
+ if not title and payload.get("tmdb_id") and (allow_remote or allow_title_hydrate):
+ hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
+ client, payload.get("media_type"), payload.get("tmdb_id")
+ )
+ if hydrated_title:
+ title = hydrated_title
+ if hydrated_year:
+ year = hydrated_year
+ if allow_remote and isinstance(payload.get("request_id"), int):
+ upsert_request_cache(
+ request_id=payload.get("request_id"),
+ media_id=payload.get("media_id"),
+ media_type=payload.get("media_type"),
+ status=payload.get("status"),
+ title=title or payload.get("title"),
+ year=year or payload.get("year"),
+ requested_by=payload.get("requested_by"),
+ requested_by_norm=payload.get("requested_by_norm"),
+ requested_by_id=payload.get("requested_by_id"),
+ created_at=payload.get("created_at"),
+ updated_at=payload.get("updated_at"),
+ payload_json=json.dumps(details, ensure_ascii=True),
+ )
+ row["title"] = title
+ row["year"] = year
+ row["media_type"] = payload.get("media_type") or row.get("media_type")
+ row["status"] = payload.get("status") or row.get("status")
+ if details is None and row.get("request_id") and allow_remote:
+ details = await _get_request_details(client, int(row["request_id"]))
+
+ poster_path = None
+ backdrop_path = None
+ if isinstance(details, dict):
+ media = details.get("media") or {}
+ if isinstance(media, dict):
+ poster_path = media.get("posterPath") or media.get("poster_path")
+ backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
+ tmdb_id = media.get("tmdbId") or details.get("tmdbId")
+ else:
+ tmdb_id = details.get("tmdbId")
+ media_type = media.get("mediaType") if isinstance(media, dict) else None
+ media_type = media_type or details.get("type") or row.get("media_type")
+ if not poster_path and tmdb_id and allow_artwork_hydrate:
+ hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb(
+ client, media_type, tmdb_id
+ )
+ poster_path = poster_path or hydrated_poster
+ backdrop_path = backdrop_path or hydrated_backdrop
+ if (hydrated_poster or hydrated_backdrop) and isinstance(details, dict):
+ media = dict(media) if isinstance(media, dict) else {}
+ if hydrated_poster:
+ media["posterPath"] = hydrated_poster
+ if hydrated_backdrop:
+ media["backdropPath"] = hydrated_backdrop
+ details["media"] = media
+ payload = _parse_request_payload(details)
+ if isinstance(payload.get("request_id"), int):
+ upsert_request_cache(
+ request_id=payload.get("request_id"),
+ media_id=payload.get("media_id"),
+ media_type=payload.get("media_type"),
+ status=payload.get("status"),
+ title=payload.get("title"),
+ year=payload.get("year"),
+ requested_by=payload.get("requested_by"),
+ requested_by_norm=payload.get("requested_by_norm"),
+ requested_by_id=payload.get("requested_by_id"),
+ created_at=payload.get("created_at"),
+ updated_at=payload.get("updated_at"),
+ payload_json=json.dumps(details, ensure_ascii=True),
+ )
+ status_label = _status_label(status)
+ if status_label in {"Working on it", "Ready to watch", "Partially ready"}:
+ is_available = await _request_is_available_in_jellyfin(
+ jellyfin,
+ title,
+ year,
+ row.get("media_type"),
+ details if isinstance(details, dict) else None,
+ jellyfin_cache,
+ )
+ status_label = _status_label_with_jellyfin(status, is_available)
+ results.append(
+ {
+ "id": row.get("request_id"),
+ "title": title,
+ "year": year,
+ "type": row.get("media_type"),
+ "status": status,
+ "statusLabel": status_label,
+ "mediaId": row.get("media_id"),
+ "createdAt": row.get("created_at") or row.get("updated_at"),
+ "artwork": {
+ "poster_url": _artwork_url(poster_path, "w185", cache_mode),
+ "backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode),
+ },
+ }
+ )
+
+ return {"results": results}
+
+
+@router.get("/search")
+async def search_requests(
+ query: str, page: int = 1, user: Dict[str, str] = Depends(get_current_user)
+) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+
+ try:
+ response = await client.search(query=query, page=page)
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ if not isinstance(response, dict):
+ return {"results": []}
+
+ try:
+ await _ensure_requests_cache(client)
+ except httpx.HTTPStatusError:
+ pass
+
+ results = []
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ jellyfin_cache: Dict[str, bool] = {}
+ for item in response.get("results", []):
+ media_type = item.get("mediaType")
+ title = item.get("title") or item.get("name")
+ year = None
+ if item.get("releaseDate"):
+ year = int(item["releaseDate"][:4])
+ if item.get("firstAirDate"):
+ year = int(item["firstAirDate"][:4])
+
+ request_id = None
+ status = None
+ status_label = None
+ requested_by = None
+ accessible = False
+ media_info = item.get("mediaInfo") or {}
+ media_info_id = media_info.get("id")
+ requests = media_info.get("requests")
+ if isinstance(requests, list) and requests:
+ request_id = requests[0].get("id")
+ status = requests[0].get("status")
+ status_label = _status_label(status)
+ elif isinstance(media_info_id, int):
+ cached = get_cached_request_by_media_id(
+ media_info_id,
+ )
+ if cached:
+ request_id = cached.get("request_id")
+ status = cached.get("status")
+ status_label = _status_label(status)
+
+ if isinstance(request_id, int):
+ details = get_request_cache_payload(request_id)
+ if not isinstance(details, dict):
+ details = await _get_request_details(client, request_id)
+ if user.get("role") == "admin":
+ requested_by = _request_display_name(details)
+ accessible = True
+ if status is not None:
+ is_available = await _request_is_available_in_jellyfin(
+ jellyfin,
+ title,
+ year,
+ media_type,
+ details if isinstance(details, dict) else None,
+ jellyfin_cache,
+ )
+ status_label = _status_label_with_jellyfin(status, is_available)
+
+ results.append(
+ {
+ "title": title,
+ "year": year,
+ "type": media_type,
+ "tmdbId": item.get("id"),
+ "requestId": request_id,
+ "status": status,
+ "statusLabel": status_label,
+ "requestedBy": requested_by,
+ "accessible": accessible,
+ "posterPath": item.get("posterPath") or item.get("poster_path"),
+ "backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
+ }
+ )
+
+ return {"results": results}
+
+
+@router.post("/create")
+async def create_request(
+ payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
+) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+
+ media_type = _normalize_media_type(
+ payload.get("mediaType") or payload.get("type") or payload.get("media_type")
+ )
+ if media_type is None:
+ raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'")
+
+ raw_tmdb_id = payload.get("tmdbId")
+ if raw_tmdb_id is None:
+ raw_tmdb_id = payload.get("mediaId")
+ if raw_tmdb_id is None:
+ raw_tmdb_id = payload.get("id")
+ try:
+ tmdb_id = int(raw_tmdb_id)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc
+ if tmdb_id <= 0:
+ raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
+
+ seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
+ raw_is_4k = payload.get("is4k")
+ if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
+ raise HTTPException(status_code=400, detail="is4k must be true or false")
+ is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None
+
+ try:
+ details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id))
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
+
+ if not isinstance(details, dict):
+ raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
+
+ media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
+ requests_list = media_info.get("requests")
+ existing_request: Optional[Dict[str, Any]] = None
+ if isinstance(requests_list, list) and requests_list:
+ first_request = requests_list[0]
+ if isinstance(first_request, dict):
+ existing_request = first_request
+
+ title = details.get("title") or details.get("name")
+ year: Optional[int] = None
+ date_value = details.get("releaseDate") or details.get("firstAirDate")
+ if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit():
+ year = int(date_value[:4])
+
+ if isinstance(existing_request, dict):
+ existing_request_id = _quality_profile_id(existing_request.get("id"))
+ existing_status = existing_request.get("status")
+ if existing_request_id is not None:
+ request_payload = await _get_request_details(client, existing_request_id)
+ if isinstance(request_payload, dict):
+ parsed_payload = _parse_request_payload(request_payload)
+ upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload))
+ _cache_set(f"request:{existing_request_id}", request_payload)
+ title = parsed_payload.get("title") or title
+ year = parsed_payload.get("year") or year
+ return {
+ "status": "exists",
+ "requestId": existing_request_id,
+ "type": media_type,
+ "tmdbId": tmdb_id,
+ "title": title,
+ "year": year,
+ "statusCode": existing_status,
+ "statusLabel": _status_label(existing_status),
+ }
+
+ try:
+ created = await client.create_request(
+ media_type=media_type,
+ media_id=tmdb_id,
+ seasons=seasons if media_type == "tv" else None,
+ is_4k=is_4k,
+ )
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
+
+ if not isinstance(created, dict):
+ raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
+
+ parsed = _parse_request_payload(created)
+ request_id = _quality_profile_id(parsed.get("request_id"))
+ status_code = parsed.get("status")
+ title = parsed.get("title") or title
+ year = parsed.get("year") or year
+
+ if request_id is not None:
+ upsert_request_cache(**_build_request_cache_record(parsed, created))
+ _cache_set(f"request:{request_id}", created)
+ _recent_cache["updated_at"] = None
+ await asyncio.to_thread(
+ save_action,
+ str(request_id),
+ "request_created",
+ "Create request",
+ "ok",
+ f"{media_type} request created from discovery by {user.get('username')}.",
+ )
+
+ return {
+ "status": "created",
+ "requestId": request_id,
+ "type": media_type,
+ "tmdbId": tmdb_id,
+ "title": title,
+ "year": year,
+ "statusCode": status_code,
+ "statusLabel": _status_label(status_code),
+ }
+
+
+@router.post("/{request_id}/ai/triage", response_model=TriageResult)
+async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
+ return triage_snapshot(snapshot)
+
+
+@router.post("/{request_id}/actions/search")
+async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ prowlarr_results: List[Dict[str, Any]] = []
+ prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ if not prowlarr.configured():
+ raise HTTPException(status_code=400, detail="Prowlarr not configured")
+ query = snapshot.title
+ if snapshot.year:
+ query = f"{query} {snapshot.year}"
+ try:
+ results = await prowlarr.search(query=query)
+ prowlarr_results = _filter_prowlarr_results(results, snapshot.request_type)
+ except httpx.HTTPStatusError:
+ prowlarr_results = []
+
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "search_releases",
+ "Search and choose a download",
+ "ok",
+ f"Found {len(prowlarr_results)} releases.",
+ )
+ return {"status": "ok", "releases": prowlarr_results}
+
+
+@router.post("/{request_id}/actions/search_auto")
+async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict):
+ raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
+
+ if snapshot.request_type.value == "tv":
+ client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Sonarr not configured")
+ target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
+ current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
+ profile_message = None
+ series_id = _quality_profile_id(arr_item.get("id"))
+ if target_profile_id and series_id and current_profile_id != target_profile_id:
+ series = await client.get_series(series_id)
+ if not isinstance(series, dict):
+ raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
+ series["qualityProfileId"] = target_profile_id
+ await client.update_series(series)
+ profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
+ episodes = await client.get_episodes(int(arr_item["id"]))
+ missing_by_season = _missing_episode_ids_by_season(episodes)
+ if not missing_by_season:
+ message = "No missing monitored episodes found."
+ if profile_message:
+ message = f"{profile_message} {message}"
+ await asyncio.to_thread(
+ save_action, request_id, "search_auto", "Search and auto-download", "ok", message
+ )
+ return {"status": "ok", "message": message, "searched": []}
+ responses = []
+ for season_number in sorted(missing_by_season.keys()):
+ episode_ids = missing_by_season[season_number]
+ if episode_ids:
+ response = await client.search_episodes(episode_ids)
+ responses.append(
+ {"season": season_number, "episodeCount": len(episode_ids), "response": response}
+ )
+ message = "Search sent to Sonarr."
+ if profile_message:
+ message = f"{profile_message} {message}"
+ await asyncio.to_thread(
+ save_action, request_id, "search_auto", "Search and auto-download", "ok", message
+ )
+ return {"status": "ok", "message": message, "searched": responses}
+ if snapshot.request_type.value == "movie":
+ client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Radarr not configured")
+ target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
+ current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
+ profile_message = None
+ movie_id = _quality_profile_id(arr_item.get("id"))
+ if target_profile_id and movie_id and current_profile_id != target_profile_id:
+ movie = await client.get_movie(movie_id)
+ if not isinstance(movie, dict):
+ raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
+ movie["qualityProfileId"] = target_profile_id
+ await client.update_movie(movie)
+ profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
+ response = await client.search(int(arr_item["id"]))
+ message = "Search sent to Radarr."
+ if profile_message:
+ message = f"{profile_message} {message}"
+ await asyncio.to_thread(
+ save_action, request_id, "search_auto", "Search and auto-download", "ok", message
+ )
+ return {"status": "ok", "message": message, "response": response}
+
+ raise HTTPException(status_code=400, detail="Unknown request type")
+
+
+@router.post("/{request_id}/actions/qbit/resume")
+async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ queue = snapshot.raw.get("arr", {}).get("queue")
+ download_ids = _download_ids(_queue_records(queue))
+ if not download_ids:
+ message = "Nothing to force resume."
+ await asyncio.to_thread(
+ save_action, request_id, "resume_torrent", "Resume torrent", "ok", message
+ )
+ return {"status": "ok", "message": message}
+
+ runtime = get_runtime_settings()
+ client = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="qBittorrent not configured")
+
+ try:
+ torrents = await client.get_torrents_by_hashes("|".join(download_ids))
+ torrent_list = torrents if isinstance(torrents, list) else []
+ downloading_states = {"downloading", "stalleddl", "queueddl", "checkingdl", "forceddl"}
+ if torrent_list and all(
+ str(t.get("state", "")).lower() in downloading_states for t in torrent_list
+ ):
+ message = "No need to force resume. Already downloading."
+ await asyncio.to_thread(
+ save_action, request_id, "resume_torrent", "Resume torrent", "ok", message
+ )
+ return {"status": "ok", "message": message}
+ await client.resume_torrents("|".join(download_ids))
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ message = "Resume sent to qBittorrent."
+ await asyncio.to_thread(
+ save_action, request_id, "resume_torrent", "Resume torrent", "ok", message
+ )
+ return {"status": "ok", "resumed": download_ids, "message": message}
+
+
+@router.post("/{request_id}/actions/readd")
+async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ jelly = snapshot.raw.get("jellyseerr") or {}
+ media = jelly.get("media") or {}
+
+ if snapshot.request_type.value == "tv":
+ tvdb_id = media.get("tvdbId")
+ if not tvdb_id:
+ raise HTTPException(status_code=400, detail="Missing tvdbId for series")
+ title = snapshot.title
+ if title in {None, "", "Unknown"}:
+ title = (
+ media.get("name")
+ or media.get("title")
+ or jelly.get("title")
+ or jelly.get("name")
+ )
+ if not runtime.sonarr_quality_profile_id or not runtime.sonarr_root_folder:
+ raise HTTPException(status_code=400, detail="Sonarr profile/root not configured")
+ client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Sonarr not configured")
+ try:
+ existing = await client.get_series_by_tvdb_id(int(tvdb_id))
+ except httpx.HTTPStatusError as exc:
+ detail = _format_upstream_error("Sonarr", exc)
+ await asyncio.to_thread(
+ save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ if isinstance(existing, list) and existing:
+ series_id = existing[0].get("id")
+ message = f"Already in Sonarr (seriesId {series_id})."
+ await asyncio.to_thread(
+ save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "ok", message
+ )
+ return {"status": "ok", "message": message, "seriesId": series_id}
+ root_folder = await _resolve_root_folder_path(client, runtime.sonarr_root_folder, "Sonarr")
+ try:
+ response = await client.add_series(
+ int(tvdb_id), runtime.sonarr_quality_profile_id, root_folder, title=title
+ )
+ except httpx.HTTPStatusError as exc:
+ detail = _format_upstream_error("Sonarr", exc)
+ await asyncio.to_thread(
+ save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "readd_to_arr",
+ "Re-add to Sonarr/Radarr",
+ "ok",
+ f"Re-added in Sonarr to {root_folder}.",
+ )
+ return {"status": "ok", "response": response, "rootFolder": root_folder}
+
+ if snapshot.request_type.value == "movie":
+ tmdb_id = media.get("tmdbId")
+ if not tmdb_id:
+ raise HTTPException(status_code=400, detail="Missing tmdbId for movie")
+ if not runtime.radarr_quality_profile_id or not runtime.radarr_root_folder:
+ raise HTTPException(status_code=400, detail="Radarr profile/root not configured")
+ client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Radarr not configured")
+ try:
+ existing = await client.get_movie_by_tmdb_id(int(tmdb_id))
+ except httpx.HTTPStatusError as exc:
+ detail = _format_upstream_error("Radarr", exc)
+ await asyncio.to_thread(
+ save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ if isinstance(existing, list) and existing:
+ movie_id = existing[0].get("id")
+ message = f"Already in Radarr (movieId {movie_id})."
+ await asyncio.to_thread(
+ save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "ok", message
+ )
+ return {"status": "ok", "message": message, "movieId": movie_id}
+ root_folder = await _resolve_root_folder_path(client, runtime.radarr_root_folder, "Radarr")
+ try:
+ response = await client.add_movie(
+ int(tmdb_id), runtime.radarr_quality_profile_id, root_folder
+ )
+ except httpx.HTTPStatusError as exc:
+ detail = _format_upstream_error("Radarr", exc)
+ await asyncio.to_thread(
+ save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "readd_to_arr",
+ "Re-add to Sonarr/Radarr",
+ "ok",
+ f"Re-added in Radarr to {root_folder}.",
+ )
+ return {"status": "ok", "response": response, "rootFolder": root_folder}
+
+ raise HTTPException(status_code=400, detail="Unknown request type")
+
+
+@router.get("/{request_id}/history")
+async def request_history(
+ request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
+) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshots = await asyncio.to_thread(get_recent_snapshots, request_id, limit)
+ return {"snapshots": snapshots}
+
+
+@router.get("/{request_id}/actions")
+async def request_actions(
+ request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
+) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ actions = await asyncio.to_thread(get_recent_actions, request_id, limit)
+ return {"actions": actions}
+
+
+@router.post("/{request_id}/actions/grab")
+async def action_grab(
+ request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)
+) -> dict:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if client.configured():
+ await _ensure_request_access(client, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ guid = payload.get("guid")
+ indexer_id = payload.get("indexerId")
+ indexer_name = payload.get("indexerName") or payload.get("indexer")
+ download_url = payload.get("downloadUrl")
+ release_title = payload.get("title")
+ if not guid or not indexer_id:
+ raise HTTPException(status_code=400, detail="Missing guid or indexerId")
+ try:
+ prowlarr_indexer_id = int(indexer_id)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="indexerId must be an integer") from exc
+
+ logger.info(
+ "Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s",
+ request_id,
+ guid,
+ indexer_id,
+ indexer_name,
+ bool(download_url),
+ bool(release_title),
+ )
+
+ if snapshot.request_type.value == "tv":
+ arr_client: Any = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ service_label = "Sonarr"
+ category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
+ elif snapshot.request_type.value == "movie":
+ arr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ service_label = "Radarr"
+ category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
+ else:
+ raise HTTPException(status_code=400, detail="Unknown request type")
+
+ arr_error: Optional[str] = None
+ if arr_client.configured():
+ try:
+ indexers = await arr_client.get_indexers()
+ arr_indexer_id = _resolve_arr_indexer_id(
+ indexers,
+ str(indexer_name) if indexer_name else None,
+ prowlarr_indexer_id,
+ service_label,
+ )
+ if arr_indexer_id is not None:
+ response = await arr_client.grab_release(str(guid), arr_indexer_id)
+ accepted, rejection = _release_push_accepted(response)
+ if accepted:
+ action_message = (
+ f"{release_title or 'Selected release'} was sent to {service_label} for download."
+ )
+ await asyncio.to_thread(
+ save_action, request_id, "grab", "Download selected release", "ok", action_message
+ )
+ return {
+ "status": "ok",
+ "message": action_message,
+ "response": {"collector": service_label, "queued": True},
+ }
+ arr_error = rejection or f"{service_label} rejected the selected release"
+ else:
+ arr_error = f"The Prowlarr indexer is not connected to {service_label}"
+ except httpx.HTTPStatusError as exc:
+ _log_arr_http_error(service_label, "release grab", exc)
+ arr_error = _format_upstream_error(service_label, exc)
+ except Exception as exc:
+ logger.exception("%s release grab failed request_id=%s", service_label, request_id)
+ arr_error = str(exc)
+
+ if download_url:
+ try:
+ qbittorrent_added = await _fallback_qbittorrent_download(
+ str(download_url), category, request_id
+ )
+ except Exception as exc:
+ logger.exception("qBittorrent release fallback failed request_id=%s", request_id)
+ qbittorrent_added = False
+ if not arr_error:
+ arr_error = str(exc)
+ if qbittorrent_added:
+ action_message = (
+ f"{release_title or 'Selected release'} was sent directly to qBittorrent."
+ )
+ await asyncio.to_thread(
+ save_action, request_id, "grab", "Download selected release", "ok", action_message
+ )
+ return {
+ "status": "ok",
+ "message": action_message,
+ "response": {"qbittorrent": "queued"},
+ }
+
+ failure_message = (
+ "The selected release could not be started. "
+ + (arr_error or "No compatible collector or direct download URL was available.")
+ )
+ await asyncio.to_thread(
+ save_action, request_id, "grab", "Download selected release", "failed", failure_message
+ )
+ raise HTTPException(status_code=502, detail=failure_message)
diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py
new file mode 100644
index 0000000..71724e9
--- /dev/null
+++ b/backend/app/routers/site.py
@@ -0,0 +1,49 @@
+from typing import Any, Dict
+
+from fastapi import APIRouter, Depends
+
+from ..auth import get_current_user
+from ..build_info import BUILD_NUMBER, CHANGELOG
+from ..runtime import get_runtime_settings
+
+router = APIRouter(prefix="/site", tags=["site"])
+
+_BANNER_TONES = {"info", "warning", "error", "maintenance"}
+
+
+def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ banner_message = (runtime.site_banner_message or "").strip()
+ tone = (runtime.site_banner_tone or "info").strip().lower()
+ if tone not in _BANNER_TONES:
+ tone = "info"
+ info = {
+ "buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
+ "banner": {
+ "enabled": bool(runtime.site_banner_enabled and banner_message),
+ "message": banner_message,
+ "tone": tone,
+ },
+ "login": {
+ "showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
+ "showLocalLogin": bool(runtime.site_login_show_local_login),
+ "showForgotPassword": bool(runtime.site_login_show_forgot_password),
+ "showSignupLink": bool(runtime.site_login_show_signup_link),
+ },
+ "navigation": {
+ "showRequests": bool(runtime.site_nav_show_requests),
+ },
+ }
+ if include_changelog:
+ info["changelog"] = (CHANGELOG or "").strip()
+ return info
+
+
+@router.get("/public")
+async def site_public() -> Dict[str, Any]:
+ return _build_site_info(False)
+
+
+@router.get("/info")
+async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ return _build_site_info(True)
diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py
new file mode 100644
index 0000000..3d38730
--- /dev/null
+++ b/backend/app/routers/status.py
@@ -0,0 +1,164 @@
+from typing import Any, Dict
+import httpx
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..auth import get_current_user
+from ..runtime import get_runtime_settings
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.sonarr import SonarrClient
+from ..clients.radarr import RadarrClient
+from ..clients.prowlarr import ProwlarrClient
+from ..clients.qbittorrent import QBittorrentClient
+from ..clients.jellyfin import JellyfinClient
+
+router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
+
+
+async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
+ if not configured:
+ return {"name": name, "status": "not_configured"}
+ try:
+ result = await func()
+ return {"name": name, "status": "up", "detail": result}
+ except httpx.HTTPError as exc:
+ return {"name": name, "status": "down", "message": str(exc)}
+ except Exception as exc:
+ return {"name": name, "status": "down", "message": str(exc)}
+
+
+async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
+ if not qbittorrent.base_url:
+ return {"name": "qBittorrent", "status": "not_configured"}
+ if not qbittorrent.username or not qbittorrent.password:
+ reachable = await qbittorrent.is_webui_reachable()
+ return {
+ "name": "qBittorrent",
+ "status": "degraded" if reachable else "not_configured",
+ "message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
+ }
+ try:
+ result = await qbittorrent.get_app_version()
+ return {"name": "qBittorrent", "status": "up", "detail": result}
+ except RuntimeError as exc:
+ if "login failed" in str(exc).lower():
+ reachable = await qbittorrent.is_webui_reachable()
+ if reachable:
+ return {
+ "name": "qBittorrent",
+ "status": "degraded",
+ "message": "qBittorrent is reachable but the saved credentials were rejected",
+ }
+ return {"name": "qBittorrent", "status": "down", "message": str(exc)}
+ except httpx.HTTPError as exc:
+ return {"name": "qBittorrent", "status": "down", "message": str(exc)}
+ except Exception as exc:
+ return {"name": "qBittorrent", "status": "down", "message": str(exc)}
+
+
+@router.get("/services")
+async def services_status() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ qbittorrent = QBittorrentClient(
+ runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
+ )
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+
+ services = []
+ services.append(
+ await _check(
+ "Seerr",
+ jellyseerr.configured(),
+ lambda: jellyseerr.get_recent_requests(take=1, skip=0),
+ )
+ )
+ services.append(
+ await _check(
+ "Sonarr",
+ sonarr.configured(),
+ sonarr.get_system_status,
+ )
+ )
+ services.append(
+ await _check(
+ "Radarr",
+ radarr.configured(),
+ radarr.get_system_status,
+ )
+ )
+ prowlarr_status = await _check(
+ "Prowlarr",
+ prowlarr.configured(),
+ prowlarr.get_health,
+ )
+ if prowlarr_status.get("status") == "up":
+ health = prowlarr_status.get("detail")
+ if isinstance(health, list) and health:
+ prowlarr_status["status"] = "degraded"
+ prowlarr_status["message"] = "Health warnings"
+ services.append(prowlarr_status)
+ services.append(await _check_qbittorrent(qbittorrent))
+ services.append(
+ await _check(
+ "Jellyfin",
+ jellyfin.configured(),
+ jellyfin.get_system_info,
+ )
+ )
+
+ overall = "up"
+ if any(s.get("status") == "down" for s in services):
+ overall = "down"
+ elif any(s.get("status") in {"degraded", "not_configured"} for s in services):
+ overall = "degraded"
+
+ return {"overall": overall, "services": services}
+
+
+@router.post("/services/{service}/test")
+async def test_service(service: str) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ qbittorrent = QBittorrentClient(
+ runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
+ )
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+
+ service_key = service.strip().lower()
+ checks = {
+ "seerr": (
+ "Seerr",
+ jellyseerr.configured(),
+ lambda: jellyseerr.get_recent_requests(take=1, skip=0),
+ ),
+ "jellyseerr": (
+ "Seerr",
+ jellyseerr.configured(),
+ lambda: jellyseerr.get_recent_requests(take=1, skip=0),
+ ),
+ "sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
+ "radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
+ "prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
+ "jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
+ }
+
+ if service_key == "qbittorrent":
+ return await _check_qbittorrent(qbittorrent)
+
+ if service_key not in checks:
+ raise HTTPException(status_code=404, detail="Unknown service")
+
+ name, configured, func = checks[service_key]
+ result = await _check(name, configured, func)
+ if name == "Prowlarr" and result.get("status") == "up":
+ health = result.get("detail")
+ if isinstance(health, list) and health:
+ result["status"] = "degraded"
+ result["message"] = "Health warnings"
+ return result
diff --git a/backend/app/runtime.py b/backend/app/runtime.py
new file mode 100644
index 0000000..f75c1f0
--- /dev/null
+++ b/backend/app/runtime.py
@@ -0,0 +1,67 @@
+from .config import settings
+from .db import get_settings_overrides
+
+_INT_FIELDS = {
+ "magent_application_port",
+ "magent_api_port",
+ "auth_rate_limit_window_seconds",
+ "auth_rate_limit_max_attempts_ip",
+ "auth_rate_limit_max_attempts_user",
+ "password_reset_rate_limit_window_seconds",
+ "password_reset_rate_limit_max_attempts_ip",
+ "password_reset_rate_limit_max_attempts_identifier",
+ "sonarr_quality_profile_id",
+ "radarr_quality_profile_id",
+ "jwt_exp_minutes",
+ "log_file_max_bytes",
+ "log_file_backup_count",
+ "requests_sync_ttl_minutes",
+ "requests_poll_interval_seconds",
+ "requests_delta_sync_interval_minutes",
+ "requests_cleanup_days",
+ "magent_notify_email_smtp_port",
+}
+_BOOL_FIELDS = {
+ "magent_proxy_enabled",
+ "magent_proxy_trust_forwarded_headers",
+ "magent_ssl_bind_enabled",
+ "magent_notify_enabled",
+ "magent_notify_email_enabled",
+ "magent_notify_email_use_tls",
+ "magent_notify_email_use_ssl",
+ "magent_notify_discord_enabled",
+ "magent_notify_telegram_enabled",
+ "magent_notify_push_enabled",
+ "magent_notify_webhook_enabled",
+ "jellyfin_sync_to_arr",
+ "site_banner_enabled",
+ "site_login_show_jellyfin_login",
+ "site_login_show_local_login",
+ "site_login_show_forgot_password",
+ "site_login_show_signup_link",
+ "site_nav_show_requests",
+}
+_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
+
+
+def get_runtime_settings():
+ overrides = get_settings_overrides()
+ update = {}
+ for key, value in overrides.items():
+ if value is None:
+ continue
+ if key in _SKIP_OVERRIDE_FIELDS:
+ continue
+ if key in _INT_FIELDS:
+ try:
+ update[key] = int(value)
+ except (TypeError, ValueError):
+ continue
+ elif key in _BOOL_FIELDS:
+ if isinstance(value, bool):
+ update[key] = value
+ else:
+ update[key] = str(value).strip().lower() in {"1", "true", "yes", "on"}
+ else:
+ update[key] = value
+ return settings.model_copy(update=update)
diff --git a/backend/app/security.py b/backend/app/security.py
new file mode 100644
index 0000000..1a2adbd
--- /dev/null
+++ b/backend/app/security.py
@@ -0,0 +1,73 @@
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, Optional
+
+from passlib.context import CryptContext
+import jwt
+from jwt import InvalidTokenError
+
+from .config import settings
+
+_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+_ALGORITHM = "HS256"
+MIN_PASSWORD_LENGTH = 8
+PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
+
+
+def hash_password(password: str) -> str:
+ return _pwd_context.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ return _pwd_context.verify(plain_password, hashed_password)
+
+
+def validate_password_policy(password: str) -> str:
+ candidate = password.strip()
+ if len(candidate) < MIN_PASSWORD_LENGTH:
+ raise ValueError(PASSWORD_POLICY_MESSAGE)
+ return candidate
+
+
+def _create_token(
+ subject: str,
+ role: str,
+ *,
+ expires_at: datetime,
+ token_type: str = "access",
+) -> str:
+ payload: Dict[str, Any] = {
+ "sub": subject,
+ "role": role,
+ "typ": token_type,
+ "exp": expires_at,
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
+
+def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
+ if not settings.jwt_secret:
+ raise ValueError("JWT_SECRET is not configured")
+ minutes = expires_minutes or settings.jwt_exp_minutes
+ expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
+ return _create_token(subject, role, expires_at=expires, token_type="access")
+
+
+def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
+ expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
+ return _create_token(subject, role, expires_at=expires, token_type="sse")
+
+
+def decode_token(token: str) -> Dict[str, Any]:
+ if not settings.jwt_secret:
+ raise ValueError("JWT_SECRET is not configured")
+ return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
+
+
+class TokenError(Exception):
+ pass
+
+
+def safe_decode_token(token: str) -> Dict[str, Any]:
+ try:
+ return decode_token(token)
+ except InvalidTokenError as exc:
+ raise TokenError("Invalid token") from exc
diff --git a/backend/app/services/diagnostics.py b/backend/app/services/diagnostics.py
new file mode 100644
index 0000000..394c527
--- /dev/null
+++ b/backend/app/services/diagnostics.py
@@ -0,0 +1,735 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from time import perf_counter
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
+from urllib.parse import urlparse
+
+import httpx
+
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.prowlarr import ProwlarrClient
+from ..clients.qbittorrent import QBittorrentClient
+from ..clients.radarr import RadarrClient
+from ..clients.sonarr import SonarrClient
+from ..config import settings as env_settings
+from ..db import get_database_diagnostics
+from ..network_security import validate_notification_target_url
+from ..runtime import get_runtime_settings
+from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
+
+
+DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
+
+
+@dataclass(frozen=True)
+class DiagnosticCheck:
+ key: str
+ label: str
+ category: str
+ description: str
+ live_safe: bool
+ configured: bool
+ config_detail: str
+ target: Optional[str]
+ runner: DiagnosticRunner
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _clean_text(value: Any, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else fallback
+ return str(value)
+
+
+def _url_target(url: Optional[str]) -> Optional[str]:
+ raw = _clean_text(url)
+ if not raw:
+ return None
+ try:
+ parsed = urlparse(raw)
+ except Exception:
+ return raw
+ host = parsed.hostname or parsed.netloc or raw
+ if parsed.port:
+ host = f"{host}:{parsed.port}"
+ return host
+
+
+def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
+ resolved_host = _clean_text(host)
+ if not resolved_host:
+ return None
+ if port is None:
+ return resolved_host
+ return f"{resolved_host}:{port}"
+
+
+def _http_error_detail(exc: Exception) -> str:
+ if isinstance(exc, httpx.HTTPStatusError):
+ response = exc.response
+ body = ""
+ try:
+ body = response.text.strip()
+ except Exception:
+ body = ""
+ if body:
+ return f"HTTP {response.status_code}: {body}"
+ return f"HTTP {response.status_code}"
+ return str(exc)
+
+
+def _config_status(detail: str) -> str:
+ lowered = detail.lower()
+ if "disabled" in lowered:
+ return "disabled"
+ return "not_configured"
+
+
+def _discord_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
+ return False, "Discord notifications are disabled."
+ webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
+ if webhook_url:
+ try:
+ validate_notification_target_url(webhook_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Discord webhook URL is required."
+
+
+def _telegram_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
+ return False, "Telegram notifications are disabled."
+ if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
+ return True, "ok"
+ return False, "Telegram bot token and chat ID are required."
+
+
+def _webhook_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
+ return False, "Generic webhook notifications are disabled."
+ webhook_url = _clean_text(runtime.magent_notify_webhook_url)
+ if webhook_url:
+ try:
+ validate_notification_target_url(webhook_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Generic webhook URL is required."
+
+
+def _push_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
+ return False, "Push notifications are disabled."
+ provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ if provider == "ntfy":
+ push_url = _clean_text(runtime.magent_notify_push_base_url)
+ if push_url and _clean_text(runtime.magent_notify_push_topic):
+ try:
+ validate_notification_target_url(push_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "ntfy requires a base URL and topic."
+ if provider == "gotify":
+ push_url = _clean_text(runtime.magent_notify_push_base_url)
+ if push_url and _clean_text(runtime.magent_notify_push_token):
+ try:
+ validate_notification_target_url(push_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Gotify requires a base URL and app token."
+ if provider == "pushover":
+ if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
+ return True, "ok"
+ return False, "Pushover requires an application token and user key."
+ if provider == "webhook":
+ push_url = _clean_text(runtime.magent_notify_push_base_url)
+ if push_url:
+ try:
+ validate_notification_target_url(push_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Webhook relay requires a target URL."
+ if provider == "telegram":
+ return _telegram_config_ready(runtime)
+ if provider == "discord":
+ return _discord_config_ready(runtime)
+ return False, f"Unsupported push provider: {provider or 'unknown'}"
+
+
+def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
+ summary = {
+ "total": len(results),
+ "up": 0,
+ "down": 0,
+ "degraded": 0,
+ "not_configured": 0,
+ "disabled": 0,
+ }
+ for result in results:
+ status = str(result.get("status") or "").strip().lower()
+ if status in summary:
+ summary[status] += 1
+ return summary
+
+
+async def _run_http_json_get(
+ url: str,
+ *,
+ headers: Optional[Dict[str, str]] = None,
+ params: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
+ response = await client.get(url, headers=headers, params=params)
+ response.raise_for_status()
+ payload = response.json()
+ return {"response": payload}
+
+
+async def _run_http_text_get(url: str) -> Dict[str, Any]:
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
+ response = await client.get(url)
+ response.raise_for_status()
+ body = response.text
+ return {"response": body, "message": f"HTTP {response.status_code}"}
+
+
+async def _run_http_post(
+ url: str,
+ *,
+ json_payload: Optional[Dict[str, Any]] = None,
+ data_payload: Any = None,
+ params: Optional[Dict[str, Any]] = None,
+ headers: Optional[Dict[str, str]] = None,
+) -> Dict[str, Any]:
+ validate_notification_target_url(url)
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
+ response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
+ response.raise_for_status()
+ if not response.content:
+ return {"message": f"HTTP {response.status_code}"}
+ content_type = response.headers.get("content-type", "")
+ if "application/json" in content_type.lower():
+ try:
+ return {"response": response.json(), "message": f"HTTP {response.status_code}"}
+ except Exception:
+ pass
+ return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
+
+
+async def _run_database_check() -> Dict[str, Any]:
+ detail = await asyncio.to_thread(get_database_diagnostics)
+ integrity = _clean_text(detail.get("integrity_check"), "unknown")
+ requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
+ wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
+ wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
+ status = "up" if integrity == "ok" else "degraded"
+ return {
+ "status": status,
+ "message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
+ "detail": detail,
+ }
+
+
+async def _run_magent_api_check(runtime) -> Dict[str, Any]:
+ base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
+ result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
+ payload = result.get("response")
+ build_number = payload.get("build") if isinstance(payload, dict) else None
+ message = "Health endpoint responded"
+ if build_number:
+ message = f"Health endpoint responded (build {build_number})"
+ return {"message": message, "detail": payload}
+
+
+async def _run_magent_web_check(runtime) -> Dict[str, Any]:
+ base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
+ result = await _run_http_text_get(base_url.rstrip("/"))
+ body = result.get("response")
+ if isinstance(body, str) and " Dict[str, Any]:
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ payload = await client.get_status()
+ version = payload.get("version") if isinstance(payload, dict) else None
+ message = "Seerr responded"
+ if version:
+ message = f"Seerr version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_sonarr_check(runtime) -> Dict[str, Any]:
+ client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ payload = await client.get_system_status()
+ version = payload.get("version") if isinstance(payload, dict) else None
+ message = "Sonarr responded"
+ if version:
+ message = f"Sonarr version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_radarr_check(runtime) -> Dict[str, Any]:
+ client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ payload = await client.get_system_status()
+ version = payload.get("version") if isinstance(payload, dict) else None
+ message = "Radarr responded"
+ if version:
+ message = f"Radarr version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
+ client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ payload = await client.get_health()
+ if isinstance(payload, list) and payload:
+ return {
+ "status": "degraded",
+ "message": f"Prowlarr health warnings: {len(payload)}",
+ "detail": payload,
+ }
+ return {"message": "Prowlarr reported healthy", "detail": payload}
+
+
+async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
+ client = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+ version = await client.get_app_version()
+ message = "qBittorrent responded"
+ if isinstance(version, str) and version:
+ message = f"qBittorrent version {version}"
+ return {"message": message, "detail": version}
+
+
+async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ payload = await client.get_system_info()
+ version = payload.get("Version") if isinstance(payload, dict) else None
+ message = "Jellyfin responded"
+ if version:
+ message = f"Jellyfin version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
+ result = await send_test_email(recipient_email=recipient_email)
+ recipient = _clean_text(result.get("recipient_email"), "configured recipient")
+ warning = _clean_text(result.get("warning"))
+ if warning:
+ return {
+ "status": "degraded",
+ "message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
+ "detail": result,
+ }
+ return {"message": f"Test email sent to {recipient}", "detail": result}
+
+
+async def _run_discord_check(runtime) -> Dict[str, Any]:
+ webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
+ payload = {
+ "content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
+ }
+ result = await _run_http_post(webhook_url, json_payload=payload)
+ return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
+
+
+async def _run_telegram_check(runtime) -> Dict[str, Any]:
+ bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
+ chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+ payload = {
+ "chat_id": chat_id,
+ "text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
+ }
+ result = await _run_http_post(url, json_payload=payload)
+ return {"message": "Telegram ping accepted", "detail": result.get("response")}
+
+
+async def _run_webhook_check(runtime) -> Dict[str, Any]:
+ webhook_url = _clean_text(runtime.magent_notify_webhook_url)
+ payload = {
+ "type": "diagnostics.ping",
+ "application": env_settings.app_name,
+ "build": env_settings.site_build_number,
+ "checked_at": _now_iso(),
+ }
+ result = await _run_http_post(webhook_url, json_payload=payload)
+ return {"message": "Webhook accepted ping", "detail": result.get("response")}
+
+
+async def _run_push_check(runtime) -> Dict[str, Any]:
+ provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ message = f"{env_settings.app_name} diagnostics ping"
+ build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
+
+ if provider == "ntfy":
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ topic = _clean_text(runtime.magent_notify_push_topic)
+ result = await _run_http_post(
+ f"{base_url.rstrip('/')}/{topic}",
+ data_payload=f"{message}\n{build_suffix}",
+ headers={"Content-Type": "text/plain; charset=utf-8"},
+ )
+ return {"message": "ntfy push accepted", "detail": result.get("response")}
+
+ if provider == "gotify":
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ token = _clean_text(runtime.magent_notify_push_token)
+ result = await _run_http_post(
+ f"{base_url.rstrip('/')}/message",
+ json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
+ params={"token": token},
+ )
+ return {"message": "Gotify push accepted", "detail": result.get("response")}
+
+ if provider == "pushover":
+ token = _clean_text(runtime.magent_notify_push_token)
+ user_key = _clean_text(runtime.magent_notify_push_user_key)
+ device = _clean_text(runtime.magent_notify_push_device)
+ payload = {
+ "token": token,
+ "user": user_key,
+ "message": f"{message}\n{build_suffix}",
+ "title": env_settings.app_name,
+ }
+ if device:
+ payload["device"] = device
+ result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
+ return {"message": "Pushover push accepted", "detail": result.get("response")}
+
+ if provider == "webhook":
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ payload = {
+ "type": "diagnostics.push",
+ "application": env_settings.app_name,
+ "build": env_settings.site_build_number,
+ "checked_at": _now_iso(),
+ }
+ result = await _run_http_post(base_url, json_payload=payload)
+ return {"message": "Push webhook accepted", "detail": result.get("response")}
+
+ if provider == "telegram":
+ return await _run_telegram_check(runtime)
+
+ if provider == "discord":
+ return await _run_discord_check(runtime)
+
+ raise RuntimeError(f"Unsupported push provider: {provider}")
+
+
+def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
+ runtime = get_runtime_settings()
+ seerr_target = _url_target(runtime.jellyseerr_base_url)
+ jellyfin_target = _url_target(runtime.jellyfin_base_url)
+ sonarr_target = _url_target(runtime.sonarr_base_url)
+ radarr_target = _url_target(runtime.radarr_base_url)
+ prowlarr_target = _url_target(runtime.prowlarr_base_url)
+ qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
+ application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
+ api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
+ smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
+ discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
+ telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
+ webhook_target = _url_target(runtime.magent_notify_webhook_url)
+
+ push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ push_target = None
+ if push_provider == "pushover":
+ push_target = "api.pushover.net"
+ elif push_provider == "telegram":
+ push_target = telegram_target or "api.telegram.org"
+ elif push_provider == "discord":
+ push_target = discord_target or "discord.com"
+ else:
+ push_target = _url_target(runtime.magent_notify_push_base_url)
+
+ email_ready, email_detail = smtp_email_config_ready()
+ email_warning = smtp_email_delivery_warning()
+ discord_ready, discord_detail = _discord_config_ready(runtime)
+ telegram_ready, telegram_detail = _telegram_config_ready(runtime)
+ push_ready, push_detail = _push_config_ready(runtime)
+ webhook_ready, webhook_detail = _webhook_config_ready(runtime)
+
+ checks = [
+ DiagnosticCheck(
+ key="magent-web",
+ label="Magent application",
+ category="Application",
+ description="Checks that the frontend application URL is responding.",
+ live_safe=True,
+ configured=True,
+ config_detail="ok",
+ target=application_target,
+ runner=lambda runtime=runtime: _run_magent_web_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="magent-api",
+ label="Magent API",
+ category="Application",
+ description="Checks the Magent API health endpoint.",
+ live_safe=True,
+ configured=True,
+ config_detail="ok",
+ target=api_target,
+ runner=lambda runtime=runtime: _run_magent_api_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="database",
+ label="SQLite database",
+ category="Application",
+ description="Runs SQLite integrity_check against the current Magent database.",
+ live_safe=True,
+ configured=True,
+ config_detail="ok",
+ target="sqlite",
+ runner=_run_database_check,
+ ),
+ DiagnosticCheck(
+ key="seerr",
+ label="Seerr",
+ category="Media services",
+ description="Checks Seerr API reachability and version.",
+ live_safe=True,
+ configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
+ config_detail="Seerr URL and API key are required.",
+ target=seerr_target,
+ runner=lambda runtime=runtime: _run_seerr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="jellyfin",
+ label="Jellyfin",
+ category="Media services",
+ description="Checks Jellyfin system info with the configured API key.",
+ live_safe=True,
+ configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
+ config_detail="Jellyfin URL and API key are required.",
+ target=jellyfin_target,
+ runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="sonarr",
+ label="Sonarr",
+ category="Media services",
+ description="Checks Sonarr system status with the configured API key.",
+ live_safe=True,
+ configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
+ config_detail="Sonarr URL and API key are required.",
+ target=sonarr_target,
+ runner=lambda runtime=runtime: _run_sonarr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="radarr",
+ label="Radarr",
+ category="Media services",
+ description="Checks Radarr system status with the configured API key.",
+ live_safe=True,
+ configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
+ config_detail="Radarr URL and API key are required.",
+ target=radarr_target,
+ runner=lambda runtime=runtime: _run_radarr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="prowlarr",
+ label="Prowlarr",
+ category="Media services",
+ description="Checks Prowlarr health and flags warnings as degraded.",
+ live_safe=True,
+ configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
+ config_detail="Prowlarr URL and API key are required.",
+ target=prowlarr_target,
+ runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="qbittorrent",
+ label="qBittorrent",
+ category="Media services",
+ description="Checks qBittorrent login and app version.",
+ live_safe=True,
+ configured=bool(
+ runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
+ ),
+ config_detail="qBittorrent URL, username, and password are required.",
+ target=qbittorrent_target,
+ runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="email",
+ label="SMTP email",
+ category="Notifications",
+ description="Sends a live test email using the configured SMTP provider.",
+ live_safe=False,
+ configured=email_ready,
+ config_detail=email_warning or email_detail,
+ target=smtp_target,
+ runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
+ ),
+ DiagnosticCheck(
+ key="discord",
+ label="Discord webhook",
+ category="Notifications",
+ description="Posts a live test message to the configured Discord webhook.",
+ live_safe=False,
+ configured=discord_ready,
+ config_detail=discord_detail,
+ target=discord_target,
+ runner=lambda runtime=runtime: _run_discord_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="telegram",
+ label="Telegram",
+ category="Notifications",
+ description="Sends a live test message to the configured Telegram chat.",
+ live_safe=False,
+ configured=telegram_ready,
+ config_detail=telegram_detail,
+ target=telegram_target,
+ runner=lambda runtime=runtime: _run_telegram_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="push",
+ label="Push/mobile provider",
+ category="Notifications",
+ description="Sends a live test message through the configured push provider.",
+ live_safe=False,
+ configured=push_ready,
+ config_detail=push_detail,
+ target=push_target,
+ runner=lambda runtime=runtime: _run_push_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="webhook",
+ label="Generic webhook",
+ category="Notifications",
+ description="Posts a live test payload to the configured generic webhook.",
+ live_safe=False,
+ configured=webhook_ready,
+ config_detail=webhook_detail,
+ target=webhook_target,
+ runner=lambda runtime=runtime: _run_webhook_check(runtime),
+ ),
+ ]
+ return checks
+
+
+async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
+ if not check.configured:
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": False,
+ "status": _config_status(check.config_detail),
+ "message": check.config_detail,
+ "checked_at": _now_iso(),
+ "duration_ms": 0,
+ }
+
+ started = perf_counter()
+ checked_at = _now_iso()
+ try:
+ payload = await check.runner()
+ status = _clean_text(payload.get("status"), "up")
+ message = _clean_text(payload.get("message"), "Check passed")
+ detail = payload.get("detail")
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": True,
+ "status": status,
+ "message": message,
+ "detail": detail,
+ "checked_at": checked_at,
+ "duration_ms": round((perf_counter() - started) * 1000, 1),
+ }
+ except httpx.HTTPError as exc:
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": True,
+ "status": "down",
+ "message": _http_error_detail(exc),
+ "checked_at": checked_at,
+ "duration_ms": round((perf_counter() - started) * 1000, 1),
+ }
+ except Exception as exc:
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": True,
+ "status": "down",
+ "message": str(exc),
+ "checked_at": checked_at,
+ "duration_ms": round((perf_counter() - started) * 1000, 1),
+ }
+
+
+def get_diagnostics_catalog() -> Dict[str, Any]:
+ checks = _build_diagnostic_checks()
+ items = []
+ for check in checks:
+ items.append(
+ {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "live_safe": check.live_safe,
+ "target": check.target,
+ "configured": check.configured,
+ "config_status": "configured" if check.configured else _config_status(check.config_detail),
+ "config_detail": "Ready to test." if check.configured else check.config_detail,
+ }
+ )
+ categories = sorted({item["category"] for item in items})
+ return {
+ "checks": items,
+ "categories": categories,
+ "generated_at": _now_iso(),
+ }
+
+
+async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
+ checks = _build_diagnostic_checks(recipient_email=recipient_email)
+ selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
+ if selected:
+ checks = [check for check in checks if check.key.lower() in selected]
+ results = await asyncio.gather(*(_execute_check(check) for check in checks))
+ return {
+ "results": results,
+ "summary": _summary_from_results(results),
+ "checked_at": _now_iso(),
+ }
diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py
new file mode 100644
index 0000000..cec7d41
--- /dev/null
+++ b/backend/app/services/invite_email.py
@@ -0,0 +1,1404 @@
+from __future__ import annotations
+
+import asyncio
+import html
+import json
+import logging
+import re
+import smtplib
+from functools import lru_cache
+from pathlib import Path
+from email.generator import BytesGenerator
+from email.message import EmailMessage
+from email.policy import SMTP as SMTP_POLICY
+from email.utils import formataddr, formatdate, make_msgid
+from io import BytesIO
+from typing import Any, Dict, Optional
+from urllib.parse import urlparse
+
+from ..build_info import BUILD_NUMBER
+from ..config import settings as env_settings
+from ..db import delete_setting, get_setting, set_setting
+from ..runtime import get_runtime_settings
+
+logger = logging.getLogger(__name__)
+
+TEMPLATE_SETTING_PREFIX = "invite_email_template_"
+TEMPLATE_KEYS = ("invited", "welcome", "warning", "banned")
+EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
+EXCHANGE_MESSAGE_ID_PATTERN = re.compile(r"<([^>]+)>")
+EXCHANGE_INTERNAL_ID_PATTERN = re.compile(r"\[InternalId=([^\],]+)")
+EMAIL_LOGO_CID = "magent-logo"
+
+TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = {
+ "invited": {
+ "label": "You have been invited",
+ "description": "Sent when an invite link is created and emailed to a recipient.",
+ },
+ "welcome": {
+ "label": "Welcome / How it works",
+ "description": "Sent after an invited user completes signup.",
+ },
+ "warning": {
+ "label": "Warning",
+ "description": "Manual warning template for account or behavior notices.",
+ },
+ "banned": {
+ "label": "Banned",
+ "description": "Sent when an account is banned or removed.",
+ },
+}
+
+TEMPLATE_PLACEHOLDERS = [
+ "app_name",
+ "app_url",
+ "build_number",
+ "how_it_works_url",
+ "invite_code",
+ "invite_description",
+ "invite_expires_at",
+ "invite_label",
+ "invite_link",
+ "invite_remaining_uses",
+ "inviter_username",
+ "message",
+ "reason",
+ "recipient_email",
+ "role",
+ "username",
+]
+
+EMAIL_TAGLINE = "Find and fix media requests fast."
+
+EMAIL_TONE_STYLES: Dict[str, Dict[str, str]] = {
+ "brand": {
+ "chip_bg": "rgba(255, 107, 43, 0.16)",
+ "chip_border": "rgba(255, 107, 43, 0.38)",
+ "chip_text": "#ffd2bf",
+ "accent_a": "#ff6b2b",
+ "accent_b": "#1c6bff",
+ },
+ "success": {
+ "chip_bg": "rgba(34, 197, 94, 0.16)",
+ "chip_border": "rgba(34, 197, 94, 0.38)",
+ "chip_text": "#c7f9d7",
+ "accent_a": "#22c55e",
+ "accent_b": "#1c6bff",
+ },
+ "warning": {
+ "chip_bg": "rgba(251, 146, 60, 0.16)",
+ "chip_border": "rgba(251, 146, 60, 0.38)",
+ "chip_text": "#ffe0ba",
+ "accent_a": "#fb923c",
+ "accent_b": "#ff6b2b",
+ },
+ "danger": {
+ "chip_bg": "rgba(248, 113, 113, 0.16)",
+ "chip_border": "rgba(248, 113, 113, 0.38)",
+ "chip_text": "#ffd0d0",
+ "accent_a": "#ef4444",
+ "accent_b": "#ff6b2b",
+ },
+}
+
+TEMPLATE_PRESENTATION: Dict[str, Dict[str, str]] = {
+ "invited": {
+ "tone": "brand",
+ "title": "You have been invited",
+ "subtitle": "A new account invitation is ready for you.",
+ "primary_label": "Accept invite",
+ "primary_url_key": "invite_link",
+ "secondary_label": "How it works",
+ "secondary_url_key": "how_it_works_url",
+ },
+ "welcome": {
+ "tone": "success",
+ "title": "Welcome to Magent",
+ "subtitle": "Your account is ready and synced.",
+ "primary_label": "Open Magent",
+ "primary_url_key": "app_url",
+ "secondary_label": "How it works",
+ "secondary_url_key": "how_it_works_url",
+ },
+ "warning": {
+ "tone": "warning",
+ "title": "Account warning",
+ "subtitle": "Please review the note below.",
+ "primary_label": "Open Magent",
+ "primary_url_key": "app_url",
+ "secondary_label": "How it works",
+ "secondary_url_key": "how_it_works_url",
+ },
+ "banned": {
+ "tone": "danger",
+ "title": "Account status changed",
+ "subtitle": "Your account has been restricted or removed.",
+ "primary_label": "How it works",
+ "primary_url_key": "how_it_works_url",
+ "secondary_label": "",
+ "secondary_url_key": "",
+ },
+}
+
+
+def _build_email_stat_card(label: str, value: str, detail: str = "") -> str:
+ detail_html = (
+ f""
+ f"{html.escape(detail)}
"
+ if detail
+ else ""
+ )
+ return (
+ ""
+ ""
+ f""
+ f"{html.escape(label)}
"
+ f""
+ f"{html.escape(value)}
"
+ f"{detail_html}"
+ "
"
+ )
+
+
+def _build_email_stat_grid(cards: list[str]) -> str:
+ if not cards:
+ return ""
+ rows: list[str] = []
+ for index in range(0, len(cards), 2):
+ left = cards[index]
+ right = cards[index + 1] if index + 1 < len(cards) else ""
+ rows.append(
+ ""
+ f"{left} "
+ f"{right} "
+ " "
+ )
+ return (
+ ""
+ f"{''.join(rows)}"
+ "
"
+ )
+
+
+def _build_email_list(items: list[str], *, ordered: bool = False) -> str:
+ tag = "ol" if ordered else "ul"
+ marker = "padding-left:20px;" if ordered else "padding-left:18px;"
+ rendered_items = "".join(
+ f"{html.escape(item)} " for item in items if item
+ )
+ return (
+ f"<{tag} style=\"margin:0; {marker} color:#132033; line-height:1.8; font-size:14px;\">"
+ f"{rendered_items}"
+ f"{tag}>"
+ )
+
+
+def _build_email_panel(title: str, body_html: str, *, variant: str = "neutral") -> str:
+ styles = {
+ "neutral": {
+ "background": "#f8fafc",
+ "border": "#d9e2ef",
+ "eyebrow": "#6b778c",
+ "text": "#132033",
+ },
+ "brand": {
+ "background": "#eef4ff",
+ "border": "#bfd2ff",
+ "eyebrow": "#2754b6",
+ "text": "#132033",
+ },
+ "success": {
+ "background": "#edf9f0",
+ "border": "#bfe4c6",
+ "eyebrow": "#1f7a3f",
+ "text": "#132033",
+ },
+ "warning": {
+ "background": "#fff5ea",
+ "border": "#ffd5a8",
+ "eyebrow": "#c46a10",
+ "text": "#132033",
+ },
+ "danger": {
+ "background": "#fff0f0",
+ "border": "#f3c1c1",
+ "eyebrow": "#bb2d2d",
+ "text": "#132033",
+ },
+ }.get(variant, {
+ "background": "#f8fafc",
+ "border": "#d9e2ef",
+ "eyebrow": "#6b778c",
+ "text": "#132033",
+ })
+ return (
+ ""
+ f""
+ f""
+ f"{html.escape(title)}
"
+ f"{body_html}
"
+ "
"
+ )
+
+
+DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
+ "invited": {
+ "subject": "{{app_name}} invite for {{recipient_email}}",
+ "body_text": (
+ "You have been invited to {{app_name}}.\n\n"
+ "Invite code: {{invite_code}}\n"
+ "Signup link: {{invite_link}}\n"
+ "Invited by: {{inviter_username}}\n"
+ "Invite label: {{invite_label}}\n"
+ "Expires: {{invite_expires_at}}\n"
+ "Remaining uses: {{invite_remaining_uses}}\n\n"
+ "{{invite_description}}\n\n"
+ "{{message}}\n\n"
+ "How it works: {{how_it_works_url}}\n"
+ "Build: {{build_number}}\n"
+ ),
+ "body_html": (
+ ""
+ "A new invitation has been prepared for {{recipient_email}} . Use the details below to sign up."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Invite code", "{{invite_code}}"),
+ _build_email_stat_card("Invited by", "{{inviter_username}}"),
+ _build_email_stat_card("Invite label", "{{invite_label}}"),
+ _build_email_stat_card(
+ "Access window",
+ "{{invite_expires_at}}",
+ "Remaining uses: {{invite_remaining_uses}}",
+ ),
+ ]
+ )
+ + _build_email_panel(
+ "Invitation details",
+ "{{invite_description}}
",
+ variant="brand",
+ )
+ + _build_email_panel(
+ "Message from admin",
+ "{{message}}
",
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "What happens next",
+ _build_email_list(
+ [
+ "Open the invite link and complete the signup flow.",
+ "Sign in using the shared credentials for Magent and Seerr.",
+ "Use the How it works page if you want a quick overview first.",
+ ],
+ ordered=True,
+ ),
+ variant="neutral",
+ )
+ ),
+ },
+ "welcome": {
+ "subject": "Welcome to {{app_name}}",
+ "body_text": (
+ "Welcome to {{app_name}}, {{username}}.\n\n"
+ "Your account is ready.\n"
+ "Open: {{app_url}}\n"
+ "How it works: {{how_it_works_url}}\n"
+ "Role: {{role}}\n\n"
+ "{{message}}\n"
+ ),
+ "body_html": (
+ ""
+ "Your account is live and ready to use. Everything below mirrors the current site behavior."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Username", "{{username}}"),
+ _build_email_stat_card("Role", "{{role}}"),
+ _build_email_stat_card("Magent", "{{app_url}}"),
+ _build_email_stat_card("Guides", "{{how_it_works_url}}"),
+ ]
+ )
+ + _build_email_panel(
+ "What to do next",
+ _build_email_list(
+ [
+ "Open Magent and sign in using your shared credentials.",
+ "Search all requests or review your own activity without refreshing the page.",
+ "Use the invite tools in your profile if your account allows it.",
+ ],
+ ordered=True,
+ ),
+ variant="success",
+ )
+ + _build_email_panel(
+ "Additional notes",
+ "{{message}}
",
+ variant="neutral",
+ )
+ ),
+ },
+ "warning": {
+ "subject": "{{app_name}} account warning",
+ "body_text": (
+ "Hello {{username}},\n\n"
+ "This is a warning regarding your {{app_name}} account.\n\n"
+ "Reason: {{reason}}\n\n"
+ "{{message}}\n\n"
+ "If you need help, contact the admin.\n"
+ ),
+ "body_html": (
+ ""
+ "Please review this account notice carefully. This message was sent by an administrator."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Account", "{{username}}"),
+ _build_email_stat_card("Role", "{{role}}"),
+ _build_email_stat_card("Application", "{{app_name}}"),
+ _build_email_stat_card("Support", "{{how_it_works_url}}"),
+ ]
+ )
+ + _build_email_panel(
+ "Reason",
+ "{{reason}}
",
+ variant="warning",
+ )
+ + _build_email_panel(
+ "Administrator note",
+ "{{message}}
",
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "What to do next",
+ _build_email_list(
+ [
+ "Review the note above and confirm you understand what needs to change.",
+ "If you need help, reply through your usual support path or contact an administrator.",
+ "Keep this email for reference until the matter is resolved.",
+ ]
+ ),
+ variant="neutral",
+ )
+ ),
+ },
+ "banned": {
+ "subject": "{{app_name}} account status changed",
+ "body_text": (
+ "Hello {{username}},\n\n"
+ "Your {{app_name}} account has been banned or removed.\n\n"
+ "Reason: {{reason}}\n\n"
+ "{{message}}\n"
+ ),
+ "body_html": (
+ ""
+ "Your account access has changed. Review the details below."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Account", "{{username}}"),
+ _build_email_stat_card("Status", "Restricted"),
+ _build_email_stat_card("Application", "{{app_name}}"),
+ _build_email_stat_card("Guidance", "{{how_it_works_url}}"),
+ ]
+ )
+ + _build_email_panel(
+ "Reason",
+ "{{reason}}
",
+ variant="danger",
+ )
+ + _build_email_panel(
+ "Administrator note",
+ "{{message}}
",
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "What this means",
+ _build_email_list(
+ [
+ "Your access has been removed or restricted across the linked services.",
+ "If you believe this is incorrect, contact the site administrator directly.",
+ "Do not rely on old links or cached sessions after this change.",
+ ]
+ ),
+ variant="neutral",
+ )
+ ),
+ },
+}
+
+
+def _template_setting_key(template_key: str) -> str:
+ return f"{TEMPLATE_SETTING_PREFIX}{template_key}"
+
+
+def _is_valid_email(value: object) -> bool:
+ if not isinstance(value, str):
+ return False
+ candidate = value.strip()
+ if not candidate:
+ return False
+ return bool(EMAIL_PATTERN.match(candidate))
+
+
+def _normalize_email(value: object) -> Optional[str]:
+ if not _is_valid_email(value):
+ return None
+ return str(value).strip()
+
+
+def normalize_delivery_email(value: object) -> Optional[str]:
+ return _normalize_email(value)
+
+
+def _normalize_display_text(value: object, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else fallback
+ return str(value)
+
+
+def _template_context_value(value: object, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ return value.strip()
+ return str(value)
+
+
+def _safe_template_context(context: Dict[str, object]) -> Dict[str, str]:
+ safe: Dict[str, str] = {}
+ for key in TEMPLATE_PLACEHOLDERS:
+ safe[key] = _template_context_value(context.get(key), "")
+ return safe
+
+
+def _render_template_string(template: str, context: Dict[str, str], *, escape_html: bool = False) -> str:
+ if not isinstance(template, str):
+ return ""
+
+ def _replace(match: re.Match[str]) -> str:
+ key = match.group(1)
+ value = context.get(key, "")
+ return html.escape(value) if escape_html else value
+
+ return PLACEHOLDER_PATTERN.sub(_replace, template)
+
+
+def _strip_html_for_text(value: str) -> str:
+ text = re.sub(r" ", "\n", value, flags=re.IGNORECASE)
+ text = re.sub(r"
", "\n\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"<[^>]+>", "", text)
+ return html.unescape(text).strip()
+
+
+def _build_default_base_url() -> str:
+ runtime = get_runtime_settings()
+ for candidate in (
+ runtime.magent_application_url,
+ runtime.magent_proxy_base_url,
+ env_settings.cors_allow_origin,
+ ):
+ normalized = _normalize_display_text(candidate)
+ if normalized:
+ return normalized.rstrip("/")
+ port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
+ return f"http://localhost:{port}"
+
+
+def _derive_mail_hostname(*, from_address: str) -> str:
+ runtime = get_runtime_settings()
+ candidates = (
+ runtime.magent_application_url,
+ runtime.magent_proxy_base_url,
+ env_settings.cors_allow_origin,
+ )
+ for candidate in candidates:
+ normalized = _normalize_display_text(candidate)
+ if not normalized:
+ continue
+ parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
+ hostname = _normalize_display_text(parsed.hostname)
+ if hostname and "." in hostname:
+ return hostname
+ domain = _normalize_display_text(from_address.split("@", 1)[1] if "@" in from_address else None)
+ if domain and "." in domain:
+ return domain
+ return "localhost"
+
+
+def _add_transactional_headers(
+ message: EmailMessage,
+ *,
+ from_name: str,
+ from_address: str,
+) -> None:
+ message["Reply-To"] = formataddr((from_name, from_address))
+ message["Organization"] = env_settings.app_name
+ message["X-Mailer"] = f"{env_settings.app_name}/{BUILD_NUMBER}"
+ message["Auto-Submitted"] = "auto-generated"
+ message["X-Auto-Response-Suppress"] = "All"
+
+
+def _looks_like_full_html_document(value: str) -> bool:
+ probe = value.lstrip().lower()
+ return probe.startswith(" str:
+ background = "linear-gradient(135deg, #ff6b2b 0%, #1c6bff 100%)" if primary else "#ffffff"
+ fallback = "#1c6bff" if primary else "#ffffff"
+ border = "1px solid rgba(28, 107, 255, 0.28)" if primary else "1px solid #d5deed"
+ color = "#ffffff" if primary else "#132033"
+ return (
+ f"{html.escape(label)} "
+ )
+
+
+@lru_cache(maxsize=1)
+def _get_email_logo_bytes() -> bytes:
+ logo_path = Path(__file__).resolve().parents[1] / "assets" / "branding" / "logo.png"
+ try:
+ return logo_path.read_bytes()
+ except OSError:
+ return b""
+
+
+def _build_email_logo_block(app_name: str) -> str:
+ if _get_email_logo_bytes():
+ return (
+ f" "
+ )
+ return (
+ "M
"
+ )
+
+
+def _build_outlook_safe_test_email_html(
+ *,
+ app_name: str,
+ application_url: str,
+ build_number: str,
+ smtp_target: str,
+ security_mode: str,
+ auth_mode: str,
+ warning: str,
+ primary_url: str = "",
+) -> str:
+ action_html = (
+ _build_email_action_button("Open Magent", primary_url, primary=True) if primary_url else ""
+ )
+ logo_block = _build_email_logo_block(app_name)
+ warning_block = (
+ ""
+ ""
+ ""
+ ""
+ ""
+ "Delivery notes
"
+ f"{html.escape(warning)}"
+ "
"
+ " "
+ " "
+ ) if warning else ""
+ return (
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ f"{logo_block} "
+ ""
+ f"{html.escape(app_name)} email test
"
+ "This confirms Magent can generate and hand off branded mail.
"
+ " "
+ " "
+ "
"
+ " "
+ " "
+ ""
+ "This is a test email from Magent .
"
+ " "
+ ""
+ ""
+ ""
+ ""
+ ""
+ f"{_build_email_stat_card('Build', build_number)}"
+ " "
+ ""
+ f"{_build_email_stat_card('Application URL', application_url)}"
+ " "
+ " "
+ ""
+ ""
+ f"{_build_email_stat_card('SMTP target', smtp_target)}"
+ " "
+ ""
+ f"{_build_email_stat_card('Security', security_mode, auth_mode)}"
+ " "
+ " "
+ "
"
+ " "
+ " "
+ ""
+ ""
+ ""
+ ""
+ ""
+ "What this verifies
"
+ "Magent can build the HTML template shell correctly.
"
+ "The configured SMTP route accepts and relays the message.
"
+ "Branding, links, and build metadata are rendering consistently.
"
+ "
"
+ " "
+ " "
+ f"{warning_block}"
+ ""
+ ""
+ f"{action_html}"
+ " "
+ " "
+ "
"
+ "
"
+ ""
+ ""
+ )
+
+
+def _wrap_email_html(
+ *,
+ app_name: str,
+ app_url: str,
+ build_number: str,
+ title: str,
+ subtitle: str,
+ tone: str,
+ body_html: str,
+ primary_label: str = "",
+ primary_url: str = "",
+ secondary_label: str = "",
+ secondary_url: str = "",
+ footer_note: str = "",
+) -> str:
+ styles = EMAIL_TONE_STYLES.get(tone, EMAIL_TONE_STYLES["brand"])
+ actions = []
+ if primary_label and primary_url:
+ actions.append(_build_email_action_button(primary_label, primary_url, primary=True))
+ if secondary_label and secondary_url:
+ actions.append(_build_email_action_button(secondary_label, secondary_url, primary=False))
+ actions_html = "".join(actions)
+
+ footer = footer_note or "This email was generated automatically by Magent."
+ logo_block = _build_email_logo_block(app_name)
+
+ return (
+ ""
+ ""
+ ""
+ f"{html.escape(title)} - {html.escape(subtitle)}"
+ "
"
+ ""
+ ""
+ ""
+ ""
+ f""
+ "
"
+ ""
+ f"{logo_block} "
+ ""
+ f"{html.escape(app_name)}
"
+ f"{html.escape(title)}
"
+ f"{html.escape(subtitle or EMAIL_TAGLINE)}
"
+ " "
+ " "
+ "
"
+ f"
"
+ f"
"
+ f"{html.escape(EMAIL_TAGLINE)}
"
+ f"
{body_html}
"
+ f"
{actions_html}
"
+ "
"
+ "
"
+ ""
+ f"{html.escape(footer)} "
+ f"Build {html.escape(build_number)} "
+ " "
+ "
"
+ "
"
+ "
"
+ "
"
+ "
"
+ ""
+ )
+
+
+def build_invite_email_context(
+ *,
+ invite: Optional[Dict[str, Any]] = None,
+ user: Optional[Dict[str, Any]] = None,
+ recipient_email: Optional[str] = None,
+ message: Optional[str] = None,
+ reason: Optional[str] = None,
+ overrides: Optional[Dict[str, object]] = None,
+) -> Dict[str, str]:
+ app_url = _build_default_base_url()
+ invite_code = _normalize_display_text(invite.get("code") if invite else None, "Not set")
+ invite_link = f"{app_url}/signup?code={invite_code}" if invite_code != "Not set" else f"{app_url}/signup"
+ remaining_uses = invite.get("remaining_uses") if invite else None
+ resolved_recipient = _normalize_email(recipient_email)
+ if not resolved_recipient and invite:
+ resolved_recipient = _normalize_email(invite.get("recipient_email"))
+ if not resolved_recipient and user:
+ resolved_recipient = resolve_user_delivery_email(user)
+
+ context: Dict[str, object] = {
+ "app_name": env_settings.app_name,
+ "app_url": app_url,
+ "build_number": BUILD_NUMBER,
+ "how_it_works_url": f"{app_url}/how-it-works",
+ "invite_code": invite_code,
+ "invite_description": _normalize_display_text(invite.get("description") if invite else None, "No extra details."),
+ "invite_expires_at": _normalize_display_text(invite.get("expires_at") if invite else None, "Never"),
+ "invite_label": _normalize_display_text(invite.get("label") if invite else None, "No label"),
+ "invite_link": invite_link,
+ "invite_remaining_uses": (
+ "Unlimited" if remaining_uses in (None, "") else _normalize_display_text(remaining_uses)
+ ),
+ "inviter_username": _normalize_display_text(
+ invite.get("created_by") if invite else (user.get("username") if user else None),
+ "Admin",
+ ),
+ "message": _normalize_display_text(message, "No additional note."),
+ "reason": _normalize_display_text(reason, "Not specified"),
+ "recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"),
+ "role": _normalize_display_text(user.get("role") if user else None, "user"),
+ "username": _normalize_display_text(user.get("username") if user else None, "there"),
+ }
+ if isinstance(overrides, dict):
+ context.update(overrides)
+ return _safe_template_context(context)
+
+
+def get_invite_email_templates() -> Dict[str, Dict[str, Any]]:
+ templates: Dict[str, Dict[str, Any]] = {}
+ for template_key in TEMPLATE_KEYS:
+ template = dict(DEFAULT_TEMPLATES[template_key])
+ raw_value = get_setting(_template_setting_key(template_key))
+ if raw_value:
+ try:
+ stored = json.loads(raw_value)
+ except (TypeError, json.JSONDecodeError):
+ stored = {}
+ if isinstance(stored, dict):
+ for field in ("subject", "body_text", "body_html"):
+ if isinstance(stored.get(field), str):
+ template[field] = stored[field]
+ templates[template_key] = {
+ "key": template_key,
+ "label": TEMPLATE_METADATA[template_key]["label"],
+ "description": TEMPLATE_METADATA[template_key]["description"],
+ "placeholders": TEMPLATE_PLACEHOLDERS,
+ **template,
+ }
+ return templates
+
+
+def get_invite_email_template(template_key: str) -> Dict[str, Any]:
+ if template_key not in TEMPLATE_KEYS:
+ raise ValueError(f"Unknown email template: {template_key}")
+ return get_invite_email_templates()[template_key]
+
+
+def save_invite_email_template(
+ template_key: str,
+ *,
+ subject: str,
+ body_text: str,
+ body_html: str,
+) -> Dict[str, Any]:
+ if template_key not in TEMPLATE_KEYS:
+ raise ValueError(f"Unknown email template: {template_key}")
+ payload = {
+ "subject": subject,
+ "body_text": body_text,
+ "body_html": body_html,
+ }
+ set_setting(_template_setting_key(template_key), json.dumps(payload))
+ return get_invite_email_template(template_key)
+
+
+def reset_invite_email_template(template_key: str) -> Dict[str, Any]:
+ if template_key not in TEMPLATE_KEYS:
+ raise ValueError(f"Unknown email template: {template_key}")
+ delete_setting(_template_setting_key(template_key))
+ return get_invite_email_template(template_key)
+
+
+def render_invite_email_template(
+ template_key: str,
+ *,
+ invite: Optional[Dict[str, Any]] = None,
+ user: Optional[Dict[str, Any]] = None,
+ recipient_email: Optional[str] = None,
+ message: Optional[str] = None,
+ reason: Optional[str] = None,
+ overrides: Optional[Dict[str, object]] = None,
+) -> Dict[str, str]:
+ template = get_invite_email_template(template_key)
+ context = build_invite_email_context(
+ invite=invite,
+ user=user,
+ recipient_email=recipient_email,
+ message=message,
+ reason=reason,
+ overrides=overrides,
+ )
+ raw_body_html = _render_template_string(template["body_html"], context, escape_html=True)
+ body_text = _render_template_string(template["body_text"], context, escape_html=False)
+ if not body_text.strip() and raw_body_html.strip():
+ body_text = _strip_html_for_text(raw_body_html)
+ subject = _render_template_string(template["subject"], context, escape_html=False)
+ presentation = TEMPLATE_PRESENTATION.get(template_key, TEMPLATE_PRESENTATION["invited"])
+ primary_url = _normalize_display_text(context.get(presentation["primary_url_key"], ""))
+ secondary_url = _normalize_display_text(context.get(presentation["secondary_url_key"], ""))
+ if _looks_like_full_html_document(raw_body_html):
+ body_html = raw_body_html.strip()
+ else:
+ body_html = _wrap_email_html(
+ app_name=_normalize_display_text(context.get("app_name"), env_settings.app_name),
+ app_url=_normalize_display_text(context.get("app_url"), _build_default_base_url()),
+ build_number=_normalize_display_text(context.get("build_number"), BUILD_NUMBER),
+ title=_normalize_display_text(context.get("title"), presentation["title"]),
+ subtitle=_normalize_display_text(context.get("subtitle"), presentation["subtitle"]),
+ tone=_normalize_display_text(context.get("tone"), presentation["tone"]),
+ body_html=raw_body_html.strip(),
+ primary_label=_normalize_display_text(
+ context.get("primary_label"), presentation["primary_label"]
+ ),
+ primary_url=primary_url,
+ secondary_label=_normalize_display_text(
+ context.get("secondary_label"), presentation["secondary_label"]
+ ),
+ secondary_url=secondary_url,
+ footer_note=_normalize_display_text(context.get("footer_note"), ""),
+ ).strip()
+ return {
+ "subject": subject.strip(),
+ "body_text": body_text.strip(),
+ "body_html": body_html.strip(),
+ }
+
+
+def resolve_user_delivery_email(user: Optional[Dict[str, Any]], invite: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ if not isinstance(user, dict):
+ return _normalize_email(invite.get("recipient_email") if isinstance(invite, dict) else None)
+ stored_email = _normalize_email(user.get("email"))
+ if stored_email:
+ return stored_email
+ username_email = _normalize_email(user.get("username"))
+ if username_email:
+ return username_email
+ if isinstance(invite, dict):
+ invite_email = _normalize_email(invite.get("recipient_email"))
+ if invite_email:
+ return invite_email
+ return None
+
+
+def smtp_email_config_ready() -> tuple[bool, str]:
+ runtime = get_runtime_settings()
+ if not runtime.magent_notify_enabled:
+ return False, "Notifications are disabled."
+ if not runtime.magent_notify_email_enabled:
+ return False, "Email notifications are disabled."
+ if not _normalize_display_text(runtime.magent_notify_email_smtp_host):
+ return False, "SMTP host is not configured."
+ if not _normalize_email(runtime.magent_notify_email_from_address):
+ return False, "From email address is not configured."
+ return True, "ok"
+
+
+def smtp_email_delivery_warning() -> Optional[str]:
+ runtime = get_runtime_settings()
+ host = _normalize_display_text(runtime.magent_notify_email_smtp_host).lower()
+ username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
+ password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
+ if host.endswith(".mail.protection.outlook.com") and not (username and password):
+ return (
+ "Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
+ "confirm mailbox delivery, and suspicious messages can still be filtered. For reliable "
+ "delivery, use smtp.office365.com:587 with SMTP credentials or configure a verified "
+ "Exchange relay connector and make sure SPF, DKIM, and DMARC are healthy for the "
+ "sender domain."
+ )
+ return None
+
+
+def _flatten_message(message: EmailMessage) -> bytes:
+ buffer = BytesIO()
+ BytesGenerator(buffer, policy=SMTP_POLICY).flatten(message)
+ return buffer.getvalue()
+
+
+def _decode_smtp_message(value: bytes | str | None) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return str(value)
+
+
+def _parse_exchange_receipt(value: bytes | str | None) -> Dict[str, str]:
+ message = _decode_smtp_message(value)
+ receipt: Dict[str, str] = {"raw": message}
+ message_id_match = EXCHANGE_MESSAGE_ID_PATTERN.search(message)
+ internal_id_match = EXCHANGE_INTERNAL_ID_PATTERN.search(message)
+ if message_id_match:
+ receipt["provider_message_id"] = message_id_match.group(1)
+ if internal_id_match:
+ receipt["provider_internal_id"] = internal_id_match.group(1)
+ return receipt
+
+
+def _send_via_smtp_session(
+ smtp: smtplib.SMTP,
+ *,
+ from_address: str,
+ recipient_email: str,
+ message: EmailMessage,
+) -> Dict[str, str]:
+ mail_code, mail_message = smtp.mail(from_address)
+ if mail_code >= 400:
+ raise smtplib.SMTPResponseException(mail_code, mail_message)
+ rcpt_code, rcpt_message = smtp.rcpt(recipient_email)
+ if rcpt_code >= 400:
+ raise smtplib.SMTPRecipientsRefused({recipient_email: (rcpt_code, rcpt_message)})
+ data_code, data_message = smtp.data(_flatten_message(message))
+ if data_code >= 400:
+ raise smtplib.SMTPDataError(data_code, data_message)
+ receipt = _parse_exchange_receipt(data_message)
+ receipt["mail_response"] = _decode_smtp_message(mail_message)
+ receipt["rcpt_response"] = _decode_smtp_message(rcpt_message)
+ receipt["data_response"] = _decode_smtp_message(data_message)
+ return receipt
+
+
+def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> Dict[str, str]:
+ runtime = get_runtime_settings()
+ host = _normalize_display_text(runtime.magent_notify_email_smtp_host)
+ port = int(runtime.magent_notify_email_smtp_port or 587)
+ username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
+ password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
+ from_address = _normalize_email(runtime.magent_notify_email_from_address)
+ from_name = _normalize_display_text(runtime.magent_notify_email_from_name, env_settings.app_name)
+ use_tls = bool(runtime.magent_notify_email_use_tls)
+ use_ssl = bool(runtime.magent_notify_email_use_ssl)
+ delivery_warning = smtp_email_delivery_warning()
+ if not host or not from_address:
+ raise RuntimeError("SMTP email settings are incomplete.")
+ local_hostname = _derive_mail_hostname(from_address=from_address)
+ logger.info(
+ "smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
+ recipient_email,
+ from_address,
+ host,
+ port,
+ use_tls,
+ use_ssl,
+ bool(username and password),
+ subject,
+ local_hostname,
+ )
+ if delivery_warning:
+ logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
+
+ message = EmailMessage()
+ message["Subject"] = subject
+ message["From"] = formataddr((from_name, from_address))
+ message["To"] = recipient_email
+ message["Date"] = formatdate(localtime=True)
+ if "@" in from_address:
+ message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
+ else:
+ message["Message-ID"] = make_msgid()
+ _add_transactional_headers(
+ message,
+ from_name=from_name,
+ from_address=from_address,
+ )
+ message.set_content(body_text or _strip_html_for_text(body_html))
+ if body_html.strip():
+ message.add_alternative(body_html, subtype="html")
+ if f"cid:{EMAIL_LOGO_CID}" in body_html:
+ logo_bytes = _get_email_logo_bytes()
+ if logo_bytes:
+ html_part = message.get_body(preferencelist=("html",))
+ if html_part is not None:
+ html_part.add_related(
+ logo_bytes,
+ maintype="image",
+ subtype="png",
+ cid=f"<{EMAIL_LOGO_CID}>",
+ filename="logo.png",
+ disposition="inline",
+ )
+
+ if use_ssl:
+ with smtplib.SMTP_SSL(host, port, timeout=20, local_hostname=local_hostname) as smtp:
+ logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
+ if username and password:
+ smtp.login(username, password)
+ logger.debug("smtp login succeeded host=%s username=%s", host, username)
+ receipt = _send_via_smtp_session(
+ smtp,
+ from_address=from_address,
+ recipient_email=recipient_email,
+ message=message,
+ )
+ logger.info(
+ "smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s",
+ recipient_email,
+ host,
+ receipt.get("provider_message_id"),
+ receipt.get("provider_internal_id"),
+ )
+ return receipt
+
+ with smtplib.SMTP(host, port, timeout=20, local_hostname=local_hostname) as smtp:
+ logger.debug("smtp connection opened host=%s port=%s", host, port)
+ smtp.ehlo()
+ if use_tls:
+ smtp.starttls()
+ smtp.ehlo()
+ logger.debug("smtp starttls negotiated host=%s port=%s", host, port)
+ if username and password:
+ smtp.login(username, password)
+ logger.debug("smtp login succeeded host=%s username=%s", host, username)
+ receipt = _send_via_smtp_session(
+ smtp,
+ from_address=from_address,
+ recipient_email=recipient_email,
+ message=message,
+ )
+ logger.info(
+ "smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s",
+ recipient_email,
+ host,
+ receipt.get("provider_message_id"),
+ receipt.get("provider_internal_id"),
+ )
+ return receipt
+
+
+async def send_templated_email(
+ template_key: str,
+ *,
+ invite: Optional[Dict[str, Any]] = None,
+ user: Optional[Dict[str, Any]] = None,
+ recipient_email: Optional[str] = None,
+ message: Optional[str] = None,
+ reason: Optional[str] = None,
+ overrides: Optional[Dict[str, object]] = None,
+) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+
+ resolved_email = _normalize_email(recipient_email)
+ if not resolved_email:
+ resolved_email = resolve_user_delivery_email(user, invite)
+ if not resolved_email:
+ raise RuntimeError("No valid recipient email is available for this action.")
+
+ rendered = render_invite_email_template(
+ template_key,
+ invite=invite,
+ user=user,
+ recipient_email=resolved_email,
+ message=message,
+ reason=reason,
+ overrides=overrides,
+ )
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=rendered["subject"],
+ body_text=rendered["body_text"],
+ body_html=rendered["body_html"],
+ )
+ logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email)
+ return {
+ "recipient_email": resolved_email,
+ "subject": rendered["subject"],
+ **{
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ },
+ }
+
+
+async def send_generic_email(
+ *,
+ recipient_email: str,
+ subject: str,
+ body_text: str,
+ body_html: str = "",
+) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+ resolved_email = _normalize_email(recipient_email)
+ if not resolved_email:
+ raise RuntimeError("A valid recipient email is required.")
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=subject.strip() or f"{env_settings.app_name} notification",
+ body_text=body_text.strip(),
+ body_html=body_html.strip(),
+ )
+ logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
+ return {
+ "recipient_email": resolved_email,
+ "subject": subject.strip() or f"{env_settings.app_name} notification",
+ **{
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ },
+ }
+
+
+async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+
+ runtime = get_runtime_settings()
+ resolved_email = _normalize_email(recipient_email) or _normalize_email(
+ runtime.magent_notify_email_from_address
+ )
+ if not resolved_email:
+ raise RuntimeError("No valid recipient email is configured for the test message.")
+
+ application_url = _normalize_display_text(runtime.magent_application_url, "Not configured")
+ primary_url = application_url if application_url.lower().startswith(("http://", "https://")) else ""
+ smtp_target = f"{_normalize_display_text(runtime.magent_notify_email_smtp_host, 'Not configured')}:{int(runtime.magent_notify_email_smtp_port or 587)}"
+ security_mode = "SSL" if runtime.magent_notify_email_use_ssl else ("STARTTLS" if runtime.magent_notify_email_use_tls else "Plain SMTP")
+ auth_mode = "Authenticated" if (
+ _normalize_display_text(runtime.magent_notify_email_smtp_username)
+ and _normalize_display_text(runtime.magent_notify_email_smtp_password)
+ ) else "No SMTP auth"
+ delivery_warning = smtp_email_delivery_warning()
+ subject = f"{env_settings.app_name} email test"
+ body_text = (
+ f"This is a test email from {env_settings.app_name}.\n\n"
+ f"Build: {BUILD_NUMBER}\n"
+ f"Application URL: {application_url}\n"
+ f"SMTP target: {smtp_target}\n"
+ f"Security: {security_mode} ({auth_mode})\n\n"
+ "What this verifies:\n"
+ "- Magent can build the HTML template shell correctly.\n"
+ "- The configured SMTP route accepts and relays the message.\n"
+ "- Branding, links, and build metadata are rendering consistently.\n"
+ )
+ body_html = _wrap_email_html(
+ app_name=env_settings.app_name,
+ app_url=_build_default_base_url(),
+ build_number=BUILD_NUMBER,
+ title="Email delivery test",
+ subtitle="This confirms Magent can generate and hand off branded mail.",
+ tone="brand",
+ body_html=(
+ ""
+ "This is a live test email from Magent. If this renders correctly, the HTML template shell and SMTP handoff are both working."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Recipient", resolved_email),
+ _build_email_stat_card("Build", BUILD_NUMBER),
+ _build_email_stat_card("SMTP target", smtp_target),
+ _build_email_stat_card("Security", security_mode, auth_mode),
+ _build_email_stat_card("Application URL", application_url),
+ _build_email_stat_card("Template shell", "Branded HTML", "Logo, gradient, action buttons"),
+ ]
+ )
+ + _build_email_panel(
+ "What this verifies",
+ _build_email_list(
+ [
+ "Magent can build the HTML template shell correctly.",
+ "The configured SMTP route accepts and relays the message.",
+ "Branding, links, and build metadata are rendering consistently.",
+ ]
+ ),
+ variant="brand",
+ )
+ + _build_email_panel(
+ "Delivery notes",
+ (
+ f"{html.escape(delivery_warning)}
"
+ if delivery_warning
+ else "Use this test when changing SMTP settings, relay targets, or branding."
+ ),
+ variant="warning" if delivery_warning else "neutral",
+ )
+ ),
+ primary_label="Open Magent" if primary_url else "",
+ primary_url=primary_url,
+ footer_note="SMTP test email generated by Magent.",
+ )
+
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ logger.info("SMTP test email sent: recipient=%s", resolved_email)
+ result = {"recipient_email": resolved_email, "subject": subject}
+ result.update(
+ {
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ }
+ )
+ warning = smtp_email_delivery_warning()
+ if warning:
+ result["warning"] = warning
+ return result
+
+
+async def send_password_reset_email(
+ *,
+ recipient_email: str,
+ username: str,
+ token: str,
+ expires_at: str,
+ auth_provider: str,
+) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+
+ resolved_email = _normalize_email(recipient_email)
+ if not resolved_email:
+ raise RuntimeError("No valid recipient email is available for password reset.")
+
+ app_url = _build_default_base_url()
+ reset_url = f"{app_url}/reset-password?token={token}"
+ provider_label = "Jellyfin, Seerr, and Magent" if auth_provider == "jellyfin" else "Magent"
+ subject = f"{env_settings.app_name} password reset"
+ body_text = (
+ f"A password reset was requested for {username}.\n\n"
+ f"This link will reset the password used for {provider_label}.\n"
+ f"Reset link: {reset_url}\n"
+ f"Expires: {expires_at}\n\n"
+ "If you did not request this reset, you can ignore this email.\n"
+ )
+ body_html = _wrap_email_html(
+ app_name=env_settings.app_name,
+ app_url=app_url,
+ build_number=BUILD_NUMBER,
+ title="Reset your password",
+ subtitle=f"This will update the credentials used for {provider_label}.",
+ tone="brand",
+ body_html=(
+ f""
+ f"A password reset was requested for {html.escape(username)} ."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Account", username),
+ _build_email_stat_card("Expires", expires_at),
+ _build_email_stat_card("Credentials updated", provider_label),
+ _build_email_stat_card("Delivery target", resolved_email),
+ ]
+ )
+ + _build_email_panel(
+ "What will be updated",
+ f"This reset will update the password used for {html.escape(provider_label)} .",
+ variant="brand",
+ )
+ + _build_email_panel(
+ "What happens next",
+ _build_email_list(
+ [
+ "Open the reset link and choose a new password.",
+ "Complete the form before the expiry time shown above.",
+ "Use the new password the next time you sign in.",
+ ],
+ ordered=True,
+ ),
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "Safety note",
+ "If you did not request this reset, ignore this email. No changes will be applied until the reset link is opened and completed.",
+ variant="warning",
+ )
+ ),
+ primary_label="Reset password",
+ primary_url=reset_url,
+ secondary_label="Open Magent",
+ secondary_url=app_url,
+ footer_note="Password reset email generated by Magent.",
+ )
+
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ logger.info(
+ "Password reset email sent: username=%s recipient=%s provider=%s",
+ username,
+ resolved_email,
+ auth_provider,
+ )
+ result = {
+ "recipient_email": resolved_email,
+ "subject": subject,
+ "reset_url": reset_url,
+ **{
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ },
+ }
+ warning = smtp_email_delivery_warning()
+ if warning:
+ result["warning"] = warning
+ return result
diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py
new file mode 100644
index 0000000..0946ef3
--- /dev/null
+++ b/backend/app/services/jellyfin_sync.py
@@ -0,0 +1,100 @@
+import logging
+
+from fastapi import HTTPException
+
+from ..clients.jellyfin import JellyfinClient
+from ..db import (
+ create_user_if_missing,
+ get_user_by_username,
+ set_user_email,
+ set_user_auth_provider,
+ set_user_jellyseerr_id,
+)
+from ..runtime import get_runtime_settings
+from .user_cache import (
+ build_jellyseerr_candidate_map,
+ extract_jellyseerr_user_email,
+ find_matching_jellyseerr_user,
+ get_cached_jellyseerr_users,
+ match_jellyseerr_user_id,
+ save_jellyfin_users_cache,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def sync_jellyfin_users() -> int:
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Jellyfin not configured")
+ users = await client.get_users()
+ if not isinstance(users, list):
+ return 0
+ save_jellyfin_users_cache(users)
+ # Jellyfin is the canonical source for local user objects; Seerr IDs are
+ # matched as enrichment when possible.
+ jellyseerr_users = get_cached_jellyseerr_users()
+ candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
+ imported = 0
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ name = user.get("Name")
+ if not name:
+ continue
+ matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
+ matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
+ matched_email = extract_jellyseerr_user_email(matched_seerr_user)
+ created = create_user_if_missing(
+ name,
+ "jellyfin-user",
+ role="user",
+ email=matched_email,
+ auth_provider="jellyfin",
+ jellyseerr_user_id=matched_id,
+ )
+ if created:
+ imported += 1
+ else:
+ existing = get_user_by_username(name)
+ if (
+ existing
+ and str(existing.get("role") or "user").strip().lower() != "admin"
+ and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
+ ):
+ set_user_auth_provider(name, "jellyfin")
+ if matched_id is not None:
+ set_user_jellyseerr_id(name, matched_id)
+ if matched_email:
+ set_user_email(name, matched_email)
+ return imported
+
+
+async def run_daily_jellyfin_sync() -> None:
+ while True:
+ delay = _seconds_until_midnight()
+ await _sleep_seconds(delay)
+ try:
+ imported = await sync_jellyfin_users()
+ logger.info("Jellyfin daily sync complete: imported=%s", imported)
+ except HTTPException as exc:
+ logger.warning("Jellyfin daily sync skipped: %s", exc.detail)
+ except Exception:
+ logger.exception("Jellyfin daily sync failed")
+
+
+def _seconds_until_midnight() -> float:
+ from datetime import datetime, timedelta
+
+ now = datetime.now()
+ next_midnight = (now + timedelta(days=1)).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+ return max((next_midnight - now).total_seconds(), 0.0)
+
+
+async def _sleep_seconds(delay: float) -> None:
+ import asyncio
+
+ await asyncio.sleep(delay)
diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py
new file mode 100644
index 0000000..5816031
--- /dev/null
+++ b/backend/app/services/notifications.py
@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, Optional
+from urllib.parse import quote
+
+import httpx
+
+from ..config import settings as env_settings
+from ..db import get_setting
+from ..network_security import validate_notification_target_url
+from ..runtime import get_runtime_settings
+from .invite_email import send_generic_email
+
+logger = logging.getLogger(__name__)
+
+
+def _clean_text(value: Any, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else fallback
+ return str(value)
+
+
+def _split_emails(value: str) -> list[str]:
+ if not value:
+ return []
+ parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
+ return [entry for entry in parts if entry and "@" in entry]
+
+
+def _resolve_app_url() -> str:
+ runtime = get_runtime_settings()
+ for candidate in (
+ runtime.magent_application_url,
+ runtime.magent_proxy_base_url,
+ env_settings.cors_allow_origin,
+ ):
+ normalized = _clean_text(candidate)
+ if normalized:
+ return normalized.rstrip("/")
+ port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
+ return f"http://localhost:{port}"
+
+
+def _portal_item_url(item_id: int) -> str:
+ return f"{_resolve_app_url()}/portal?item={item_id}"
+
+
+async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ validate_notification_target_url(url)
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post(url, json=payload)
+ response.raise_for_status()
+ try:
+ body = response.json()
+ except ValueError:
+ body = response.text
+ return {"status_code": response.status_code, "body": body}
+
+
+async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
+ runtime.discord_webhook_url
+ )
+ if not webhook:
+ return {"status": "skipped", "detail": "Discord webhook not configured."}
+ data = {
+ "content": f"**{title}**\n{message}",
+ "embeds": [
+ {
+ "title": title,
+ "description": message,
+ "fields": [
+ {"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
+ {"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
+ {"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
+ ],
+ "url": _clean_text(payload.get("item_url")),
+ }
+ ],
+ }
+ result = await _http_post_json(webhook, data)
+ return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
+
+
+async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
+ chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
+ if not bot_token or not chat_id:
+ return {"status": "skipped", "detail": "Telegram is not configured."}
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+ payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
+ result = await _http_post_json(url, payload)
+ return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
+
+
+async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ webhook = _clean_text(runtime.magent_notify_webhook_url)
+ if not webhook:
+ return {"status": "skipped", "detail": "Generic webhook is not configured."}
+ result = await _http_post_json(webhook, payload)
+ return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
+
+
+async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ token = _clean_text(runtime.magent_notify_push_token)
+ topic = _clean_text(runtime.magent_notify_push_topic)
+ if provider == "ntfy":
+ if not base_url or not topic:
+ return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
+ validate_notification_target_url(base_url)
+ url = f"{base_url.rstrip('/')}/{quote(topic)}"
+ headers = {"Title": title, "Tags": "magent,portal"}
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post(url, content=message.encode("utf-8"), headers=headers)
+ response.raise_for_status()
+ return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
+ if provider == "gotify":
+ if not base_url or not token:
+ return {"status": "skipped", "detail": "Gotify needs base URL and token."}
+ validate_notification_target_url(base_url)
+ url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
+ body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
+ result = await _http_post_json(url, body)
+ return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
+ if provider == "pushover":
+ user_key = _clean_text(runtime.magent_notify_push_user_key)
+ if not token or not user_key:
+ return {"status": "skipped", "detail": "Pushover needs token and user key."}
+ form = {"token": token, "user": user_key, "title": title, "message": message}
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post("https://api.pushover.net/1/messages.json", data=form)
+ response.raise_for_status()
+ return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
+ if provider == "discord":
+ return await _send_discord(title, message, payload)
+ if provider == "telegram":
+ return await _send_telegram(title, message)
+ if provider == "webhook":
+ return await _send_webhook(payload)
+ return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
+
+
+async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
+ fallback = _clean_text(runtime.magent_notify_email_from_address)
+ if fallback and fallback not in recipients:
+ recipients.append(fallback)
+ if not recipients:
+ return {"status": "skipped", "detail": "No portal notification recipient is configured."}
+
+ body_text = (
+ f"{title}\n\n"
+ f"{message}\n\n"
+ f"Kind: {_clean_text(payload.get('kind'))}\n"
+ f"Status: {_clean_text(payload.get('status'))}\n"
+ f"Priority: {_clean_text(payload.get('priority'))}\n"
+ f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
+ f"Open: {_clean_text(payload.get('item_url'))}\n"
+ )
+ body_html = (
+ ""
+ f"
{title} "
+ f"
{message}
"
+ "
"
+ f"Kind {_clean_text(payload.get('kind'))} "
+ f"Status {_clean_text(payload.get('status'))} "
+ f"Priority {_clean_text(payload.get('priority'))} "
+ f"Requested by {_clean_text(payload.get('requested_by'))} "
+ "
"
+ f"
Open portal item "
+ "
"
+ )
+ deliveries: list[Dict[str, Any]] = []
+ for recipient in recipients:
+ try:
+ result = await send_generic_email(
+ recipient_email=recipient,
+ subject=title,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ deliveries.append({"recipient": recipient, "status": "ok", **result})
+ except Exception as exc:
+ deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
+ successful = [entry for entry in deliveries if entry.get("status") == "ok"]
+ if successful:
+ return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
+ return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
+
+
+async def send_portal_notification(
+ *,
+ event_type: str,
+ item: Dict[str, Any],
+ actor_username: str,
+ actor_role: str,
+ note: Optional[str] = None,
+) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ if not runtime.magent_notify_enabled:
+ return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
+
+ item_id = int(item.get("id") or 0)
+ title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
+ message_lines = [
+ f"Event: {event_type}",
+ f"Actor: {actor_username} ({actor_role})",
+ f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
+ ]
+ if note:
+ message_lines.append(f"Note: {note}")
+ message_lines.append(f"Open: {_portal_item_url(item_id)}")
+ message = "\n".join(message_lines)
+ payload = {
+ "type": "portal.notification",
+ "event": event_type,
+ "item_id": item_id,
+ "item_url": _portal_item_url(item_id),
+ "kind": _clean_text(item.get("kind")),
+ "status": _clean_text(item.get("status")),
+ "priority": _clean_text(item.get("priority")),
+ "requested_by": _clean_text(item.get("created_by_username")),
+ "actor_username": actor_username,
+ "actor_role": actor_role,
+ "note": note or "",
+ }
+
+ channels: Dict[str, Dict[str, Any]] = {}
+ if runtime.magent_notify_discord_enabled:
+ try:
+ channels["discord"] = await _send_discord(title, message, payload)
+ except Exception as exc:
+ channels["discord"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_telegram_enabled:
+ try:
+ channels["telegram"] = await _send_telegram(title, message)
+ except Exception as exc:
+ channels["telegram"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_webhook_enabled:
+ try:
+ channels["webhook"] = await _send_webhook(payload)
+ except Exception as exc:
+ channels["webhook"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_push_enabled:
+ try:
+ channels["push"] = await _send_push(title, message, payload)
+ except Exception as exc:
+ channels["push"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_email_enabled:
+ try:
+ channels["email"] = await _send_email(title, message, payload)
+ except Exception as exc:
+ channels["email"] = {"status": "error", "detail": str(exc)}
+
+ successful = [name for name, value in channels.items() if value.get("status") == "ok"]
+ failed = [name for name, value in channels.items() if value.get("status") == "error"]
+ skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
+ logger.info(
+ "portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
+ event_type,
+ item_id,
+ successful,
+ failed,
+ skipped,
+ )
+ overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
+ if not channels:
+ overall = "skipped"
+ return {"status": overall, "channels": channels}
diff --git a/backend/app/services/password_reset.py b/backend/app/services/password_reset.py
new file mode 100644
index 0000000..4b4844b
--- /dev/null
+++ b/backend/app/services/password_reset.py
@@ -0,0 +1,333 @@
+from __future__ import annotations
+
+import logging
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, Optional
+
+from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..db import (
+ create_password_reset_token,
+ delete_expired_password_reset_tokens,
+ get_password_reset_token,
+ get_user_by_jellyseerr_id,
+ get_user_by_username,
+ get_users_by_username_ci,
+ mark_password_reset_token_used,
+ set_user_auth_provider,
+ set_user_password,
+ sync_jellyfin_password_state,
+)
+from ..runtime import get_runtime_settings
+from .invite_email import send_password_reset_email
+from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
+
+logger = logging.getLogger(__name__)
+
+PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
+
+
+class PasswordResetUnavailableError(RuntimeError):
+ pass
+
+
+def _normalize_handles(value: object) -> list[str]:
+ if not isinstance(value, str):
+ return []
+ normalized = value.strip().lower()
+ if not normalized:
+ return []
+ handles = [normalized]
+ if "@" in normalized:
+ handles.append(normalized.split("@", 1)[0])
+ return list(dict.fromkeys(handles))
+
+
+def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
+ if not users:
+ return None
+ requested = str(requested_identifier or "").strip().lower()
+
+ def _rank(user: dict) -> tuple[int, int, int, int]:
+ provider = str(user.get("auth_provider") or "local").strip().lower()
+ role = str(user.get("role") or "user").strip().lower()
+ username = str(user.get("username") or "").strip().lower()
+ return (
+ 0 if role == "admin" else 1,
+ 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
+ 0 if provider == "jellyfin" else (1 if provider == "local" else 2),
+ 0 if username == requested else 1,
+ )
+
+ return sorted(users, key=_rank)[0]
+
+
+def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
+ target_handles = set(_normalize_handles(identifier))
+ if not target_handles:
+ return None
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ for key in ("username", "email"):
+ value = user.get(key)
+ if target_handles.intersection(_normalize_handles(value)):
+ return user
+ return None
+
+
+async def _fetch_all_seerr_users() -> list[dict]:
+ cached = get_cached_jellyseerr_users()
+ if cached is not None:
+ return cached
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ return []
+ users: list[dict] = []
+ take = 100
+ skip = 0
+ while True:
+ payload = await client.get_users(take=take, skip=skip)
+ if not payload:
+ break
+ if isinstance(payload, list):
+ batch = payload
+ elif isinstance(payload, dict):
+ batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
+ else:
+ batch = None
+ if not isinstance(batch, list) or not batch:
+ break
+ users.extend([user for user in batch if isinstance(user, dict)])
+ if len(batch) < take:
+ break
+ skip += take
+ if users:
+ return save_jellyseerr_users_cache(users)
+ return users
+
+
+def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
+ if isinstance(local_user, dict):
+ stored_email = str(local_user.get("email") or "").strip()
+ if "@" in stored_email:
+ return stored_email
+ username = str(local_user.get("username") or "").strip()
+ if "@" in username:
+ return username
+ if isinstance(seerr_user, dict):
+ email = str(seerr_user.get("email") or "").strip()
+ if "@" in email:
+ return email
+ return None
+
+
+async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
+ normalized_identifier = str(identifier or "").strip()
+ if not normalized_identifier:
+ return None
+
+ local_user = normalize_user_auth_provider(
+ _pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
+ )
+ seerr_users: list[dict] | None = None
+ seerr_user: dict | None = None
+
+ if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
+ seerr_users = await _fetch_all_seerr_users()
+ seerr_user = next(
+ (
+ user
+ for user in seerr_users
+ if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
+ ),
+ None,
+ )
+
+ if not local_user:
+ seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
+ seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
+ if seerr_user:
+ seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
+ try:
+ seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
+ except (TypeError, ValueError):
+ seerr_user_id = None
+ if seerr_user_id is not None:
+ local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
+ if not local_user:
+ for candidate in (seerr_user.get("email"), seerr_user.get("username")):
+ if not isinstance(candidate, str) or not candidate.strip():
+ continue
+ local_user = normalize_user_auth_provider(
+ _pick_preferred_user(get_users_by_username_ci(candidate), candidate)
+ )
+ if local_user:
+ break
+
+ if not local_user:
+ return None
+
+ auth_provider = resolve_user_auth_provider(local_user)
+ username = str(local_user.get("username") or "").strip()
+ recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
+ if not recipient_email:
+ seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
+ if isinstance(local_user.get("jellyseerr_user_id"), int):
+ seerr_user = next(
+ (
+ user
+ for user in seerr_users
+ if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
+ ),
+ None,
+ )
+ if not seerr_user:
+ seerr_user = _find_matching_seerr_user(username, seerr_users)
+ recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
+ if not recipient_email:
+ return None
+
+ if auth_provider == "jellyseerr":
+ runtime = get_runtime_settings()
+ jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if jellyfin_client.configured():
+ try:
+ jellyfin_user = await jellyfin_client.find_user_by_name(username)
+ except Exception:
+ jellyfin_user = None
+ if isinstance(jellyfin_user, dict):
+ auth_provider = "jellyfin"
+
+ if auth_provider not in {"local", "jellyfin"}:
+ return None
+
+ return {
+ "username": username,
+ "recipient_email": recipient_email,
+ "auth_provider": auth_provider,
+ }
+
+
+def _token_record_is_usable(record: Optional[dict]) -> bool:
+ if not isinstance(record, dict):
+ return False
+ if record.get("is_used"):
+ return False
+ if record.get("is_expired"):
+ return False
+ return True
+
+
+def _mask_email(email: str) -> str:
+ candidate = str(email or "").strip()
+ if "@" not in candidate:
+ return "valid reset link"
+ local_part, domain = candidate.split("@", 1)
+ if not local_part:
+ return f"***@{domain}"
+ if len(local_part) == 1:
+ return f"{local_part}***@{domain}"
+ return f"{local_part[0]}***{local_part[-1]}@{domain}"
+
+
+async def request_password_reset(
+ identifier: str,
+ *,
+ requested_by_ip: Optional[str] = None,
+ requested_user_agent: Optional[str] = None,
+) -> Dict[str, Any]:
+ delete_expired_password_reset_tokens()
+ target = await _resolve_reset_target(identifier)
+ if not target:
+ logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
+ return {"status": "ok", "issued": False}
+
+ token = secrets.token_urlsafe(32)
+ expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
+ create_password_reset_token(
+ token,
+ target["username"],
+ target["recipient_email"],
+ target["auth_provider"],
+ expires_at,
+ requested_by_ip=requested_by_ip,
+ requested_user_agent=requested_user_agent,
+ )
+ await send_password_reset_email(
+ recipient_email=target["recipient_email"],
+ username=target["username"],
+ token=token,
+ expires_at=expires_at,
+ auth_provider=target["auth_provider"],
+ )
+ return {
+ "status": "ok",
+ "issued": True,
+ "username": target["username"],
+ "recipient_email": target["recipient_email"],
+ "auth_provider": target["auth_provider"],
+ "expires_at": expires_at,
+ }
+
+
+def verify_password_reset_token(token: str) -> Dict[str, Any]:
+ delete_expired_password_reset_tokens()
+ record = get_password_reset_token(token)
+ if not _token_record_is_usable(record):
+ raise ValueError("Password reset link is invalid or has expired.")
+ return {
+ "status": "ok",
+ "recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
+ "auth_provider": record.get("auth_provider"),
+ "expires_at": record.get("expires_at"),
+ }
+
+
+async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
+ delete_expired_password_reset_tokens()
+ record = get_password_reset_token(token)
+ if not _token_record_is_usable(record):
+ raise ValueError("Password reset link is invalid or has expired.")
+
+ username = str(record.get("username") or "").strip()
+ if not username:
+ raise ValueError("Password reset link is invalid or has expired.")
+
+ stored_user = normalize_user_auth_provider(get_user_by_username(username))
+ if not stored_user:
+ raise ValueError("Password reset link is invalid or has expired.")
+
+ auth_provider = resolve_user_auth_provider(stored_user)
+ if auth_provider == "jellyseerr":
+ auth_provider = "jellyfin"
+
+ if auth_provider == "local":
+ set_user_password(username, new_password)
+ if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
+ set_user_auth_provider(username, "local")
+ mark_password_reset_token_used(token)
+ logger.info("password reset applied username=%s provider=local", username)
+ return {"status": "ok", "provider": "local", "username": username}
+
+ if auth_provider == "jellyfin":
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
+ jellyfin_user = await client.find_user_by_name(username)
+ user_id = client._extract_user_id(jellyfin_user)
+ if not user_id:
+ raise ValueError("Password reset link is invalid or has expired.")
+ await client.set_user_password(user_id, new_password)
+ sync_jellyfin_password_state(username, new_password)
+ if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
+ set_user_auth_provider(username, "jellyfin")
+ mark_password_reset_token_used(token)
+ logger.info("password reset applied username=%s provider=jellyfin", username)
+ return {"status": "ok", "provider": "jellyfin", "username": username}
+
+ raise ValueError("Password reset is not available for this sign-in provider.")
diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py
new file mode 100644
index 0000000..88fd3c0
--- /dev/null
+++ b/backend/app/services/snapshot.py
@@ -0,0 +1,1202 @@
+from typing import Any, Dict, List, Optional
+import asyncio
+import logging
+import re
+from datetime import datetime, timezone
+from urllib.parse import quote
+import httpx
+
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.jellyfin import JellyfinClient
+from ..clients.sonarr import SonarrClient
+from ..clients.radarr import RadarrClient
+from ..clients.prowlarr import ProwlarrClient
+from ..clients.qbittorrent import QBittorrentClient
+from ..runtime import get_runtime_settings
+from ..db import (
+ save_snapshot,
+ get_request_cache_payload,
+ get_request_cache_by_id,
+ get_request_download_evidence,
+ get_recent_snapshots,
+ get_setting,
+ set_setting,
+ is_seerr_media_failure_suppressed,
+ record_seerr_media_failure,
+ clear_seerr_media_failure,
+)
+from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
+
+logger = logging.getLogger(__name__)
+
+JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
+_jellyfin_scan_key = "jellyfin_scan_last_at"
+
+
+STATUS_LABELS = {
+ 1: "Waiting for approval",
+ 2: "Approved",
+ 3: "Declined",
+ 4: "Ready to watch",
+ 5: "Working on it",
+ 6: "Partially ready",
+}
+
+
+def _status_label(value: Any) -> str:
+ try:
+ numeric = int(value)
+ return STATUS_LABELS.get(numeric, f"Status {numeric}")
+ except (TypeError, ValueError):
+ return "Unknown"
+
+
+def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
+ if isinstance(value, list):
+ return value[0] if value else None
+ if isinstance(value, dict):
+ return value
+ return None
+
+
+def _normalize_media_title(value: Any) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
+ return normalized or None
+
+
+def _canonical_provider_key(value: str) -> str:
+ normalized = value.strip().lower()
+ if normalized.endswith("id"):
+ normalized = normalized[:-2]
+ return normalized
+
+
+def extract_request_provider_ids(payload: Any) -> Dict[str, str]:
+ provider_ids: Dict[str, str] = {}
+ candidates: List[Any] = []
+ if isinstance(payload, dict):
+ candidates.append(payload)
+ media = payload.get("media")
+ if isinstance(media, dict):
+ candidates.append(media)
+ for candidate in candidates:
+ if not isinstance(candidate, dict):
+ continue
+ embedded = candidate.get("ProviderIds") or candidate.get("providerIds")
+ if isinstance(embedded, dict):
+ for key, value in embedded.items():
+ if value is None:
+ continue
+ text = str(value).strip()
+ if text:
+ provider_ids[_canonical_provider_key(str(key))] = text
+ for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"):
+ value = candidate.get(key)
+ if value is None:
+ continue
+ text = str(value).strip()
+ if text:
+ provider_ids[_canonical_provider_key(key)] = text
+ return provider_ids
+
+
+def jellyfin_item_matches_request(
+ item: Dict[str, Any],
+ *,
+ title: Optional[str],
+ year: Optional[int],
+ request_type: RequestType,
+ request_payload: Optional[Dict[str, Any]] = None,
+) -> bool:
+ request_provider_ids = extract_request_provider_ids(request_payload or {})
+ item_provider_ids = extract_request_provider_ids(item)
+
+ provider_priority = ("tmdb", "tvdb", "imdb")
+ for key in provider_priority:
+ request_id = request_provider_ids.get(key)
+ item_id = item_provider_ids.get(key)
+ if request_id and item_id and request_id == item_id:
+ return True
+
+ request_title = _normalize_media_title(title)
+ if not request_title:
+ return False
+
+ item_titles = [
+ _normalize_media_title(item.get("Name")),
+ _normalize_media_title(item.get("OriginalTitle")),
+ _normalize_media_title(item.get("SortName")),
+ _normalize_media_title(item.get("SeriesName")),
+ _normalize_media_title(item.get("title")),
+ ]
+ item_titles = [candidate for candidate in item_titles if candidate]
+
+ item_year = item.get("ProductionYear") or item.get("Year")
+ try:
+ item_year_value = int(item_year) if item_year is not None else None
+ except (TypeError, ValueError):
+ item_year_value = None
+
+ if year and item_year_value and int(year) != item_year_value:
+ return False
+
+ if request_title in item_titles:
+ return True
+
+ if request_type == RequestType.tv:
+ for candidate in item_titles:
+ if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
+ return True
+
+ return False
+
+
+def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
+ response = exc.response
+ if response is None:
+ return None
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = response.text
+ if isinstance(payload, dict):
+ message = payload.get("message") or payload.get("error")
+ return str(message).strip() if message else str(payload)
+ if isinstance(payload, str):
+ trimmed = payload.strip()
+ return trimmed or None
+ return str(payload)
+
+
+def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
+ response = exc.response
+ if response is None:
+ return False
+ return response.status_code == 404 or response.status_code >= 500
+
+
+async def _get_seerr_media_details(
+ jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int
+) -> Optional[Dict[str, Any]]:
+ media_type = request_type.value
+ if media_type not in {"movie", "tv"}:
+ return None
+ if is_seerr_media_failure_suppressed(media_type, tmdb_id):
+ logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id)
+ return None
+ try:
+ if request_type == RequestType.movie:
+ details = await jellyseerr.get_movie(int(tmdb_id))
+ else:
+ details = await jellyseerr.get_tv(int(tmdb_id))
+ except httpx.HTTPStatusError as exc:
+ if _should_persist_seerr_media_failure(exc):
+ record_seerr_media_failure(
+ media_type,
+ int(tmdb_id),
+ status_code=exc.response.status_code if exc.response is not None else None,
+ error_message=_extract_http_error_message(exc),
+ )
+ return None
+ if isinstance(details, dict):
+ clear_seerr_media_failure(media_type, int(tmdb_id))
+ return details
+ return None
+
+
+async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
+ if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
+ return
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ return
+ last_scan = get_setting(_jellyfin_scan_key)
+ if last_scan:
+ try:
+ parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00"))
+ if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS:
+ return
+ except ValueError:
+ pass
+ previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
+ if previous:
+ prev_state = previous[0].get("state")
+ if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
+ return
+ try:
+ await client.refresh_library()
+ except Exception as exc:
+ logger.warning("Jellyfin library refresh failed: %s", exc)
+ return
+ set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat())
+ logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id)
+
+
+def _queue_records(queue: Any) -> List[Dict[str, Any]]:
+ if isinstance(queue, dict):
+ records = queue.get("records")
+ if isinstance(records, list):
+ return records
+ if isinstance(queue, list):
+ return queue
+ return []
+
+
+def _filter_queue(queue: Any, item_id: Optional[int], request_type: RequestType) -> Any:
+ if not item_id:
+ return queue
+ records = _queue_records(queue)
+ if not records:
+ return queue
+ key = "seriesId" if request_type == RequestType.tv else "movieId"
+ filtered = [record for record in records if record.get(key) == item_id]
+ if isinstance(queue, dict):
+ filtered_queue = dict(queue)
+ filtered_queue["records"] = filtered
+ filtered_queue["totalRecords"] = len(filtered)
+ return filtered_queue
+ return filtered
+
+
+def _download_ids(records: List[Dict[str, Any]]) -> List[str]:
+ ids = []
+ for record in records:
+ download_id = record.get("downloadId") or record.get("download_id")
+ if isinstance(download_id, str) and download_id:
+ ids.append(download_id)
+ return ids
+
+
+def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]:
+ if not isinstance(episodes, list):
+ return {}
+ grouped: Dict[int, List[int]] = {}
+ now = datetime.now(timezone.utc)
+ for episode in episodes:
+ if not isinstance(episode, dict):
+ continue
+ if not episode.get("monitored", True):
+ continue
+ if episode.get("hasFile"):
+ continue
+ air_date = episode.get("airDateUtc")
+ if isinstance(air_date, str):
+ try:
+ aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
+ except ValueError:
+ aired_at = None
+ if aired_at and aired_at > now:
+ continue
+ season_number = episode.get("seasonNumber")
+ episode_number = episode.get("episodeNumber")
+ if not isinstance(episode_number, int):
+ episode_number = episode.get("absoluteEpisodeNumber")
+ if isinstance(season_number, int) and isinstance(episode_number, int):
+ grouped.setdefault(season_number, []).append(episode_number)
+ for season_number in list(grouped.keys()):
+ grouped[season_number] = sorted(set(grouped[season_number]))
+ return grouped
+
+
+def _episode_availability(episodes: Any) -> Dict[str, Any]:
+ if not isinstance(episodes, list):
+ return {"available": 0, "missing": 0, "total": 0, "seasons": []}
+ now = datetime.now(timezone.utc)
+ season_rows: Dict[int, Dict[str, Any]] = {}
+ for episode in episodes:
+ if not isinstance(episode, dict) or not episode.get("monitored", True):
+ continue
+ air_date = episode.get("airDateUtc")
+ if isinstance(air_date, str):
+ try:
+ aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
+ except ValueError:
+ aired_at = None
+ if aired_at and aired_at > now:
+ continue
+ season_number = episode.get("seasonNumber")
+ if not isinstance(season_number, int):
+ continue
+ row = season_rows.setdefault(
+ season_number,
+ {"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0},
+ )
+ row["total"] += 1
+ if episode.get("hasFile"):
+ row["available"] += 1
+ else:
+ row["missing"] += 1
+ seasons = [season_rows[key] for key in sorted(season_rows)]
+ return {
+ "available": sum(int(row["available"]) for row in seasons),
+ "missing": sum(int(row["missing"]) for row in seasons),
+ "total": sum(int(row["total"]) for row in seasons),
+ "seasons": seasons,
+ }
+
+
+def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
+ if not torrents:
+ return {"state": "idle", "message": "0 active downloads."}
+
+ downloading_states = {"downloading", "stalleddl", "queueddl", "checkingdl", "forceddl"}
+ paused_states = {"pauseddl", "pausedup"}
+ completed_states = {"uploading", "stalledup", "queuedup", "checkingup", "forcedup", "stoppedup"}
+
+ downloading = [t for t in torrents if str(t.get("state", "")).lower() in downloading_states]
+ paused = [t for t in torrents if str(t.get("state", "")).lower() in paused_states]
+ completed = [t for t in torrents if str(t.get("state", "")).lower() in completed_states]
+
+ if downloading:
+ return {
+ "state": "downloading",
+ "message": f"Downloading ({len(downloading)} active).",
+ }
+ if paused:
+ return {
+ "state": "paused",
+ "message": f"Paused ({len(paused)} paused).",
+ }
+ if completed:
+ return {
+ "state": "completed",
+ "message": f"Completed/seeding ({len(completed)} seeding).",
+ }
+
+ return {
+ "state": "idle",
+ "message": "0 active downloads.",
+ }
+
+
+def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
+ if not path:
+ return None
+ if not path.startswith("/"):
+ path = f"/{path}"
+ if cache_mode == "cache":
+ return f"/images/tmdb?path={quote(path)}&size={size}"
+ return f"https://image.tmdb.org/t/p/{size}{path}"
+
+
+def _torrent_progress(torrent: Dict[str, Any]) -> Optional[int]:
+ progress = torrent.get("progress")
+ try:
+ numeric = float(progress)
+ except (TypeError, ValueError):
+ numeric = -1
+ if 0 <= numeric <= 1:
+ return round(numeric * 100)
+ try:
+ size = float(torrent.get("size"))
+ amount_left = float(torrent.get("amount_left"))
+ except (TypeError, ValueError):
+ return None
+ if size <= 0:
+ return None
+ return max(0, min(100, round(((size - amount_left) / size) * 100)))
+
+
+def _build_presentation(
+ snapshot: Snapshot,
+ *,
+ approved: bool,
+ arr_state: str,
+ arr_details: Dict[str, Any],
+ prowlarr_state: str,
+ download: Dict[str, Any],
+ jellyfin_found: bool,
+ jellyfin_link: Optional[str],
+) -> Dict[str, Any]:
+ collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
+ noun = "episode" if snapshot.request_type == RequestType.tv else "movie"
+ availability = arr_details.get("availability")
+ if not isinstance(availability, dict):
+ availability = {"available": 0, "missing": 0, "total": 0, "seasons": []}
+ available = int(availability.get("available") or 0)
+ missing = int(availability.get("missing") or 0)
+ total = int(availability.get("total") or 0)
+ partial = available > 0 and missing > 0
+ download_visible = bool(download.get("visible"))
+ download_state = str(download.get("state") or "not_started")
+
+ if snapshot.state == NormalizedState.requested:
+ status_label = "Waiting for approval"
+ meaning = "This request has been received, but it must be approved before collection can begin."
+ elif snapshot.state == NormalizedState.needs_add:
+ status_label = "Approved, but not yet in the library queue"
+ meaning = (
+ f"The request was approved, but it has not reached the {collector} collector yet. "
+ "Adding it to the library queue is the next step."
+ )
+ elif jellyfin_found and partial:
+ status_label = f"Partially available — {available} of {total} episodes collected"
+ meaning = (
+ f"Some of this request is ready to watch. {collector} is still looking for "
+ f"{missing} missing episode{'s' if missing != 1 else ''}."
+ )
+ elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
+ status_label = "Available to watch"
+ meaning = "Collection is complete and the title is available on the media server."
+ elif download_visible and download_state == "paused":
+ status_label = "Download paused"
+ meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed."
+ elif download_visible and download_state == "missing":
+ status_label = "Download attempt is no longer visible"
+ meaning = (
+ "A download was previously queued for this request, but qBittorrent no longer reports it. "
+ "A fresh release search may be required."
+ )
+ elif download_visible and download_state == "error":
+ status_label = "Unable to read the current download"
+ meaning = (
+ "A download attempt exists, but Magent cannot currently read its progress from qBittorrent."
+ )
+ elif snapshot.state == NormalizedState.downloading:
+ status_label = "Download in progress"
+ meaning = "A release has been collected and is currently downloading."
+ elif snapshot.state == NormalizedState.importing:
+ status_label = "Downloaded — waiting for library import"
+ meaning = f"The download has finished and {collector} is preparing it for the media server."
+ elif arr_state == "error":
+ status_label = "Unable to read the library queue"
+ meaning = (
+ f"The request is approved, but Magent could not read its current state from {collector}. "
+ "The service may be temporarily unavailable."
+ )
+ elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total:
+ if partial:
+ status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing"
+ meaning = (
+ f"The request was approved and sent to {collector}. {available} of {total} aired "
+ f"episodes have been collected; {missing} still need a matching release."
+ )
+ elif missing:
+ status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}"
+ meaning = (
+ f"The request was approved and sent to the {collector} collector, but none of the "
+ f"{total} aired episodes have been collected yet."
+ )
+ else:
+ status_label = "Added to library queue"
+ meaning = f"The request was approved and sent to the {collector} collector."
+ elif arr_state in {"added", "searching"}:
+ status_label = "Added to library queue — waiting for a matching release"
+ meaning = (
+ f"The request was approved and sent to the {collector} collector, but a usable release "
+ "has not been collected yet."
+ )
+ elif snapshot.state == NormalizedState.failed:
+ status_label = "This request needs attention"
+ meaning = snapshot.state_reason or "Magent could not determine the next stage for this request."
+ else:
+ status_label = "Approved — preparing collection" if approved else "Request received"
+ meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process."
+
+ action_ids = [action.id for action in snapshot.actions]
+ if "resume_torrent" in action_ids:
+ next_title = "Resume the interrupted download"
+ next_description = "The download exists but is not currently progressing. Resume it to continue collection."
+ recommended = ["resume_torrent"]
+ elif "readd_to_arr" in action_ids:
+ next_title = "Add this request to the library queue"
+ next_description = f"Send the approved request to {collector} so collection can begin."
+ recommended = ["readd_to_arr"]
+ elif "search_auto" in action_ids or "search_releases" in action_ids:
+ if snapshot.request_type == RequestType.tv and missing:
+ target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
+ else:
+ target = f"a matching {noun} release"
+ next_title = f"Search for {target}"
+ next_description = (
+ "Run an automatic search, or review the available releases and choose one manually."
+ )
+ recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids]
+ elif download_state == "downloading":
+ next_title = "Let the current download finish"
+ next_description = "Magent is tracking the active download; no action is needed right now."
+ recommended = []
+ elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
+ next_title = "Ready to watch"
+ next_description = "Collection is complete. Open the title on the media server when you are ready."
+ recommended = []
+ elif snapshot.state == NormalizedState.requested:
+ next_title = "Wait for approval"
+ next_description = "An administrator must approve this request before collection can start."
+ recommended = []
+ else:
+ next_title = "Magent is checking the next step"
+ next_description = "No safe action is available until the current service state is known."
+ recommended = []
+
+ requested_stage = {
+ "id": "requested",
+ "label": "Requested",
+ "state": "complete",
+ "summary": "Request received",
+ }
+ approved_stage = {
+ "id": "approved",
+ "label": "Approved",
+ "state": "complete" if approved else "active",
+ "summary": "Approved for collection" if approved else "Waiting for approval",
+ }
+ if arr_state == "missing":
+ library_state, library_summary = "attention", "Not yet added to the collector"
+ elif arr_state == "error":
+ library_state, library_summary = "attention", f"Unable to read {collector}"
+ elif partial:
+ library_state, library_summary = "partial", f"{available} of {total} episodes collected"
+ elif arr_state == "available":
+ library_state, library_summary = "complete", "Collection complete"
+ elif arr_state in {"added", "searching"}:
+ library_state = "active" if missing or not available else "complete"
+ library_summary = (
+ f"{missing} episode{'s' if missing != 1 else ''} still missing"
+ if snapshot.request_type == RequestType.tv and missing
+ else "In the library queue"
+ )
+ else:
+ library_state, library_summary = "waiting", "Waiting for collector information"
+
+ if download_visible:
+ search_state = "complete"
+ search_summary = "A release was found"
+ elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
+ search_state = "active" if prowlarr_state == "ok" else "attention"
+ search_summary = (
+ f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}"
+ if snapshot.request_type == RequestType.tv and missing
+ else "Ready to search for a release"
+ )
+ else:
+ search_state, search_summary = "waiting", "Search has not started"
+
+ if download_visible:
+ download_stage_state = {
+ "downloading": "active",
+ "paused": "attention",
+ "completed": "complete",
+ "missing": "attention",
+ "error": "attention",
+ }.get(download_state, "waiting")
+ download_summary = str(download.get("summary") or "A prior download attempt was found")
+ else:
+ download_stage_state, download_summary = "waiting", "No download attempt yet"
+
+ if jellyfin_found and partial:
+ available_state, available_summary = "partial", f"{available} of {total} episodes available"
+ elif jellyfin_found:
+ available_state, available_summary = "complete", "Available to watch"
+ else:
+ available_state, available_summary = "waiting", "Not available on the media server yet"
+
+ return {
+ "status": {"label": status_label, "meaning": meaning},
+ "download": download,
+ "nextStep": {
+ "title": next_title,
+ "description": next_description,
+ "actionIds": recommended,
+ },
+ "pipeline": [
+ requested_stage,
+ approved_stage,
+ {
+ "id": "library",
+ "label": "Library collection",
+ "state": library_state,
+ "summary": library_summary,
+ "available": available,
+ "missing": missing,
+ "total": total,
+ "seasons": availability.get("seasons") or [],
+ "missingEpisodes": arr_details.get("missingEpisodes") or {},
+ },
+ {
+ "id": "search",
+ "label": "Release search",
+ "state": search_state,
+ "summary": search_summary,
+ "actionIds": [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids],
+ },
+ {
+ "id": "download",
+ "label": "Download",
+ "state": download_stage_state,
+ "summary": download_summary,
+ "visible": download_visible,
+ "torrents": download.get("torrents") or [],
+ },
+ {
+ "id": "available",
+ "label": "Available",
+ "state": available_state,
+ "summary": available_summary,
+ "link": jellyfin_link,
+ },
+ ],
+ }
+
+
+async def build_snapshot(request_id: str) -> Snapshot:
+ timeline = []
+ runtime = get_runtime_settings()
+
+ jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ qbittorrent = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+
+ snapshot = Snapshot(
+ request_id=request_id,
+ title="Unknown",
+ state=NormalizedState.unknown,
+ state_reason="Awaiting configuration",
+ )
+
+ cached_request = None
+ mode = (runtime.requests_data_source or "prefer_cache").lower()
+ if mode != "always_js" and request_id.isdigit():
+ cached_request = get_request_cache_payload(int(request_id))
+ if cached_request is not None:
+ logging.getLogger(__name__).debug(
+ "snapshot cache hit: request_id=%s mode=%s", request_id, mode
+ )
+ else:
+ logging.getLogger(__name__).debug(
+ "snapshot cache miss: request_id=%s mode=%s", request_id, mode
+ )
+ if cached_request is not None:
+ cache_meta = get_request_cache_by_id(int(request_id))
+ cached_title = cache_meta.get("title") if cache_meta else None
+ if cached_title and isinstance(cached_request, dict):
+ media = cached_request.get("media")
+ if not isinstance(media, dict):
+ media = {}
+ cached_request["media"] = media
+ if not media.get("title") and not media.get("name"):
+ media["title"] = cached_title
+ media["name"] = cached_title
+ if not cached_request.get("title") and not cached_request.get("name"):
+ cached_request["title"] = cached_title
+
+ allow_remote = mode == "always_js" and jellyseerr.configured()
+ if not jellyseerr.configured() and not cached_request:
+ timeline.append(TimelineHop(service="Seerr", status="not_configured"))
+ timeline.append(TimelineHop(service="Sonarr/Radarr", status="not_configured"))
+ timeline.append(TimelineHop(service="Prowlarr", status="not_configured"))
+ timeline.append(TimelineHop(service="qBittorrent", status="not_configured"))
+ snapshot.timeline = timeline
+ return snapshot
+ if cached_request is None and not allow_remote:
+ timeline.append(TimelineHop(service="Seerr", status="cache_miss"))
+ snapshot.timeline = timeline
+ snapshot.state = NormalizedState.unknown
+ snapshot.state_reason = "Request not found in cache"
+ return snapshot
+
+ jelly_request = cached_request
+ if allow_remote and (jelly_request is None or mode == "always_js"):
+ try:
+ jelly_request = await jellyseerr.get_request(request_id)
+ logging.getLogger(__name__).debug(
+ "snapshot Seerr fetch: request_id=%s mode=%s", request_id, mode
+ )
+ except Exception as exc:
+ timeline.append(TimelineHop(service="Seerr", status="error", details={"error": str(exc)}))
+ snapshot.timeline = timeline
+ snapshot.state = NormalizedState.failed
+ snapshot.state_reason = "Failed to reach Seerr"
+ return snapshot
+
+ if not jelly_request:
+ timeline.append(TimelineHop(service="Seerr", status="not_found"))
+ snapshot.timeline = timeline
+ snapshot.state = NormalizedState.unknown
+ snapshot.state_reason = "Request not found in Seerr"
+ return snapshot
+
+ jelly_status = jelly_request.get("status", "unknown")
+ jelly_status_label = _status_label(jelly_status)
+ jelly_type = jelly_request.get("type") or "unknown"
+ media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {}
+ if not isinstance(media, dict):
+ media = {}
+ snapshot.title = (
+ media.get("title")
+ or media.get("name")
+ or jelly_request.get("title")
+ or jelly_request.get("name")
+ or "Unknown"
+ )
+ snapshot.year = media.get("year") or jelly_request.get("year")
+ snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
+ poster_path = None
+ backdrop_path = None
+ if isinstance(media, dict):
+ poster_path = media.get("posterPath") or media.get("poster_path")
+ backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
+
+ if snapshot.title in {None, "", "Unknown"} and allow_remote:
+ tmdb_id = jelly_request.get("media", {}).get("tmdbId")
+ if tmdb_id:
+ details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
+ if isinstance(details, dict):
+ if snapshot.request_type == RequestType.movie:
+ snapshot.title = details.get("title") or snapshot.title
+ release_date = details.get("releaseDate")
+ snapshot.year = int(release_date[:4]) if release_date else snapshot.year
+ elif snapshot.request_type == RequestType.tv:
+ snapshot.title = details.get("name") or details.get("title") or snapshot.title
+ first_air = details.get("firstAirDate")
+ snapshot.year = int(first_air[:4]) if first_air else snapshot.year
+ poster_path = poster_path or details.get("posterPath") or details.get("poster_path")
+ backdrop_path = (
+ backdrop_path
+ or details.get("backdropPath")
+ or details.get("backdrop_path")
+ )
+
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ snapshot.artwork = {
+ "poster_path": poster_path,
+ "backdrop_path": backdrop_path,
+ "poster_url": _artwork_url(poster_path, "w342", cache_mode),
+ "backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode),
+ }
+
+ timeline.append(
+ TimelineHop(
+ service="Seerr",
+ status=jelly_status_label,
+ details={
+ "requestedBy": jelly_request.get("requestedBy", {}).get("displayName")
+ or jelly_request.get("requestedBy", {}).get("username")
+ or jelly_request.get("requestedBy", {}).get("jellyfinUsername")
+ or jelly_request.get("requestedBy", {}).get("email"),
+ "createdAt": jelly_request.get("createdAt"),
+ "updatedAt": jelly_request.get("updatedAt"),
+ "approved": jelly_request.get("isApproved"),
+ "statusCode": jelly_status,
+ },
+ )
+ )
+
+ arr_state = None
+ arr_details: Dict[str, Any] = {}
+ arr_item = None
+ arr_queue = None
+ media_status = jelly_request.get("media", {}).get("status")
+ try:
+ media_status_code = int(media_status) if media_status is not None else None
+ except (TypeError, ValueError):
+ media_status_code = None
+ if snapshot.request_type == RequestType.tv:
+ tvdb_id = jelly_request.get("media", {}).get("tvdbId")
+ if tvdb_id:
+ try:
+ series = await sonarr.get_series_by_tvdb_id(int(tvdb_id))
+ arr_item = _pick_first(series)
+ arr_details["series"] = arr_item
+ arr_state = "added" if arr_item else "missing"
+ if arr_item:
+ stats = arr_item.get("statistics") if isinstance(arr_item, dict) else None
+ if isinstance(stats, dict):
+ file_count = stats.get("episodeFileCount")
+ total_count = (
+ stats.get("totalEpisodeCount")
+ if isinstance(stats.get("totalEpisodeCount"), int)
+ else stats.get("episodeCount")
+ )
+ if (
+ isinstance(file_count, int)
+ and isinstance(total_count, int)
+ and total_count > 0
+ and file_count >= total_count
+ ):
+ arr_state = "available"
+ if arr_item and isinstance(arr_item.get("id"), int):
+ series_id = int(arr_item["id"])
+ arr_queue = await sonarr.get_queue(series_id)
+ arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
+ arr_details["queue"] = arr_queue
+ episodes = await sonarr.get_episodes(series_id)
+ arr_details["availability"] = _episode_availability(episodes)
+ missing_by_season = _missing_episode_numbers_by_season(episodes)
+ if missing_by_season:
+ arr_details["missingEpisodes"] = missing_by_season
+ except Exception as exc:
+ arr_state = "error"
+ arr_details["error"] = str(exc)
+ elif snapshot.request_type == RequestType.movie:
+ tmdb_id = jelly_request.get("media", {}).get("tmdbId")
+ if tmdb_id:
+ try:
+ movie = await radarr.get_movie_by_tmdb_id(int(tmdb_id))
+ arr_item = _pick_first(movie)
+ if not arr_item:
+ title_hint = (
+ jelly_request.get("media", {}).get("title")
+ or jelly_request.get("title")
+ or snapshot.title
+ )
+ year_hint = (
+ jelly_request.get("media", {}).get("year")
+ or jelly_request.get("year")
+ or snapshot.year
+ )
+ try:
+ all_movies = await radarr.get_movies()
+ except Exception:
+ all_movies = None
+ if isinstance(all_movies, list):
+ for candidate in all_movies:
+ if not isinstance(candidate, dict):
+ continue
+ if tmdb_id and candidate.get("tmdbId") == int(tmdb_id):
+ arr_item = candidate
+ break
+ if title_hint and candidate.get("title") == title_hint:
+ if not year_hint or candidate.get("year") == year_hint:
+ arr_item = candidate
+ break
+ arr_details["movie"] = arr_item
+ if arr_item:
+ if arr_item.get("hasFile"):
+ arr_state = "available"
+ elif arr_item.get("isAvailable"):
+ arr_state = "searching"
+ else:
+ arr_state = "added"
+ else:
+ arr_state = "missing"
+ arr_details["availability"] = {
+ "available": 1 if arr_item and arr_item.get("hasFile") else 0,
+ "missing": 0 if arr_item and arr_item.get("hasFile") else 1,
+ "total": 1,
+ "seasons": [],
+ }
+ if arr_item and isinstance(arr_item.get("id"), int):
+ arr_queue = await radarr.get_queue(int(arr_item["id"]))
+ arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
+ arr_details["queue"] = arr_queue
+ except Exception as exc:
+ arr_state = "error"
+ arr_details["error"] = str(exc)
+
+ if arr_state is None:
+ arr_state = "unknown"
+
+ timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
+
+ prowlarr_state = "unknown"
+ try:
+ prowlarr_health = await prowlarr.get_health()
+ if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
+ prowlarr_state = "issues"
+ timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
+ else:
+ prowlarr_state = "ok"
+ timeline.append(TimelineHop(service="Prowlarr", status="ok"))
+ except Exception as exc:
+ prowlarr_state = "error"
+ timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
+
+ jellyfin_available = False
+ jellyfin_item = None
+ if jellyfin.configured() and snapshot.title:
+ types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"]
+ try:
+ search = await jellyfin.search_items(snapshot.title, types, limit=50)
+ except Exception:
+ search = None
+ if isinstance(search, dict):
+ items = search.get("Items") or search.get("items") or []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ if jellyfin_item_matches_request(
+ item,
+ title=snapshot.title,
+ year=snapshot.year,
+ request_type=snapshot.request_type,
+ request_payload=jelly_request,
+ ):
+ jellyfin_available = True
+ jellyfin_item = item
+ break
+
+ if jellyfin_available and arr_state == "missing" and runtime.jellyfin_sync_to_arr:
+ arr_details["note"] = "Found in Jellyfin but not tracked in Sonarr/Radarr."
+ if snapshot.request_type == RequestType.movie:
+ if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
+ radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if radarr_client.configured():
+ root_folder = await _resolve_root_folder_path(
+ radarr_client, runtime.radarr_root_folder, "Radarr"
+ )
+ tmdb_id = jelly_request.get("media", {}).get("tmdbId")
+ if tmdb_id:
+ try:
+ await radarr_client.add_movie(
+ int(tmdb_id),
+ runtime.radarr_quality_profile_id,
+ root_folder,
+ monitored=False,
+ search_for_movie=False,
+ )
+ except Exception:
+ pass
+ if snapshot.request_type == RequestType.tv:
+ if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
+ sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if sonarr_client.configured():
+ root_folder = await _resolve_root_folder_path(
+ sonarr_client, runtime.sonarr_root_folder, "Sonarr"
+ )
+ tvdb_id = jelly_request.get("media", {}).get("tvdbId")
+ if tvdb_id:
+ try:
+ await sonarr_client.add_series(
+ int(tvdb_id),
+ runtime.sonarr_quality_profile_id,
+ root_folder,
+ monitored=False,
+ search_missing=False,
+ )
+ except Exception:
+ pass
+
+ qbit_state = "not_started"
+ qbit_message = "No download attempt has been observed."
+ download_ids = _download_ids(_queue_records(arr_queue))
+ download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100)
+ torrent_list: List[Dict[str, Any]] = []
+ download_visible = bool(download_ids) or bool(download_history.get("observed"))
+ qbit_error = None
+ try:
+ if qbittorrent.configured():
+ if download_ids:
+ torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
+ torrent_list = torrents if isinstance(torrents, list) else []
+ else:
+ request_tag = f"magent-{request_id}"
+ torrents = await qbittorrent.get_torrents_by_tag(request_tag)
+ torrent_list = torrents if isinstance(torrents, list) else []
+ for torrent in torrent_list:
+ if isinstance(torrent, dict):
+ torrent["progressPercent"] = _torrent_progress(torrent)
+ if torrent_list:
+ download_visible = True
+ summary = _summarize_qbit(torrent_list)
+ qbit_state = str(summary.get("state") or "idle")
+ qbit_message = str(summary.get("message") or "Download found in qBittorrent.")
+ elif download_ids:
+ qbit_state = "missing"
+ qbit_message = (
+ "The collector queued a download, but it is no longer visible in qBittorrent."
+ )
+ elif download_history.get("observed"):
+ qbit_state = "missing"
+ qbit_message = (
+ "A previous download was observed, but it is not currently visible in qBittorrent."
+ )
+ except Exception as exc:
+ qbit_error = str(exc)
+ if download_visible:
+ qbit_state = "error"
+ qbit_message = (
+ "A download attempt exists, but Magent cannot currently read its state from qBittorrent."
+ )
+
+ download_presentation = {
+ "visible": download_visible,
+ "observed": download_visible,
+ "state": qbit_state,
+ "summary": qbit_message,
+ "torrents": torrent_list,
+ "lastSeenAt": download_history.get("last_seen_at"),
+ }
+ timeline.append(
+ TimelineHop(
+ service="qBittorrent",
+ status=qbit_state,
+ details={
+ **download_presentation,
+ "error": qbit_error,
+ },
+ )
+ )
+
+ status_code = None
+ try:
+ status_code = int(jelly_status)
+ except (TypeError, ValueError):
+ status_code = None
+
+ derived_approved = bool(jelly_request.get("isApproved")) or status_code in {2, 4, 5, 6}
+
+ if derived_approved:
+ snapshot.state = NormalizedState.approved
+ snapshot.state_reason = "Approved and queued for processing."
+ else:
+ snapshot.state = NormalizedState.requested
+ snapshot.state_reason = "Waiting for approval before we can search."
+
+ queue_records = _queue_records(arr_queue)
+ if qbit_state in {"downloading", "paused"}:
+ snapshot.state = NormalizedState.downloading
+ snapshot.state_reason = "Downloading in qBittorrent."
+ if qbit_message:
+ snapshot.state_reason = qbit_message
+ elif qbit_state == "completed":
+ if arr_state == "available":
+ snapshot.state = NormalizedState.completed
+ snapshot.state_reason = "In your library and ready to watch."
+ else:
+ snapshot.state = NormalizedState.importing
+ snapshot.state_reason = "Download finished. Waiting for library import."
+ elif queue_records:
+ if arr_state == "missing":
+ snapshot.state_reason = "Queue shows a download, but qBittorrent has no active torrent."
+ else:
+ snapshot.state_reason = "Waiting for download to start in qBittorrent."
+ elif arr_state == "missing" and derived_approved:
+ snapshot.state = NormalizedState.needs_add
+ snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr."
+ elif arr_state == "searching":
+ snapshot.state = NormalizedState.searching
+ snapshot.state_reason = "Searching for a matching release."
+ elif arr_state == "available":
+ snapshot.state = NormalizedState.completed
+ snapshot.state_reason = "In your library and ready to watch."
+ elif arr_state == "added" and snapshot.state == NormalizedState.approved:
+ snapshot.state = NormalizedState.added_to_arr
+ snapshot.state_reason = "Item is present in Sonarr/Radarr"
+
+ if jellyfin_available:
+ missing_episodes = arr_details.get("missingEpisodes")
+ if snapshot.request_type == RequestType.tv and isinstance(missing_episodes, dict) and missing_episodes:
+ snapshot.state = NormalizedState.importing
+ snapshot.state_reason = "Some episodes are available in Jellyfin, but the request is still incomplete."
+ for hop in timeline:
+ if hop.service == "Seerr":
+ hop.status = "Partially ready"
+ else:
+ snapshot.state = NormalizedState.completed
+ snapshot.state_reason = "Ready to watch in Jellyfin."
+ for hop in timeline:
+ if hop.service == "Seerr":
+ hop.status = "Available"
+ elif hop.service == "Sonarr/Radarr" and hop.status not in {"error"}:
+ hop.status = "available"
+
+ snapshot.timeline = timeline
+ actions: List[ActionOption] = []
+ if arr_state == "missing":
+ actions.append(
+ ActionOption(
+ id="readd_to_arr",
+ label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}",
+ risk="medium",
+ description="Send this approved request to the library collector.",
+ )
+ )
+ elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}:
+ missing_count = int((arr_details.get("availability") or {}).get("missing") or 0)
+ automatic_label = (
+ f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}"
+ if snapshot.request_type == RequestType.tv and missing_count
+ else "Search automatically for a release"
+ )
+ actions.append(
+ ActionOption(
+ id="search_auto",
+ label=automatic_label,
+ risk="low",
+ description="Ask the library collector to find and download the best permitted match.",
+ )
+ )
+ actions.append(
+ ActionOption(
+ id="search_releases",
+ label="Review available releases",
+ risk="low",
+ description="Search the configured indexers and choose a release yourself.",
+ )
+ )
+
+ download_ids = _download_ids(_queue_records(arr_queue))
+ if download_ids and qbittorrent.configured():
+ actions.append(
+ ActionOption(
+ id="resume_torrent",
+ label="Resume the download",
+ risk="low",
+ description="Resume the existing qBittorrent job if it is paused or stalled.",
+ )
+ )
+
+ snapshot.actions = actions
+ jellyfin_link = None
+ if runtime.jellyfin_public_url and jellyfin_available:
+ base_url = runtime.jellyfin_public_url.rstrip("/")
+ jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
+ if jellyfin_item_id:
+ jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}"
+ else:
+ query = quote(snapshot.title or "")
+ jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
+ availability = arr_details.get("availability") or {}
+ is_partial = bool(jellyfin_available and int(availability.get("missing") or 0) > 0)
+ snapshot.raw = {
+ "jellyseerr": jelly_request,
+ "arr": {
+ "item": arr_item,
+ "queue": arr_queue,
+ },
+ "jellyfin": {
+ "publicUrl": runtime.jellyfin_public_url,
+ "found": jellyfin_available,
+ "available": jellyfin_available and snapshot.state in {
+ NormalizedState.available,
+ NormalizedState.completed,
+ },
+ "partial": is_partial,
+ "link": jellyfin_link,
+ "item": jellyfin_item,
+ },
+ "qbittorrent": {
+ **download_presentation,
+ "downloadIds": download_ids,
+ "error": qbit_error,
+ },
+ }
+
+ snapshot.presentation = _build_presentation(
+ snapshot,
+ approved=derived_approved,
+ arr_state=arr_state,
+ arr_details=arr_details,
+ prowlarr_state=prowlarr_state,
+ download=download_presentation,
+ jellyfin_found=jellyfin_available,
+ jellyfin_link=jellyfin_link,
+ )
+ status_presentation = snapshot.presentation.get("status")
+ if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
+ snapshot.state_reason = str(status_presentation["meaning"])
+
+ await _maybe_refresh_jellyfin(snapshot)
+ await asyncio.to_thread(save_snapshot, snapshot)
+ return snapshot
diff --git a/backend/app/services/user_cache.py b/backend/app/services/user_cache.py
new file mode 100644
index 0000000..abd5913
--- /dev/null
+++ b/backend/app/services/user_cache.py
@@ -0,0 +1,185 @@
+import json
+import logging
+from datetime import datetime, timezone, timedelta
+from typing import Any, Dict, List, Optional
+
+from ..db import get_setting, set_setting, delete_setting
+
+logger = logging.getLogger(__name__)
+
+JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
+JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
+JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
+JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _parse_iso(value: Optional[str]) -> Optional[datetime]:
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(value)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
+ parsed = _parse_iso(cached_at)
+ if not parsed:
+ return False
+ age = datetime.now(timezone.utc) - parsed
+ return age <= timedelta(minutes=max_age_minutes)
+
+
+def _load_cached_users(
+ cache_key: str, cache_at_key: str, max_age_minutes: int
+) -> Optional[List[Dict[str, Any]]]:
+ cached_at = get_setting(cache_at_key)
+ if not _cache_is_fresh(cached_at, max_age_minutes):
+ return None
+ raw = get_setting(cache_key)
+ if not raw:
+ return None
+ try:
+ data = json.loads(raw)
+ except (TypeError, json.JSONDecodeError):
+ return None
+ if isinstance(data, list):
+ return [item for item in data if isinstance(item, dict)]
+ return None
+
+
+def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
+ payload = json.dumps(users, ensure_ascii=True)
+ set_setting(cache_key, payload)
+ set_setting(cache_at_key, _now_iso())
+
+
+def _normalized_handles(value: Any) -> List[str]:
+ if not isinstance(value, str):
+ return []
+ normalized = value.strip().lower()
+ if not normalized:
+ return []
+ handles = [normalized]
+ if "@" in normalized:
+ handles.append(normalized.split("@", 1)[0])
+ return list(dict.fromkeys(handles))
+
+
+def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
+ candidate_to_id: Dict[str, int] = {}
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ user_id = user.get("id") or user.get("userId") or user.get("Id")
+ try:
+ user_id = int(user_id)
+ except (TypeError, ValueError):
+ continue
+ for key in ("username", "email", "displayName", "name"):
+ for handle in _normalized_handles(user.get(key)):
+ candidate_to_id.setdefault(handle, user_id)
+ return candidate_to_id
+
+
+def find_matching_jellyseerr_user(
+ identifier: str, users: List[Dict[str, Any]]
+) -> Optional[Dict[str, Any]]:
+ target_handles = set(_normalized_handles(identifier))
+ if not target_handles:
+ return None
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ for key in ("username", "email", "displayName", "name"):
+ if target_handles.intersection(_normalized_handles(user.get(key))):
+ return user
+ return None
+
+
+def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
+ if not isinstance(user, dict):
+ return None
+ value = user.get("email")
+ if not isinstance(value, str):
+ return None
+ candidate = value.strip()
+ if not candidate or "@" not in candidate:
+ return None
+ return candidate
+
+
+def match_jellyseerr_user_id(
+ username: str, candidate_map: Dict[str, int]
+) -> Optional[int]:
+ for handle in _normalized_handles(username):
+ matched = candidate_map.get(handle)
+ if matched is not None:
+ return matched
+ return None
+
+
+def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ normalized: List[Dict[str, Any]] = []
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ normalized.append(
+ {
+ "id": user.get("id") or user.get("userId") or user.get("Id"),
+ "email": user.get("email"),
+ "username": user.get("username"),
+ "displayName": user.get("displayName"),
+ "name": user.get("name"),
+ }
+ )
+ _save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
+ logger.debug("Cached Seerr users: %s", len(normalized))
+ return normalized
+
+
+def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
+ return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
+
+
+def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ normalized: List[Dict[str, Any]] = []
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ normalized.append(
+ {
+ "id": user.get("Id"),
+ "name": user.get("Name"),
+ "hasPassword": user.get("HasPassword"),
+ "lastLoginDate": user.get("LastLoginDate"),
+ }
+ )
+ _save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
+ logger.debug("Cached Jellyfin users: %s", len(normalized))
+ return normalized
+
+
+def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
+ return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
+
+
+def clear_user_import_caches() -> Dict[str, int]:
+ cleared = 0
+ for key in (
+ JELLYSEERR_CACHE_KEY,
+ JELLYSEERR_CACHE_AT_KEY,
+ JELLYFIN_CACHE_KEY,
+ JELLYFIN_CACHE_AT_KEY,
+ ):
+ delete_setting(key)
+ cleared += 1
+ logger.debug("Cleared user import cache keys: %s", cleared)
+ return {"settingsKeysCleared": cleared}
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..e9b011d
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,9 @@
+fastapi==0.134.0
+uvicorn==0.41.0
+httpx==0.28.1
+pydantic==2.12.5
+pydantic-settings==2.14.2
+PyJWT==2.13.0
+passlib==1.7.4
+python-multipart==0.0.31
+Pillow==12.3.0
diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py
new file mode 100644
index 0000000..bb9930d
--- /dev/null
+++ b/backend/tests/test_backend_quality.py
@@ -0,0 +1,385 @@
+import os
+from types import SimpleNamespace
+import tempfile
+import unittest
+from unittest.mock import AsyncMock, patch
+
+import httpx
+from fastapi import HTTPException
+from starlette.requests import Request
+
+from backend.app import db
+from backend.app.config import settings
+from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
+from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop
+from backend.app.routers import auth as auth_router
+from backend.app.routers import portal as portal_router
+from backend.app.routers import requests as requests_router
+from backend.app.routers import site as site_router
+from backend.app.routers import status as status_router
+from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
+from backend.app.services import password_reset
+from backend.app.services.snapshot import _build_presentation, _episode_availability
+
+
+def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
+ scope = {
+ "type": "http",
+ "http_version": "1.1",
+ "method": "POST",
+ "scheme": "http",
+ "path": "/auth/password/forgot",
+ "raw_path": b"/auth/password/forgot",
+ "query_string": b"",
+ "headers": [(b"user-agent", user_agent.encode("utf-8"))],
+ "client": (ip, 12345),
+ "server": ("testserver", 8000),
+ }
+
+ async def receive() -> dict:
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ return Request(scope, receive)
+
+
+class TempDatabaseMixin:
+ def setUp(self) -> None:
+ super_method = getattr(super(), "setUp", None)
+ if callable(super_method):
+ super_method()
+ self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self._original_sqlite_path = settings.sqlite_path
+ self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
+ settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
+ settings.sqlite_journal_mode = "DELETE"
+ auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
+ auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
+ auth_router._RESET_ATTEMPTS_BY_IP.clear()
+ auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
+ db.init_db()
+
+ def tearDown(self) -> None:
+ settings.sqlite_path = self._original_sqlite_path
+ settings.sqlite_journal_mode = self._original_journal_mode
+ auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
+ auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
+ auth_router._RESET_ATTEMPTS_BY_IP.clear()
+ auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
+ self._tempdir.cleanup()
+ super_method = getattr(super(), "tearDown", None)
+ if callable(super_method):
+ super_method()
+
+
+class PasswordPolicyTests(unittest.TestCase):
+ def test_validate_password_policy_rejects_short_passwords(self) -> None:
+ with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE):
+ validate_password_policy("short")
+
+ def test_validate_password_policy_trims_whitespace(self) -> None:
+ self.assertEqual(validate_password_policy(" password123 "), "password123")
+
+
+class NetworkSecurityTests(unittest.TestCase):
+ def test_notification_targets_reject_loopback(self) -> None:
+ with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
+ validate_notification_target_url("http://127.0.0.1:8080/webhook")
+
+ def test_forwarded_headers_require_trusted_proxy(self) -> None:
+ original_enabled = settings.magent_proxy_enabled
+ original_trust = settings.magent_proxy_trust_forwarded_headers
+ original_proxies = settings.magent_proxy_trusted_proxies
+ settings.magent_proxy_enabled = True
+ settings.magent_proxy_trust_forwarded_headers = True
+ settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
+ try:
+ self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
+ self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
+ finally:
+ settings.magent_proxy_enabled = original_enabled
+ settings.magent_proxy_trust_forwarded_headers = original_trust
+ settings.magent_proxy_trusted_proxies = original_proxies
+
+
+class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
+ async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
+ class FakeClient:
+ def __init__(self) -> None:
+ self.cookies = httpx.Cookies()
+
+ async def post(self, *_args, **_kwargs) -> httpx.Response:
+ self.cookies.set("QBT_SID_8080", "session")
+ return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login"))
+
+ client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
+
+ await client._login(FakeClient())
+
+ async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
+ client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
+ with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
+ result = await status_router._check_qbittorrent(client)
+
+ self.assertEqual(result["status"], "degraded")
+ self.assertIn("credentials", result["message"].lower())
+
+ async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
+ client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
+ with patch.object(
+ client,
+ "get_app_version",
+ new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
+ ), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
+ result = await status_router._check_qbittorrent(client)
+
+ self.assertEqual(result["status"], "degraded")
+ self.assertIn("credentials", result["message"].lower())
+
+
+class SiteInfoTests(unittest.TestCase):
+ def test_site_public_exposes_requests_navigation_toggle(self) -> None:
+ runtime = SimpleNamespace(
+ site_build_number="test-build",
+ site_banner_enabled=False,
+ site_banner_message="",
+ site_banner_tone="info",
+ site_login_show_jellyfin_login=True,
+ site_login_show_local_login=True,
+ site_login_show_forgot_password=True,
+ site_login_show_signup_link=True,
+ site_nav_show_requests=False,
+ )
+
+ with patch.object(site_router, "get_runtime_settings", return_value=runtime):
+ info = site_router._build_site_info(False)
+
+ self.assertEqual(info["navigation"], {"showRequests": False})
+
+
+class RequestCacheTests(unittest.TestCase):
+ def tearDown(self) -> None:
+ requests_router._detail_cache.clear()
+ requests_router._failed_detail_cache.clear()
+
+ def test_successful_detail_cache_write_clears_prior_failure(self) -> None:
+ key = "request:123"
+ requests_router._failure_cache_set(key)
+ self.assertTrue(requests_router._failure_cache_has(key))
+
+ requests_router._cache_set(key, {"id": 123})
+
+ self.assertFalse(requests_router._failure_cache_has(key))
+ self.assertEqual(requests_router._cache_get(key), {"id": 123})
+
+
+class RequestPresentationTests(unittest.TestCase):
+ def test_episode_availability_counts_only_aired_monitored_episodes(self) -> None:
+ episodes = [
+ {"seasonNumber": 1, "episodeNumber": 1, "monitored": True, "hasFile": True},
+ {"seasonNumber": 1, "episodeNumber": 2, "monitored": True, "hasFile": False},
+ {"seasonNumber": 1, "episodeNumber": 3, "monitored": False, "hasFile": False},
+ {
+ "seasonNumber": 1,
+ "episodeNumber": 4,
+ "monitored": True,
+ "hasFile": False,
+ "airDateUtc": "2999-01-01T00:00:00Z",
+ },
+ ]
+
+ availability = _episode_availability(episodes)
+
+ self.assertEqual(availability["available"], 1)
+ self.assertEqual(availability["missing"], 1)
+ self.assertEqual(availability["total"], 2)
+
+ def test_presentation_hides_download_without_download_evidence(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Example",
+ request_type=RequestType.tv,
+ state=NormalizedState.added_to_arr,
+ actions=[],
+ )
+
+ presentation = _build_presentation(
+ snapshot,
+ approved=True,
+ arr_state="added",
+ arr_details={
+ "availability": {"available": 0, "missing": 6, "total": 6, "seasons": []}
+ },
+ prowlarr_state="ok",
+ download={
+ "visible": False,
+ "state": "not_started",
+ "summary": "No download attempt has been observed.",
+ "torrents": [],
+ },
+ jellyfin_found=False,
+ jellyfin_link=None,
+ )
+
+ self.assertFalse(presentation["download"]["visible"])
+ self.assertIn("waiting for 6 episodes", presentation["status"]["label"])
+ download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
+ self.assertEqual(download_stage["summary"], "No download attempt yet")
+
+
+class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
+ def test_set_user_email_is_case_insensitive(self) -> None:
+ created = db.create_user_if_missing(
+ "MixedCaseUser",
+ "password123",
+ email=None,
+ auth_provider="local",
+ )
+ self.assertTrue(created)
+ updated = db.set_user_email("mixedcaseuser", "mixed@example.com")
+ self.assertTrue(updated)
+ stored = db.get_user_by_username("MIXEDCASEUSER")
+ self.assertIsNotNone(stored)
+ self.assertEqual(stored.get("email"), "mixed@example.com")
+
+
+class SnapshotHistoryTests(TempDatabaseMixin, unittest.TestCase):
+ def test_duplicate_snapshots_are_not_saved_and_download_evidence_is_retained(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Example",
+ request_type=RequestType.tv,
+ state=NormalizedState.downloading,
+ state_reason="Downloading one episode.",
+ timeline=[
+ TimelineHop(
+ service="qBittorrent",
+ status="downloading",
+ details={
+ "summary": "Downloading one item.",
+ "torrents": [{"hash": "abc", "progress": 0.5}],
+ },
+ )
+ ],
+ )
+
+ db.save_snapshot(snapshot)
+ db.save_snapshot(snapshot)
+
+ history = db.get_recent_snapshots("3909", 10)
+ evidence = db.get_request_download_evidence("3909")
+ self.assertEqual(len(history), 1)
+ self.assertTrue(evidence["observed"])
+ self.assertEqual(evidence["state"], "downloading")
+
+
+class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ async def test_forgot_password_is_rate_limited(self) -> None:
+ request = _build_request(ip="10.1.2.3")
+ payload = {"identifier": "resetuser@example.com"}
+ with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object(
+ auth_router,
+ "request_password_reset",
+ new=AsyncMock(return_value={"status": "ok", "issued": False}),
+ ):
+ for _ in range(3):
+ result = await auth_router.forgot_password(payload, request)
+ self.assertEqual(result["status"], "ok")
+
+ with self.assertRaises(HTTPException) as context:
+ await auth_router.forgot_password(payload, request)
+
+ self.assertEqual(context.exception.status_code, 429)
+ self.assertEqual(
+ context.exception.detail,
+ "Too many password reset attempts. Try again shortly.",
+ )
+
+ async def test_request_password_reset_prefers_local_user_email(self) -> None:
+ db.create_user_if_missing(
+ "ResetUser",
+ "password123",
+ email="local@example.com",
+ auth_provider="local",
+ )
+ with patch.object(
+ password_reset,
+ "send_password_reset_email",
+ new=AsyncMock(return_value={"status": "ok"}),
+ ) as send_email:
+ result = await password_reset.request_password_reset("ResetUser")
+
+ self.assertTrue(result["issued"])
+ self.assertEqual(result["recipient_email"], "local@example.com")
+ send_email.assert_awaited_once()
+ self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com")
+
+ async def test_profile_invite_requires_recipient_email(self) -> None:
+ current_user = {
+ "username": "invite-owner",
+ "role": "user",
+ "invite_management_enabled": True,
+ "profile_id": None,
+ }
+ with self.assertRaises(HTTPException) as context:
+ await auth_router.create_profile_invite({"label": "Missing email"}, current_user)
+
+ self.assertEqual(context.exception.status_code, 400)
+ self.assertEqual(
+ context.exception.detail,
+ "recipient_email is required and must be a valid email address.",
+ )
+
+
+class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
+ def test_legacy_request_status_maps_to_workflow(self) -> None:
+ item = {"kind": "request", "status": "in_progress"}
+ serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
+ workflow = serialized.get("workflow") or {}
+ self.assertEqual(workflow.get("request_status"), "approved")
+ self.assertEqual(workflow.get("media_status"), "processing")
+
+ def test_invalid_pipeline_transition_is_rejected(self) -> None:
+ with self.assertRaises(HTTPException) as context:
+ portal_router._validate_pipeline_transition(
+ "approved",
+ "processing",
+ "pending",
+ "pending",
+ )
+ self.assertEqual(context.exception.status_code, 400)
+
+ def test_portal_workflow_filters(self) -> None:
+ db.create_portal_item(
+ kind="request",
+ title="Request A",
+ description="A",
+ created_by_username="alpha",
+ created_by_id=None,
+ status="processing",
+ workflow_request_status="approved",
+ workflow_media_status="processing",
+ )
+ db.create_portal_item(
+ kind="request",
+ title="Request B",
+ description="B",
+ created_by_username="bravo",
+ created_by_id=None,
+ status="pending",
+ workflow_request_status="pending",
+ workflow_media_status="pending",
+ )
+ processing = db.list_portal_items(
+ kind="request",
+ workflow_request_status="approved",
+ workflow_media_status="processing",
+ limit=10,
+ offset=0,
+ )
+ pending_count = db.count_portal_items(
+ kind="request",
+ workflow_request_status="pending",
+ workflow_media_status="pending",
+ )
+ self.assertEqual(len(processing), 1)
+ self.assertEqual(pending_count, 1)
diff --git a/data/branding/favicon.ico b/data/branding/favicon.ico
new file mode 100644
index 0000000..68b4d3d
Binary files /dev/null and b/data/branding/favicon.ico differ
diff --git a/data/branding/logo.png b/data/branding/logo.png
new file mode 100644
index 0000000..d76a78a
Binary files /dev/null and b/data/branding/logo.png differ
diff --git a/docker-compose.beta.yml b/docker-compose.beta.yml
new file mode 100644
index 0000000..af85e24
--- /dev/null
+++ b/docker-compose.beta.yml
@@ -0,0 +1,28 @@
+name: magent-beta
+
+services:
+ magent:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file:
+ - ./.env
+ environment:
+ APP_NAME: Magent Beta
+ CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
+ MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
+ MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
+ AUTH_COOKIE_NAME: magent_beta_auth
+ AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
+ AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
+ SQLITE_PATH: /app/data/magent.db
+ LOG_FILE: /app/data/magent.log
+ SITE_BANNER_ENABLED: "true"
+ SITE_BANNER_MESSAGE: "Beta environment"
+ SITE_BANNER_TONE: warning
+ ports:
+ - "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
+ - "127.0.0.1:8100:8000"
+ volumes:
+ - ./data:/app/data
+ restart: unless-stopped
diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml
new file mode 100644
index 0000000..f3c695c
--- /dev/null
+++ b/docker-compose.hub.yml
@@ -0,0 +1,10 @@
+services:
+ magent:
+ image: rephl3xnz/magent:latest
+ env_file:
+ - ./.env
+ ports:
+ - "3000:3000"
+ - "8000:8000"
+ volumes:
+ - ./data:/app/data
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ebbc613
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,12 @@
+services:
+ magent:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file:
+ - ./.env
+ ports:
+ - "3000:3000"
+ - "8000:8000"
+ volumes:
+ - ./data:/app/data
diff --git a/docker/supervisord.conf b/docker/supervisord.conf
new file mode 100644
index 0000000..2cb5ceb
--- /dev/null
+++ b/docker/supervisord.conf
@@ -0,0 +1,28 @@
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/tmp/supervisord.pid
+
+[program:backend]
+directory=/app
+command=uvicorn app.main:app --host 0.0.0.0 --port 8000
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=10
+
+[program:frontend]
+directory=/app/frontend
+command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
+environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=20
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..b2e279a
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,3 @@
+node_modules/
+.next/
+.env
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..dfa29a1
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,33 @@
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+ENV NEXT_TELEMETRY_DISABLED=1
+
+COPY package.json ./
+RUN npm install
+
+COPY app ./app
+COPY public ./public
+COPY next-env.d.ts ./next-env.d.ts
+COPY next.config.js ./next.config.js
+COPY tsconfig.json ./tsconfig.json
+
+RUN npm run build
+
+FROM node:20-alpine
+
+WORKDIR /app
+
+ENV NEXT_TELEMETRY_DISABLED=1 \
+ NODE_ENV=production
+
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/next.config.js ./next.config.js
+
+EXPOSE 3000
+
+CMD ["npm", "run", "start"]
diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx
new file mode 100644
index 0000000..940fe94
--- /dev/null
+++ b/frontend/app/admin/SettingsPage.tsx
@@ -0,0 +1,2571 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
+import AdminShell from '../ui/AdminShell'
+import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
+
+type AdminSetting = {
+ key: string
+ value: string | null
+ isSet: boolean
+ source: string
+ sensitive: boolean
+}
+
+type ServiceOptions = {
+ rootFolders: { id: number; path: string; label: string }[]
+ qualityProfiles: { id: number; name: string; label: string }[]
+}
+
+type ServiceStatus = {
+ name: string
+ status: string
+ message?: string
+}
+
+const SECTION_LABELS: Record = {
+ magent: 'Magent',
+ general: 'General',
+ notifications: 'Notifications',
+ seerr: 'Seerr',
+ jellyseerr: 'Seerr',
+ jellyfin: 'Jellyfin',
+ artwork: 'Artwork cache',
+ cache: 'Cache Control',
+ sonarr: 'Sonarr',
+ radarr: 'Radarr',
+ prowlarr: 'Prowlarr',
+ qbittorrent: 'qBittorrent',
+ log: 'Activity log',
+ requests: 'Request sync',
+ site: 'Site',
+}
+
+const BOOL_SETTINGS = new Set([
+ 'jellyfin_sync_to_arr',
+ 'site_banner_enabled',
+ 'site_login_show_jellyfin_login',
+ 'site_login_show_local_login',
+ 'site_login_show_forgot_password',
+ 'site_login_show_signup_link',
+ 'site_nav_show_requests',
+ 'magent_proxy_enabled',
+ 'magent_proxy_trust_forwarded_headers',
+ 'magent_ssl_bind_enabled',
+ 'magent_notify_enabled',
+ 'magent_notify_email_enabled',
+ 'magent_notify_email_use_tls',
+ 'magent_notify_email_use_ssl',
+ 'magent_notify_discord_enabled',
+ 'magent_notify_telegram_enabled',
+ 'magent_notify_push_enabled',
+ 'magent_notify_webhook_enabled',
+])
+const TEXTAREA_SETTINGS = new Set([
+ 'site_banner_message',
+ 'site_changelog',
+ 'magent_ssl_certificate_pem',
+ 'magent_ssl_private_key_pem',
+])
+const URL_SETTINGS = new Set([
+ 'magent_application_url',
+ 'magent_api_url',
+ 'magent_proxy_base_url',
+ 'magent_notify_discord_webhook_url',
+ 'magent_notify_push_base_url',
+ 'magent_notify_webhook_url',
+ 'jellyseerr_base_url',
+ 'jellyfin_base_url',
+ 'jellyfin_public_url',
+ 'sonarr_base_url',
+ 'radarr_base_url',
+ 'prowlarr_base_url',
+ 'qbittorrent_base_url',
+])
+const NUMBER_SETTINGS = new Set([
+ 'magent_application_port',
+ 'magent_api_port',
+ 'magent_notify_email_smtp_port',
+ 'log_file_max_bytes',
+ 'log_file_backup_count',
+ 'requests_sync_ttl_minutes',
+ 'requests_poll_interval_seconds',
+ 'requests_delta_sync_interval_minutes',
+ 'requests_cleanup_days',
+])
+const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
+
+const SECTION_DESCRIPTIONS: Record = {
+ magent:
+ 'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
+ general:
+ 'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
+ notifications:
+ 'Notification providers and delivery channel settings used by Magent messaging features.',
+ seerr: 'Connect Seerr where users submit content requests.',
+ jellyseerr: 'Connect Seerr where users submit content requests.',
+ jellyfin: 'Control Jellyfin login and availability checks.',
+ artwork: 'Cache posters/backdrops and review artwork coverage.',
+ cache: 'Manage saved requests cache and refresh behavior.',
+ sonarr: 'TV automation settings.',
+ radarr: 'Movie automation settings.',
+ prowlarr: 'Indexer search settings.',
+ qbittorrent: 'Downloader connection settings.',
+ requests: 'Control how often requests are refreshed and cleaned up.',
+ log: 'Activity log for troubleshooting.',
+ site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
+}
+
+const SETTINGS_SECTION_MAP: Record = {
+ magent: 'magent',
+ general: 'magent',
+ notifications: 'magent',
+ seerr: 'jellyseerr',
+ jellyseerr: 'jellyseerr',
+ jellyfin: 'jellyfin',
+ artwork: null,
+ sonarr: 'sonarr',
+ radarr: 'radarr',
+ prowlarr: 'prowlarr',
+ qbittorrent: 'qbittorrent',
+ requests: 'requests',
+ cache: null,
+ logs: 'log',
+ maintenance: null,
+ site: 'site',
+}
+
+const MAGENT_SECTION_GROUPS: Array<{
+ key: string
+ title: string
+ description: string
+ keys: string[]
+}> = [
+ {
+ key: 'magent-runtime',
+ title: 'Application',
+ description:
+ 'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
+ keys: [
+ 'magent_application_url',
+ 'magent_application_port',
+ 'magent_api_url',
+ 'magent_api_port',
+ 'magent_bind_host',
+ ],
+ },
+ {
+ key: 'magent-proxy',
+ title: 'Proxy',
+ description:
+ 'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
+ keys: [
+ 'magent_proxy_enabled',
+ 'magent_proxy_base_url',
+ 'magent_proxy_trust_forwarded_headers',
+ 'magent_proxy_forwarded_prefix',
+ ],
+ },
+ {
+ key: 'magent-ssl',
+ title: 'Manual SSL Bind',
+ description:
+ 'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
+ keys: [
+ 'magent_ssl_bind_enabled',
+ 'magent_ssl_certificate_path',
+ 'magent_ssl_private_key_path',
+ 'magent_ssl_certificate_pem',
+ 'magent_ssl_private_key_pem',
+ ],
+ },
+ {
+ key: 'magent-notify-core',
+ title: 'Notifications',
+ description:
+ 'Global notification controls and provider-independent defaults used by Magent messaging features.',
+ keys: ['magent_notify_enabled'],
+ },
+ {
+ key: 'magent-notify-email',
+ title: 'Email',
+ description: 'SMTP configuration for email notifications.',
+ keys: [
+ 'magent_notify_email_enabled',
+ 'magent_notify_email_smtp_host',
+ 'magent_notify_email_smtp_port',
+ 'magent_notify_email_smtp_username',
+ 'magent_notify_email_smtp_password',
+ 'magent_notify_email_from_address',
+ 'magent_notify_email_from_name',
+ 'magent_notify_email_use_tls',
+ 'magent_notify_email_use_ssl',
+ ],
+ },
+ {
+ key: 'magent-notify-discord',
+ title: 'Discord',
+ description: 'Webhook settings for Discord notifications and feedback routing.',
+ keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
+ },
+ {
+ key: 'magent-notify-telegram',
+ title: 'Telegram',
+ description: 'Bot token and chat target for Telegram notifications.',
+ keys: [
+ 'magent_notify_telegram_enabled',
+ 'magent_notify_telegram_bot_token',
+ 'magent_notify_telegram_chat_id',
+ ],
+ },
+ {
+ key: 'magent-notify-push',
+ title: 'Push / Mobile',
+ description:
+ 'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
+ keys: [
+ 'magent_notify_push_enabled',
+ 'magent_notify_push_provider',
+ 'magent_notify_push_base_url',
+ 'magent_notify_push_topic',
+ 'magent_notify_push_token',
+ 'magent_notify_push_user_key',
+ 'magent_notify_push_device',
+ 'magent_notify_webhook_enabled',
+ 'magent_notify_webhook_url',
+ ],
+ },
+]
+
+const MAGENT_GROUPS_BY_SECTION: Record> = {
+ general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']),
+ notifications: new Set([
+ 'magent-notify-core',
+ 'magent-notify-email',
+ 'magent-notify-discord',
+ 'magent-notify-telegram',
+ 'magent-notify-push',
+ ]),
+}
+
+const SITE_SECTION_GROUPS: Array<{
+ key: string
+ title: string
+ description: string
+ keys: string[]
+}> = [
+ {
+ key: 'site-banner',
+ title: 'Site Banner',
+ description: 'Control the sitewide banner message, tone, and visibility.',
+ keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'],
+ },
+ {
+ key: 'site-login',
+ title: 'Login Page Behaviour',
+ description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
+ keys: [
+ 'site_login_show_jellyfin_login',
+ 'site_login_show_local_login',
+ 'site_login_show_forgot_password',
+ 'site_login_show_signup_link',
+ ],
+ },
+ {
+ key: 'site-navigation',
+ title: 'Beta Navigation',
+ description: 'Temporarily show or hide beta navigation entries while new request pipelines are built.',
+ keys: ['site_nav_show_requests'],
+ },
+]
+
+const SETTING_LABEL_OVERRIDES: Record = {
+ jellyseerr_base_url: 'Seerr base URL',
+ jellyseerr_api_key: 'Seerr API key',
+ magent_application_url: 'Application URL',
+ magent_application_port: 'Application port',
+ magent_api_url: 'API URL',
+ magent_api_port: 'API port',
+ magent_bind_host: 'Bind host',
+ magent_proxy_enabled: 'Proxy support enabled',
+ magent_proxy_base_url: 'Proxy base URL',
+ magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
+ magent_proxy_forwarded_prefix: 'Forwarded path prefix',
+ magent_ssl_bind_enabled: 'Manual SSL bind enabled',
+ magent_ssl_certificate_path: 'Certificate path',
+ magent_ssl_private_key_path: 'Private key path',
+ magent_ssl_certificate_pem: 'Certificate (PEM)',
+ magent_ssl_private_key_pem: 'Private key (PEM)',
+ magent_notify_enabled: 'Notifications enabled',
+ magent_notify_email_enabled: 'Email notifications enabled',
+ magent_notify_email_smtp_host: 'SMTP host',
+ magent_notify_email_smtp_port: 'SMTP port',
+ magent_notify_email_smtp_username: 'SMTP username',
+ magent_notify_email_smtp_password: 'SMTP password',
+ magent_notify_email_from_address: 'From email address',
+ magent_notify_email_from_name: 'From display name',
+ magent_notify_email_use_tls: 'Use STARTTLS',
+ magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
+ magent_notify_discord_enabled: 'Discord notifications enabled',
+ magent_notify_discord_webhook_url: 'Discord webhook URL',
+ magent_notify_telegram_enabled: 'Telegram notifications enabled',
+ magent_notify_telegram_bot_token: 'Telegram bot token',
+ magent_notify_telegram_chat_id: 'Telegram chat ID',
+ magent_notify_push_enabled: 'Push notifications enabled',
+ magent_notify_push_provider: 'Push provider',
+ magent_notify_push_base_url: 'Push provider/base URL',
+ magent_notify_push_topic: 'Topic / channel',
+ magent_notify_push_token: 'API token / password',
+ magent_notify_push_user_key: 'User key / recipient key',
+ magent_notify_push_device: 'Device / target',
+ magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
+ magent_notify_webhook_url: 'Generic webhook URL',
+ site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
+ site_login_show_local_login: 'Login page: local Magent sign-in',
+ site_login_show_forgot_password: 'Login page: forgot password',
+ site_login_show_signup_link: 'Login page: invite signup link',
+ site_nav_show_requests: 'Top navigation: Requests',
+ log_file_max_bytes: 'Log file max size (bytes)',
+ log_file_backup_count: 'Rotated log files to keep',
+ log_http_client_level: 'Service HTTP log level',
+ log_background_sync_level: 'Background sync log level',
+}
+
+const labelFromKey = (key: string) =>
+ SETTING_LABEL_OVERRIDES[key] ??
+ key
+ .replaceAll('_', ' ')
+ .replace('jellyseerr', 'Seerr')
+ .replace('base url', 'URL')
+ .replace('api key', 'API key')
+ .replace('quality profile id', 'Quality profile ID')
+ .replace('root folder', 'Root folder')
+ .replace('qbittorrent', 'qBittorrent')
+ .replace('requests sync ttl minutes', 'Saved request refresh TTL (minutes)')
+ .replace('requests poll interval seconds', 'Full refresh check interval (seconds)')
+ .replace('requests delta sync interval minutes', 'Delta sync interval (minutes)')
+ .replace('requests full sync time', 'Daily full refresh time (24h)')
+ .replace('requests cleanup time', 'Daily history cleanup time (24h)')
+ .replace('requests cleanup days', 'History retention window (days)')
+ .replace('requests data source', 'Request source (cache vs Seerr)')
+ .replace('jellyfin public url', 'Jellyfin public URL')
+ .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
+ .replace('artwork cache mode', 'Artwork cache mode')
+ .replace('site build number', 'Build number')
+ .replace('site banner enabled', 'Sitewide banner enabled')
+ .replace('site banner message', 'Sitewide banner message')
+ .replace('site banner tone', 'Sitewide banner tone')
+ .replace('site nav show requests', 'Top navigation: Requests')
+ .replace('site changelog', 'Changelog text')
+
+const formatBytes = (value?: number | null) => {
+ if (!value || value <= 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ let size = value
+ let unitIndex = 0
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024
+ unitIndex += 1
+ }
+ const decimals = unitIndex === 0 || size >= 10 ? 0 : 1
+ return `${size.toFixed(decimals)} ${units[unitIndex]}`
+}
+
+type SettingsPageProps = {
+ section: string
+}
+
+type SettingsSectionGroup = {
+ key: string
+ title: string
+ items: AdminSetting[]
+ description?: string
+}
+
+type SectionFeedback = {
+ tone: 'status' | 'error'
+ message: string
+}
+
+const SERVICE_TEST_ENDPOINTS: Record = {
+ jellyseerr: 'seerr',
+ jellyfin: 'jellyfin',
+ sonarr: 'sonarr',
+ radarr: 'radarr',
+ prowlarr: 'prowlarr',
+ qbittorrent: 'qbittorrent',
+}
+
+export default function SettingsPage({ section }: SettingsPageProps) {
+ const router = useRouter()
+ const [settings, setSettings] = useState([])
+ const [formValues, setFormValues] = useState>({})
+ const [status, setStatus] = useState(null)
+ const [sectionFeedback, setSectionFeedback] = useState>({})
+ const [sectionSaving, setSectionSaving] = useState>({})
+ const [sectionTesting, setSectionTesting] = useState>({})
+ const [emailTestRecipient, setEmailTestRecipient] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [sonarrOptions, setSonarrOptions] = useState(null)
+ const [radarrOptions, setRadarrOptions] = useState(null)
+ const [sonarrError, setSonarrError] = useState(null)
+ const [radarrError, setRadarrError] = useState(null)
+ const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState(null)
+ const [requestsSyncStatus, setRequestsSyncStatus] = useState(null)
+ const [artworkPrefetchStatus, setArtworkPrefetchStatus] = useState(null)
+ const [logsStatus, setLogsStatus] = useState(null)
+ const [logsLines, setLogsLines] = useState([])
+ const [logsCount, setLogsCount] = useState(200)
+ const [cacheRows, setCacheRows] = useState([])
+ const [cacheCount, setCacheCount] = useState(50)
+ const [cacheStatus, setCacheStatus] = useState(null)
+ const [cacheLoading, setCacheLoading] = useState(false)
+ const [requestsSync, setRequestsSync] = useState(null)
+ const [artworkPrefetch, setArtworkPrefetch] = useState(null)
+ const [artworkSummary, setArtworkSummary] = useState(null)
+ const [artworkSummaryStatus, setArtworkSummaryStatus] = useState(null)
+ const [maintenanceStatus, setMaintenanceStatus] = useState(null)
+ const [maintenanceBusy, setMaintenanceBusy] = useState(false)
+ const [liveStreamConnected, setLiveStreamConnected] = useState(false)
+ const [serviceStatuses, setServiceStatuses] = useState([])
+ const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState(null)
+ const requestsSyncRef = useRef(null)
+ const artworkPrefetchRef = useRef(null)
+ const computeProgressPercent = (
+ completedValue: unknown,
+ totalValue: unknown,
+ statusValue: unknown
+ ): number => {
+ if (String(statusValue).toLowerCase() === 'completed') {
+ return 100
+ }
+ const completed = Number(completedValue)
+ const total = Number(totalValue)
+ if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0 || completed <= 0) {
+ return 0
+ }
+ return Math.max(0, Math.min(100, Math.round((completed / total) * 100)))
+ }
+
+ const loadSettings = useCallback(async (refreshedKeys?: Set) => {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/settings`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (response.status === 403) {
+ router.push('/')
+ return
+ }
+ throw new Error('Failed to load settings')
+ }
+ const data = await response.json()
+ const fetched = Array.isArray(data?.settings) ? data.settings : []
+ setSettings(fetched)
+ const initialValues: Record = {}
+ for (const setting of fetched) {
+ if (!setting.sensitive && setting.value) {
+ if (BOOL_SETTINGS.has(setting.key)) {
+ initialValues[setting.key] = String(setting.value).toLowerCase()
+ } else {
+ initialValues[setting.key] = setting.value
+ }
+ } else {
+ initialValues[setting.key] = ''
+ }
+ }
+ setFormValues((current) => {
+ if (!refreshedKeys || refreshedKeys.size === 0) {
+ return initialValues
+ }
+ const nextValues = { ...initialValues }
+ for (const [key, value] of Object.entries(current)) {
+ if (!refreshedKeys.has(key)) {
+ nextValues[key] = value
+ }
+ }
+ return nextValues
+ })
+ setStatus(null)
+ }, [router])
+
+ const loadArtworkPrefetchStatus = useCallback(async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
+ if (!response.ok) {
+ return
+ }
+ const data = await response.json()
+ setArtworkPrefetch(data?.prefetch ?? null)
+ } catch (err) {
+ console.error(err)
+ }
+ }, [])
+
+ const loadArtworkSummary = useCallback(async () => {
+ setArtworkSummaryStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`)
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Artwork summary fetch failed')
+ }
+ const data = await response.json()
+ setArtworkSummary(data?.summary ?? null)
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not load artwork stats.'
+ setArtworkSummaryStatus(message)
+ }
+ }, [])
+
+ const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/${service}/options`)
+ if (!response.ok) {
+ throw new Error('Options unavailable')
+ }
+ const data = await response.json()
+ if (service === 'sonarr') {
+ setSonarrOptions({
+ rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
+ qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
+ })
+ setSonarrError(null)
+ } else {
+ setRadarrOptions({
+ rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
+ qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
+ })
+ setRadarrError(null)
+ }
+ } catch (err) {
+ console.error(err)
+ if (service === 'sonarr') {
+ setSonarrError('Could not load Sonarr options.')
+ } else {
+ setRadarrError('Could not load Radarr options.')
+ }
+ }
+ }, [])
+
+ const loadServiceStatuses = useCallback(async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/status/services`)
+ if (!response.ok) {
+ return
+ }
+ const data = await response.json()
+ setServiceStatuses(Array.isArray(data?.services) ? data.services : [])
+ setServiceStatusCheckedAt(new Date().toISOString())
+ } catch (err) {
+ console.error(err)
+ }
+ }, [])
+
+ useEffect(() => {
+ const load = async () => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ try {
+ await Promise.all([loadSettings(), loadServiceStatuses()])
+ if (section === 'cache' || section === 'artwork') {
+ await loadArtworkPrefetchStatus()
+ await loadArtworkSummary()
+ }
+ } catch (err) {
+ console.error(err)
+ setStatus('Could not load admin settings.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ load()
+ if (section === 'sonarr') {
+ void loadOptions('sonarr')
+ }
+ if (section === 'radarr') {
+ void loadOptions('radarr')
+ }
+ }, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
+
+ const groupedSettings = useMemo(() => {
+ const groups: Record = {}
+ for (const setting of settings) {
+ const section = setting.key.split('_')[0] ?? 'other'
+ if (!groups[section]) groups[section] = []
+ groups[section].push(setting)
+ }
+ return groups
+ }, [settings])
+
+ const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
+ const statusNamesBySection: Record = {
+ seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
+ jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
+ jellyfin: ['Jellyfin'],
+ sonarr: ['Sonarr'],
+ radarr: ['Radarr'],
+ prowlarr: ['Prowlarr'],
+ qbittorrent: ['qBittorrent', 'Qbittorrent'],
+ }
+ const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ''] ?? []
+ const currentServiceStatus = serviceStatuses.find((service) =>
+ statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase())
+ )
+ const currentServiceConfigured = currentServiceStatus
+ ? currentServiceStatus.status !== 'not_configured'
+ : null
+ const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
+ const isSiteGroupedSection = section === 'site'
+ const visibleSections = settingsSection ? [settingsSection] : []
+ const isCacheSection = section === 'cache'
+ const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
+ const artworkSettingKeys = new Set(['artwork_cache_mode'])
+ const generatedSettingKeys = new Set(['site_changelog'])
+ const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
+ const requestSettingOrder = [
+ 'requests_poll_interval_seconds',
+ 'requests_delta_sync_interval_minutes',
+ 'requests_full_sync_time',
+ 'requests_cleanup_time',
+ 'requests_cleanup_days',
+ ]
+ const siteSettingOrder = [
+ 'site_banner_enabled',
+ 'site_banner_message',
+ 'site_banner_tone',
+ 'site_login_show_jellyfin_login',
+ 'site_login_show_local_login',
+ 'site_login_show_forgot_password',
+ 'site_login_show_signup_link',
+ 'site_nav_show_requests',
+ ]
+ const sortByOrder = (items: AdminSetting[], order: string[]) => {
+ const position = new Map(order.map((key, index) => [key, index]))
+ return [...items].sort((a, b) => {
+ const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
+ const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
+ if (aIndex !== bIndex) return aIndex - bIndex
+ return a.key.localeCompare(b.key)
+ })
+ }
+ const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
+ const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
+ const settingsSections: SettingsSectionGroup[] = isCacheSection
+ ? [
+ { key: 'cache', title: 'Cache control', items: cacheSettings },
+ { key: 'artwork', title: 'Artwork cache', items: artworkSettings },
+ ]
+ : isMagentGroupedSection
+ ? (() => {
+ if (section === 'magent') {
+ return []
+ }
+ const magentItems = groupedSettings.magent ?? []
+ const byKey = new Map(magentItems.map((item) => [item.key, item]))
+ const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set()
+ const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
+ allowedGroupKeys.has(group.key),
+ ).map((group) => {
+ const items = group.keys
+ .map((key) => byKey.get(key))
+ .filter((item): item is AdminSetting => Boolean(item))
+ return {
+ key: group.key,
+ title: group.title,
+ description: group.description,
+ items,
+ }
+ })
+ return groups
+ })()
+ : isSiteGroupedSection
+ ? (() => {
+ const siteItems = groupedSettings.site ?? []
+ const byKey = new Map(siteItems.map((item) => [item.key, item]))
+ return SITE_SECTION_GROUPS.map((group) => {
+ const items = group.keys
+ .map((key) => byKey.get(key))
+ .filter((item): item is AdminSetting => Boolean(item))
+ return {
+ key: group.key,
+ title: group.title,
+ description: group.description,
+ items,
+ }
+ })
+ })()
+ : visibleSections.map((sectionKey) => ({
+ key: sectionKey,
+ title: SECTION_LABELS[sectionKey] ?? sectionKey,
+ items: (() => {
+ const sectionItems = groupedSettings[sectionKey] ?? []
+ const filtered =
+ sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
+ ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
+ : sectionItems
+ if (sectionKey === 'requests') {
+ return sortByOrder(filtered, requestSettingOrder)
+ }
+ if (sectionKey === 'site') {
+ return sortByOrder(filtered, siteSettingOrder)
+ }
+ return filtered
+ })(),
+ }))
+ const showLogs = section === 'logs'
+ const showMaintenance = section === 'maintenance'
+ const showRequestsExtras = section === 'requests'
+ const showArtworkExtras = section === 'cache'
+ const showCacheExtras = section === 'cache'
+ const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
+ if (sectionGroup.items && sectionGroup.items.length > 0) return true
+ if (showArtworkExtras && sectionGroup.key === 'artwork') return true
+ if (showCacheExtras && sectionGroup.key === 'cache') return true
+ if (showRequestsExtras && sectionGroup.key === 'requests') return true
+ return false
+ }
+
+ useEffect(() => {
+ requestsSyncRef.current = requestsSync
+ }, [requestsSync])
+
+ useEffect(() => {
+ artworkPrefetchRef.current = artworkPrefetch
+ }, [artworkPrefetch])
+
+ const settingDescriptions: Record = {
+ magent_application_url:
+ 'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
+ magent_application_port:
+ 'Preferred frontend/UI port for local or direct-hosted deployments.',
+ magent_api_url:
+ 'Canonical public URL for the Magent API when it differs from the app URL.',
+ magent_api_port: 'Preferred API port for local or direct-hosted deployments.',
+ magent_bind_host:
+ 'Host/IP to bind the application services to when running without an external process manager.',
+ magent_proxy_enabled:
+ 'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.',
+ magent_proxy_base_url:
+ 'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.',
+ magent_proxy_trust_forwarded_headers:
+ 'Trust X-Forwarded-* headers from your reverse proxy.',
+ magent_proxy_forwarded_prefix:
+ 'Optional path prefix added by your proxy (example: /magent).',
+ magent_ssl_bind_enabled:
+ 'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).',
+ magent_ssl_certificate_path:
+ 'Path to the TLS certificate file on disk (PEM).',
+ magent_ssl_private_key_path:
+ 'Path to the TLS private key file on disk (PEM).',
+ magent_ssl_certificate_pem:
+ 'Paste the TLS certificate PEM if you want Magent to store it directly.',
+ magent_ssl_private_key_pem:
+ 'Paste the TLS private key PEM if you want Magent to store it directly.',
+ magent_notify_enabled:
+ 'Master switch for Magent notifications. Individual provider toggles still apply.',
+ magent_notify_email_enabled: 'Enable SMTP email notifications.',
+ magent_notify_email_smtp_host: 'SMTP server hostname or IP.',
+ magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).',
+ magent_notify_email_smtp_username: 'SMTP account username.',
+ magent_notify_email_smtp_password: 'SMTP account password or app password.',
+ magent_notify_email_from_address: 'Sender email address used by Magent.',
+ magent_notify_email_from_name: 'Sender display name shown to recipients.',
+ magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.',
+ magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).',
+ magent_notify_discord_enabled: 'Enable Discord webhook notifications.',
+ magent_notify_discord_webhook_url:
+ 'Discord channel webhook URL used for notifications and optional feedback routing.',
+ magent_notify_telegram_enabled: 'Enable Telegram notifications.',
+ magent_notify_telegram_bot_token: 'Bot token from BotFather.',
+ magent_notify_telegram_chat_id:
+ 'Default Telegram chat/group/user ID for notifications.',
+ magent_notify_push_enabled: 'Enable generic push notifications.',
+ magent_notify_push_provider:
+ 'Push backend to target (ntfy, gotify, pushover, webhook, etc.).',
+ magent_notify_push_base_url:
+ 'Base URL for your push provider (for example ntfy/gotify server URL).',
+ magent_notify_push_topic: 'Topic/channel/room name used by the push provider.',
+ magent_notify_push_token: 'Provider token/API key/password.',
+ magent_notify_push_user_key:
+ 'Provider recipient key/user key (for example Pushover user key).',
+ magent_notify_push_device:
+ 'Optional device or target override, depending on provider.',
+ magent_notify_webhook_enabled: 'Enable generic webhook notifications.',
+ magent_notify_webhook_url:
+ 'Generic webhook endpoint for custom integrations or automation flows.',
+ jellyseerr_base_url:
+ 'Base URL for your Seerr server (FQDN or IP). Scheme is optional.',
+ jellyseerr_api_key: 'API key used to read requests and status.',
+ jellyfin_base_url:
+ 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
+ jellyfin_api_key: 'Admin API key for syncing users and availability.',
+ jellyfin_public_url:
+ 'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
+ jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
+ artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
+ sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
+ sonarr_api_key: 'API key for Sonarr.',
+ sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
+ sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
+ sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
+ radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
+ radarr_api_key: 'API key for Radarr.',
+ radarr_quality_profile_id: 'Quality profile used when adding movies.',
+ radarr_root_folder: 'Root folder where Radarr stores movies.',
+ radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
+ prowlarr_base_url:
+ 'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
+ prowlarr_api_key: 'API key for Prowlarr.',
+ qbittorrent_base_url:
+ 'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
+ qbittorrent_username: 'qBittorrent login username.',
+ qbittorrent_password: 'qBittorrent login password.',
+ requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
+ requests_poll_interval_seconds:
+ 'How often Magent checks if a full refresh should run.',
+ requests_delta_sync_interval_minutes:
+ 'How often we poll for new or updated requests.',
+ requests_full_sync_time: 'Daily time to rebuild the full request cache.',
+ requests_cleanup_time: 'Daily time to trim old request history.',
+ requests_cleanup_days: 'History older than this is removed during cleanup.',
+ requests_data_source:
+ 'Pick where Magent should read requests from. Cache-only avoids Seerr lookups on reads.',
+ log_level: 'How much detail is written to the activity log.',
+ log_file: 'Where the activity log is stored.',
+ log_file_max_bytes: 'Rotate the log file when it reaches this size in bytes.',
+ log_file_backup_count: 'How many rotated log files to retain on disk.',
+ log_http_client_level:
+ 'Verbosity for per-call outbound service traffic logs from Seerr, Jellyfin, Sonarr, Radarr, and related clients.',
+ log_background_sync_level:
+ 'Verbosity for scheduled background sync progress messages.',
+ site_build_number: 'Build number shown in the account menu (auto-set from releases).',
+ site_banner_enabled: 'Enable a sitewide banner for announcements.',
+ site_banner_message: 'Short banner message for maintenance or updates.',
+ site_banner_tone: 'Visual tone for the banner.',
+ site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
+ site_login_show_local_login: 'Show the local Magent login button on the login page.',
+ site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
+ site_login_show_signup_link: 'Show the invite signup link on the login page.',
+ site_nav_show_requests:
+ 'Show the Requests item in the top navigation. Disable during beta while request routing is being reworked.',
+ site_changelog: 'One update per line for the public changelog.',
+ }
+
+ const settingPlaceholders: Record = {
+ magent_application_url: 'https://magent.example.com',
+ magent_application_port: '3000',
+ magent_api_url: 'https://api.example.com or https://magent.example.com/api',
+ magent_api_port: '8000',
+ magent_bind_host: '0.0.0.0',
+ magent_proxy_base_url: 'https://proxy.example.com/magent',
+ magent_proxy_forwarded_prefix: '/magent',
+ magent_ssl_certificate_path: '/certs/fullchain.pem',
+ magent_ssl_private_key_path: '/certs/privkey.pem',
+ magent_ssl_certificate_pem: '-----BEGIN CERTIFICATE-----',
+ magent_ssl_private_key_pem: '-----BEGIN PRIVATE KEY-----',
+ magent_notify_email_smtp_host: 'smtp.office365.com',
+ magent_notify_email_smtp_port: '587',
+ magent_notify_email_smtp_username: 'notifications@example.com',
+ magent_notify_email_from_address: 'notifications@example.com',
+ magent_notify_email_from_name: 'Magent',
+ log_file_max_bytes: '20000000',
+ log_file_backup_count: '10',
+ magent_notify_discord_webhook_url: 'https://discord.com/api/webhooks/...',
+ magent_notify_telegram_bot_token: '123456789:AA...',
+ magent_notify_telegram_chat_id: '-1001234567890',
+ magent_notify_push_base_url: 'https://ntfy.example.com or https://gotify.example.com',
+ magent_notify_push_topic: 'magent-alerts',
+ magent_notify_push_device: 'iphone-zak',
+ magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
+ jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
+ jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
+ jellyfin_public_url: 'https://jelly.example.com',
+ sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
+ radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
+ prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
+ qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
+ }
+
+ const buildSelectOptions = (
+ currentValue: string,
+ options: { id: number; label: string; path?: string }[],
+ includePath: boolean
+ ) => {
+ const optionValues = new Set(options.map((option) => String(option.id)))
+ const list = options.map((option) => (
+
+ {includePath && option.path ? option.path : option.label}
+
+ ))
+ if (currentValue && !optionValues.has(currentValue)) {
+ list.unshift(
+
+ Custom: {currentValue}
+
+ )
+ }
+ return list
+ }
+
+ const parseActionError = (err: unknown, fallback: string) => {
+ if (err instanceof Error && err.message) {
+ return err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ }
+ return fallback
+ }
+
+ const buildSettingsPayload = (items: AdminSetting[]) => {
+ const payload: Record = {}
+ for (const setting of items) {
+ const rawValue = formValues[setting.key]
+ if (typeof rawValue !== 'string') {
+ continue
+ }
+ const value = rawValue.trim()
+ if (setting.sensitive && value === '') {
+ continue
+ }
+ payload[setting.key] = value
+ }
+ return payload
+ }
+
+ const saveSettingGroup = async (
+ sectionGroup: SettingsSectionGroup,
+ options?: { successMessage?: string | null },
+ ) => {
+ setSectionFeedback((current) => {
+ const next = { ...current }
+ delete next[sectionGroup.key]
+ return next
+ })
+ setSectionSaving((current) => ({ ...current, [sectionGroup.key]: true }))
+ try {
+ const payload = buildSettingsPayload(sectionGroup.items)
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/settings`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Update failed')
+ }
+ await loadSettings(new Set(sectionGroup.items.map((item) => item.key)))
+ if (options?.successMessage !== null) {
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: 'status',
+ message: options?.successMessage ?? `${sectionGroup.title} settings saved.`,
+ },
+ }))
+ }
+ return true
+ } catch (err) {
+ console.error(err)
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: 'error',
+ message: parseActionError(err, 'Could not save settings.'),
+ },
+ }))
+ return false
+ } finally {
+ setSectionSaving((current) => ({ ...current, [sectionGroup.key]: false }))
+ }
+ }
+
+ const formatServiceTestFeedback = (result: any): SectionFeedback => {
+ const name = result?.name ?? 'Service'
+ const state = String(result?.status ?? 'unknown').toLowerCase()
+ if (state === 'up') {
+ return { tone: 'status', message: `${name} connection test passed.` }
+ }
+ if (state === 'degraded') {
+ return {
+ tone: 'error',
+ message: result?.message ? `${name}: ${result.message}` : `${name} reported warnings.`,
+ }
+ }
+ if (state === 'not_configured') {
+ return { tone: 'error', message: `${name} is not fully configured yet.` }
+ }
+ return {
+ tone: 'error',
+ message: result?.message ? `${name}: ${result.message}` : `${name} connection test failed.`,
+ }
+ }
+
+ const getSectionTestLabel = (sectionKey: string) => {
+ if (sectionKey === 'magent-notify-email') {
+ return 'Send test email'
+ }
+ if (sectionKey in SERVICE_TEST_ENDPOINTS) {
+ return 'Test connection'
+ }
+ return null
+ }
+
+ const testSettingGroup = async (sectionGroup: SettingsSectionGroup) => {
+ setSectionFeedback((current) => {
+ const next = { ...current }
+ delete next[sectionGroup.key]
+ return next
+ })
+ setSectionTesting((current) => ({ ...current, [sectionGroup.key]: true }))
+ try {
+ const saved = await saveSettingGroup(sectionGroup, { successMessage: null })
+ if (!saved) {
+ return
+ }
+
+ const baseUrl = getApiBase()
+ if (sectionGroup.key === 'magent-notify-email') {
+ const recipientEmail =
+ emailTestRecipient.trim() || formValues.magent_notify_email_from_address?.trim()
+ const response = await authFetch(`${baseUrl}/admin/settings/test/email`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(
+ recipientEmail ? { recipient_email: recipientEmail } : {},
+ ),
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Email test failed')
+ }
+ const data = await response.json()
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: data?.warning ? 'error' : 'status',
+ message: data?.warning
+ ? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? 'the configured mailbox'}, but delivery is not guaranteed. ${data.warning}`
+ : `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
+ },
+ }))
+ return
+ }
+
+ const serviceKey = SERVICE_TEST_ENDPOINTS[sectionGroup.key]
+ if (!serviceKey) {
+ return
+ }
+ const response = await authFetch(`${baseUrl}/status/services/${serviceKey}/test`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Connection test failed')
+ }
+ const data = await response.json()
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: formatServiceTestFeedback(data),
+ }))
+ } catch (err) {
+ console.error(err)
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: 'error',
+ message: parseActionError(err, 'Could not run test.'),
+ },
+ }))
+ } finally {
+ setSectionTesting((current) => ({ ...current, [sectionGroup.key]: false }))
+ }
+ }
+
+ const syncJellyfinUsers = async () => {
+ setJellyfinSyncStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Sync failed')
+ }
+ const data = await response.json()
+ setJellyfinSyncStatus(`Imported ${data?.imported ?? 0} users from Jellyfin.`)
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not import Jellyfin users.'
+ setJellyfinSyncStatus(message)
+ }
+ }
+
+ const syncRequests = async () => {
+ setRequestsSyncStatus(null)
+ setRequestsSync({
+ status: 'running',
+ stored: 0,
+ total: 0,
+ skip: 0,
+ message: 'Starting sync',
+ })
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Sync failed')
+ }
+ const data = await response.json()
+ setRequestsSync(data?.sync ?? null)
+ setRequestsSyncStatus('Sync started.')
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not sync requests.'
+ setRequestsSyncStatus(message)
+ }
+ }
+
+ const syncRequestsDelta = async () => {
+ setRequestsSyncStatus(null)
+ setRequestsSync({
+ status: 'running',
+ stored: 0,
+ total: 0,
+ skip: 0,
+ message: 'Starting delta sync',
+ })
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Delta sync failed')
+ }
+ const data = await response.json()
+ setRequestsSync(data?.sync ?? null)
+ setRequestsSyncStatus('Delta sync started.')
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not run delta sync.'
+ setRequestsSyncStatus(message)
+ }
+ }
+
+ const prefetchArtwork = async () => {
+ setArtworkPrefetchStatus(null)
+ setArtworkPrefetch({
+ status: 'running',
+ processed: 0,
+ total: 0,
+ message: 'Starting artwork caching',
+ })
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Artwork prefetch failed')
+ }
+ const data = await response.json()
+ setArtworkPrefetch(data?.prefetch ?? null)
+ setArtworkPrefetchStatus('Artwork caching started.')
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not cache artwork.'
+ setArtworkPrefetchStatus(message)
+ }
+ }
+
+ const prefetchArtworkMissing = async () => {
+ setArtworkPrefetchStatus(null)
+ setArtworkPrefetch({
+ status: 'running',
+ processed: 0,
+ total: 0,
+ message: 'Starting missing artwork caching',
+ })
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`,
+ { method: 'POST' }
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Missing artwork prefetch failed')
+ }
+ const data = await response.json()
+ setArtworkPrefetch(data?.prefetch ?? null)
+ setArtworkPrefetchStatus('Missing artwork caching started.')
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not cache missing artwork.'
+ setArtworkPrefetchStatus(message)
+ }
+ }
+
+ useEffect(() => {
+ const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
+ if (!shouldSubscribe) {
+ setLiveStreamConnected(false)
+ return
+ }
+ const token = getToken()
+ if (!token) {
+ setLiveStreamConnected(false)
+ return
+ }
+
+ const baseUrl = getApiBase()
+ let closed = false
+ let source: EventSource | null = null
+
+ const connect = async () => {
+ try {
+ const streamToken = await getEventStreamToken()
+ if (closed) return
+ const params = new URLSearchParams()
+ params.set('stream_token', streamToken)
+ if (showLogs) {
+ params.set('include_logs', '1')
+ params.set('log_lines', String(logsCount))
+ }
+ const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
+ source = new EventSource(streamUrl)
+
+ source.onopen = () => {
+ if (closed) return
+ setLiveStreamConnected(true)
+ }
+
+ source.onmessage = (event) => {
+ if (closed) return
+ setLiveStreamConnected(true)
+ try {
+ const payload = JSON.parse(event.data)
+ if (payload?.type !== 'admin_live_state') {
+ return
+ }
+
+ const rawSync =
+ payload.requestsSync && typeof payload.requestsSync === 'object'
+ ? payload.requestsSync
+ : null
+ const nextSync = rawSync?.status === 'idle' ? null : rawSync
+ const prevSync = requestsSyncRef.current
+ requestsSyncRef.current = nextSync
+ setRequestsSync(nextSync)
+ if (
+ prevSync?.status === 'running' &&
+ nextSync?.status &&
+ nextSync.status !== 'running'
+ ) {
+ setRequestsSyncStatus(nextSync.message || 'Sync complete.')
+ }
+
+ const rawArtwork =
+ payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
+ ? payload.artworkPrefetch
+ : null
+ const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
+ const prevArtwork = artworkPrefetchRef.current
+ artworkPrefetchRef.current = nextArtwork
+ setArtworkPrefetch(nextArtwork)
+ if (
+ prevArtwork?.status === 'running' &&
+ nextArtwork?.status &&
+ nextArtwork.status !== 'running'
+ ) {
+ setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
+ if (showArtworkExtras) {
+ void loadArtworkSummary()
+ }
+ }
+
+ if (payload.logs && typeof payload.logs === 'object') {
+ if (Array.isArray(payload.logs.lines)) {
+ setLogsLines(payload.logs.lines)
+ setLogsStatus(null)
+ } else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
+ setLogsStatus(payload.logs.error)
+ }
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ source.onerror = () => {
+ if (closed) return
+ setLiveStreamConnected(false)
+ }
+ } catch (err) {
+ if (closed) return
+ console.error(err)
+ setLiveStreamConnected(false)
+ }
+ }
+
+ void connect()
+
+ return () => {
+ closed = true
+ setLiveStreamConnected(false)
+ source?.close()
+ }
+ }, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
+
+ useEffect(() => {
+ if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
+ return
+ }
+ let active = true
+ const timer = setInterval(async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
+ if (!response.ok) {
+ return
+ }
+ const data = await response.json()
+ if (!active) return
+ setArtworkPrefetch(data?.prefetch ?? null)
+ if (data?.prefetch?.status && data.prefetch.status !== 'running') {
+ setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
+ void loadArtworkSummary()
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }, 2000)
+ return () => {
+ active = false
+ clearInterval(timer)
+ }
+ }, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
+
+ useEffect(() => {
+ if (!artworkPrefetch || artworkPrefetch.status === 'running') {
+ return
+ }
+ const timer = setTimeout(() => {
+ setArtworkPrefetch(null)
+ }, 5000)
+ return () => clearTimeout(timer)
+ }, [artworkPrefetch])
+
+ useEffect(() => {
+ if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
+ return
+ }
+ let active = true
+ const timer = setInterval(async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/sync/status`)
+ if (!response.ok) {
+ return
+ }
+ const data = await response.json()
+ if (!active) return
+ setRequestsSync(data?.sync ?? null)
+ if (data?.sync?.status && data.sync.status !== 'running') {
+ setRequestsSyncStatus(data.sync.message || 'Sync complete.')
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }, 2000)
+ return () => {
+ active = false
+ clearInterval(timer)
+ }
+ }, [liveStreamConnected, requestsSync])
+
+ useEffect(() => {
+ if (!requestsSync || requestsSync.status === 'running') {
+ return
+ }
+ const timer = setTimeout(() => {
+ setRequestsSync(null)
+ }, 5000)
+ return () => clearTimeout(timer)
+ }, [requestsSync])
+
+ const loadLogs = useCallback(async () => {
+ setLogsStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/logs?lines=${encodeURIComponent(String(logsCount))}`
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Log fetch failed')
+ }
+ const data = await response.json()
+ if (Array.isArray(data?.lines)) {
+ setLogsLines(data.lines)
+ } else {
+ setLogsLines([])
+ }
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not load logs.'
+ setLogsStatus(message)
+ }
+ }, [logsCount])
+
+ useEffect(() => {
+ if (!showLogs) {
+ return
+ }
+ if (liveStreamConnected) {
+ return
+ }
+ void loadLogs()
+ const timer = setInterval(() => {
+ void loadLogs()
+ }, 5000)
+ return () => clearInterval(timer)
+ }, [liveStreamConnected, loadLogs, showLogs])
+
+ const loadCache = async () => {
+ setCacheStatus(null)
+ setCacheLoading(true)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/requests/cache?limit=${encodeURIComponent(String(cacheCount))}`
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Cache fetch failed')
+ }
+ const data = await response.json()
+ if (Array.isArray(data?.rows)) {
+ setCacheRows(data.rows)
+ } else {
+ setCacheRows([])
+ }
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
+ : 'Could not load cache.'
+ setCacheStatus(message)
+ } finally {
+ setCacheLoading(false)
+ }
+ }
+
+ const runRepair = async () => {
+ setMaintenanceStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/maintenance/repair`, { method: 'POST' })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Repair failed')
+ }
+ const data = await response.json()
+ setMaintenanceStatus(`Integrity check: ${data?.integrity ?? 'unknown'}. Vacuum complete.`)
+ } catch (err) {
+ console.error(err)
+ setMaintenanceStatus('Database repair failed.')
+ }
+ }
+
+ const runCleanup = async () => {
+ setMaintenanceStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/maintenance/cleanup?days=90`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Cleanup failed')
+ }
+ const data = await response.json()
+ setMaintenanceStatus(
+ `Cleaned history older than ${data?.days ?? 90} days.`
+ )
+ } catch (err) {
+ console.error(err)
+ setMaintenanceStatus('Cleanup failed.')
+ }
+ }
+
+ const runFlushAndResync = async () => {
+ setMaintenanceStatus(null)
+ setMaintenanceBusy(true)
+ if (typeof window !== 'undefined') {
+ const ok = window.confirm(
+ 'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Seerr. Continue?'
+ )
+ if (!ok) {
+ setMaintenanceBusy(false)
+ return
+ }
+ }
+ try {
+ const baseUrl = getApiBase()
+ setMaintenanceStatus('Running nuclear flush...')
+ const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
+ method: 'POST',
+ })
+ if (!flushResponse.ok) {
+ const text = await flushResponse.text()
+ throw new Error(text || 'Flush failed')
+ }
+ const flushData = await flushResponse.json()
+ const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
+ setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
+ const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
+ method: 'POST',
+ })
+ if (!usersResyncResponse.ok) {
+ const text = await usersResyncResponse.text()
+ throw new Error(text || 'User resync failed')
+ }
+ const usersResyncData = await usersResyncResponse.json()
+ setMaintenanceStatus(
+ `Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
+ )
+ await syncRequests()
+ setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
+ } catch (err) {
+ console.error(err)
+ setMaintenanceStatus('Nuclear flush + resync failed.')
+ } finally {
+ setMaintenanceBusy(false)
+ }
+ }
+
+ const clearLogFile = async () => {
+ setMaintenanceStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/maintenance/logs/clear`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Clear logs failed')
+ }
+ setMaintenanceStatus('Log file cleared.')
+ setLogsLines([])
+ } catch (err) {
+ console.error(err)
+ setMaintenanceStatus('Clearing logs failed.')
+ }
+ }
+
+ const cacheSourceLabel =
+ formValues.requests_data_source === 'always_js'
+ ? 'Seerr direct'
+ : formValues.requests_data_source === 'prefer_cache'
+ ? 'Saved requests only'
+ : 'Saved requests only'
+ const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
+ const maintenanceRail = showMaintenance ? (
+
+
+
Maintenance
+
Admin tools
+
Repair, cleanup, diagnostics, and nuclear resync are grouped into a single operating page.
+
+
+
Runtime
+
Service state
+
+
+ Maintenance job
+ {maintenanceBusy ? 'Running' : 'Idle'}
+
+
+ Live updates
+ {liveStreamConnected ? 'Connected' : 'Polling'}
+
+
+ Log lines in view
+ {logsLines.length}
+
+
+ Last tool status
+ {maintenanceStatus || 'Idle'}
+
+
+
+
+ ) : undefined
+ const cacheRail = showCacheExtras ? (
+
+
+
Cache control
+
Saved requests
+
Load and inspect cached request entries from the right rail.
+
+
+ Data source
+ {cacheSourceLabel}
+
+
+ Refresh TTL
+ {cacheTtlLabel} min
+
+
+ Rows loaded
+ {cacheRows.length}
+
+
+ Live updates
+ {liveStreamConnected ? 'Connected' : 'Polling'}
+
+
+
+ Rows to load
+ setCacheCount(Number(event.target.value))}
+ >
+ 25
+ 50
+ 100
+ 200
+
+
+
+ {cacheLoading ? (
+ <>
+
+ Loading saved requests
+ >
+ ) : (
+ 'Load saved requests'
+ )}
+
+ {cacheStatus &&
{cacheStatus}
}
+
+
+
Artwork
+
Cache stats
+
+
+ Missing artwork
+ {artworkSummary?.missing_artwork ?? '--'}
+
+
+ Cache size
+ {formatBytes(artworkSummary?.cache_bytes)}
+
+
+ Cached files
+ {artworkSummary?.cache_files ?? '--'}
+
+
+ Mode
+ {artworkSummary?.cache_mode ?? '--'}
+
+
+
+
+ ) : undefined
+
+ if (loading) {
+ return Loading admin settings...
+ }
+
+ return (
+ router.push('/admin')}>
+ Back to settings
+
+ }
+ >
+ {status && {status}
}
+ {currentServiceStatus ? (
+
+
+
+
+
Connection status
+
{currentServiceStatus.name}
+
+ {currentServiceStatus.message ?? 'No service message was returned.'}
+
+
+
+
+
+ Status
+ {currentServiceStatus.status.replaceAll('_', ' ')}
+
+
+ Configuration
+ {currentServiceConfigured ? 'Configured' : 'Not configured'}
+
+
+ Last checked
+
+ {serviceStatusCheckedAt ? new Date(serviceStatusCheckedAt).toLocaleString() : 'Not checked yet'}
+
+
+
void loadServiceStatuses()}>
+ Refresh status
+
+
+
+ ) : null}
+ {settingsSections.length > 0 ? (
+
+ {settingsSections
+ .filter(shouldRenderSection)
+ .map((sectionGroup) => (
+
+
+
+ {sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
+
+ {sectionGroup.key === 'sonarr' && (
+
loadOptions('sonarr')}>
+ Refresh Sonarr options
+
+ )}
+ {sectionGroup.key === 'radarr' && (
+
loadOptions('radarr')}>
+ Refresh Radarr options
+
+ )}
+ {sectionGroup.key === 'jellyfin' && (
+
+ Import Jellyfin users
+
+ )}
+ {showArtworkExtras && sectionGroup.key === 'artwork' ? (
+
+
+ Cache all artwork now
+
+
+ Sync only missing artwork
+
+
+ ) : null}
+ {showRequestsExtras && sectionGroup.key === 'requests' && (
+
+
+
+ Run full refresh (rebuild cache)
+
+
+ Run delta sync (recent changes)
+
+
+
+ Full refresh rebuilds the entire cache. Delta sync only checks new or updated
+ requests.
+
+
+ )}
+
+ {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
+ (!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
+
+ {sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
+
+ )}
+ {section === 'general' && sectionGroup.key === 'magent-runtime' && (
+
+ Runtime host/port and SSL values are configuration settings. Container/process
+ restarts may still be required before bind/port changes take effect.
+
+ )}
+ {sectionGroup.key === 'sonarr' && sonarrError && (
+ {sonarrError}
+ )}
+ {sectionGroup.key === 'radarr' && radarrError && (
+ {radarrError}
+ )}
+ {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
+ {jellyfinSyncStatus}
+ )}
+ {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
+ {artworkPrefetchStatus}
+ )}
+ {showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && (
+ {artworkSummaryStatus}
+ )}
+ {showArtworkExtras && sectionGroup.key === 'artwork' && (
+
+
+
Missing artwork
+
{artworkSummary?.missing_artwork ?? '--'}
+
Requests missing poster/backdrop or cache files.
+
+
+
Artwork cache size
+
{formatBytes(artworkSummary?.cache_bytes)}
+
+ {artworkSummary?.cache_files ?? '--'} cached files
+
+
+
+
Total requests
+
{artworkSummary?.total_requests ?? '--'}
+
Requests currently tracked in cache.
+
+
+
Cache mode
+
{artworkSummary?.cache_mode ?? '--'}
+
Artwork setting applied to posters/backdrops.
+
+
+ )}
+ {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
+ {requestsSyncStatus}
+ )}
+ {showRequestsExtras && sectionGroup.key === 'requests' && (
+
+ Full refresh checks only decide when to run a full refresh. The delta sync interval
+ polls for new or updated requests.
+
+ )}
+ {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
+
+
+ Status: {artworkPrefetch.status}
+
+ {artworkPrefetch.processed ?? 0}
+ {artworkPrefetch.total ? ` / ${artworkPrefetch.total}` : ''} cached
+
+
+
+ {artworkPrefetch.message &&
{artworkPrefetch.message}
}
+
+ )}
+ {showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
+
+
+ Status: {requestsSync.status}
+
+ {requestsSync.stored ?? 0}
+ {requestsSync.total ? ` / ${requestsSync.total}` : ''} synced
+
+
+
+ {requestsSync.message &&
{requestsSync.message}
}
+
+ )}
+
+ {sectionGroup.items.map((setting) => {
+ const value = formValues[setting.key] ?? ''
+ const helperText = settingDescriptions[setting.key]
+ const isSonarrProfile = setting.key === 'sonarr_quality_profile_id'
+ const isSonarrRoot = setting.key === 'sonarr_root_folder'
+ const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
+ const isRadarrRoot = setting.key === 'radarr_root_folder'
+ const isBoolSetting = BOOL_SETTINGS.has(setting.key)
+ const isUrlSetting = URL_SETTINGS.has(setting.key)
+ const inputPlaceholder = setting.sensitive && setting.isSet
+ ? 'Configured (enter to replace)'
+ : settingPlaceholders[setting.key] ?? ''
+ if (isBoolSetting) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Enabled
+ Disabled
+
+
+ )
+ }
+ if (isSonarrProfile && sonarrOptions) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+ {setting.sensitive && setting.isSet ? ' • stored' : ''}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Select a quality profile
+ {buildSelectOptions(value, sonarrOptions.qualityProfiles, false)}
+
+
+ )
+ }
+ if (isSonarrRoot && sonarrOptions) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+ {setting.sensitive && setting.isSet ? ' • stored' : ''}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Select a root folder
+ {buildSelectOptions(value, sonarrOptions.rootFolders, true)}
+
+
+ )
+ }
+ if (isRadarrProfile && radarrOptions) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+ {setting.sensitive && setting.isSet ? ' • stored' : ''}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Select a quality profile
+ {buildSelectOptions(value, radarrOptions.qualityProfiles, false)}
+
+
+ )
+ }
+ if (isRadarrRoot && radarrOptions) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+ {setting.sensitive && setting.isSet ? ' • stored' : ''}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Select a root folder
+ {buildSelectOptions(value, radarrOptions.rootFolders, true)}
+
+
+ )
+ }
+ if (setting.key === 'log_level') {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ DEBUG
+ INFO
+ WARNING
+ ERROR
+
+
+ )
+ }
+ if (
+ setting.key === 'log_http_client_level' ||
+ setting.key === 'log_background_sync_level'
+ ) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ DEBUG
+ INFO
+ WARNING
+ ERROR
+
+
+ )
+ }
+ if (setting.key === 'artwork_cache_mode') {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Pull from the internet
+ Cache locally
+
+
+ )
+ }
+ if (setting.key === 'site_banner_tone') {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ {BANNER_TONES.map((tone) => (
+
+ {tone.charAt(0).toUpperCase() + tone.slice(1)}
+
+ ))}
+
+
+ )
+ }
+ if (setting.key === 'magent_notify_push_provider') {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ ntfy
+ Gotify
+ Pushover
+ Webhook
+ Telegram relay
+ Discord relay
+
+
+ )
+ }
+ if (
+ setting.key === 'requests_full_sync_time' ||
+ setting.key === 'requests_cleanup_time'
+ ) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ />
+
+ )
+ }
+ if (NUMBER_SETTINGS.has(setting.key)) {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ />
+
+ )
+ }
+ if (setting.key === 'requests_data_source') {
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ >
+ Always use Seerr (slower)
+
+ Use saved requests only (fastest)
+
+
+
+ )
+ }
+ if (TEXTAREA_SETTINGS.has(setting.key)) {
+ const isPemField =
+ setting.key === 'magent_ssl_certificate_pem' ||
+ setting.key === 'magent_ssl_private_key_pem'
+ const shouldSpanFull = isPemField || setting.key === 'site_banner_message'
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+ {setting.sensitive && setting.isSet ? ' stored' : ''}
+
+
+
+ )
+ }
+ return (
+
+
+ {labelFromKey(setting.key)}
+
+ {setting.isSet ? `Source: ${setting.source}` : 'Not set'}
+ {setting.sensitive && setting.isSet ? ' • stored' : ''}
+
+
+
+ setFormValues((current) => ({
+ ...current,
+ [setting.key]: event.target.value,
+ }))
+ }
+ />
+
+ )
+ })}
+
+ {sectionFeedback[sectionGroup.key] && (
+
+ {sectionFeedback[sectionGroup.key]?.message}
+
+ )}
+
+ {sectionGroup.key === 'magent-notify-email' ? (
+
+ Test email recipient
+ setEmailTestRecipient(event.target.value)}
+ />
+
+ ) : null}
+ {getSectionTestLabel(sectionGroup.key) ? (
+ void testSettingGroup(sectionGroup)}
+ disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
+ >
+ {sectionTesting[sectionGroup.key]
+ ? 'Testing...'
+ : getSectionTestLabel(sectionGroup.key)}
+
+ ) : null}
+ void saveSettingGroup(sectionGroup)}
+ disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
+ >
+ {sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
+
+
+
+ ))}
+
+ ) : (
+
+ {section === 'magent'
+ ? 'Magent runtime settings have moved to General. Notification provider settings have moved to Notifications.'
+ : 'No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.'}
+
+ )}
+ {showLogs && (
+
+
+
Activity log
+
+
+ Lines to show
+ setLogsCount(Number(event.target.value))}
+ >
+ 100
+ 200
+ 500
+ 1000
+
+
+
+ Refresh log
+
+
+
+ {logsStatus && {logsStatus}
}
+ {logsLines.join('')}
+
+ )}
+ {showCacheExtras && (
+
+
+
Saved requests (cache)
+
+
+
+ Request
+ Title
+ Type
+ Status
+ Last update
+
+ {cacheRows.length === 0 ? (
+
No saved requests loaded yet.
+ ) : (
+ cacheRows.map((row) => (
+
+ #{row.request_id}
+ {row.title || 'Untitled'}
+ {row.media_type || 'unknown'}
+ {row.status ?? 'n/a'}
+ {row.updated_at || row.created_at || 'n/a'}
+
+ ))
+ )}
+
+
+ )}
+ {showMaintenance && (
+
+
+
Maintenance
+
+
+
+
+
Recovery and cleanup
+
+ Run repair, cleanup, logging, and full reset actions from one place. Nuclear flush
+ wipes non-admin users, invite links, profiles, cached requests, and history before
+ re-syncing Seerr users and requests.
+
+
+
+ Emergency tools. Use with care, especially on live data.
+
+ {maintenanceStatus &&
{maintenanceStatus}
}
+
+
+
+
Repair database
+
Run integrity and repair routines against the local Magent database.
+
+
+ Repair database
+
+
+
+
+
Clean request history
+
Remove request history entries older than 90 days.
+
+
+ Clean history
+
+
+
+
+
Clear activity log
+
Truncate the local activity log file so fresh troubleshooting starts clean.
+
+
+ Clear activity log
+
+
+
+
+
Nuclear flush + resync
+
Wipe non-admin user and request objects, then rebuild from Seerr.
+
+
+ {maintenanceBusy ? 'Running...' : 'Nuclear flush + resync'}
+
+
+
+
+
+
+
+ )}
+ {showRequestsExtras && (
+
+
+
Scheduled tasks
+
+
+ Automated jobs keep requests and housekeeping up to date.
+
+
+
+
Quick request check
+
+ Every {formValues.requests_delta_sync_interval_minutes || '5'} minutes, checks for
+ new or updated requests.
+
+
+
+
Full daily refresh
+
+ Every day at {formValues.requests_full_sync_time || '00:00'}, refreshes the entire
+ requests list.
+
+
+
+
History cleanup
+
+ Every day at {formValues.requests_cleanup_time || '02:00'}, removes history older
+ than {formValues.requests_cleanup_days || '90'} days.
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/app/admin/[section]/page.tsx b/frontend/app/admin/[section]/page.tsx
new file mode 100644
index 0000000..4401483
--- /dev/null
+++ b/frontend/app/admin/[section]/page.tsx
@@ -0,0 +1,33 @@
+import { notFound } from 'next/navigation'
+import SettingsPage from '../SettingsPage'
+
+const ALLOWED_SECTIONS = new Set([
+ 'seerr',
+ 'jellyseerr',
+ 'jellyfin',
+ 'artwork',
+ 'sonarr',
+ 'radarr',
+ 'prowlarr',
+ 'qbittorrent',
+ 'requests',
+ 'cache',
+ 'logs',
+ 'maintenance',
+ 'magent',
+ 'general',
+ 'notifications',
+ 'site',
+])
+
+type PageProps = {
+ params: Promise<{ section: string }>
+}
+
+export default async function AdminSectionPage({ params }: PageProps) {
+ const { section } = await params
+ if (!ALLOWED_SECTIONS.has(section)) {
+ notFound()
+ }
+ return
+}
diff --git a/frontend/app/admin/diagnostics/page.tsx b/frontend/app/admin/diagnostics/page.tsx
new file mode 100644
index 0000000..afd93d6
--- /dev/null
+++ b/frontend/app/admin/diagnostics/page.tsx
@@ -0,0 +1,27 @@
+'use client'
+
+import AdminShell from '../../ui/AdminShell'
+import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
+
+export default function AdminDiagnosticsPage() {
+ return (
+
+
+
Diagnostics
+
Shared console
+
+ This page and Maintenance now use the same diagnostics panel, so every test target and
+ notification ping stays in one source of truth.
+
+
+
+ }
+ >
+
+
+ )
+}
diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx
new file mode 100644
index 0000000..42a4550
--- /dev/null
+++ b/frontend/app/admin/invites/page.tsx
@@ -0,0 +1,2103 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import AdminShell from '../../ui/AdminShell'
+import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
+
+type AdminUserLite = {
+ id: number
+ username: string
+ role: string
+ auth_provider?: string | null
+ invite_management_enabled?: boolean
+ profile_id?: number | null
+ expires_at?: string | null
+ created_at?: string | null
+ invited_by_code?: string | null
+ invited_at?: string | null
+}
+
+type Profile = {
+ id: number
+ name: string
+ description?: string | null
+ role: 'user' | 'admin'
+ auto_search_enabled: boolean
+ account_expires_days?: number | null
+ is_active: boolean
+ assigned_users?: number
+ assigned_invites?: number
+}
+
+type Invite = {
+ id: number
+ code: string
+ label?: string | null
+ description?: string | null
+ profile_id?: number | null
+ profile?: { id: number; name: string } | null
+ role?: 'user' | 'admin' | null
+ max_uses?: number | null
+ use_count: number
+ remaining_uses?: number | null
+ enabled: boolean
+ expires_at?: string | null
+ recipient_email?: string | null
+ is_expired?: boolean
+ is_usable?: boolean
+ created_at?: string | null
+ created_by?: string | null
+}
+
+type InviteForm = {
+ code: string
+ label: string
+ description: string
+ profile_id: string
+ role: '' | 'user' | 'admin'
+ max_uses: string
+ enabled: boolean
+ expires_at: string
+ recipient_email: string
+ send_email: boolean
+ message: string
+}
+
+type ProfileForm = {
+ name: string
+ description: string
+ role: 'user' | 'admin'
+ auto_search_enabled: boolean
+ account_expires_days: string
+ is_active: boolean
+}
+
+type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
+type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
+type InviteTraceScope = 'all' | 'invited' | 'direct'
+type InviteTraceView = 'list' | 'graph'
+
+type InviteEmailTemplate = {
+ key: InviteEmailTemplateKey
+ label: string
+ description: string
+ placeholders: string[]
+ subject: string
+ body_text: string
+ body_html: string
+}
+
+type InviteEmailSendForm = {
+ template_key: InviteEmailTemplateKey
+ recipient_email: string
+ invite_id: string
+ username: string
+ message: string
+ reason: string
+}
+
+type InviteTraceRow = {
+ username: string
+ role: string
+ authProvider: string
+ level: number
+ inviterUsername: string | null
+ inviteCode: string | null
+ inviteLabel: string | null
+ createdAt: string | null
+ childCount: number
+ isCycle?: boolean
+}
+
+type InvitePolicy = {
+ master_invite_id?: number | null
+ master_invite?: Invite | null
+ non_admin_users?: number
+ invite_access_enabled_users?: number
+}
+
+const defaultInviteForm = (): InviteForm => ({
+ code: '',
+ label: '',
+ description: '',
+ profile_id: '',
+ role: '',
+ max_uses: '',
+ enabled: true,
+ expires_at: '',
+ recipient_email: '',
+ send_email: false,
+ message: '',
+})
+
+const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({
+ template_key: 'invited',
+ recipient_email: '',
+ invite_id: '',
+ username: '',
+ message: '',
+ reason: '',
+})
+
+const defaultProfileForm = (): ProfileForm => ({
+ name: '',
+ description: '',
+ role: 'user',
+ auto_search_enabled: true,
+ account_expires_days: '',
+ is_active: true,
+})
+
+const formatDate = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
+
+const isInviteTraceRowInvited = (row: InviteTraceRow) =>
+ Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
+
+export default function AdminInviteManagementPage() {
+ const router = useRouter()
+ const [invites, setInvites] = useState([])
+ const [profiles, setProfiles] = useState([])
+ const [users, setUsers] = useState([])
+ const [jellyfinUsersCount, setJellyfinUsersCount] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ const [inviteSaving, setInviteSaving] = useState(false)
+ const [profileSaving, setProfileSaving] = useState(false)
+ const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
+ const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
+ const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false)
+ const [invitePolicySaving, setInvitePolicySaving] = useState(false)
+ const [templateSaving, setTemplateSaving] = useState(false)
+ const [templateResetting, setTemplateResetting] = useState(false)
+ const [emailSending, setEmailSending] = useState(false)
+
+ const [error, setError] = useState(null)
+ const [status, setStatus] = useState(null)
+
+ const [inviteEditingId, setInviteEditingId] = useState(null)
+ const [inviteForm, setInviteForm] = useState(defaultInviteForm())
+
+ const [profileEditingId, setProfileEditingId] = useState(null)
+ const [profileForm, setProfileForm] = useState(defaultProfileForm())
+
+ const [bulkProfileId, setBulkProfileId] = useState('')
+ const [bulkExpiryDays, setBulkExpiryDays] = useState('')
+ const [masterInviteSelection, setMasterInviteSelection] = useState('')
+ const [invitePolicy, setInvitePolicy] = useState(null)
+ const [activeTab, setActiveTab] = useState('bulk')
+ const [emailTemplates, setEmailTemplates] = useState([])
+ const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
+ const [selectedTemplateKey, setSelectedTemplateKey] = useState('invited')
+ const [templateForm, setTemplateForm] = useState({
+ subject: '',
+ body_text: '',
+ body_html: '',
+ })
+ const [emailSendForm, setEmailSendForm] = useState(defaultInviteEmailSendForm())
+ const [traceFilter, setTraceFilter] = useState('')
+ const [traceScope, setTraceScope] = useState('all')
+ const [traceView, setTraceView] = useState('graph')
+
+ const signupBaseUrl = useMemo(() => {
+ if (typeof window === 'undefined') return '/signup'
+ return `${window.location.origin}/signup`
+ }, [])
+
+ const loadTemplateEditor = (
+ templateKey: InviteEmailTemplateKey,
+ templates: InviteEmailTemplate[]
+ ) => {
+ const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null
+ if (!template) {
+ setTemplateForm({ subject: '', body_text: '', body_html: '' })
+ return
+ }
+ setSelectedTemplateKey(template.key)
+ setTemplateForm({
+ subject: template.subject ?? '',
+ body_text: template.body_text ?? '',
+ body_html: template.body_html ?? '',
+ })
+ }
+
+ const handleAuthResponse = (response: Response) => {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return true
+ }
+ if (response.status === 403) {
+ router.push('/')
+ return true
+ }
+ return false
+ }
+
+ const loadData = async () => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const [inviteRes, profileRes, usersRes, policyRes, emailTemplateRes] = await Promise.all([
+ authFetch(`${baseUrl}/admin/invites`),
+ authFetch(`${baseUrl}/admin/profiles`),
+ authFetch(`${baseUrl}/admin/users`),
+ authFetch(`${baseUrl}/admin/invites/policy`),
+ authFetch(`${baseUrl}/admin/invites/email/templates`),
+ ])
+ if (!inviteRes.ok) {
+ if (handleAuthResponse(inviteRes)) return
+ throw new Error(`Failed to load invites (${inviteRes.status})`)
+ }
+ if (!profileRes.ok) {
+ if (handleAuthResponse(profileRes)) return
+ throw new Error(`Failed to load profiles (${profileRes.status})`)
+ }
+ if (!usersRes.ok) {
+ if (handleAuthResponse(usersRes)) return
+ throw new Error(`Failed to load users (${usersRes.status})`)
+ }
+ if (!policyRes.ok) {
+ if (handleAuthResponse(policyRes)) return
+ throw new Error(`Failed to load invite policy (${policyRes.status})`)
+ }
+ if (!emailTemplateRes.ok) {
+ if (handleAuthResponse(emailTemplateRes)) return
+ throw new Error(`Failed to load email templates (${emailTemplateRes.status})`)
+ }
+ const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([
+ inviteRes.json(),
+ profileRes.json(),
+ usersRes.json(),
+ policyRes.json(),
+ emailTemplateRes.json(),
+ ])
+ const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
+ setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
+ setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
+ setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
+ setInvitePolicy(nextPolicy)
+ setMasterInviteSelection(
+ nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
+ )
+ const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : []
+ setEmailTemplates(nextTemplates)
+ setEmailConfigured(emailTemplateData?.email ?? null)
+ loadTemplateEditor(selectedTemplateKey, nextTemplates)
+ try {
+ const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
+ if (jellyfinRes.ok) {
+ const jellyfinData = await jellyfinRes.json()
+ setJellyfinUsersCount(Array.isArray(jellyfinData?.users) ? jellyfinData.users.length : 0)
+ } else if (jellyfinRes.status === 401 || jellyfinRes.status === 403) {
+ if (handleAuthResponse(jellyfinRes)) return
+ } else {
+ setJellyfinUsersCount(null)
+ }
+ } catch (jellyfinErr) {
+ console.warn('Could not load Jellyfin user count for invite overview', jellyfinErr)
+ setJellyfinUsersCount(null)
+ }
+ } catch (err) {
+ console.error(err)
+ setError('Could not load invite management data.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ void loadData()
+ }, [])
+
+ const resetInviteEditor = () => {
+ setInviteEditingId(null)
+ setInviteForm(defaultInviteForm())
+ }
+
+ const editInvite = (invite: Invite) => {
+ setInviteEditingId(invite.id)
+ setInviteForm({
+ code: invite.code ?? '',
+ label: invite.label ?? '',
+ description: invite.description ?? '',
+ profile_id:
+ typeof invite.profile_id === 'number' && invite.profile_id > 0
+ ? String(invite.profile_id)
+ : '',
+ role: (invite.role ?? '') as '' | 'user' | 'admin',
+ max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
+ enabled: invite.enabled !== false,
+ expires_at: invite.expires_at ?? '',
+ recipient_email: invite.recipient_email ?? '',
+ send_email: false,
+ message: '',
+ })
+ setStatus(null)
+ setError(null)
+ }
+
+ const saveInvite = async (event: React.FormEvent) => {
+ event.preventDefault()
+ const recipientEmail = inviteForm.recipient_email.trim()
+ if (!recipientEmail) {
+ setError('Recipient email is required.')
+ setStatus(null)
+ return
+ }
+ if (!isValidEmail(recipientEmail)) {
+ setError('Recipient email must be valid.')
+ setStatus(null)
+ return
+ }
+ setInviteSaving(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const payload = {
+ code: inviteForm.code || null,
+ label: inviteForm.label || null,
+ description: inviteForm.description || null,
+ profile_id: inviteForm.profile_id || null,
+ role: inviteForm.role || null,
+ max_uses: inviteForm.max_uses || null,
+ enabled: inviteForm.enabled,
+ expires_at: inviteForm.expires_at || null,
+ recipient_email: recipientEmail,
+ send_email: inviteForm.send_email,
+ message: inviteForm.message || null,
+ }
+ const url =
+ inviteEditingId == null
+ ? `${baseUrl}/admin/invites`
+ : `${baseUrl}/admin/invites/${inviteEditingId}`
+ const response = await authFetch(url, {
+ method: inviteEditingId == null ? 'POST' : 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Save failed')
+ }
+ resetInviteEditor()
+ const data = await response.json()
+ if (data?.email?.status === 'ok') {
+ setStatus(
+ `${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
+ )
+ } else if (data?.email?.status === 'error') {
+ setStatus(
+ `${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
+ )
+ } else {
+ setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
+ }
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not save invite.')
+ } finally {
+ setInviteSaving(false)
+ }
+ }
+
+ const deleteInvite = async (invite: Invite) => {
+ if (!window.confirm(`Delete invite "${invite.code}"?`)) return
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/invites/${invite.id}`, {
+ method: 'DELETE',
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Delete failed')
+ }
+ if (inviteEditingId === invite.id) resetInviteEditor()
+ setStatus(`Deleted invite ${invite.code}.`)
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not delete invite.')
+ }
+ }
+
+ const copyInviteLink = async (invite: Invite) => {
+ const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(url)
+ setStatus(`Copied invite link for ${invite.code}.`)
+ } else {
+ window.prompt('Copy invite link', url)
+ }
+ } catch (err) {
+ console.error(err)
+ window.prompt('Copy invite link', url)
+ }
+ }
+
+ const prepareInviteEmail = (invite: Invite) => {
+ setEmailSendForm({
+ template_key: 'invited',
+ recipient_email: invite.recipient_email ?? '',
+ invite_id: String(invite.id),
+ username: '',
+ message: '',
+ reason: '',
+ })
+ setActiveTab('emails')
+ setStatus(
+ invite.recipient_email
+ ? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.`
+ : `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.`
+ )
+ setError(null)
+ }
+
+ const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
+ setSelectedTemplateKey(templateKey)
+ loadTemplateEditor(templateKey, emailTemplates)
+ }
+
+ const saveEmailTemplate = async () => {
+ setTemplateSaving(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(templateForm),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Template save failed')
+ }
+ setStatus(`Saved ${selectedTemplateKey} email template.`)
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not save email template.')
+ } finally {
+ setTemplateSaving(false)
+ }
+ }
+
+ const resetEmailTemplate = async () => {
+ if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return
+ setTemplateResetting(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
+ method: 'DELETE',
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Template reset failed')
+ }
+ setStatus(`Reset ${selectedTemplateKey} template to default.`)
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not reset email template.')
+ } finally {
+ setTemplateResetting(false)
+ }
+ }
+
+ const sendEmailTemplate = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setEmailSending(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/invites/email/send`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ template_key: emailSendForm.template_key,
+ recipient_email: emailSendForm.recipient_email || null,
+ invite_id: emailSendForm.invite_id || null,
+ username: emailSendForm.username || null,
+ message: emailSendForm.message || null,
+ reason: emailSendForm.reason || null,
+ }),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Email send failed')
+ }
+ const data = await response.json()
+ setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? 'recipient'}.`)
+ if (emailSendForm.template_key === 'invited') {
+ await loadData()
+ }
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not send email.')
+ } finally {
+ setEmailSending(false)
+ }
+ }
+
+ const resetProfileEditor = () => {
+ setProfileEditingId(null)
+ setProfileForm(defaultProfileForm())
+ }
+
+ const editProfile = (profile: Profile) => {
+ setProfileEditingId(profile.id)
+ setProfileForm({
+ name: profile.name ?? '',
+ description: profile.description ?? '',
+ role: profile.role ?? 'user',
+ auto_search_enabled: Boolean(profile.auto_search_enabled),
+ account_expires_days:
+ typeof profile.account_expires_days === 'number' ? String(profile.account_expires_days) : '',
+ is_active: profile.is_active !== false,
+ })
+ setStatus(null)
+ setError(null)
+ }
+
+ const saveProfile = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setProfileSaving(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const payload = {
+ name: profileForm.name,
+ description: profileForm.description || null,
+ role: profileForm.role,
+ auto_search_enabled: profileForm.auto_search_enabled,
+ account_expires_days: profileForm.account_expires_days || null,
+ is_active: profileForm.is_active,
+ }
+ const url =
+ profileEditingId == null
+ ? `${baseUrl}/admin/profiles`
+ : `${baseUrl}/admin/profiles/${profileEditingId}`
+ const response = await authFetch(url, {
+ method: profileEditingId == null ? 'POST' : 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Save failed')
+ }
+ setStatus(profileEditingId == null ? 'Profile created.' : 'Profile updated.')
+ resetProfileEditor()
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not save profile.')
+ } finally {
+ setProfileSaving(false)
+ }
+ }
+
+ const deleteProfile = async (profile: Profile) => {
+ if (!window.confirm(`Delete profile "${profile.name}"?`)) return
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/profiles/${profile.id}`, {
+ method: 'DELETE',
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Delete failed')
+ }
+ if (profileEditingId === profile.id) resetProfileEditor()
+ if (bulkProfileId === String(profile.id)) setBulkProfileId('')
+ setStatus(`Deleted profile "${profile.name}".`)
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not delete profile.')
+ }
+ }
+
+ const bulkApplyProfile = async () => {
+ setBulkProfileBusy(true)
+ setStatus(null)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/users/profile/bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ profile_id: bulkProfileId || null,
+ scope: 'non-admin-users',
+ }),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Bulk profile update failed')
+ }
+ const data = await response.json()
+ setStatus(
+ bulkProfileId
+ ? `Applied profile ${bulkProfileId} to ${data?.updated ?? 0} non-admin users.`
+ : `Cleared profile assignment for ${data?.updated ?? 0} non-admin users.`
+ )
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not apply profile to all users.')
+ } finally {
+ setBulkProfileBusy(false)
+ }
+ }
+
+ const bulkSetExpiryDays = async () => {
+ if (!bulkExpiryDays.trim()) {
+ setError('Enter expiry days before applying bulk expiry.')
+ return
+ }
+ setBulkExpiryBusy(true)
+ setStatus(null)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ days: bulkExpiryDays, scope: 'non-admin-users' }),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Bulk expiry update failed')
+ }
+ const data = await response.json()
+ setStatus(`Set expiry for ${data?.updated ?? 0} non-admin users (${bulkExpiryDays} days).`)
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not set expiry for all users.')
+ } finally {
+ setBulkExpiryBusy(false)
+ }
+ }
+
+ const bulkClearExpiry = async () => {
+ setBulkExpiryBusy(true)
+ setStatus(null)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ clear: true, scope: 'non-admin-users' }),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Bulk expiry clear failed')
+ }
+ const data = await response.json()
+ setStatus(`Cleared expiry for ${data?.updated ?? 0} non-admin users.`)
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not clear expiry for all users.')
+ } finally {
+ setBulkExpiryBusy(false)
+ }
+ }
+
+ const bulkSetInviteAccess = async (enabled: boolean) => {
+ setBulkInviteAccessBusy(true)
+ setStatus(null)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Bulk invite access update failed')
+ }
+ const data = await response.json()
+ setStatus(
+ `${enabled ? 'Enabled' : 'Disabled'} self-service invites for ${data?.updated ?? 0} non-admin users.`
+ )
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not update invite access for all users.')
+ } finally {
+ setBulkInviteAccessBusy(false)
+ }
+ }
+
+ const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => {
+ const selectedValue =
+ nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || ''
+ setInvitePolicySaving(true)
+ setStatus(null)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/invites/policy`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ master_invite_id: selectedValue || null }),
+ })
+ if (!response.ok) {
+ if (handleAuthResponse(response)) return
+ const text = await response.text()
+ throw new Error(text || 'Invite policy update failed')
+ }
+ setStatus(selectedValue ? 'Master invite template updated.' : 'Master invite template cleared.')
+ await loadData()
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not update invite policy.')
+ } finally {
+ setInvitePolicySaving(false)
+ }
+ }
+
+ const nonAdminUsers = users.filter((user) => user.role !== 'admin')
+ const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length
+ const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
+ const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
+ const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
+ const disabledInvites = invites.filter((invite) => invite.enabled === false).length
+ const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
+ const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
+ const masterInvite = invitePolicy?.master_invite ?? null
+ const selectedTemplate =
+ emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
+
+ const inviteTraceRows = useMemo(() => {
+ const inviteByCode = new Map()
+ invites.forEach((invite) => {
+ const code = String(invite.code || '').trim()
+ if (code) inviteByCode.set(code.toLowerCase(), invite)
+ })
+
+ const userByName = new Map()
+ users.forEach((user) => {
+ const username = String(user.username || '').trim()
+ if (username) userByName.set(username.toLowerCase(), user)
+ })
+
+ const childrenByInviter = new Map()
+ const inviterMetaByUser = new Map<
+ string,
+ { inviterUsername: string | null; inviteCode: string | null; inviteLabel: string | null }
+ >()
+
+ users.forEach((user) => {
+ const username = String(user.username || '').trim()
+ if (!username) return
+ const inviteCodeRaw = String(user.invited_by_code || '').trim()
+ let inviterUsername: string | null = null
+ let inviteLabel: string | null = null
+ if (inviteCodeRaw) {
+ const invite = inviteByCode.get(inviteCodeRaw.toLowerCase())
+ inviteLabel = (invite?.label as string | undefined) || null
+ const createdBy = String(invite?.created_by || '').trim()
+ if (createdBy) inviterUsername = createdBy
+ }
+ inviterMetaByUser.set(username.toLowerCase(), {
+ inviterUsername,
+ inviteCode: inviteCodeRaw || null,
+ inviteLabel,
+ })
+ const key = (inviterUsername || '__root__').toLowerCase()
+ const bucket = childrenByInviter.get(key) ?? []
+ bucket.push(user)
+ childrenByInviter.set(key, bucket)
+ })
+
+ childrenByInviter.forEach((bucket) => {
+ bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' }))
+ })
+
+ const rows: Array<{
+ username: string
+ role: string
+ authProvider: string
+ level: number
+ inviterUsername: string | null
+ inviteCode: string | null
+ inviteLabel: string | null
+ createdAt: string | null
+ childCount: number
+ isCycle?: boolean
+ }> = []
+
+ const visited = new Set()
+ const walk = (user: AdminUserLite, level: number, path: Set) => {
+ const username = String(user.username || '').trim()
+ const userKey = username.toLowerCase()
+ if (!username) return
+ const meta = inviterMetaByUser.get(userKey) ?? {
+ inviterUsername: null,
+ inviteCode: null,
+ inviteLabel: null,
+ }
+ const childCount = (childrenByInviter.get(userKey) ?? []).length
+ if (path.has(userKey)) {
+ rows.push({
+ username,
+ role: String(user.role || 'user'),
+ authProvider: String(user.auth_provider || 'local'),
+ level,
+ inviterUsername: meta.inviterUsername,
+ inviteCode: meta.inviteCode,
+ inviteLabel: meta.inviteLabel,
+ createdAt: (user.created_at as string | null) ?? null,
+ childCount,
+ isCycle: true,
+ })
+ return
+ }
+ rows.push({
+ username,
+ role: String(user.role || 'user'),
+ authProvider: String(user.auth_provider || 'local'),
+ level,
+ inviterUsername: meta.inviterUsername,
+ inviteCode: meta.inviteCode,
+ inviteLabel: meta.inviteLabel,
+ createdAt: (user.created_at as string | null) ?? null,
+ childCount,
+ })
+ visited.add(userKey)
+ const nextPath = new Set(path)
+ nextPath.add(userKey)
+ ;(childrenByInviter.get(userKey) ?? []).forEach((child) => {
+ walk(child, level + 1, nextPath)
+ })
+ }
+
+ ;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => {
+ walk(rootUser, 0, new Set())
+ })
+ users.forEach((user) => {
+ const key = String(user.username || '').toLowerCase()
+ if (key && !visited.has(key)) {
+ walk(user, 0, new Set())
+ }
+ })
+
+ const filter = traceFilter.trim().toLowerCase()
+ if (!filter) return rows
+ return rows.filter((row) =>
+ [
+ row.username,
+ row.inviterUsername || '',
+ row.inviteCode || '',
+ row.inviteLabel || '',
+ row.role || '',
+ row.authProvider || '',
+ ]
+ .join(' ')
+ .toLowerCase()
+ .includes(filter)
+ )
+ }, [invites, traceFilter, users])
+
+ const scopedInviteTraceRows = useMemo(() => {
+ if (traceScope === 'invited') return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row))
+ if (traceScope === 'direct') return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row))
+ return inviteTraceRows
+ }, [inviteTraceRows, traceScope])
+
+ const traceInvitedCount = useMemo(
+ () => inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)).length,
+ [inviteTraceRows]
+ )
+ const traceDirectCount = inviteTraceRows.length - traceInvitedCount
+
+ const inviteTraceGraphColumns = useMemo(() => {
+ if (scopedInviteTraceRows.length === 0) return [] as Array<{ level: number; rows: InviteTraceRow[] }>
+
+ const minLevel = Math.min(...scopedInviteTraceRows.map((row) => row.level))
+ const grouped = new Map()
+ scopedInviteTraceRows.forEach((row) => {
+ const level = Math.max(0, row.level - minLevel)
+ const bucket = grouped.get(level) ?? []
+ bucket.push(row)
+ grouped.set(level, bucket)
+ })
+
+ return Array.from(grouped.entries())
+ .sort((a, b) => a[0] - b[0])
+ .map(([level, rows]) => ({
+ level,
+ rows: [...rows].sort((a, b) =>
+ String(a.username || '').localeCompare(String(b.username || ''), undefined, {
+ sensitivity: 'base',
+ })
+ ),
+ }))
+ }, [scopedInviteTraceRows])
+
+ const inviteManagementRail = (
+
+
+
+
+
Overview
+
Invite stats
+
Live counts for invites, profiles, and managed user defaults.
+
+
+
+
+
Invites
+
+ {invites.length}
+ {usableInvites} usable • {disabledInvites} disabled
+
+
+
+
Profiles
+
+ {profiles.length}
+ {activeProfiles} active profiles
+
+
+
+
Local non-admin accounts
+
+ {nonAdminUsers.length}
+ {profiledUsers} with profile
+
+
+
+
Jellyfin users
+
+ {jellyfinUsersCount ?? '—'}
+
+ {jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
+
+
+
+
+
Self-service invites
+
+ {inviteAccessEnabledUsers}
+
+ {masterInvite
+ ? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
+ : 'users enabled • no master template set'}
+
+
+
+
+
Expiry rules
+
+ {expiringUsers}
+ users with custom expiry
+
+
+
+
Email templates
+
+ {emailTemplates.length}
+ {invitesWithRecipient} invites with recipient email
+
+
+
+
SMTP email
+
+ {emailConfigured?.configured ? 'Ready' : 'Needs setup'}
+ {emailConfigured?.detail ?? 'Email settings unavailable'}
+
+
+
+
+
+ )
+
+ return (
+
+
+ {error && {error}
}
+ {status && {status}
}
+
+
+
+ setActiveTab('bulk')}
+ >
+ Blanket controls
+
+ setActiveTab('profiles')}
+ >
+ Profiles
+
+ setActiveTab('invites')}
+ >
+ Invites
+
+ setActiveTab('trace')}
+ >
+ Trace map
+
+ setActiveTab('emails')}
+ >
+ Email
+
+
+
+
+ {loading ? 'Loading…' : 'Reload'}
+
+ {
+ resetInviteEditor()
+ setActiveTab('invites')
+ }}
+ >
+ New invite
+
+ {
+ resetProfileEditor()
+ setActiveTab('profiles')
+ }}
+ >
+ New profile
+
+
+
+
+ {activeTab === 'bulk' && (
+
+
+
+
+
Blanket controls
+
+ Apply invite access, master invite template rules, profile defaults, or expiry to all local non-admin accounts. Individual users can still be edited from their user page.
+
+
+
+
+
+
+ Self-service invites
+
+ Enable or disable the “My invites” tab for all non-admin users.
+
+
+
+ void bulkSetInviteAccess(true)}
+ disabled={bulkInviteAccessBusy}
+ >
+ {bulkInviteAccessBusy ? 'Working…' : 'Enable for all users'}
+
+ void bulkSetInviteAccess(false)}
+ disabled={bulkInviteAccessBusy}
+ >
+ {bulkInviteAccessBusy ? 'Working…' : 'Disable for all users'}
+
+
+
+
+
+ Master invite template
+ setMasterInviteSelection(e.target.value)}
+ disabled={invitePolicySaving}
+ >
+ None (users use their own defaults)
+ {invites.map((invite) => (
+
+ {invite.code}
+ {invite.label ? ` - ${invite.label}` : ''}
+ {invite.enabled === false ? ' (disabled)' : ''}
+
+ ))}
+
+
+
+ void saveMasterInvitePolicy()} disabled={invitePolicySaving}>
+ {invitePolicySaving ? 'Saving…' : 'Save master template'}
+
+ {
+ setMasterInviteSelection('')
+ void saveMasterInvitePolicy('')
+ }}
+ disabled={invitePolicySaving}
+ >
+ {invitePolicySaving ? 'Saving…' : 'Clear master template'}
+
+
+
+ {masterInvite
+ ? `Current master template: ${masterInvite.code}${masterInvite.label ? ` (${masterInvite.label})` : ''}. Self-service invites inherit its limits/status/profile.`
+ : 'No master template set. Self-service invites use each user’s profile/defaults.'}
+
+
+
+
+ Profile
+ setBulkProfileId(e.target.value)}
+ disabled={bulkProfileBusy}
+ >
+ None / clear assignment
+ {profiles.map((profile) => (
+
+ {profile.name}{profile.is_active === false ? ' (disabled)' : ''}
+
+ ))}
+
+
+
+ {bulkProfileBusy ? 'Applying…' : 'Apply profile to all users'}
+
+
+
+
+ Expiry days
+ setBulkExpiryDays(e.target.value)}
+ inputMode="numeric"
+ placeholder="e.g. 30"
+ disabled={bulkExpiryBusy}
+ />
+
+
+ {bulkExpiryBusy ? 'Working…' : 'Set expiry for all users'}
+
+
+ {bulkExpiryBusy ? 'Working…' : 'Clear expiry for all users'}
+
+
+
+
+
+ )}
+
+ {activeTab === 'profiles' && (
+
+
+
Profiles
+
Assign these to invites or apply them to all users using the blanket controls above.
+ {loading ? (
+
Loading profiles…
+ ) : profiles.length === 0 ? (
+
No profiles created yet.
+ ) : (
+
+ {profiles.map((profile) => (
+
+
+
+ {profile.name}
+
+ {profile.is_active ? 'Active' : 'Disabled'}
+
+ {profile.role}
+
+ {profile.description && (
+
{profile.description}
+ )}
+
+ Auto search: {profile.auto_search_enabled ? 'On' : 'Off'}
+
+ Account expiry:{' '}
+ {typeof profile.account_expires_days === 'number'
+ ? `${profile.account_expires_days} days`
+ : 'Never'}
+
+ Users: {profile.assigned_users ?? 0}
+ Invites: {profile.assigned_invites ?? 0}
+
+
+
+ editProfile(profile)}>
+ Edit
+
+ deleteProfile(profile)}>
+ Delete
+
+
+
+ ))}
+
+ )}
+
+
+
{profileEditingId == null ? 'Create profile' : 'Edit profile'}
+
+ Profiles define defaults applied when a user signs up using an invite.
+
+
+
+
+ )}
+
+ {activeTab === 'invites' && (
+
+
+
Invite links
+
Copy and share invite links. Profiles can be applied per invite.
+ {loading ? (
+
Loading invites…
+ ) : invites.length === 0 ? (
+
No invites created yet.
+ ) : (
+
+ {invites.map((invite) => (
+
+
+
+ {invite.code}
+
+ {invite.is_usable ? 'Usable' : 'Unavailable'}
+
+ {invite.profile?.name && {invite.profile.name} }
+
+ {invite.label &&
{invite.label}
}
+ {invite.description && (
+
+ {invite.description}
+
+ )}
+
+
+ Uses: {invite.use_count}
+ {typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
+
+ Remaining: {invite.remaining_uses ?? 'Unlimited'}
+ Expires: {formatDate(invite.expires_at)}
+ Recipient: {invite.recipient_email || 'Not set'}
+ Created: {formatDate(invite.created_at)}
+
+
+
+ copyInviteLink(invite)}>
+ Copy link
+
+ prepareInviteEmail(invite)}>
+ Email invite
+
+ editInvite(invite)}>
+ Edit
+
+ deleteInvite(invite)}>
+ Delete
+
+
+
+ ))}
+
+ )}
+
+
+
{inviteEditingId == null ? 'Create invite' : 'Edit invite'}
+
+ Link an invite to a profile to apply account defaults at sign-up.
+
+
+
+
+
+
+ Description
+ Optional note shown on the signup page.
+
+
+
+ setInviteForm((current) => ({ ...current, description: e.target.value }))
+ }
+ placeholder="Optional note shown on the signup page"
+ />
+
+
+
+
+
+ Defaults
+ Choose a profile and optional role override for sign-up.
+
+
+
+ Profile
+
+ setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
+ }
+ >
+ None
+ {profiles.map((profile) => (
+
+ {profile.name}{profile.is_active === false ? ' (disabled)' : ''}
+
+ ))}
+
+
+
+ Role override
+
+ setInviteForm((current) => ({
+ ...current,
+ role: e.target.value as '' | 'user' | 'admin',
+ }))
+ }
+ >
+ Use profile/default
+ User
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+ Status
+ Enable or disable the invite before sharing.
+
+
+
+
+ setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
+ }
+ />
+ Invite is enabled
+
+
+
+ {inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
+
+ {inviteEditingId != null && (
+
+ Cancel edit
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'emails' && (
+
+
+
+
+
Email templates
+
+ Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
+
+
+
+ {!emailConfigured?.configured && (
+
+ {emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
+
+ )}
+
+ {emailTemplates.map((template) => (
+ selectEmailTemplate(template.key)}
+ >
+ {template.label}
+
+ ))}
+
+ {selectedTemplate ? (
+
+
{selectedTemplate.label}
+
{selectedTemplate.description}
+
+ ) : null}
+
{
+ event.preventDefault()
+ void saveEmailTemplate()
+ }}
+ >
+
+
+ Subject
+ Rendered with the same placeholder variables as the body.
+
+
+
+ setTemplateForm((current) => ({ ...current, subject: event.target.value }))
+ }
+ placeholder="Email subject"
+ />
+
+
+
+
+ Plain text body
+ Used for mail clients that prefer text only.
+
+
+
+ setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
+ }
+ placeholder="Plain text email body"
+ />
+
+
+
+
+ HTML body
+ Optional rich HTML version. Basic HTML is supported.
+
+
+
+ setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
+ }
+ placeholder="Hello HTML email body
"
+ />
+
+
+
+
+ Placeholders
+ Use these anywhere in the subject or body.
+
+
+ {(selectedTemplate?.placeholders ?? []).map((placeholder) => (
+ {`{{${placeholder}}}`}
+ ))}
+
+
+
+
+ {templateSaving ? 'Saving…' : 'Save template'}
+
+ void resetEmailTemplate()}
+ disabled={templateResetting}
+ >
+ {templateResetting ? 'Resetting…' : 'Reset to default'}
+
+
+
+
+
+
+
Send email
+
+ Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
+
+
+
+
+ Template
+ Select which lifecycle email to send.
+
+
+
+ Template
+
+ setEmailSendForm((current) => ({
+ ...current,
+ template_key: event.target.value as InviteEmailTemplateKey,
+ }))
+ }
+ >
+ {emailTemplates.map((template) => (
+
+ {template.label}
+
+ ))}
+
+
+
+ Recipient email
+
+ setEmailSendForm((current) => ({
+ ...current,
+ recipient_email: event.target.value,
+ }))
+ }
+ placeholder="Optional if invite/user already has one"
+ />
+
+
+
+
+
+
+ Context
+ Link the email to an invite or username to fill placeholders automatically.
+
+
+
+ Invite
+
+ setEmailSendForm((current) => ({
+ ...current,
+ invite_id: event.target.value,
+ }))
+ }
+ >
+ None
+ {invites.map((invite) => (
+
+ {invite.code}
+ {invite.label ? ` - ${invite.label}` : ''}
+
+ ))}
+
+
+
+ Username
+
+ setEmailSendForm((current) => ({
+ ...current,
+ username: event.target.value,
+ }))
+ }
+ placeholder="Optional user lookup"
+ />
+
+
+
+
+
+
+ Reason / note
+ Used by warning and banned templates, and appended to other emails.
+
+
+
+ Reason
+
+ setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
+ }
+ placeholder="Optional reason"
+ />
+
+
+ Message
+
+ setEmailSendForm((current) => ({ ...current, message: event.target.value }))
+ }
+ placeholder="Optional message"
+ />
+
+
+
+
+
+ {emailSending ? 'Sending…' : 'Send email'}
+
+
+
+
+
+ )}
+
+ {activeTab === 'trace' && (
+
+
+
+
+
Invite trace map
+
+ Visual lineage of who invited who, including the invite code used for each sign-up.
+
+
+
+
+
+ Find user / inviter / code
+ setTraceFilter(e.target.value)}
+ placeholder="Search by username, inviter, or invite code"
+ />
+
+
+
+ Scope
+
+ setTraceScope(e.target.value as InviteTraceScope)
+ }
+ >
+ All users
+ Invited only
+ Direct / root only
+
+
+
+ Trace view mode
+ setTraceView('graph')}
+ >
+ Graph
+
+ setTraceView('list')}
+ >
+ List
+
+
+
+
+ {scopedInviteTraceRows.length} rows shown
+ {traceInvitedCount} invited
+ {traceDirectCount} direct/root
+ {users.length} users loaded
+ {invites.length} invites loaded
+
+
+ {loading ? (
+
Loading trace map…
+ ) : scopedInviteTraceRows.length === 0 ? (
+
No trace matches found.
+ ) : traceView === 'graph' ? (
+
+ {inviteTraceGraphColumns.map((column) => (
+
+
+ Level {column.level}
+ {column.rows.length}
+
+
+ {column.rows.map((row) => (
+
+
+
+ {row.username}
+ {row.role}
+ {row.authProvider}
+ {row.isCycle && cycle }
+
+
+ {row.inviterUsername
+ ? `\u2190 Invited by ${row.inviterUsername}`
+ : row.inviteCode
+ ? `\u2190 Invited via code ${row.inviteCode}`
+ : 'Direct/root account'}
+
+
+
+
+ Invite code
+ {row.inviteCode || 'None'}
+
+
+ Invite label
+ {row.inviteLabel || 'None'}
+
+
+ Children
+ {row.childCount}
+
+
+ Created
+ {formatDate(row.createdAt)}
+
+
+
+ ))}
+
+
+ ))}
+
+ ) : (
+
+ {scopedInviteTraceRows.map((row) => (
+
+
+
+ {row.username}
+ {row.role}
+ {row.authProvider}
+ {row.isCycle && cycle }
+
+
+
+ Invited by
+ {row.inviterUsername || 'Root/direct'}
+
+
+ Via code
+ {row.inviteCode || 'None'}
+
+
+ Invite label
+ {row.inviteLabel || 'None'}
+
+
+ Children
+ {row.childCount}
+
+
+ Created
+ {formatDate(row.createdAt)}
+
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/app/admin/issues/page.tsx b/frontend/app/admin/issues/page.tsx
new file mode 100644
index 0000000..e626fa4
--- /dev/null
+++ b/frontend/app/admin/issues/page.tsx
@@ -0,0 +1,5 @@
+import PortalClient from '../../portal/PortalClient'
+
+export default function AdminIssuesPage() {
+ return
+}
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
new file mode 100644
index 0000000..932d298
--- /dev/null
+++ b/frontend/app/admin/page.tsx
@@ -0,0 +1,260 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+import AdminShell from '../ui/AdminShell'
+
+type ServiceState = {
+ name: string
+ status: string
+ message?: string
+}
+
+type RecentRequest = {
+ id: number
+ title?: string | null
+ year?: number | null
+ statusLabel?: string | null
+ requestedBy?: string | null
+ createdAt?: string | null
+}
+
+type PortalOverview = {
+ overview?: {
+ total_items?: number
+ total_comments?: number
+ by_kind?: Record
+ by_status?: Record
+ }
+ my_items?: number
+}
+
+const formatDateTime = (value?: string | null) => {
+ if (!value) return 'Unknown'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const normalizeRecent = (items: any[]): RecentRequest[] =>
+ items
+ .filter((item) => item?.id)
+ .map((item) => ({
+ id: Number(item.id),
+ title: item.title ?? null,
+ year: item.year ?? null,
+ statusLabel: item.statusLabel ?? null,
+ requestedBy: item.requestedBy ?? null,
+ createdAt: item.createdAt ?? null,
+ }))
+
+export default function AdminLandingPage() {
+ const router = useRouter()
+ const [services, setServices] = useState([])
+ const [serviceOverall, setServiceOverall] = useState('unknown')
+ const [recent, setRecent] = useState([])
+ const [portalOverview, setPortalOverview] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+
+ const load = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
+ authFetch(`${baseUrl}/auth/me`),
+ authFetch(`${baseUrl}/status/services`),
+ authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
+ authFetch(`${baseUrl}/portal/overview`),
+ ])
+
+ if (meResponse.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (meResponse.status === 403) {
+ router.push('/')
+ return
+ }
+ const me = await meResponse.json()
+ if (me?.role !== 'admin') {
+ router.push('/')
+ return
+ }
+
+ if (serviceResponse.ok) {
+ const data = await serviceResponse.json()
+ setServiceOverall(data?.overall ?? 'unknown')
+ setServices(Array.isArray(data?.services) ? data.services : [])
+ }
+
+ if (recentResponse.ok) {
+ const data = await recentResponse.json()
+ setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
+ }
+
+ if (overviewResponse.ok) {
+ const data = await overviewResponse.json()
+ setPortalOverview(data)
+ }
+ } catch (err) {
+ console.error(err)
+ setError('Unable to load the operations dashboard.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ void load()
+ }, [router])
+
+ const serviceCounts = useMemo(() => {
+ const up = services.filter((service) => service.status === 'up').length
+ const down = services.filter((service) => service.status === 'down').length
+ const degraded = services.filter((service) => service.status === 'degraded').length
+ const notConfigured = services.filter((service) => service.status === 'not_configured').length
+ return { up, down, degraded, notConfigured, total: services.length }
+ }, [services])
+
+ const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
+ const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
+ const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
+
+ const rail = (
+
+ )
+
+ return (
+ router.push('/')}>
+ View health
+
+ }
+ >
+ {loading ? Loading operations dashboard...
: null}
+ {error ? {error}
: null}
+
+
+
+
Services online
+
+ {serviceCounts.up}/{serviceCounts.total || 0}
+
+
{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}
+
+
+
Recent requests
+
{recent.length}
+
Loaded from the live request cache.
+
+
+
Open issue items
+
{issueCount}
+
{commentCount} portal comments recorded.
+
+
+
Portal requests
+
{requestItemCount}
+
Tracked in the dedicated request workflow.
+
+
+
+
+
+
+
Recent activity
+
Live request cache entries, newest first.
+
+
+ {recent.length === 0 ? (
+ No recent requests were returned.
+ ) : (
+
+
+ Request
+ Status
+ User
+ Created
+
+ {recent.map((row) => (
+
router.push(`/requests/${row.id}`)}
+ >
+
+ {row.title || `Request #${row.id}`}
+ {row.year ? ` (${row.year})` : ''}
+
+ {row.statusLabel || 'Unknown'}
+ {row.requestedBy || 'Unknown'}
+ {formatDateTime(row.createdAt)}
+
+ ))}
+
+ )}
+
+
+
+
+
+
Attention states
+
Service states that affect request processing.
+
+
+
+ {serviceCounts.down} down
+ {serviceCounts.degraded} degraded
+ {serviceCounts.notConfigured} not configured
+
+
+
+ )
+}
diff --git a/frontend/app/admin/profiles/page.tsx b/frontend/app/admin/profiles/page.tsx
new file mode 100644
index 0000000..c3a0526
--- /dev/null
+++ b/frontend/app/admin/profiles/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from 'next/navigation'
+
+export default function AdminProfilesRedirectPage() {
+ redirect('/admin/invites')
+}
+
diff --git a/frontend/app/admin/requests-all/page.tsx b/frontend/app/admin/requests-all/page.tsx
new file mode 100644
index 0000000..b3a2d9c
--- /dev/null
+++ b/frontend/app/admin/requests-all/page.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
+import AdminShell from '../../ui/AdminShell'
+
+type RequestRow = {
+ id: number
+ title?: string | null
+ year?: number | null
+ type?: string | null
+ statusLabel?: string | null
+ requestedBy?: string | null
+ createdAt?: string | null
+}
+
+const REQUEST_STAGE_OPTIONS = [
+ { value: 'all', label: 'All stages' },
+ { value: 'pending', label: 'Waiting for approval' },
+ { value: 'approved', label: 'Approved' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'working', label: 'Working on it' },
+ { value: 'partial', label: 'Partially ready' },
+ { value: 'ready', label: 'Ready to watch' },
+ { value: 'declined', label: 'Declined' },
+]
+
+const formatDateTime = (value?: string | null) => {
+ if (!value) return 'Unknown'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+export default function AdminRequestsAllPage() {
+ const router = useRouter()
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [pageSize, setPageSize] = useState(50)
+ const [page, setPage] = useState(1)
+ const [stage, setStage] = useState('all')
+
+ const pageCount = useMemo(() => {
+ if (!total || pageSize <= 0) return 1
+ return Math.max(1, Math.ceil(total / pageSize))
+ }, [total, pageSize])
+
+ const load = async () => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const baseUrl = getApiBase()
+ const skip = (page - 1) * pageSize
+ const params = new URLSearchParams({
+ take: String(pageSize),
+ skip: String(skip),
+ })
+ if (stage !== 'all') {
+ params.set('stage', stage)
+ }
+ const response = await authFetch(
+ `${baseUrl}/admin/requests/all?${params.toString()}`
+ )
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (response.status === 403) {
+ router.push('/')
+ return
+ }
+ throw new Error(`Load failed: ${response.status}`)
+ }
+ const data = await response.json()
+ setRows(Array.isArray(data?.results) ? data.results : [])
+ setTotal(Number(data?.total ?? 0))
+ } catch (err) {
+ console.error(err)
+ setError('Unable to load requests.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ void load()
+ }, [page, pageSize, stage])
+
+ useEffect(() => {
+ if (page > pageCount) {
+ setPage(pageCount)
+ }
+ }, [pageCount, page])
+
+ useEffect(() => {
+ setPage(1)
+ }, [stage])
+
+ return (
+ router.push('/admin')}>
+ Back to settings
+
+ }
+ >
+
+
+
+ {total.toLocaleString()} total
+
+
+
+ Stage
+ setStage(e.target.value)}>
+ {REQUEST_STAGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Per page
+ setPageSize(Number(e.target.value))}>
+ 25
+ 50
+ 100
+ 200
+
+
+
+
+ {loading ? (
+ Loading requests…
+ ) : error ? (
+ {error}
+ ) : rows.length === 0 ? (
+ No requests found.
+ ) : (
+
+
+ Request
+ Status
+ Requested by
+ Created
+
+ {rows.map((row) => (
+
router.push(`/requests/${row.id}`)}
+ >
+
+ {row.title || `Request #${row.id}`}
+ {row.year ? ` (${row.year})` : ''}
+
+ {row.statusLabel || 'Unknown'}
+ {row.requestedBy || 'Unknown'}
+ {formatDateTime(row.createdAt)}
+
+ ))}
+
+ )}
+
+ setPage(1)} disabled={page <= 1}>
+ First
+
+ setPage(page - 1)} disabled={page <= 1}>
+ Previous
+
+
+ Page {page} of {pageCount}
+
+ setPage(page + 1)}
+ disabled={page >= pageCount}
+ >
+ Next
+
+ setPage(pageCount)}
+ disabled={page >= pageCount}
+ >
+ Last
+
+
+
+
+ )
+}
diff --git a/frontend/app/admin/system/page.tsx b/frontend/app/admin/system/page.tsx
new file mode 100644
index 0000000..7a74425
--- /dev/null
+++ b/frontend/app/admin/system/page.tsx
@@ -0,0 +1,304 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import AdminShell from '../../ui/AdminShell'
+import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
+
+type FlowStage = {
+ title: string
+ input: string
+ action: string
+ output: string
+}
+
+const REQUEST_FLOW: FlowStage[] = [
+ {
+ title: 'Identity + access',
+ input: 'Jellyfin/local login',
+ action: 'Magent validates credentials and role',
+ output: 'JWT token + user scope',
+ },
+ {
+ title: 'Request intake',
+ input: 'Seerr request ID',
+ action: 'Magent snapshots request + media metadata',
+ output: 'Unified request state',
+ },
+ {
+ title: 'Queue orchestration',
+ input: 'Approved request',
+ action: 'Sonarr/Radarr add/search operations',
+ output: 'Grab decision',
+ },
+ {
+ title: 'Download execution',
+ input: 'Selected release',
+ action: 'qBittorrent downloads + reports progress',
+ output: 'Import-ready payload',
+ },
+ {
+ title: 'Library import',
+ input: 'Completed download',
+ action: 'Sonarr/Radarr import and finalize',
+ output: 'Available media object',
+ },
+ {
+ title: 'Playback availability',
+ input: 'Imported media',
+ action: 'Jellyfin refresh + link resolution',
+ output: 'Ready-to-watch state',
+ },
+]
+
+export default function AdminSystemGuidePage() {
+ const router = useRouter()
+ const [loading, setLoading] = useState(true)
+ const [authorized, setAuthorized] = useState(false)
+
+ useEffect(() => {
+ let active = true
+ const load = async () => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/me`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ router.push('/')
+ return
+ }
+ const me = await response.json()
+ if (!active) return
+ if (me?.role !== 'admin') {
+ router.push('/')
+ return
+ }
+ setAuthorized(true)
+ } catch (error) {
+ console.error(error)
+ router.push('/')
+ } finally {
+ if (active) setLoading(false)
+ }
+ }
+ void load()
+ return () => {
+ active = false
+ }
+ }, [router])
+
+ if (loading) {
+ return Loading system guide...
+ }
+
+ if (!authorized) {
+ return null
+ }
+
+ const rail = (
+
+
+
How it works
+
Admin flow map
+
Identity → Request intake → Queue orchestration → Download → Import → Playback.
+
Admin only
+
+
+ )
+
+ return (
+ router.push('/admin')}>
+ Back to settings
+
+ }
+ >
+
+
+
End-to-end system flow
+
+ This is the runtime path the platform follows from authentication through to playback
+ availability.
+
+
+ {REQUEST_FLOW.map((stage, index) => (
+
+
+ {index + 1}. {stage.title}
+
+ Input
+ {stage.input}
+
+
+ Action
+ {stage.action}
+
+
+ Output
+ {stage.output}
+
+
+ {index < REQUEST_FLOW.length - 1 &&
→
}
+
+ ))}
+
+
+
+
+
What each service is responsible for
+
+
+ Magent
+
+ Handles authentication, request pages, live event updates, invite workflows,
+ diagnostics, notifications, and admin operations.
+
+
+
+ Seerr
+
+ Stores the request itself and remains the request-state source for approval and
+ media request metadata.
+
+
+
+ Jellyfin
+
+ Provides user sign-in identity and the final playback destination once content is
+ available.
+
+
+
+ Sonarr / Radarr
+
+ Control queue placement, quality-profile decisions, import handling, and release
+ monitoring.
+
+
+
+ Prowlarr
+ Provides search/indexer coverage for Arr-side release searches.
+
+
+ qBittorrent
+
+ Executes the download and exposes live progress, paused states, and queue
+ visibility.
+
+
+
+
+
+
+
Operational controls by area
+
+
+ General
+ Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.
+
+
+ Notifications
+ Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.
+
+
+ Users
+ Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.
+
+
+ Invite management
+
+ Master template, profile assignment, invite access policy, invite emails, and trace
+ map lineage.
+
+
+
+ Requests + cache
+ All-requests view, sync controls, cached request records, and maintenance operations.
+
+
+ Maintenance + diagnostics
+
+ Connectivity checks, live diagnostics, database repair, cleanup, log review, and
+ nuclear flush/resync operations.
+
+
+
+
+
+
+
User and invite model
+
+
+ Jellyfin is used for sign-in identity and user presence across the platform.
+
+
+ Seerr provides request ownership and request-state data for Magent request pages.
+
+
+ Invite links, invite profiles, blanket rules, and invite-access controls are managed
+ inside Magent.
+
+
+ If invite tracing is enabled, the lineage view shows who invited whom and how the
+ chain branches.
+
+
+ Cross-system removal and ban flows are initiated from Magent admin controls.
+
+
+
+
+
+
Stall recovery path (decision flow)
+
+
+ Request approved but not in Arr queue → run Re-add to Arr .
+
+
+ In queue but no release found → run Search releases and inspect options.
+
+
+ Release exists and user should not pick manually → run Search + auto-download .
+
+
+ Download paused/stalled in qBittorrent → run Resume download .
+
+
+ Imported but not visible to user → validate Jellyfin visibility/link from request page.
+
+
+
+
+
+
Live update surfaces
+
+
+ Landing page
+ Recent requests and service summaries refresh live for signed-in users.
+
+
+ Request pages
+ Timeline state, queue activity, and torrent progress are pushed live without refresh.
+
+
+ Admin views
+ Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/changelog/page.tsx b/frontend/app/changelog/page.tsx
new file mode 100644
index 0000000..8c79508
--- /dev/null
+++ b/frontend/app/changelog/page.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+
+type SiteInfo = {
+ changelog?: string
+}
+
+type ChangelogGroup = {
+ date: string
+ entries: string[]
+}
+
+const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
+
+const parseChangelog = (raw: string): ChangelogGroup[] => {
+ const groups: ChangelogGroup[] = []
+ for (const rawLine of raw.split('\n')) {
+ const line = rawLine.trim()
+ if (!line) continue
+ const [candidateDate, ...messageParts] = line.split('|')
+ if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
+ const message = messageParts.join('|').trim()
+ if (!message) continue
+ const currentGroup = groups[groups.length - 1]
+ if (currentGroup?.date === candidateDate) {
+ currentGroup.entries.push(message)
+ } else {
+ groups.push({ date: candidateDate, entries: [message] })
+ }
+ continue
+ }
+ if (groups.length === 0) {
+ groups.push({ date: 'Updates', entries: [line] })
+ } else {
+ groups[groups.length - 1].entries.push(line)
+ }
+ }
+ return groups
+}
+
+export default function ChangelogPage() {
+ const router = useRouter()
+ const [groups, setGroups] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ const token = getToken()
+ if (!token) {
+ router.push('/login')
+ return
+ }
+ let active = true
+ const load = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/site/info`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error('Failed to load changelog')
+ }
+ const data: SiteInfo = await response.json()
+ if (!active) return
+ setGroups(parseChangelog(data?.changelog ?? ''))
+ } catch (err) {
+ console.error(err)
+ if (!active) return
+ setGroups([])
+ } finally {
+ if (active) setLoading(false)
+ }
+ }
+ void load()
+ return () => {
+ active = false
+ }
+ }, [router])
+
+ const content = useMemo(() => {
+ if (loading) {
+ return Loading changelog...
+ }
+ if (groups.length === 0) {
+ return No updates posted yet.
+ }
+ return (
+
+ {groups.map((group) => (
+
+ {group.date}
+
+ {group.entries.map((entry, index) => (
+ {entry}
+ ))}
+
+
+ ))}
+
+ )
+ }, [groups, loading])
+
+ return (
+
+
+
+
Changelog
+
Latest updates and release notes.
+
+ {content}
+
+
+ )
+}
diff --git a/frontend/app/feedback/page.tsx b/frontend/app/feedback/page.tsx
new file mode 100644
index 0000000..ed76558
--- /dev/null
+++ b/frontend/app/feedback/page.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
+
+type Profile = {
+ username?: string
+}
+
+export default function FeedbackPage() {
+ const router = useRouter()
+ const [profile, setProfile] = useState(null)
+ const [category, setCategory] = useState('bug')
+ const [message, setMessage] = useState('')
+ const [status, setStatus] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ const load = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
+ if (!response.ok) {
+ throw new Error('Could not load profile.')
+ }
+ const data = await response.json()
+ setProfile({ username: data?.username })
+ } catch (error) {
+ if (error instanceof UnauthorizedError) {
+ router.push('/login')
+ return
+ }
+ console.error(error)
+ }
+ }
+ void load()
+ }, [router])
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setStatus(null)
+ if (!message.trim()) {
+ setStatus('Please write a short message before sending.')
+ return
+ }
+ setSubmitting(true)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: category,
+ message: message.trim(),
+ }),
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || `Request failed: ${response.status}`)
+ }
+ setMessage('')
+ setStatus('Thanks! Your message has been sent.')
+ } catch (error) {
+ if (error instanceof UnauthorizedError) {
+ router.push('/login')
+ return
+ }
+ console.error(error)
+ setStatus('That did not send. Please try again.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Your username
+
+
+ What is this about?
+ setCategory(event.target.value)}
+ >
+ Bug (something is broken)
+ Feature idea (new option)
+
+
+ Tell us what happened
+ setMessage(event.target.value)}
+ placeholder="Write the details here..."
+ />
+
+ {status && {status}
}
+
+
+ {submitting ? 'Sending...' : 'Send feedback'}
+
+
+
+ )
+}
diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx
new file mode 100644
index 0000000..024dd8d
--- /dev/null
+++ b/frontend/app/forgot-password/page.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import BrandingLogo from '../ui/BrandingLogo'
+import { getApiBase } from '../lib/auth'
+
+export default function ForgotPasswordPage() {
+ const router = useRouter()
+ const [identifier, setIdentifier] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [status, setStatus] = useState(null)
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!identifier.trim()) {
+ setError('Enter your username or email.')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await fetch(`${baseUrl}/auth/password/forgot`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identifier: identifier.trim() }),
+ })
+ const data = await response.json().catch(() => null)
+ if (!response.ok) {
+ throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.')
+ }
+ setStatus(
+ typeof data?.message === 'string'
+ ? data.message
+ : 'If an account exists for that username or email, a password reset link has been sent.',
+ )
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Unable to send reset link.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ Forgot password
+
+ Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
+ will be emailed to you.
+
+
+
+ Username or email
+ setIdentifier(event.target.value)}
+ autoComplete="username"
+ placeholder="you@example.com"
+ />
+
+ {error && {error}
}
+ {status && {status}
}
+
+
+ {loading ? 'Sending reset link…' : 'Send reset link'}
+
+
+ router.push('/login')} disabled={loading}>
+ Back to sign in
+
+
+
+ )
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 0000000..1694cd1
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,6924 @@
+@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
+
+:root {
+ color-scheme: light;
+ --ink: #0f1117;
+ --ink-muted: #3f4656;
+ --paper: #f0f4ff;
+ --paper-strong: #ffffff;
+ --accent: #ff6b2b;
+ --accent-2: #1c6bff;
+ --accent-3: #11d6c6;
+ --border: rgba(15, 17, 23, 0.12);
+ --shadow: rgba(15, 17, 23, 0.18);
+ --glow: 0 0 18px rgba(28, 107, 255, 0.25);
+ --input-bg: rgba(15, 17, 23, 0.04);
+ --input-ink: var(--ink);
+ --error-bg: rgba(255, 107, 43, 0.12);
+ --error-ink: #6b2c17;
+}
+
+[data-theme='dark'] {
+ color-scheme: dark;
+ --ink: #e9ecf5;
+ --ink-muted: #9aa3b8;
+ --paper: #0b0f18;
+ --paper-strong: #111827;
+ --accent: #ff6b2b;
+ --accent-2: #3b82f6;
+ --accent-3: #22f6e3;
+ --border: rgba(255, 255, 255, 0.08);
+ --shadow: rgba(0, 0, 0, 0.6);
+ --glow: 0 0 22px rgba(59, 130, 246, 0.45);
+ --input-bg: rgba(255, 255, 255, 0.08);
+ --input-ink: var(--ink);
+ --error-bg: rgba(255, 107, 43, 0.18);
+ --error-ink: #ffd3bf;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
+ background: radial-gradient(circle at top, rgba(17, 33, 74, 0.9) 0%, rgba(8, 12, 22, 1) 55%, #05070d 100%);
+ color: var(--ink);
+ min-height: 100vh;
+ transition: background 0.4s ease, color 0.4s ease;
+}
+
+[data-theme='light'] body {
+ background: radial-gradient(circle at top, #f7faff 0%, #eef2ff 45%, #e3edff 100%);
+}
+
+.page {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 40px 24px 80px;
+ display: grid;
+ gap: 32px;
+}
+
+.header {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
+ align-items: center;
+ gap: 12px 16px;
+}
+
+.header-left {
+ grid-column: 1 / 2;
+ grid-row: 1 / 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.brand-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+ color: inherit;
+ text-decoration: none;
+}
+
+.brand-link:hover .brand {
+ color: var(--ink);
+}
+
+.brand-stack {
+ display: grid;
+ gap: 4px;
+}
+
+.header-right {
+ grid-column: 2 / 3;
+ grid-row: 1 / 2;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.header-nav {
+ grid-column: 1 / -1;
+ grid-row: 2 / 3;
+ display: flex;
+ justify-content: flex-start;
+}
+
+.brand {
+ font-size: 32px;
+ letter-spacing: 0.02em;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.tagline {
+ color: var(--ink-muted);
+ font-size: 16px;
+}
+
+.header-actions {
+ display: flex;
+ gap: 16px;
+ font-size: 14px;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ width: 100%;
+}
+
+.header-actions a {
+ color: var(--ink);
+ text-decoration: none;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border);
+ backdrop-filter: blur(8px);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+.header-actions .header-link {
+ color: var(--ink);
+ text-decoration: none;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border);
+ backdrop-filter: blur(8px);
+ font-size: 14px;
+ box-shadow: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+.header-actions .header-cta {
+ background: linear-gradient(120deg, rgba(255, 107, 43, 0.95), rgba(255, 168, 75, 0.95));
+ color: #151515;
+ border: 1px solid rgba(255, 140, 60, 0.7);
+ box-shadow: 0 12px 24px rgba(255, 107, 43, 0.35);
+ font-weight: 700;
+}
+
+.header-actions .header-cta--left {
+ margin-right: auto;
+}
+
+.signed-in-menu {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+.avatar-button {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: linear-gradient(130deg, rgba(28, 107, 255, 0.35), rgba(17, 214, 198, 0.25));
+ color: var(--ink);
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 10px 20px rgba(28, 107, 255, 0.25);
+ cursor: pointer;
+}
+
+.signed-in-dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ width: min(260px, 90vw);
+ background: rgba(14, 20, 32, 0.96);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 8px;
+ box-shadow: 0 12px 26px var(--shadow);
+ z-index: 20;
+}
+
+.signed-in-header {
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+ padding: 8px 10px 6px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.signed-in-actions {
+ display: grid;
+ gap: 6px;
+ padding: 8px 4px 4px;
+}
+
+.signed-in-actions a,
+.signed-in-signout {
+ display: block;
+ padding: 8px 12px;
+ border-radius: 10px;
+ color: var(--ink);
+ text-decoration: none;
+ text-align: left;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.signed-in-signout {
+ cursor: pointer;
+ font: inherit;
+}
+
+.signed-in-actions a:hover,
+.signed-in-signout:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.signed-in-build {
+ margin-top: 6px;
+ padding: 6px 10px 8px;
+ font-size: 11px;
+ color: var(--ink-muted);
+ text-align: left;
+ letter-spacing: 0.04em;
+}
+
+.theme-toggle {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 50%;
+ background: linear-gradient(120deg, rgba(28, 107, 255, 0.2), rgba(34, 246, 227, 0.2));
+ border: 1px solid var(--border);
+ color: var(--ink);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: var(--glow);
+}
+
+.theme-toggle svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.card {
+ background: var(--paper-strong);
+ border-radius: 24px;
+ padding: 32px;
+ box-shadow: 0 18px 40px var(--shadow);
+ display: grid;
+ gap: 24px;
+ animation: rise 0.5s ease-out;
+ border: 1px solid var(--border);
+}
+
+.layout-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
+ gap: 28px;
+ align-items: start;
+}
+
+.side-panel {
+ position: sticky;
+ top: 24px;
+ align-self: start;
+}
+
+.find-panel {
+ display: grid;
+ gap: 20px;
+}
+
+.find-header {
+ display: grid;
+ gap: 8px;
+}
+
+.find-controls {
+ display: grid;
+ gap: 16px;
+}
+
+.centerpiece {
+ padding: 8px 0 4px;
+}
+
+.centerpiece .recent-grid button {
+ padding: 14px 18px;
+ border-radius: 18px;
+}
+
+h1 {
+ font-size: 36px;
+}
+
+h2 {
+ font-size: 22px;
+}
+
+h3 {
+ font-size: 18px;
+}
+
+.lede {
+ font-size: 18px;
+ color: var(--ink-muted);
+}
+
+.search {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 14px;
+}
+
+.search-row button {
+ align-self: stretch;
+}
+
+input {
+ padding: 14px 16px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ font-size: 16px;
+ background: var(--input-bg);
+ color: var(--input-ink);
+}
+
+input::placeholder {
+ color: var(--ink-muted);
+}
+
+select {
+ padding: 14px 16px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ font-size: 16px;
+ background: var(--input-bg);
+ color: var(--input-ink);
+}
+
+select option {
+ background: var(--paper-strong);
+ color: var(--ink);
+}
+
+textarea {
+ padding: 14px 16px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ font-size: 16px;
+ background: var(--input-bg);
+ color: var(--input-ink);
+ font-family: inherit;
+ resize: vertical;
+}
+
+button {
+ padding: 12px 18px;
+ border-radius: 999px;
+ border: none;
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
+ color: #fff;
+ font-size: 15px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ box-shadow: var(--glow);
+ text-align: center;
+}
+
+button span {
+ font-size: 12px;
+ text-transform: uppercase;
+ opacity: 0.8;
+ text-align: center;
+}
+
+.filters {
+ display: grid;
+ gap: 12px;
+}
+
+.filters-compact {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ align-items: start;
+ padding: 12px;
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.filter {
+ display: grid;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--ink-muted);
+}
+
+.pill-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.pill-group button {
+ background: rgba(28, 107, 255, 0.15);
+ color: var(--ink);
+ padding: 8px 14px;
+ font-size: 13px;
+}
+
+.recent-grid {
+ display: grid;
+ gap: 10px;
+}
+
+.recent-grid button {
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+ border: 1px solid var(--border);
+}
+
+.recent-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ text-align: left;
+}
+
+.recent-poster {
+ width: 46px;
+ height: 68px;
+ border-radius: 10px;
+ object-fit: cover;
+ border: 1px solid var(--border);
+ box-shadow: 0 8px 18px var(--shadow);
+ flex-shrink: 0;
+}
+
+.recent-info {
+ display: grid;
+ gap: 4px;
+}
+
+.recent-title {
+ font-weight: 600;
+}
+
+.recent-meta {
+ color: var(--ink-muted);
+ font-size: 13px;
+}
+
+.request-header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.request-header-main {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.request-poster {
+ width: 90px;
+ height: 135px;
+ border-radius: 14px;
+ object-fit: cover;
+ border: 1px solid var(--border);
+ box-shadow: 0 12px 26px var(--shadow);
+}
+
+.brand-preview {
+ margin-top: 12px;
+ max-width: 300px;
+ max-height: 300px;
+ width: 100%;
+ height: auto;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.05);
+ box-shadow: 0 10px 24px var(--shadow);
+}
+
+.brand-logo {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ object-fit: contain;
+}
+
+.brand-logo--header {
+ width: 100px;
+ height: 100px;
+ border-radius: 0;
+ border: none;
+ background: transparent;
+ box-shadow: none;
+}
+
+.brand-logo--login {
+ width: 180px;
+ height: 180px;
+ margin: 40px auto 16px;
+ border-radius: 0;
+ border: none;
+ background: transparent;
+ box-shadow: none;
+}
+
+.meta {
+ color: var(--ink-muted);
+ margin-top: 4px;
+}
+
+.profile-grid {
+ display: grid;
+ gap: 20px;
+}
+
+.profile-section {
+ display: grid;
+ gap: 12px;
+}
+
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+}
+
+.stat-card {
+ padding: 14px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.05);
+ display: grid;
+ gap: 6px;
+}
+
+.stat-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-muted);
+}
+
+.stat-value {
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.stat-value--small {
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.connection-list {
+ display: grid;
+ gap: 10px;
+}
+
+.connection-item {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.connection-label {
+ font-weight: 600;
+}
+
+.connection-count {
+ font-size: 12px;
+ color: var(--ink-muted);
+ white-space: nowrap;
+}
+
+.state {
+ display: grid;
+ gap: 6px;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+}
+
+.status-box {
+ background: rgba(11, 15, 24, 0.7);
+ border-radius: 20px;
+ padding: 20px;
+ display: grid;
+ gap: 16px;
+ border: 1px solid var(--border);
+ box-shadow: var(--glow);
+}
+
+.status-box h2 {
+ font-size: 18px;
+}
+
+.status-box p {
+ color: var(--ink-muted);
+}
+
+.status-text {
+ font-size: 20px;
+ color: var(--ink);
+ font-weight: 600;
+}
+
+.timeline {
+ display: grid;
+ gap: 20px;
+ position: relative;
+ padding-left: 20px;
+}
+
+.timeline::before {
+ content: "";
+ position: absolute;
+ left: 10px;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: linear-gradient(180deg, var(--accent-2), transparent);
+}
+
+.timeline-item {
+ display: grid;
+ grid-template-columns: 20px 1fr;
+ gap: 12px;
+}
+
+.timeline-marker {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent-3);
+ box-shadow: 0 0 10px rgba(34, 246, 227, 0.6);
+ margin-top: 6px;
+}
+
+.timeline-card {
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 16px;
+ padding: 16px;
+ display: grid;
+ gap: 12px;
+ border: 1px solid var(--border);
+}
+
+.timeline-card pre {
+ background: rgba(255, 255, 255, 0.08);
+ padding: 12px;
+ border-radius: 12px;
+ overflow-x: auto;
+ font-size: 12px;
+ font-family: "JetBrains Mono", "Consolas", monospace;
+}
+
+.timeline-sublist {
+ display: grid;
+ gap: 8px;
+}
+
+.timeline-sublist ul {
+ list-style: none;
+ display: grid;
+ gap: 6px;
+}
+
+.timeline-sublist li {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 14px;
+ color: var(--ink-muted);
+}
+
+.timeline-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ text-transform: uppercase;
+ font-size: 12px;
+ letter-spacing: 0.08em;
+}
+
+.summary {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.summary-card {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 18px;
+ padding: 18px;
+ display: grid;
+ gap: 10px;
+ border: 1px solid var(--border);
+}
+
+.user-card {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: start;
+ gap: 16px;
+}
+
+.user-card strong {
+ display: block;
+ font-size: 16px;
+ margin-bottom: 6px;
+}
+
+.user-meta {
+ display: grid;
+ gap: 6px;
+ font-size: 13px;
+}
+
+.user-meta .meta {
+ display: block;
+}
+
+.user-actions {
+ display: grid;
+ gap: 8px;
+ justify-items: end;
+}
+
+.toggle {
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 13px;
+ color: var(--ink);
+}
+
+.toggle input[type='checkbox'] {
+ width: 16px;
+ height: 16px;
+}
+
+.summary-card ul {
+ list-style: disc;
+ padding-left: 18px;
+ color: var(--ink-muted);
+}
+
+.summary-card p {
+ color: var(--ink-muted);
+}
+
+.summary-card .helper {
+ font-size: 13px;
+ line-height: 1.4;
+ color: #6a5b4c;
+}
+
+.details-toggle {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.details-toggle button {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+ border: 1px solid var(--border);
+}
+
+.actions {
+ display: grid;
+ gap: 12px;
+}
+
+.action-grid {
+ display: grid;
+ gap: 10px;
+}
+
+.action-message {
+ padding: 10px 14px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink-muted);
+ font-size: 14px;
+}
+
+.action-grid button {
+ background: linear-gradient(120deg, rgba(59, 130, 246, 0.8), rgba(34, 246, 227, 0.7));
+}
+
+.history {
+ display: grid;
+ gap: 12px;
+}
+
+.history-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.history-grid ul {
+ list-style: none;
+ display: grid;
+ gap: 8px;
+ color: var(--ink-muted);
+}
+
+.history-grid li {
+ display: grid;
+ gap: 4px;
+}
+
+.history-grid li span:last-child {
+ font-size: 13px;
+ color: #6a5b4c;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(27, 28, 30, 0.45);
+ display: grid;
+ place-items: center;
+ z-index: 40;
+ padding: 24px;
+}
+
+.modal-card {
+ background: var(--paper-strong);
+ border-radius: 20px;
+ padding: 24px;
+ box-shadow: 0 20px 45px var(--shadow);
+ display: grid;
+ gap: 12px;
+ max-width: 420px;
+ width: 100%;
+}
+
+.modal-card button {
+ justify-self: start;
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
+}
+
+.auth-card {
+ max-width: 520px;
+ margin: 0 auto;
+}
+
+.auth-form {
+ display: grid;
+ gap: 16px;
+}
+
+.auth-actions {
+ display: grid;
+ gap: 10px;
+}
+
+.ghost-button {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+ border: 1px solid var(--border);
+ box-shadow: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 12px 18px;
+ border-radius: 999px;
+ font-size: 15px;
+ text-decoration: none;
+ text-align: center;
+}
+
+.auth-form label {
+ display: grid;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--ink-muted);
+ text-align: center;
+}
+
+.error-banner {
+ padding: 10px 14px;
+ border-radius: 12px;
+ background: var(--error-bg);
+ color: var(--error-ink);
+ font-size: 14px;
+ border: 1px solid var(--border);
+}
+
+.admin-card {
+ gap: 32px;
+}
+
+.admin-shell {
+ display: grid;
+ grid-template-columns: minmax(210px, 240px) minmax(0, 1fr);
+ gap: 24px;
+ align-items: start;
+}
+
+.admin-shell-nav {
+ position: sticky;
+ top: 24px;
+}
+
+.admin-sidebar {
+ display: grid;
+ gap: 16px;
+ padding: 16px;
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.admin-sidebar-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--ink-muted);
+}
+
+.admin-nav-group {
+ display: grid;
+ gap: 8px;
+}
+
+.admin-nav-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-muted);
+}
+
+.admin-nav-links {
+ display: grid;
+ gap: 8px;
+}
+
+.admin-nav-links a {
+ color: var(--ink);
+ text-decoration: none;
+ padding: 8px 12px;
+ border-radius: 12px;
+ border: 1px solid transparent;
+ background: rgba(255, 255, 255, 0.04);
+ font-size: 14px;
+}
+
+.admin-nav-links a.is-active {
+ border-color: rgba(59, 130, 246, 0.4);
+ background: rgba(59, 130, 246, 0.18);
+ color: var(--ink);
+}
+
+.admin-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.admin-form {
+ display: grid;
+ gap: 24px;
+}
+
+.admin-section {
+ display: grid;
+ gap: 12px;
+}
+
+/* Header account menu layering fix */
+.header {
+ position: relative;
+ overflow: visible;
+}
+
+.header-right {
+ position: relative;
+ z-index: 40;
+}
+
+.signed-in-menu {
+ z-index: 50;
+}
+
+.signed-in-dropdown {
+ z-index: 2000;
+}
+
+.header-nav {
+ position: relative;
+ z-index: 1;
+}
+
+.admin-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.admin-toolbar-info {
+ color: var(--ink-muted);
+ font-size: 13px;
+}
+
+.admin-toolbar-actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.admin-select {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--ink-muted);
+ font-size: 13px;
+}
+
+.admin-table {
+ display: grid;
+ gap: 8px;
+}
+
+.admin-table-head {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr 1fr;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--ink-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 0 12px;
+}
+
+.admin-table-row {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr 1fr;
+ gap: 12px;
+ align-items: center;
+ text-align: left;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 16px;
+ padding: 12px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.admin-table-row:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 12px 24px rgba(15, 20, 45, 0.18);
+}
+
+.admin-pagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+ justify-content: flex-end;
+ color: var(--ink-muted);
+ font-size: 13px;
+}
+
+.admin-pagination button {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+}
+
+.admin-pagination span {
+ padding: 0 6px;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.section-subtitle {
+ color: var(--ink-muted);
+ font-size: 13px;
+ margin-top: -6px;
+}
+
+.sync-actions {
+ display: inline-flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.sync-actions-block {
+ display: grid;
+ gap: 6px;
+ justify-items: end;
+ text-align: right;
+}
+
+.sync-note {
+ margin-top: 0;
+}
+
+.section-header button {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+}
+
+.admin-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+}
+
+.admin-grid label {
+ display: grid;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--ink-muted);
+ text-align: center;
+}
+
+.admin-grid label[data-helper]::after {
+ content: attr(data-helper);
+ font-size: 12px;
+ color: var(--ink-muted);
+ line-height: 1.4;
+}
+
+.user-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 16px;
+}
+
+.user-grid-card {
+ display: grid;
+ gap: 14px;
+ padding: 16px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--ink);
+ text-decoration: none;
+ transition: border-color 0.2s ease, transform 0.2s ease;
+}
+
+.user-grid-card:hover {
+ border-color: rgba(59, 130, 246, 0.5);
+ transform: translateY(-2px);
+}
+
+.user-grid-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.user-grid-meta {
+ display: block;
+ font-size: 12px;
+ color: var(--ink-muted);
+}
+
+.user-grid-pill {
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ border: 1px solid rgba(59, 130, 246, 0.4);
+ color: var(--ink);
+ background: rgba(59, 130, 246, 0.2);
+}
+
+.user-grid-pill.is-blocked {
+ border-color: rgba(255, 82, 82, 0.5);
+ background: rgba(255, 82, 82, 0.2);
+}
+
+.user-grid-pill.is-disabled {
+ border-color: rgba(255, 200, 87, 0.45);
+ background: rgba(255, 200, 87, 0.16);
+ color: var(--ink-muted);
+}
+
+.user-grid-subpills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.user-grid-stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.user-grid-stats .label {
+ font-size: 12px;
+ color: var(--ink-muted);
+ display: block;
+}
+
+.user-grid-stats .value {
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.user-grid-footer {
+ display: grid;
+ gap: 6px;
+}
+
+.user-detail-card {
+ display: grid;
+ gap: 16px;
+ padding: 18px;
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.user-detail-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.user-detail-meta {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.user-bulk-toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ align-items: center;
+ padding: 14px 16px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+ margin-bottom: 14px;
+}
+
+.user-bulk-summary {
+ display: grid;
+ gap: 4px;
+}
+
+.user-bulk-summary strong {
+ font-size: 14px;
+}
+
+.user-bulk-summary span {
+ font-size: 13px;
+ color: var(--ink-muted);
+}
+
+.user-bulk-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.user-detail-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(260px, 360px);
+ gap: 14px;
+ align-items: start;
+}
+
+.user-detail-identity {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+.user-detail-title-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.user-detail-name {
+ font-size: 24px;
+ line-height: 1.1;
+}
+
+.user-detail-meta-pills {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.user-detail-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 30px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--ink-muted);
+ font-size: 13px;
+ line-height: 1.2;
+}
+
+.user-detail-controls {
+ display: grid;
+ gap: 10px;
+ padding: 14px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.user-detail-controls-title {
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.user-detail-actions {
+ display: grid;
+ gap: 10px;
+ justify-items: start;
+}
+
+.user-detail-actions .ghost-button {
+ width: 100%;
+}
+
+.user-detail-helper {
+ font-size: 12px;
+ color: var(--ink-muted);
+ line-height: 1.35;
+}
+
+.user-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+}
+
+.user-detail-stat {
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.user-detail-stat--wide {
+ grid-column: 1 / -1;
+}
+
+.user-detail-grid .label {
+ font-size: 12px;
+ color: var(--ink-muted);
+ display: block;
+}
+
+.user-detail-grid .value {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+@media (max-width: 980px) {
+ .user-bulk-toolbar {
+ grid-template-columns: 1fr;
+ }
+
+ .user-bulk-actions {
+ justify-content: flex-start;
+ }
+
+ .user-detail-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .user-detail-actions .ghost-button {
+ width: auto;
+ }
+}
+
+.label-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ text-align: center;
+}
+
+.status-banner {
+ padding: 12px 16px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+ font-size: 14px;
+ border: 1px solid var(--border);
+}
+
+.site-banner {
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.site-banner--info {
+ background: rgba(59, 130, 246, 0.18);
+ border-color: rgba(59, 130, 246, 0.4);
+}
+
+.site-banner--warning {
+ background: rgba(255, 200, 87, 0.22);
+ border-color: rgba(255, 200, 87, 0.5);
+}
+
+.site-banner--error {
+ background: rgba(255, 59, 48, 0.2);
+ border-color: rgba(255, 59, 48, 0.4);
+}
+
+.site-banner--maintenance {
+ background: rgba(255, 107, 43, 0.18);
+ border-color: rgba(255, 107, 43, 0.4);
+}
+
+.site-version {
+ position: fixed;
+ left: 16px;
+ bottom: 12px;
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ color: var(--ink-muted);
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.08);
+ z-index: 30;
+}
+
+.recent-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.recent-filter {
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 13px;
+ color: var(--ink-muted);
+}
+
+.recent-filter-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.recent-filter select {
+ padding: 8px 12px;
+ font-size: 13px;
+}
+
+.admin-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.settings-section-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ flex-wrap: wrap;
+ align-items: end;
+}
+
+.settings-section-actions .settings-action-button {
+ width: 190px;
+ min-width: 190px;
+ flex: 0 0 190px;
+ justify-content: center;
+}
+
+.settings-inline-field {
+ display: grid;
+ gap: 6px;
+ min-width: min(100%, 320px);
+ flex: 1 1 320px;
+}
+
+.settings-inline-field span {
+ color: var(--ink-muted);
+ font-size: 12px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ font-weight: 700;
+}
+
+.settings-nav {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding: 12px 16px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.settings-group {
+ display: grid;
+ gap: 6px;
+ min-width: 180px;
+}
+
+.settings-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-muted);
+}
+
+.settings-links {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.settings-links a {
+ color: var(--ink);
+ text-decoration: none;
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.08);
+ font-size: 13px;
+}
+
+.log-actions {
+ display: inline-flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.log-viewer {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 16px;
+ max-height: 320px;
+ overflow: auto;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--ink);
+}
+
+.cache-table {
+ display: grid;
+ gap: 8px;
+}
+
+.cache-row {
+ display: grid;
+ grid-template-columns: 90px minmax(0, 1.6fr) 120px 90px 180px;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.06);
+ font-size: 13px;
+ color: var(--ink);
+}
+
+.cache-row span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cache-head {
+ background: rgba(255, 255, 255, 0.12);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+}
+
+.maintenance-grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+}
+
+.maintenance-layout {
+ display: grid;
+ gap: 14px;
+}
+
+.maintenance-tools-panel {
+ display: grid;
+ gap: 14px;
+}
+
+.maintenance-panel-copy {
+ display: grid;
+ gap: 8px;
+}
+
+.maintenance-action-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 12px;
+}
+
+.maintenance-action-card {
+ display: grid;
+ gap: 12px;
+ align-content: start;
+ padding: 14px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.maintenance-action-card button {
+ justify-self: start;
+}
+
+.maintenance-action-copy {
+ display: grid;
+ gap: 6px;
+}
+
+.maintenance-action-copy h3 {
+ font-size: 16px;
+}
+
+.maintenance-action-copy p {
+ color: var(--ink-muted);
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.maintenance-action-card-danger {
+ border-color: rgba(255, 107, 43, 0.24);
+ background: rgba(255, 107, 43, 0.05);
+}
+
+.schedule-grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.schedule-card {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ padding: 16px;
+ border: 1px solid var(--border);
+ display: grid;
+ gap: 6px;
+}
+
+.schedule-card h3 {
+ font-size: 16px;
+}
+
+.schedule-card p {
+ color: var(--ink-muted);
+ font-size: 14px;
+}
+
+.danger-button {
+ background: linear-gradient(120deg, #ff3b30, #ff8a3d);
+ color: #fff;
+}
+
+.sync-progress {
+ display: grid;
+ gap: 8px;
+ padding: 12px 16px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border);
+}
+
+.sync-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+ color: var(--ink-muted);
+}
+
+.progress {
+ position: relative;
+ height: 10px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ overflow: hidden;
+ border: 1px solid var(--border);
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(120deg, var(--accent-2), var(--accent-3));
+ transition: width 0.3s ease;
+}
+
+.progress-indeterminate .progress-fill {
+ position: absolute;
+ width: 100%;
+ left: 0;
+ top: 0;
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0),
+ var(--accent-2),
+ var(--accent-3),
+ rgba(255, 255, 255, 0)
+ );
+ background-size: 200% 100%;
+ animation: progress-indeterminate 1.6s ease-in-out infinite;
+}
+
+.progress-complete .progress-fill {
+ animation: none;
+}
+
+.system-status {
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.08);
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+.system-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.system-header h2 {
+ font-size: 18px;
+}
+
+.system-pill {
+ padding: 6px 12px;
+ border-radius: 999px;
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border);
+ color: var(--ink-muted);
+}
+
+.system-pill-up {
+ color: #0b3d2e;
+ background: rgba(61, 220, 151, 0.2);
+ border-color: rgba(61, 220, 151, 0.4);
+}
+
+[data-theme='dark'] .system-pill-up {
+ color: #e9fff6;
+ background: rgba(61, 220, 151, 0.28);
+ border-color: rgba(61, 220, 151, 0.5);
+}
+
+.system-pill-down {
+ color: #4a0c0c;
+ background: rgba(255, 59, 48, 0.2);
+ border-color: rgba(255, 59, 48, 0.4);
+}
+
+.system-pill-degraded {
+ color: #3e2b00;
+ background: rgba(255, 200, 87, 0.22);
+ border-color: rgba(255, 200, 87, 0.4);
+}
+
+.system-list {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+}
+
+.system-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border);
+ font-size: 13px;
+}
+
+.system-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.system-test-message {
+ font-size: 11px;
+ color: var(--ink-muted);
+}
+
+.system-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.system-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: #6b6b6b;
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
+}
+
+.system-up .system-dot {
+ background: #3ddc97;
+ box-shadow: 0 0 10px rgba(61, 220, 151, 0.6);
+}
+
+.system-down .system-dot {
+ background: #ff3b30;
+ box-shadow: 0 0 10px rgba(255, 59, 48, 0.6);
+}
+
+.system-degraded .system-dot,
+.system-not_configured .system-dot {
+ background: #ffc857;
+ box-shadow: 0 0 10px rgba(255, 200, 87, 0.5);
+}
+
+.system-name {
+ font-weight: 600;
+}
+
+.system-state {
+ color: var(--ink-muted);
+}
+
+.system-test {
+ padding: 4px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink-muted);
+ font-size: 11px;
+ letter-spacing: 0.02em;
+}
+
+.system-test:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.16);
+}
+
+.system-test:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.pipeline-map {
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.06);
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+.pipeline-map h2 {
+ font-size: 18px;
+ margin-bottom: 12px;
+}
+
+.pipeline-steps {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 10px;
+}
+
+.pipeline-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--ink-muted);
+ font-size: 13px;
+}
+
+.pipeline-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: #6b6b6b;
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
+}
+
+.pipeline-step.is-complete {
+ color: var(--ink);
+}
+
+.pipeline-step.is-complete .pipeline-dot {
+ background: #3ddc97;
+ box-shadow: 0 0 10px rgba(61, 220, 151, 0.4);
+}
+
+.pipeline-step.is-active {
+ color: var(--ink);
+ border-color: rgba(61, 220, 151, 0.5);
+ background: rgba(61, 220, 151, 0.12);
+}
+
+.pipeline-step.is-active .pipeline-dot {
+ background: #3ddc97;
+ box-shadow: 0 0 14px rgba(61, 220, 151, 0.8);
+}
+
+.pipeline-hint {
+ margin-top: 10px;
+ color: var(--ink-muted);
+ font-size: 13px;
+}
+
+.timeline-item.is-active .timeline-marker {
+ background: #3ddc97;
+ box-shadow: 0 0 12px rgba(61, 220, 151, 0.8);
+}
+
+@keyframes progress-indeterminate {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 720px) {
+ .page {
+ padding: 28px 18px 60px;
+ gap: 24px;
+ }
+
+ .header {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto;
+ align-items: flex-start;
+ }
+
+ .header-left {
+ width: 100%;
+ }
+
+ .brand-link {
+ width: 100%;
+ gap: 12px;
+ }
+
+ .brand-logo--header {
+ width: 64px;
+ height: 64px;
+ }
+
+ .brand {
+ font-size: 26px;
+ }
+
+ .tagline {
+ font-size: 13px;
+ }
+
+ .header-right {
+ grid-column: 1 / -1;
+ justify-content: flex-start;
+ width: 100%;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .header-nav {
+ justify-content: flex-start;
+ width: 100%;
+ }
+
+ .signed-in-menu {
+ margin-left: auto;
+ }
+
+ .avatar-button {
+ width: 40px;
+ height: 40px;
+ }
+
+ .signed-in-dropdown {
+ right: 0;
+ left: auto;
+ width: min(260px, 92vw);
+ }
+
+ .header-actions {
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+
+ .header-actions a,
+ .header-actions .header-link {
+ font-size: 12px;
+ padding: 8px 10px;
+ }
+
+ .header-actions .header-cta--left {
+ grid-column: 1 / -1;
+ margin-right: 0;
+ }
+
+ .summary {
+ grid-template-columns: 1fr;
+ }
+
+ .history-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .layout-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .side-panel {
+ position: static;
+ }
+
+ .admin-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-shell-nav {
+ position: static;
+ }
+
+ .search {
+ grid-template-columns: 1fr;
+ }
+
+ .card {
+ padding: 24px;
+ }
+
+ .cache-row {
+ grid-template-columns: 1fr;
+ }
+
+ .user-card {
+ grid-template-columns: 1fr;
+ }
+
+ .connection-item {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+@media (max-width: 480px) {
+ .header-actions {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Loading spinner */
+.loading-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 28px 0;
+}
+
+.spinner {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: 4px solid rgba(255, 255, 255, 0.12);
+ border-top-color: var(--accent-2);
+ box-shadow: 0 6px 18px rgba(28, 107, 255, 0.12);
+ animation: spin 0.9s linear infinite;
+}
+
+.button-spinner {
+ width: 16px;
+ height: 16px;
+ border-width: 2px;
+ box-shadow: none;
+ margin-right: 8px;
+ vertical-align: middle;
+ display: inline-block;
+}
+
+.loading-text {
+ font-size: 16px;
+ color: var(--ink-muted);
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* How it works */
+.how-page {
+ display: grid;
+ gap: 28px;
+}
+
+.how-hero {
+ display: grid;
+ gap: 10px;
+}
+
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ font-size: 12px;
+ color: var(--ink-muted);
+}
+
+.how-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 18px;
+}
+
+.how-card {
+ background: var(--paper-strong);
+ border: 1px solid var(--border);
+ padding: 16px;
+ border-radius: 16px;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08);
+ display: grid;
+ gap: 8px;
+}
+
+.how-title {
+ color: var(--accent-3);
+ font-weight: 600;
+}
+
+.how-flow {
+ display: grid;
+ gap: 12px;
+}
+
+.how-steps {
+ list-style: none;
+ display: grid;
+ gap: 10px;
+ padding: 0;
+}
+
+.how-steps li {
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--border);
+}
+
+.how-step-grid {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+}
+
+.how-step-card {
+ border-radius: 18px;
+ padding: 18px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.06);
+ display: grid;
+ gap: 10px;
+ position: relative;
+ overflow: hidden;
+}
+
+.how-step-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ opacity: 0.35;
+ pointer-events: none;
+}
+
+.step-seerr::before {
+ background: linear-gradient(135deg, rgba(255, 187, 92, 0.35), transparent 60%);
+}
+
+.step-arr::before {
+ background: linear-gradient(135deg, rgba(94, 204, 255, 0.35), transparent 60%);
+}
+
+.step-prowlarr::before {
+ background: linear-gradient(135deg, rgba(120, 255, 189, 0.35), transparent 60%);
+}
+
+.step-qbit::before {
+ background: linear-gradient(135deg, rgba(255, 133, 200, 0.35), transparent 60%);
+}
+
+.step-jellyfin::before {
+ background: linear-gradient(135deg, rgba(170, 140, 255, 0.35), transparent 60%);
+}
+
+.step-badge {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid var(--border);
+ color: var(--ink);
+}
+
+.step-note {
+ color: var(--ink-muted);
+ font-size: 14px;
+}
+
+.step-fix-title {
+ font-size: 13px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.step-fix-list {
+ list-style: none;
+ display: grid;
+ gap: 6px;
+ padding: 0;
+ margin: 0;
+}
+
+.step-fix-list li {
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border);
+ font-size: 13px;
+}
+
+.how-callout {
+ border-left: 4px solid var(--accent);
+ padding: 16px 18px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ display: grid;
+ gap: 8px;
+}
+
+.changelog-card {
+ gap: 18px;
+}
+
+.changelog-header {
+ display: grid;
+ gap: 8px;
+}
+
+.changelog-list {
+ list-style: disc;
+ padding-left: 22px;
+ display: grid;
+ gap: 10px;
+ color: var(--ink-muted);
+ font-size: 15px;
+}
+
+.changelog-groups {
+ display: grid;
+ gap: 18px;
+}
+
+.changelog-group {
+ display: grid;
+ gap: 10px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+
+.changelog-group:first-child {
+ padding-top: 0;
+ border-top: 0;
+}
+
+.changelog-group h2 {
+ margin: 0;
+ font-size: 16px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink);
+}
+
+/* -------------------------------------------------------------------------- */
+/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
+/* -------------------------------------------------------------------------- */
+
+:root {
+ --ink: #10151d;
+ --ink-muted: #5b6472;
+ --paper: #eaedf1;
+ --paper-strong: #f8fafc;
+ --accent: #3f78d7;
+ --accent-2: #5ea0ff;
+ --accent-3: #8fa7c8;
+ --border: rgba(16, 21, 29, 0.1);
+ --shadow: rgba(16, 21, 29, 0.14);
+ --glow: 0 0 0 transparent;
+ --input-bg: rgba(16, 21, 29, 0.03);
+ --error-bg: rgba(185, 28, 28, 0.08);
+ --error-ink: #7f1d1d;
+}
+
+[data-theme='dark'] {
+ --ink: #edf1f7;
+ --ink-muted: #98a2b3;
+ --paper: #090c10;
+ --paper-strong: #11151b;
+ --accent: #4b7fdb;
+ --accent-2: #66a3ff;
+ --accent-3: #93a6c4;
+ --border: rgba(255, 255, 255, 0.07);
+ --shadow: rgba(0, 0, 0, 0.45);
+ --glow: 0 0 0 transparent;
+ --input-bg: rgba(255, 255, 255, 0.035);
+ --error-bg: rgba(248, 113, 113, 0.12);
+ --error-ink: #fecaca;
+}
+
+body {
+ font-family: "Manrope", "Segoe UI", sans-serif;
+ background:
+ radial-gradient(800px 380px at 10% -8%, rgba(102, 163, 255, 0.09), transparent 60%),
+ radial-gradient(700px 340px at 88% 0%, rgba(149, 176, 214, 0.07), transparent 58%),
+ linear-gradient(180deg, #090b0f 0%, #07090d 100%);
+ letter-spacing: 0.005em;
+}
+
+[data-theme='light'] body {
+ background:
+ radial-gradient(700px 320px at 10% -10%, rgba(102, 163, 255, 0.12), transparent 60%),
+ linear-gradient(180deg, #f2f4f7 0%, #e9edf3 100%);
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.08;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px);
+ background-size: 24px 24px;
+ z-index: 0;
+}
+
+.page {
+ position: relative;
+ z-index: 1;
+ max-width: 1200px;
+ gap: 24px;
+}
+
+.header {
+ padding: 18px 20px;
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.01)),
+ rgba(13, 17, 23, 0.7);
+ backdrop-filter: blur(14px);
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
+}
+
+[data-theme='light'] .header {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.7)),
+ rgba(248, 250, 252, 0.9);
+}
+
+.brand {
+ font-size: 30px;
+ letter-spacing: 0.06em;
+ font-weight: 800;
+}
+
+.tagline {
+ font-size: 15px;
+ color: var(--ink-muted);
+}
+
+h1 {
+ font-size: 34px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+h2 {
+ font-size: 21px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+h3 {
+ font-size: 17px;
+ font-weight: 700;
+}
+
+.lede {
+ color: var(--ink-muted);
+ font-size: 16px;
+}
+
+.header-actions a,
+.header-actions .header-link {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 8px 14px;
+ box-shadow: none;
+ backdrop-filter: none;
+}
+
+.header-actions a:hover,
+.header-actions .header-link:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(102, 163, 255, 0.25);
+}
+
+.header-actions .header-cta {
+ background: linear-gradient(180deg, rgba(78, 133, 224, 0.95), rgba(61, 112, 196, 0.95));
+ color: #f7fbff;
+ border: 1px solid rgba(102, 163, 255, 0.45);
+ box-shadow: 0 8px 18px rgba(62, 109, 190, 0.22);
+}
+
+.avatar-button {
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ border: 1px solid rgba(102, 163, 255, 0.22);
+ background: linear-gradient(180deg, rgba(78, 133, 224, 0.16), rgba(255, 255, 255, 0.02));
+ box-shadow: none;
+}
+
+.theme-toggle {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+ box-shadow: none;
+}
+
+.theme-toggle:hover,
+.avatar-button:hover {
+ border-color: rgba(102, 163, 255, 0.28);
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.signed-in-dropdown {
+ border-radius: 14px;
+ background: rgba(12, 15, 20, 0.96);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: 0 18px 30px rgba(0, 0, 0, 0.35);
+}
+
+[data-theme='light'] .signed-in-dropdown {
+ background: rgba(250, 252, 255, 0.98);
+}
+
+.signed-in-actions a,
+.signed-in-signout {
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.signed-in-actions a:hover,
+.signed-in-signout:hover {
+ background: rgba(255, 255, 255, 0.07);
+}
+
+.card {
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.008)),
+ rgba(17, 21, 27, 0.84);
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.2);
+ padding: 26px;
+ animation: none;
+}
+
+[data-theme='light'] .card {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.78)),
+ rgba(248, 250, 252, 0.95);
+ box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
+}
+
+input,
+select,
+textarea {
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.02);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: rgba(102, 163, 255, 0.42);
+ box-shadow:
+ 0 0 0 3px rgba(102, 163, 255, 0.12),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+button {
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(74, 123, 210, 0.96), rgba(61, 103, 176, 0.96));
+ border: 1px solid rgba(102, 163, 255, 0.35);
+ box-shadow: 0 8px 16px rgba(62, 104, 179, 0.2);
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+button:hover:not(:disabled) {
+ filter: brightness(1.03);
+ transform: translateY(-1px);
+}
+
+button:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.ghost-button,
+.details-toggle button,
+.recent-grid button,
+.admin-pagination button,
+.system-test {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+ box-shadow: none;
+ color: var(--ink);
+}
+
+.ghost-button:hover:not(:disabled),
+.details-toggle button:hover:not(:disabled),
+.recent-grid button:hover:not(:disabled),
+.system-test:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.055);
+ border-color: rgba(102, 163, 255, 0.24);
+}
+
+.danger-button {
+ background: linear-gradient(180deg, #c93f3f, #9f3030);
+ border-color: rgba(255, 143, 143, 0.25);
+ box-shadow: 0 8px 16px rgba(156, 46, 46, 0.22);
+}
+
+.site-banner,
+.status-banner,
+.state,
+.action-message,
+.sync-progress,
+.system-status,
+.pipeline-map,
+.summary-card,
+.status-box,
+.timeline-card,
+.connection-item,
+.stat-card,
+.how-card,
+.how-step-card,
+.schedule-card,
+.log-viewer,
+.cache-row,
+.filters-compact,
+.settings-nav,
+.user-grid-card,
+.user-detail-card,
+.user-detail-controls,
+.user-bulk-toolbar,
+.system-item {
+ background: rgba(255, 255, 255, 0.025);
+ border-color: var(--border);
+ box-shadow: none;
+}
+
+.status-box,
+.summary-card,
+.timeline-card,
+.system-status,
+.pipeline-map {
+ border-radius: 16px;
+}
+
+.timeline::before {
+ background: linear-gradient(180deg, rgba(102, 163, 255, 0.5), rgba(102, 163, 255, 0.08));
+}
+
+.timeline-marker,
+.timeline-item.is-active .timeline-marker {
+ background: var(--accent-2);
+ box-shadow: 0 0 0 4px rgba(102, 163, 255, 0.14);
+}
+
+.admin-shell {
+ gap: 20px;
+ grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
+}
+
+.admin-sidebar {
+ padding: 14px;
+ gap: 14px;
+ border-radius: 16px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.008)),
+ rgba(15, 18, 24, 0.85);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
+}
+
+[data-theme='light'] .admin-sidebar {
+ background: rgba(255, 255, 255, 0.78);
+}
+
+.admin-sidebar-title,
+.admin-nav-title,
+.settings-title,
+.eyebrow {
+ color: #8e98a9;
+ letter-spacing: 0.12em;
+}
+
+.admin-nav-links a {
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid transparent;
+}
+
+.admin-nav-links a:hover {
+ border-color: rgba(102, 163, 255, 0.18);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.admin-nav-links a.is-active {
+ background:
+ linear-gradient(180deg, rgba(102, 163, 255, 0.14), rgba(102, 163, 255, 0.08));
+ border-color: rgba(102, 163, 255, 0.28);
+ box-shadow: inset 0 0 0 1px rgba(102, 163, 255, 0.06);
+}
+
+.admin-table-row,
+.cache-row,
+.system-item,
+.pipeline-step,
+.step-fix-list li,
+.how-steps li {
+ border-radius: 12px;
+}
+
+.admin-table-row {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.admin-table-row:hover {
+ transform: none;
+ box-shadow: none;
+ border: 1px solid rgba(102, 163, 255, 0.15);
+}
+
+.pipeline-step.is-active {
+ border-color: rgba(102, 163, 255, 0.34);
+ background: rgba(102, 163, 255, 0.1);
+}
+
+.pipeline-step.is-active .pipeline-dot,
+.pipeline-step.is-complete .pipeline-dot,
+.system-up .system-dot {
+ box-shadow: 0 0 0 3px rgba(102, 163, 255, 0.12);
+}
+
+.pipeline-dot,
+.system-dot {
+ background: #6f7a8a;
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.03);
+}
+
+.system-up .system-dot {
+ background: #7fb0ff;
+}
+
+.system-down .system-dot {
+ background: #f87171;
+ box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.12);
+}
+
+.system-degraded .system-dot,
+.system-not_configured .system-dot {
+ background: #d4b36b;
+ box-shadow: 0 0 0 3px rgba(212, 179, 107, 0.12);
+}
+
+.system-pill,
+.user-grid-pill,
+.signed-in-build,
+.site-version {
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+}
+
+.user-grid-pill {
+ font-weight: 600;
+}
+
+.user-grid-pill.is-blocked {
+ background: rgba(248, 113, 113, 0.12);
+ border-color: rgba(248, 113, 113, 0.25);
+}
+
+.user-grid-card,
+.user-detail-card {
+ transition: border-color 0.16s ease, background 0.16s ease;
+}
+
+.user-grid-card:hover {
+ transform: none;
+ background: rgba(255, 255, 255, 0.035);
+ border-color: rgba(102, 163, 255, 0.2);
+}
+
+.user-detail-chip,
+.user-detail-stat {
+ background: rgba(255, 255, 255, 0.02);
+ border-color: var(--border);
+}
+
+.user-detail-controls {
+ border-radius: 14px;
+}
+
+.toggle {
+ font-size: 13px;
+ color: var(--ink);
+}
+
+.toggle input[type='checkbox'] {
+ accent-color: var(--accent);
+}
+
+.search,
+.filters,
+.find-controls,
+.admin-form,
+.profile-grid,
+.profile-section {
+ gap: 14px;
+}
+
+.recent-poster,
+.request-poster,
+.brand-preview {
+ border-radius: 12px;
+ box-shadow: none;
+}
+
+.how-step-card::before {
+ opacity: 0.12;
+}
+
+.how-title {
+ color: #9fb4d4;
+}
+
+.step-badge {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.how-callout {
+ border-left-color: rgba(102, 163, 255, 0.45);
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.modal-card {
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: rgba(17, 21, 27, 0.96);
+ box-shadow: 0 18px 36px rgba(0, 0, 0, 0.35);
+}
+
+[data-theme='light'] .modal-card {
+ background: rgba(249, 251, 255, 0.98);
+}
+
+.site-banner {
+ border-radius: 14px;
+ padding: 14px 16px;
+}
+
+.site-banner--info {
+ background: rgba(102, 163, 255, 0.1);
+ border-color: rgba(102, 163, 255, 0.22);
+}
+
+.site-banner--warning {
+ background: rgba(212, 179, 107, 0.12);
+ border-color: rgba(212, 179, 107, 0.22);
+}
+
+.site-banner--error {
+ background: rgba(248, 113, 113, 0.11);
+ border-color: rgba(248, 113, 113, 0.22);
+}
+
+.site-banner--maintenance {
+ background: rgba(113, 128, 150, 0.12);
+ border-color: rgba(143, 160, 185, 0.22);
+}
+
+.progress {
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 999px;
+}
+
+.progress-fill {
+ background: linear-gradient(90deg, #4c7fdc, #6da8ff);
+}
+
+.spinner {
+ border: 4px solid rgba(255, 255, 255, 0.08);
+ border-top-color: #6da8ff;
+ box-shadow: none;
+}
+
+.brand-logo--header {
+ width: 86px;
+ height: 86px;
+}
+
+@media (max-width: 720px) {
+ .header {
+ padding: 14px;
+ border-radius: 16px;
+ }
+
+ .card {
+ padding: 20px;
+ border-radius: 16px;
+ }
+
+ .header-actions {
+ gap: 8px;
+ }
+
+ .header-actions a,
+ .header-actions .header-link {
+ border-radius: 10px;
+ }
+}
+
+/* Release 1.1 UI Refresh: Professional control-panel theme */
+:root {
+ --ink: #111318;
+ --ink-muted: #5f6776;
+ --paper: #eef1f6;
+ --paper-strong: #ffffff;
+ --accent: #4e8ef7;
+ --accent-2: #77abff;
+ --accent-3: #9dbdff;
+ --border: rgba(17, 19, 24, 0.1);
+ --shadow: rgba(17, 19, 24, 0.16);
+ --glow: 0 0 0 1px rgba(78, 142, 247, 0.08), 0 14px 30px rgba(16, 20, 28, 0.08);
+ --input-bg: rgba(17, 19, 24, 0.035);
+ --input-ink: var(--ink);
+ --error-bg: rgba(225, 81, 81, 0.12);
+ --error-ink: #6f1f1f;
+}
+
+[data-theme='dark'] {
+ --ink: #eef1f7;
+ --ink-muted: #9aa3b2;
+ --paper: #0a0d12;
+ --paper-strong: #12161d;
+ --accent: #5d9cff;
+ --accent-2: #87b5ff;
+ --accent-3: #a5c4ff;
+ --border: rgba(255, 255, 255, 0.08);
+ --shadow: rgba(0, 0, 0, 0.55);
+ --glow: 0 0 0 1px rgba(93, 156, 255, 0.12), 0 18px 42px rgba(0, 0, 0, 0.38);
+ --input-bg: rgba(255, 255, 255, 0.035);
+ --input-ink: var(--ink);
+ --error-bg: rgba(248, 113, 113, 0.14);
+ --error-ink: #ffd4d4;
+}
+
+body {
+ font-family: "Manrope", "Segoe UI", sans-serif;
+ background:
+ radial-gradient(circle at 12% -8%, rgba(93, 156, 255, 0.11), transparent 42%),
+ radial-gradient(circle at 88% 0%, rgba(150, 160, 180, 0.08), transparent 35%),
+ linear-gradient(180deg, #06080d 0%, #080b10 36%, #06080c 100%);
+ color: var(--ink);
+}
+
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background:
+ linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px);
+ background-size: 28px 28px;
+ opacity: 0.35;
+ z-index: -1;
+}
+
+[data-theme='light'] body {
+ background:
+ radial-gradient(circle at 15% -10%, rgba(93, 156, 255, 0.1), transparent 38%),
+ radial-gradient(circle at 90% 0%, rgba(70, 80, 95, 0.06), transparent 30%),
+ linear-gradient(180deg, #f4f6fb 0%, #eceff5 46%, #e8ecf2 100%);
+}
+
+[data-theme='light'] body::before {
+ background:
+ linear-gradient(rgba(17, 19, 24, 0.028) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(17, 19, 24, 0.02) 1px, transparent 1px);
+ opacity: 0.45;
+}
+
+.page {
+ max-width: 1240px;
+ gap: 28px;
+}
+
+.brand {
+ font-size: 2.15rem;
+ letter-spacing: 0.04em;
+ font-weight: 800;
+}
+
+.tagline {
+ color: var(--ink-muted);
+ font-size: 0.98rem;
+ letter-spacing: 0.01em;
+}
+
+.brand-logo {
+ filter: saturate(0.92) contrast(1.02);
+}
+
+.brand-logo--header {
+ width: 74px;
+ height: 74px;
+}
+
+.header {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.028), rgba(255, 255, 255, 0.012));
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ padding: 18px 20px;
+ box-shadow: 0 18px 38px rgba(0, 0, 0, 0.22);
+ backdrop-filter: blur(14px);
+}
+
+[data-theme='light'] .header {
+ background: rgba(255, 255, 255, 0.7);
+ box-shadow: 0 14px 30px rgba(17, 19, 24, 0.08);
+}
+
+.header-actions {
+ gap: 10px;
+}
+
+.header-actions a,
+.header-actions .header-link {
+ min-height: 36px;
+ padding: 8px 14px;
+ border-radius: 11px;
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ color: var(--ink);
+ font-weight: 600;
+ box-shadow: none;
+}
+
+[data-theme='light'] .header-actions a,
+[data-theme='light'] .header-actions .header-link {
+ background: rgba(255, 255, 255, 0.8);
+ border-color: rgba(17, 19, 24, 0.08);
+}
+
+.header-actions a:hover,
+.header-actions .header-link:hover {
+ border-color: rgba(93, 156, 255, 0.26);
+ background: rgba(93, 156, 255, 0.08);
+}
+
+.header-actions .header-cta {
+ background: linear-gradient(180deg, rgba(93, 156, 255, 0.28), rgba(93, 156, 255, 0.18));
+ color: #ecf2ff;
+ border: 1px solid rgba(93, 156, 255, 0.34);
+ box-shadow: 0 10px 22px rgba(11, 24, 48, 0.22);
+}
+
+[data-theme='light'] .header-actions .header-cta {
+ color: #102542;
+ background: linear-gradient(180deg, rgba(93, 156, 255, 0.16), rgba(93, 156, 255, 0.1));
+ box-shadow: 0 8px 18px rgba(93, 156, 255, 0.12);
+}
+
+.theme-toggle,
+.avatar-button {
+ width: 44px;
+ height: 44px;
+ border-radius: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
+}
+
+[data-theme='light'] .theme-toggle,
+[data-theme='light'] .avatar-button {
+ border-color: rgba(17, 19, 24, 0.08);
+ background: rgba(255, 255, 255, 0.8);
+ box-shadow: 0 10px 18px rgba(17, 19, 24, 0.08);
+}
+
+.avatar-button {
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 35% 20%, rgba(93, 156, 255, 0.22), transparent 58%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
+ font-weight: 700;
+ box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
+}
+
+.avatar-button:hover,
+.theme-toggle:hover {
+ border-color: rgba(93, 156, 255, 0.24);
+ box-shadow: 0 0 0 1px rgba(93, 156, 255, 0.12), 0 14px 24px rgba(0, 0, 0, 0.2);
+}
+
+.signed-in-dropdown {
+ border-radius: 14px;
+ background: rgba(10, 13, 19, 0.96);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: 0 18px 36px rgba(0, 0, 0, 0.34);
+ backdrop-filter: blur(14px);
+}
+
+[data-theme='light'] .signed-in-dropdown {
+ background: rgba(255, 255, 255, 0.95);
+ border-color: rgba(17, 19, 24, 0.08);
+ box-shadow: 0 18px 36px rgba(17, 19, 24, 0.11);
+}
+
+.signed-in-actions a,
+.signed-in-signout {
+ background: rgba(255, 255, 255, 0.03);
+ border-color: rgba(255, 255, 255, 0.07);
+ border-radius: 10px;
+ font-weight: 600;
+}
+
+[data-theme='light'] .signed-in-actions a,
+[data-theme='light'] .signed-in-signout {
+ background: rgba(17, 19, 24, 0.03);
+ border-color: rgba(17, 19, 24, 0.07);
+}
+
+.signed-in-actions a:hover,
+.signed-in-signout:hover {
+ background: rgba(93, 156, 255, 0.08);
+ border-color: rgba(93, 156, 255, 0.22);
+}
+
+.card,
+.admin-card,
+.status-box,
+.summary-card,
+.user-card,
+.timeline-card,
+.modal-card,
+.auth-card,
+.site-banner,
+.system-status,
+.user-detail-card,
+.user-grid-card,
+.profile-section,
+.schedule-card,
+.cache-row,
+.system-item,
+.maintenance-grid > *,
+.history-grid ul,
+.history-grid li {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.028), rgba(255, 255, 255, 0.014));
+ border-color: rgba(255, 255, 255, 0.075);
+}
+
+[data-theme='light'] .card,
+[data-theme='light'] .admin-card,
+[data-theme='light'] .status-box,
+[data-theme='light'] .summary-card,
+[data-theme='light'] .user-card,
+[data-theme='light'] .timeline-card,
+[data-theme='light'] .modal-card,
+[data-theme='light'] .auth-card,
+[data-theme='light'] .site-banner,
+[data-theme='light'] .system-status,
+[data-theme='light'] .user-detail-card,
+[data-theme='light'] .user-grid-card,
+[data-theme='light'] .profile-section,
+[data-theme='light'] .schedule-card,
+[data-theme='light'] .cache-row,
+[data-theme='light'] .system-item {
+ background: rgba(255, 255, 255, 0.8);
+ border-color: rgba(17, 19, 24, 0.08);
+}
+
+.card,
+.admin-card {
+ border-radius: 22px;
+ box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28);
+}
+
+[data-theme='light'] .card,
+[data-theme='light'] .admin-card {
+ box-shadow: 0 14px 28px rgba(17, 19, 24, 0.07);
+}
+
+.lede,
+.section-subtitle,
+.sync-note,
+.user-meta,
+.signed-in-header,
+.admin-nav-title,
+.system-test-message {
+ color: var(--ink-muted);
+}
+
+.admin-shell {
+ gap: 22px;
+}
+
+.admin-shell-nav {
+ position: sticky;
+ top: 20px;
+ align-self: start;
+}
+
+.admin-sidebar {
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.012));
+ box-shadow: 0 18px 34px rgba(0, 0, 0, 0.2);
+}
+
+[data-theme='light'] .admin-sidebar {
+ border-color: rgba(17, 19, 24, 0.08);
+ background: rgba(255, 255, 255, 0.82);
+ box-shadow: 0 12px 22px rgba(17, 19, 24, 0.06);
+}
+
+.admin-sidebar-title {
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ color: #aeb7c6;
+}
+
+[data-theme='light'] .admin-sidebar-title {
+ color: #667085;
+}
+
+.admin-nav-title {
+ letter-spacing: 0.1em;
+ font-weight: 700;
+ color: #8e97a8;
+}
+
+.admin-nav-links a {
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ font-weight: 600;
+}
+
+[data-theme='light'] .admin-nav-links a {
+ background: rgba(17, 19, 24, 0.02);
+ border-color: rgba(17, 19, 24, 0.05);
+}
+
+.admin-nav-links a:hover {
+ background: rgba(93, 156, 255, 0.07);
+ border-color: rgba(93, 156, 255, 0.22);
+}
+
+.admin-nav-links a.is-active {
+ color: #eef4ff;
+ background: linear-gradient(180deg, rgba(93, 156, 255, 0.2), rgba(93, 156, 255, 0.1));
+ border-color: rgba(93, 156, 255, 0.3);
+ box-shadow: inset 0 0 0 1px rgba(93, 156, 255, 0.08);
+}
+
+[data-theme='light'] .admin-nav-links a.is-active {
+ color: #0f2342;
+}
+
+.admin-header h1,
+.section-header h2,
+.request-header h1,
+.auth-card h1,
+.profile-section h2,
+.status-box h2 {
+ letter-spacing: -0.02em;
+ font-weight: 800;
+}
+
+.admin-form,
+.admin-section,
+.profile-section,
+.status-box,
+.summary-card,
+.timeline-card,
+.user-detail-card,
+.user-grid-card {
+ border-radius: 16px;
+}
+
+input,
+select,
+textarea {
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--ink);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
+}
+
+[data-theme='light'] input,
+[data-theme='light'] select,
+[data-theme='light'] textarea {
+ border-color: rgba(17, 19, 24, 0.1);
+ background: rgba(17, 19, 24, 0.025);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ border-color: rgba(93, 156, 255, 0.34);
+ box-shadow: 0 0 0 3px rgba(93, 156, 255, 0.12);
+ outline: none;
+}
+
+button {
+ border-radius: 11px;
+ border: 1px solid rgba(93, 156, 255, 0.28);
+ background: linear-gradient(180deg, rgba(93, 156, 255, 0.2), rgba(93, 156, 255, 0.12));
+ color: #eef4ff;
+ box-shadow: 0 8px 18px rgba(10, 20, 38, 0.18);
+ font-weight: 700;
+}
+
+[data-theme='light'] button {
+ color: #11233f;
+ box-shadow: 0 6px 14px rgba(93, 156, 255, 0.08);
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+ border-color: rgba(93, 156, 255, 0.42);
+ background: linear-gradient(180deg, rgba(93, 156, 255, 0.26), rgba(93, 156, 255, 0.15));
+}
+
+button:disabled {
+ opacity: 0.6;
+ box-shadow: none;
+}
+
+.ghost-button,
+.details-toggle button,
+.recent-grid button,
+.admin-pagination button,
+.system-test,
+.header-actions a,
+.header-actions .header-link {
+ box-shadow: none;
+}
+
+.ghost-button,
+.details-toggle button,
+.recent-grid button,
+.admin-pagination button,
+.system-test {
+ color: var(--ink);
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+[data-theme='light'] .ghost-button,
+[data-theme='light'] .details-toggle button,
+[data-theme='light'] .recent-grid button,
+[data-theme='light'] .admin-pagination button,
+[data-theme='light'] .system-test {
+ background: rgba(17, 19, 24, 0.025);
+ border-color: rgba(17, 19, 24, 0.08);
+}
+
+.ghost-button:hover:not(:disabled),
+.details-toggle button:hover:not(:disabled),
+.recent-grid button:hover:not(:disabled),
+.admin-pagination button:hover:not(:disabled),
+.system-test:hover:not(:disabled) {
+ background: rgba(93, 156, 255, 0.08);
+ border-color: rgba(93, 156, 255, 0.24);
+ color: var(--ink);
+}
+
+.danger-button {
+ border-color: rgba(244, 114, 114, 0.28);
+ background: linear-gradient(180deg, rgba(244, 114, 114, 0.2), rgba(244, 114, 114, 0.11));
+ color: #ffeaea;
+}
+
+[data-theme='light'] .danger-button {
+ color: #5c1717;
+}
+
+.admin-table {
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.015);
+}
+
+[data-theme='light'] .admin-table {
+ border-color: rgba(17, 19, 24, 0.08);
+ background: rgba(255, 255, 255, 0.7);
+}
+
+.admin-table-head {
+ background: rgba(255, 255, 255, 0.02);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+[data-theme='light'] .admin-table-head {
+ background: rgba(17, 19, 24, 0.02);
+ border-bottom-color: rgba(17, 19, 24, 0.06);
+}
+
+.admin-table-row {
+ border-top-color: rgba(255, 255, 255, 0.05);
+}
+
+[data-theme='light'] .admin-table-row {
+ border-top-color: rgba(17, 19, 24, 0.05);
+}
+
+.admin-table-row:hover {
+ background: rgba(93, 156, 255, 0.04);
+ border-color: rgba(93, 156, 255, 0.18);
+}
+
+.user-grid-card:hover,
+.recent-card:hover,
+.summary-card:hover,
+.timeline-card:hover {
+ border-color: rgba(93, 156, 255, 0.2);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme='light'] .user-grid-card:hover,
+[data-theme='light'] .recent-card:hover,
+[data-theme='light'] .summary-card:hover,
+[data-theme='light'] .timeline-card:hover {
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.recent-card,
+.user-grid-card,
+.summary-card,
+.timeline-card,
+.system-item,
+.cache-row,
+.user-detail-controls,
+.user-detail-stat,
+.user-detail-chip {
+ border-radius: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ box-shadow: none;
+}
+
+[data-theme='light'] .recent-card,
+[data-theme='light'] .user-grid-card,
+[data-theme='light'] .summary-card,
+[data-theme='light'] .timeline-card,
+[data-theme='light'] .system-item,
+[data-theme='light'] .cache-row,
+[data-theme='light'] .user-detail-controls,
+[data-theme='light'] .user-detail-stat,
+[data-theme='light'] .user-detail-chip {
+ border-color: rgba(17, 19, 24, 0.08);
+}
+
+.recent-title,
+.user-detail-name,
+.user-grid-header strong,
+.timeline-title,
+.system-name {
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.system-pill,
+.user-grid-pill,
+.user-detail-chip,
+.site-version,
+.signed-in-build {
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
+}
+
+[data-theme='light'] .system-pill,
+[data-theme='light'] .user-grid-pill,
+[data-theme='light'] .user-detail-chip,
+[data-theme='light'] .site-version,
+[data-theme='light'] .signed-in-build {
+ background: rgba(17, 19, 24, 0.02);
+ border-color: rgba(17, 19, 24, 0.08);
+}
+
+.system-pill-up {
+ background: rgba(93, 156, 255, 0.14);
+ border-color: rgba(93, 156, 255, 0.24);
+ color: #d7e8ff;
+}
+
+[data-theme='light'] .system-pill-up {
+ color: #163963;
+}
+
+.system-pill-down,
+.user-grid-pill.is-blocked {
+ background: rgba(244, 114, 114, 0.14);
+ border-color: rgba(244, 114, 114, 0.24);
+ color: #ffd5d5;
+}
+
+.system-pill-degraded,
+.user-grid-pill.is-disabled {
+ background: rgba(208, 166, 92, 0.14);
+ border-color: rgba(208, 166, 92, 0.22);
+ color: #ffe3a6;
+}
+
+.system-dot {
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.03);
+}
+
+.system-up .system-dot {
+ background: #7fb1ff;
+ box-shadow: 0 0 0 3px rgba(93, 156, 255, 0.14);
+}
+
+.system-down .system-dot {
+ background: #f87171;
+ box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.14);
+}
+
+.system-degraded .system-dot,
+.system-not_configured .system-dot {
+ background: #d7ac61;
+ box-shadow: 0 0 0 3px rgba(215, 172, 97, 0.14);
+}
+
+.site-banner {
+ border-radius: 14px;
+ border-width: 1px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.site-banner--info {
+ background: rgba(93, 156, 255, 0.08);
+ border-color: rgba(93, 156, 255, 0.18);
+}
+
+.site-banner--warning {
+ background: rgba(208, 166, 92, 0.1);
+ border-color: rgba(208, 166, 92, 0.22);
+}
+
+.site-banner--error {
+ background: rgba(244, 114, 114, 0.1);
+ border-color: rgba(244, 114, 114, 0.22);
+}
+
+.site-banner--maintenance {
+ background: rgba(148, 163, 184, 0.08);
+ border-color: rgba(148, 163, 184, 0.18);
+}
+
+.request-poster,
+.recent-poster,
+.brand-preview {
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
+}
+
+[data-theme='light'] .request-poster,
+[data-theme='light'] .recent-poster,
+[data-theme='light'] .brand-preview {
+ border-color: rgba(17, 19, 24, 0.08);
+ box-shadow: 0 8px 18px rgba(17, 19, 24, 0.08);
+}
+
+.progress {
+ background: rgba(255, 255, 255, 0.035);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 999px;
+}
+
+[data-theme='light'] .progress {
+ background: rgba(17, 19, 24, 0.03);
+ border-color: rgba(17, 19, 24, 0.05);
+}
+
+.progress-fill {
+ background: linear-gradient(90deg, #5d9cff, #87b5ff);
+}
+
+.spinner {
+ border-color: rgba(255, 255, 255, 0.08);
+ border-top-color: #87b5ff;
+}
+
+.toggle {
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.018);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+[data-theme='light'] .toggle {
+ background: rgba(17, 19, 24, 0.02);
+ border-color: rgba(17, 19, 24, 0.05);
+}
+
+.toggle input[type='checkbox'] {
+ accent-color: #5d9cff;
+}
+
+.user-bulk-toolbar {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 14px;
+ padding: 12px 14px;
+}
+
+[data-theme='light'] .user-bulk-toolbar {
+ background: rgba(17, 19, 24, 0.02);
+ border-color: rgba(17, 19, 24, 0.06);
+}
+
+.user-detail-layout {
+ align-items: start;
+ gap: 16px;
+}
+
+.user-detail-controls {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+}
+
+[data-theme='light'] .user-detail-controls {
+ background: rgba(17, 19, 24, 0.02);
+ border-color: rgba(17, 19, 24, 0.07);
+}
+
+.error-banner,
+.status-banner {
+ border-radius: 12px;
+}
+
+.error-banner {
+ border: 1px solid rgba(244, 114, 114, 0.2);
+ background: rgba(244, 114, 114, 0.1);
+ color: var(--error-ink);
+}
+
+.status-banner {
+ border: 1px solid rgba(74, 222, 128, 0.24);
+ background: rgba(74, 222, 128, 0.12);
+ color: #166534;
+}
+
+[data-theme='dark'] .error-banner {
+ color: #ffd9d9;
+}
+
+[data-theme='dark'] .status-banner {
+ color: #dcfce7;
+}
+
+.auth-card {
+ max-width: 520px;
+ margin-inline: auto;
+}
+
+.auth-form label,
+.admin-grid label {
+ font-weight: 600;
+ color: var(--ink);
+}
+
+@media (max-width: 980px) {
+ .page {
+ padding: 28px 18px 64px;
+ }
+
+ .header {
+ padding: 14px;
+ border-radius: 16px;
+ }
+
+ .brand-logo--header {
+ width: 60px;
+ height: 60px;
+ }
+}
+
+/* Enterprise polish pass */
+[data-theme='dark'] {
+ --accent: #6f95c6;
+ --accent-2: #8aa9d1;
+ --accent-3: #b2c5de;
+}
+
+body {
+ background:
+ radial-gradient(circle at 12% -8%, rgba(111, 149, 198, 0.08), transparent 40%),
+ linear-gradient(180deg, #06070a 0%, #07090d 55%, #06080a 100%);
+}
+
+body::before {
+ opacity: 0.2;
+ background-size: 32px 32px;
+}
+
+.page {
+ max-width: 1280px;
+ gap: 24px;
+}
+
+.header {
+ border-radius: 14px;
+ padding: 14px 16px;
+ backdrop-filter: blur(8px);
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.24);
+}
+
+.brand {
+ font-size: 2rem;
+ letter-spacing: 0.03em;
+}
+
+.tagline {
+ font-size: 0.95rem;
+}
+
+.header-actions a,
+.header-actions .header-link {
+ border-radius: 8px;
+ min-height: 34px;
+ padding: 7px 12px;
+ font-weight: 600;
+}
+
+.header-actions .header-cta {
+ background: rgba(111, 149, 198, 0.14);
+ border-color: rgba(111, 149, 198, 0.28);
+ color: #edf3fb;
+}
+
+[data-theme='light'] .header-actions .header-cta {
+ color: #19324f;
+}
+
+.theme-toggle {
+ border-radius: 10px;
+}
+
+.avatar-button {
+ width: 42px;
+ height: 42px;
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.16);
+}
+
+.signed-in-dropdown {
+ border-radius: 12px;
+}
+
+.signed-in-actions a,
+.signed-in-signout {
+ border-radius: 8px;
+}
+
+.card,
+.admin-card {
+ border-radius: 16px;
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.24);
+}
+
+.admin-shell {
+ gap: 18px;
+}
+
+.admin-sidebar {
+ border-radius: 14px;
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
+}
+
+.admin-sidebar-title {
+ letter-spacing: 0.14em;
+ font-size: 0.78rem;
+}
+
+.admin-nav-title {
+ letter-spacing: 0.12em;
+ font-size: 0.72rem;
+}
+
+.admin-nav-links a {
+ border-radius: 8px;
+ padding: 10px 12px;
+}
+
+.admin-nav-links a.is-active {
+ background: rgba(111, 149, 198, 0.14);
+ color: #edf3fb;
+ border-color: rgba(111, 149, 198, 0.26);
+}
+
+[data-theme='light'] .admin-nav-links a.is-active {
+ color: #183252;
+}
+
+.admin-header {
+ gap: 14px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+[data-theme='light'] .admin-header {
+ border-bottom-color: rgba(17, 19, 24, 0.06);
+}
+
+.admin-header h1,
+.section-header h2,
+.request-header h1 {
+ font-weight: 700;
+}
+
+.admin-form,
+.admin-section,
+.profile-section,
+.status-box,
+.summary-card,
+.timeline-card,
+.user-detail-card,
+.user-grid-card,
+.system-status {
+ border-radius: 12px;
+}
+
+input,
+select,
+textarea {
+ border-radius: 8px;
+ min-height: 40px;
+ background: rgba(255, 255, 255, 0.022);
+}
+
+textarea {
+ min-height: 92px;
+}
+
+[data-theme='light'] input,
+[data-theme='light'] select,
+[data-theme='light'] textarea {
+ background: rgba(17, 19, 24, 0.02);
+}
+
+button {
+ border-radius: 8px;
+ min-height: 36px;
+ padding: 8px 12px;
+ background: rgba(111, 149, 198, 0.15);
+ border-color: rgba(111, 149, 198, 0.28);
+ color: #eef2f7;
+}
+
+[data-theme='light'] button {
+ color: #16304d;
+}
+
+button:hover:not(:disabled) {
+ background: rgba(111, 149, 198, 0.22);
+ border-color: rgba(111, 149, 198, 0.36);
+ transform: none;
+}
+
+.ghost-button,
+.details-toggle button,
+.recent-grid button,
+.admin-pagination button,
+.system-test {
+ background: rgba(255, 255, 255, 0.018);
+ border-radius: 8px;
+}
+
+[data-theme='light'] .ghost-button,
+[data-theme='light'] .details-toggle button,
+[data-theme='light'] .recent-grid button,
+[data-theme='light'] .admin-pagination button,
+[data-theme='light'] .system-test {
+ background: rgba(17, 19, 24, 0.018);
+}
+
+.danger-button {
+ background: rgba(214, 93, 93, 0.14);
+ border-color: rgba(214, 93, 93, 0.28);
+}
+
+.admin-table {
+ border-radius: 12px;
+}
+
+.admin-table-head {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.admin-table-row,
+.cache-row {
+ border-radius: 8px;
+}
+
+.admin-table-row:hover {
+ background: rgba(111, 149, 198, 0.05);
+ border-color: rgba(111, 149, 198, 0.16);
+}
+
+.user-grid {
+ gap: 12px;
+}
+
+.user-grid-card {
+ border-radius: 10px;
+}
+
+.user-grid-meta,
+.user-meta,
+.user-detail-helper,
+.user-bulk-summary span,
+.system-test-message,
+.timeline-sublist,
+.request-meta,
+.recent-meta {
+ color: #98a1af;
+}
+
+.user-detail-chip,
+.site-version,
+.signed-in-build,
+.system-pill,
+.user-grid-pill {
+ border-radius: 999px;
+ font-size: 0.75rem;
+}
+
+.user-detail-controls,
+.user-detail-stat,
+.user-detail-chip,
+.system-item,
+.recent-card,
+.timeline-card,
+.summary-card {
+ border-radius: 10px;
+}
+
+.user-detail-grid {
+ gap: 10px;
+}
+
+.user-detail-stat {
+ padding: 12px;
+}
+
+.site-banner {
+ border-radius: 10px;
+ padding: 12px 14px;
+}
+
+.toggle {
+ border-radius: 8px;
+ padding: 4px 6px;
+}
+
+.progress {
+ height: 10px;
+}
+
+.spinner {
+ border-width: 3px;
+}
+
+@media (max-width: 980px) {
+ .header {
+ border-radius: 12px;
+ padding: 12px;
+ }
+
+ .card,
+ .admin-card {
+ border-radius: 14px;
+ }
+}
+
+.admin-inline-actions {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.admin-split-grid {
+ display: grid;
+ grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.admin-panel {
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 10px;
+ padding: 14px;
+}
+
+.admin-panel h2 {
+ margin: 0 0 6px;
+ font-size: 1rem;
+}
+
+.admin-panel .lede {
+ margin: 0 0 12px;
+}
+
+.compact-form {
+ gap: 12px;
+}
+
+.compact-form textarea {
+ min-height: 84px;
+ resize: vertical;
+}
+
+.admin-fields-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.inline-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.inline-checkbox input[type='checkbox'] {
+ width: 16px;
+ height: 16px;
+}
+
+.admin-list {
+ display: grid;
+ gap: 10px;
+}
+
+.admin-list-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: start;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 10px;
+ padding: 10px 12px;
+}
+
+.admin-list-item-main {
+ min-width: 0;
+ display: grid;
+ gap: 6px;
+}
+
+.admin-list-item-title-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.admin-list-item-text {
+ margin: 0;
+ color: #d0d6df;
+ line-height: 1.35;
+}
+
+.admin-list-item-text--muted {
+ color: #9ea7b6;
+}
+
+.admin-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ color: #9ea7b6;
+ font-size: 0.83rem;
+}
+
+.small-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.09);
+ background: rgba(255, 255, 255, 0.03);
+ font-size: 0.72rem;
+ color: #d0d6df;
+}
+
+.small-pill.is-muted {
+ color: #9ea7b6;
+ border-color: rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.015);
+}
+
+.invite-code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+ padding: 3px 8px;
+ border-radius: 8px;
+ border: 1px solid rgba(111, 149, 198, 0.2);
+ background: rgba(111, 149, 198, 0.07);
+ color: #c8d7ec;
+}
+
+.invite-lookup-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+}
+
+.invite-summary {
+ display: grid;
+ gap: 8px;
+ border: 1px solid rgba(111, 149, 198, 0.18);
+ background: rgba(111, 149, 198, 0.06);
+ border-radius: 10px;
+ padding: 10px 12px;
+}
+
+.invite-summary.is-disabled {
+ border-color: rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.invite-summary p {
+ margin: 0;
+}
+
+.invite-summary-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.user-bulk-toolbar--stacked {
+ align-items: stretch;
+}
+
+.user-bulk-groups {
+ display: grid;
+ gap: 10px;
+ width: 100%;
+}
+
+.user-bulk-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: flex-end;
+}
+
+.user-bulk-group > label {
+ display: grid;
+ gap: 6px;
+ min-width: 220px;
+ flex: 1 1 220px;
+}
+
+.user-bulk-label {
+ font-size: 0.78rem;
+ color: #9ea7b6;
+}
+
+.user-bulk-group input,
+.user-bulk-group select {
+ width: 100%;
+}
+
+.user-detail-actions--stacked {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.user-detail-actions--stacked > label {
+ display: grid;
+ gap: 6px;
+}
+
+@media (max-width: 980px) {
+ .admin-split-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-fields-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-list-item {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-lookup-row {
+ grid-template-columns: 1fr;
+ }
+
+ .user-bulk-group {
+ align-items: stretch;
+ }
+
+ .user-bulk-group > label {
+ min-width: 100%;
+ flex-basis: 100%;
+ }
+}
+
+/* 1.3 UI layout cleanup: users + invite management */
+.user-directory-control-grid {
+ display: grid;
+ grid-template-columns: 1.2fr minmax(320px, 0.8fr);
+ gap: 12px;
+ margin-top: 12px;
+ margin-bottom: 12px;
+ align-items: start;
+}
+
+.user-directory-search-panel,
+.user-directory-bulk-panel {
+ display: grid;
+ gap: 10px;
+}
+
+.user-directory-panel-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.user-directory-panel-header h2 {
+ margin: 0;
+ font-size: 0.98rem;
+ letter-spacing: 0.01em;
+}
+
+.user-directory-panel-header .lede {
+ margin: 4px 0 0;
+ max-width: 46ch;
+}
+
+.user-directory-toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px;
+ align-items: flex-end;
+}
+
+.user-directory-search {
+ width: 100%;
+}
+
+.user-directory-search > label {
+ display: grid;
+ gap: 6px;
+}
+
+.user-directory-search input {
+ width: 100%;
+}
+
+.user-bulk-toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.user-bulk-summary {
+ display: grid;
+ gap: 4px;
+}
+
+.user-bulk-summary strong {
+ font-size: 0.92rem;
+}
+
+.user-bulk-summary span {
+ color: #9ea7b6;
+ font-size: 0.82rem;
+}
+
+.user-bulk-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.user-directory-list {
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.user-directory-header {
+ display: grid;
+ grid-template-columns: 1.3fr 1.35fr 1fr 1.05fr auto;
+ gap: 10px;
+ padding: 12px 14px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ color: #9ea7b6;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.user-directory-row {
+ display: grid;
+ grid-template-columns: 1.3fr 1.35fr 1fr 1.05fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 13px 14px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+ color: inherit;
+ text-decoration: none;
+ transition: background-color 120ms ease, border-color 120ms ease;
+}
+
+.user-directory-row:first-of-type {
+ border-top: 0;
+}
+
+.user-directory-row:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.user-directory-cell {
+ min-width: 0;
+ display: grid;
+ gap: 5px;
+}
+
+.user-directory-cell--identity .user-directory-title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.user-directory-cell--identity strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.user-directory-subtext {
+ color: #98a1af;
+ font-size: 0.78rem;
+ line-height: 1.3;
+}
+
+.user-directory-pill-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.user-directory-stats-inline {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px 10px;
+ color: #b8c0cc;
+ font-size: 0.78rem;
+}
+
+.user-directory-stats-inline strong {
+ color: #eef2f7;
+ font-weight: 700;
+}
+
+.user-directory-row-chevron {
+ color: #a9b2c2;
+ font-size: 0.76rem;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 999px;
+ padding: 4px 10px;
+ white-space: nowrap;
+}
+
+.user-detail-page-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.user-detail-main-column,
+.user-detail-side-column {
+ display: grid;
+ gap: 14px;
+}
+
+.user-detail-panel {
+ display: grid;
+ gap: 12px;
+}
+
+.user-detail-panel-header {
+ display: grid;
+ gap: 6px;
+}
+
+.user-detail-panel-header h2 {
+ margin: 0;
+ font-size: 0.98rem;
+}
+
+.user-detail-panel-header .lede {
+ margin: 0;
+}
+
+.user-detail-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.user-detail-meta-item {
+ display: grid;
+ gap: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 10px;
+ padding: 10px 11px;
+}
+
+.user-detail-meta-item .label {
+ color: #9ea7b6;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.user-detail-meta-item strong {
+ font-size: 0.9rem;
+ color: #e7ebf1;
+ line-height: 1.3;
+ overflow-wrap: anywhere;
+}
+
+.user-detail-control-stack {
+ display: grid;
+ gap: 8px;
+}
+
+.user-detail-control-stack .toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.015);
+}
+
+.user-detail-control-stack > button {
+ justify-self: start;
+}
+
+.user-detail-grid {
+ gap: 10px;
+}
+
+.user-detail-stat {
+ border-radius: 10px;
+}
+
+.invite-admin-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.invite-admin-summary-tile {
+ min-height: 96px;
+}
+
+.admin-segmented {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 5px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px;
+ margin-bottom: 12px;
+}
+
+.admin-segmented button {
+ border: 1px solid transparent;
+ background: transparent;
+ color: #b6bfcb;
+ padding: 8px 12px;
+ border-radius: 9px;
+ font-size: 0.84rem;
+ font-weight: 600;
+}
+
+.admin-segmented button:hover {
+ background: rgba(255, 255, 255, 0.03);
+ color: #e3e8ef;
+}
+
+.admin-segmented button.is-active {
+ background: rgba(96, 132, 179, 0.14);
+ border-color: rgba(96, 132, 179, 0.25);
+ color: #e7eef9;
+}
+
+.invite-admin-bulk-grid {
+ grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr);
+}
+
+.invite-admin-summary-panel {
+ display: grid;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.invite-admin-summary-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.invite-admin-summary-header h2 {
+ margin: 0;
+ font-size: 0.98rem;
+}
+
+.invite-admin-summary-header .lede {
+ margin: 4px 0 0;
+}
+
+.invite-admin-summary-list {
+ display: grid;
+ gap: 6px;
+}
+
+.invite-admin-summary-row {
+ display: grid;
+ grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+ padding: 8px 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.015);
+}
+
+.invite-admin-summary-row .label {
+ font-size: 0.78rem;
+ color: #9ea7b6;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.invite-admin-summary-row__value {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px 12px;
+}
+
+.invite-admin-summary-row__value strong {
+ color: #eef2f7;
+ font-size: 1rem;
+}
+
+.invite-admin-summary-row__value span {
+ color: #b3bcc8;
+ font-size: 0.82rem;
+}
+
+.invite-admin-tabbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px 12px;
+ margin-bottom: 12px;
+}
+
+.invite-admin-tabbar .admin-segmented {
+ margin-bottom: 0;
+ width: max-content;
+ max-width: 100%;
+}
+
+.invite-admin-tab-actions {
+ width: auto;
+ justify-content: flex-end;
+ align-self: center;
+}
+
+.invite-admin-stack {
+ display: grid;
+ gap: 12px;
+}
+
+.invite-admin-bulk-panel .user-bulk-groups {
+ display: grid;
+ gap: 10px;
+}
+
+.invite-admin-list-panel,
+.invite-admin-form-panel {
+ width: 100%;
+}
+
+.profile-form-layout .invite-form-row-control > label {
+ display: grid;
+ gap: 6px;
+}
+
+.profile-form-layout .invite-form-row-control > label > span {
+ color: #9ea7b6;
+ font-size: 0.76rem;
+}
+
+.profile-form-layout .admin-inline-actions {
+ justify-content: flex-end;
+}
+
+.invite-form-layout {
+ gap: 10px;
+}
+
+.invite-form-row {
+ display: grid;
+ grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+ padding: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 10px;
+}
+
+.invite-form-row-label {
+ display: grid;
+ gap: 4px;
+}
+
+.invite-form-row-label > span {
+ color: #e6ebf2;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.invite-form-row-label > small {
+ color: #9ea7b6;
+ font-size: 0.76rem;
+ line-height: 1.3;
+}
+
+.invite-form-row-control {
+ display: grid;
+ gap: 8px;
+}
+
+.invite-form-row-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.invite-form-row-grid > label {
+ display: grid;
+ gap: 6px;
+}
+
+.invite-form-row-grid > label > span {
+ color: #9ea7b6;
+ font-size: 0.76rem;
+}
+
+.invite-form-row-control textarea,
+.invite-form-row-control input,
+.invite-form-row-control select {
+ width: 100%;
+}
+
+.invite-form-row-control--stacked {
+ gap: 10px;
+}
+
+.invite-email-template-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.invite-email-template-picker button {
+ border-radius: 8px;
+}
+
+.invite-email-template-picker button.is-active {
+ border-color: rgba(135, 182, 255, 0.4);
+ background: rgba(86, 132, 220, 0.14);
+ color: #eef2f7;
+}
+
+.invite-email-template-meta {
+ display: grid;
+ gap: 4px;
+ margin-bottom: 10px;
+}
+
+.invite-email-template-meta h3 {
+ margin: 0;
+}
+
+.invite-email-placeholder-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.invite-email-placeholder-list code {
+ padding: 6px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: #d6dde8;
+ font-size: 0.78rem;
+}
+
+.admin-panel > h2 + .lede {
+ margin-top: -2px;
+}
+
+@media (max-width: 1180px) {
+ .user-directory-control-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .user-detail-page-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-admin-summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .invite-admin-bulk-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-form-row-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 980px) {
+ .user-bulk-toolbar {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+
+ .user-bulk-actions {
+ justify-content: flex-start;
+ }
+
+ .user-directory-header {
+ display: none;
+ }
+
+ .user-directory-row {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ align-items: start;
+ }
+
+ .user-directory-cell {
+ gap: 6px;
+ }
+
+ .user-directory-row-chevron {
+ justify-self: start;
+ }
+
+ .user-detail-meta-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-admin-summary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-admin-summary-row {
+ grid-template-columns: 1fr;
+ align-items: start;
+ }
+
+ .invite-admin-summary-row__value {
+ justify-content: flex-start;
+ }
+
+ .invite-admin-tabbar {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+
+ .invite-admin-tab-actions {
+ justify-content: flex-start;
+ }
+}
+
+/* Enterprise UI tightening pass */
+.admin-panel,
+.user-detail-panel,
+.user-directory-search-panel,
+.user-directory-bulk-panel,
+.user-directory-table,
+.invite-admin-summary-panel,
+.invite-admin-summary-row,
+.invite-form-row,
+.admin-list-item,
+.status-banner,
+.error-banner,
+.admin-segmented {
+ border-radius: 6px;
+}
+
+.admin-segmented button,
+.small-pill,
+.user-bulk-summary span,
+.admin-inline-actions button,
+.ghost-button,
+button,
+input,
+select,
+textarea {
+ border-radius: 5px;
+}
+
+.signed-in-dropdown,
+.modal-card,
+.card,
+.admin-card,
+.summary-card {
+ border-radius: 8px;
+}
+
+.avatar-button,
+.theme-toggle {
+ border-radius: 50%;
+}
+
+.invite-trace-toolbar {
+ display: grid;
+ grid-template-columns: minmax(260px, 1fr) auto;
+ grid-template-areas:
+ 'filter controls'
+ 'summary summary';
+ gap: 10px 14px;
+ align-items: flex-end;
+ margin-bottom: 10px;
+}
+
+.invite-trace-filter {
+ grid-area: filter;
+ display: grid;
+ gap: 6px;
+}
+
+.invite-trace-filter > span {
+ color: #9ea7b6;
+ font-size: 0.76rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.invite-trace-summary {
+ grid-area: summary;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ color: #aeb7c4;
+ font-size: 0.8rem;
+}
+
+.invite-trace-summary span {
+ padding: 4px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 5px;
+}
+
+.invite-trace-controls {
+ grid-area: controls;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: flex-end;
+ gap: 10px;
+}
+
+.invite-trace-scope {
+ display: grid;
+ gap: 6px;
+ min-width: 190px;
+}
+
+.invite-trace-scope > span {
+ color: #9ea7b6;
+ font-size: 0.76rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.invite-trace-view-toggle {
+ display: inline-flex;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 5px;
+ overflow: hidden;
+ margin: 0;
+ min-inline-size: 0;
+ padding: 0;
+}
+
+.invite-trace-view-toggle legend {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
+
+.invite-trace-view-toggle button {
+ min-width: 90px;
+ border: 0;
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 0;
+ background: transparent;
+ color: #aeb7c4;
+ padding: 7px 12px;
+}
+
+.invite-trace-view-toggle button:last-child {
+ border-right: 0;
+}
+
+.invite-trace-view-toggle button.is-active {
+ background: rgba(111, 148, 224, 0.22);
+ color: #eef3fb;
+ font-weight: 700;
+}
+
+.invite-trace-map {
+ display: grid;
+ gap: 8px;
+}
+
+.invite-trace-graph {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(260px, 1fr);
+ align-items: start;
+ gap: 10px;
+ overflow-x: auto;
+ padding-bottom: 2px;
+}
+
+.invite-trace-column {
+ display: grid;
+ gap: 8px;
+ align-content: start;
+ min-width: 260px;
+}
+
+.invite-trace-column-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 5px;
+ color: #b5c0d2;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.invite-trace-column-header strong {
+ color: #edf2f8;
+ font-size: 0.86rem;
+}
+
+.invite-trace-column-body {
+ display: grid;
+ gap: 8px;
+}
+
+.invite-trace-node {
+ display: grid;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.018);
+ border-radius: 6px;
+}
+
+.invite-trace-node-main {
+ display: grid;
+ gap: 6px;
+}
+
+.invite-trace-node-title {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+
+.invite-trace-node-arrow {
+ margin: 0;
+ color: #c4cfdd;
+ font-size: 0.82rem;
+ letter-spacing: 0.01em;
+}
+
+.invite-trace-node-arrow.is-root {
+ color: #95a2b5;
+}
+
+.invite-trace-node-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.invite-trace-node-meta-item {
+ display: grid;
+ gap: 2px;
+ align-content: start;
+ min-height: 44px;
+ padding: 6px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ background: rgba(255, 255, 255, 0.01);
+ border-radius: 5px;
+}
+
+.invite-trace-node-meta-item .label {
+ color: #8f9aac;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.invite-trace-node-meta-item strong {
+ color: #e7edf6;
+ font-size: 0.81rem;
+ word-break: break-word;
+}
+
+.invite-trace-row {
+ display: grid;
+ grid-template-columns: minmax(260px, 420px) minmax(0, 1fr);
+ gap: 10px 12px;
+ align-items: start;
+ padding: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 6px;
+}
+
+.invite-trace-row-main {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ min-height: 28px;
+}
+
+.invite-trace-branch {
+ width: 12px;
+ height: 1px;
+ background: rgba(138, 163, 196, 0.55);
+ position: relative;
+ margin-right: 2px;
+}
+
+.invite-trace-branch::before {
+ content: '';
+ position: absolute;
+ left: -8px;
+ top: -7px;
+ width: 8px;
+ height: 8px;
+ border-left: 1px solid rgba(138, 163, 196, 0.38);
+ border-bottom: 1px solid rgba(138, 163, 196, 0.38);
+}
+
+.invite-trace-user {
+ color: #edf2f8;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+}
+
+.invite-trace-row-meta {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.invite-trace-meta-item {
+ display: grid;
+ gap: 3px;
+ align-content: start;
+ padding: 6px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ background: rgba(255, 255, 255, 0.01);
+ border-radius: 5px;
+ min-height: 48px;
+}
+
+.invite-trace-meta-item .label {
+ color: #8f9aac;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.invite-trace-meta-item strong {
+ color: #e7edf6;
+ font-size: 0.82rem;
+ word-break: break-word;
+}
+
+@media (max-width: 1180px) {
+ .invite-trace-toolbar {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ 'filter'
+ 'controls'
+ 'summary';
+ align-items: stretch;
+ }
+
+ .invite-trace-controls {
+ justify-content: flex-start;
+ }
+
+ .invite-trace-summary {
+ justify-content: flex-start;
+ }
+
+ .invite-trace-graph {
+ grid-auto-flow: row;
+ grid-auto-columns: 1fr;
+ }
+
+ .invite-trace-column {
+ min-width: 0;
+ }
+
+ .invite-trace-row {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-trace-row-meta {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 720px) {
+ .invite-trace-node-meta {
+ grid-template-columns: 1fr;
+ }
+
+ .invite-trace-row-meta {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Profile self-service invite management */
+.profile-tabbar {
+ display: flex;
+ justify-content: flex-start;
+ margin-top: 4px;
+}
+
+.profile-tab-panel {
+ margin-top: 2px;
+}
+
+.profile-security-form {
+ margin-top: 10px;
+}
+
+.profile-quick-link-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: start;
+ gap: 14px;
+ padding: 12px;
+ margin-bottom: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.018);
+ border-radius: 6px;
+}
+
+.profile-quick-link-card h2 {
+ margin: 0 0 4px;
+}
+
+.profile-quick-link-card .lede {
+ margin: 0;
+}
+
+.profile-invites-section {
+ display: grid;
+ gap: 12px;
+}
+
+.profile-invites-layout {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.profile-invites-list {
+ display: grid;
+ gap: 10px;
+ min-width: 0;
+}
+
+.profile-invite-form-card {
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.018);
+ border-radius: 6px;
+ padding: 12px;
+ display: grid;
+ gap: 10px;
+ min-width: 0;
+}
+
+.profile-invite-form-card h3 {
+ font-size: 1rem;
+ color: #edf2f8;
+}
+
+.profile-invite-form-lede,
+.profile-invite-hint {
+ color: #9ea7b6;
+}
+
+.profile-invite-hint code {
+ color: #d8e2ef;
+}
+
+.profile-invite-master-banner code {
+ color: #e6eefb;
+}
+
+.user-bulk-group-meta {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.user-bulk-group-meta strong {
+ color: #e7edf6;
+}
+
+/* Admin system guide */
+.system-guide {
+ display: grid;
+ gap: 12px;
+}
+
+.system-flow-track {
+ display: grid;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.system-flow-segment {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+}
+
+.system-flow-card {
+ display: grid;
+ gap: 7px;
+ padding: 11px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 6px;
+}
+
+.system-flow-card-title {
+ color: #edf2f8;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+}
+
+.system-flow-card-row {
+ display: grid;
+ grid-template-columns: 86px minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+}
+
+.system-flow-card-row span {
+ color: #8f9aac;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.system-flow-card-row strong {
+ color: #dfe8f5;
+ font-size: 0.86rem;
+ font-weight: 600;
+}
+
+.system-flow-arrow {
+ color: #7ea1d8;
+ font-size: 1.2rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.system-guide-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.system-guide-card {
+ padding: 11px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 6px;
+ display: grid;
+ gap: 6px;
+}
+
+.system-guide-card h3 {
+ color: #eef3fb;
+ font-size: 0.97rem;
+}
+
+.system-guide-card p {
+ color: #a7b2c2;
+ margin: 0;
+}
+
+.system-decision-list {
+ list-style: none;
+ margin: 8px 0 0;
+ padding: 0;
+ display: grid;
+ gap: 7px;
+}
+
+.system-decision-list li {
+ padding: 9px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.015);
+ border-radius: 6px;
+ color: #d3dce9;
+}
+
+.system-decision-list li span {
+ color: #7ea1d8;
+ font-weight: 700;
+ margin: 0 5px;
+}
+
+.system-decision-list li strong {
+ color: #eff5ff;
+}
+
+@media (max-width: 980px) {
+ .profile-quick-link-card {
+ display: grid;
+ }
+
+ .profile-invites-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+.admin-grid label.field-span-full {
+ grid-column: 1 / -1;
+}
+
+/* Admin shell right rail */
+.admin-shell {
+ grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
+ gap: 22px;
+ align-items: start;
+}
+
+.admin-shell-nav {
+ grid-column: 1;
+}
+
+.admin-card {
+ grid-column: 2;
+ min-width: 0;
+}
+
+.admin-shell-rail {
+ grid-column: 3;
+ position: sticky;
+ top: 20px;
+ align-self: start;
+ display: grid;
+ gap: 10px;
+ min-width: 0;
+}
+
+.admin-rail-stack {
+ display: grid;
+ gap: 10px;
+}
+
+.admin-rail-card {
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.016);
+ border-radius: 8px;
+ padding: 12px;
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.admin-rail-card h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.admin-rail-card p {
+ margin: 0;
+ color: #9ba5b5;
+}
+
+.admin-rail-eyebrow {
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #9ba5b5;
+ font-weight: 700;
+}
+
+.admin-shell-rail .invite-admin-summary-row {
+ grid-template-columns: 1fr;
+ align-items: start;
+}
+
+.admin-shell-rail .invite-admin-summary-row__value {
+ justify-content: space-between;
+}
+
+.cache-rail-card {
+ gap: 10px;
+}
+
+.cache-rail-metrics {
+ display: grid;
+ gap: 8px;
+}
+
+.cache-rail-metric {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.012);
+ padding: 8px 10px;
+ border-radius: 6px;
+}
+
+.cache-rail-metric span {
+ color: #9aa4b4;
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.cache-rail-metric strong {
+ color: #eef3f9;
+ font-size: 0.92rem;
+ text-align: right;
+ overflow-wrap: anywhere;
+}
+
+.cache-rail-limit {
+ display: grid;
+ gap: 6px;
+}
+
+.cache-rail-limit > span {
+ color: #9aa4b4;
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Users page streamline pass */
+.users-page-toolbar {
+ margin-bottom: 12px;
+}
+
+.users-page-toolbar-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.users-page-toolbar-group {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.016);
+ border-radius: 6px;
+}
+
+.users-page-toolbar-label {
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #9ba5b5;
+ font-weight: 700;
+}
+
+.users-page-toolbar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.users-page-toolbar-actions button {
+ white-space: nowrap;
+}
+
+.users-page-overview-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
+ gap: 12px;
+ margin: 12px 0;
+ align-items: start;
+}
+
+.users-summary-panel {
+ display: grid;
+ gap: 10px;
+}
+
+.users-rail-summary .users-summary-grid {
+ grid-template-columns: 1fr;
+}
+
+.users-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.users-summary-card {
+ min-width: 0;
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.014);
+ border-radius: 6px;
+}
+
+.users-summary-row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.users-summary-label {
+ color: #a9b3c2;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.users-summary-value {
+ color: #edf3fb;
+ font-size: 1.12rem;
+ line-height: 1;
+ font-weight: 700;
+}
+
+.users-summary-meta {
+ margin: 0;
+ color: #98a3b4;
+ font-size: 0.78rem;
+ line-height: 1.35;
+}
+
+.user-directory-search-panel {
+ margin-bottom: 12px;
+}
+
+.user-directory-bulk-panel .user-bulk-toolbar {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: stretch;
+ gap: 10px;
+}
+
+.user-directory-bulk-panel .user-bulk-summary {
+ display: grid;
+ gap: 4px;
+ align-content: start;
+ min-width: 0;
+}
+
+.user-directory-bulk-panel .user-bulk-summary strong {
+ line-height: 1.32;
+ overflow-wrap: anywhere;
+}
+
+.user-directory-bulk-panel .user-bulk-actions {
+ align-self: start;
+ justify-content: flex-start;
+}
+
+.user-directory-bulk-panel .user-bulk-actions button {
+ min-width: 190px;
+}
+
+@media (max-width: 1400px) {
+ .admin-shell {
+ grid-template-columns: minmax(210px, 250px) minmax(0, 1fr) minmax(270px, 320px);
+ }
+}
+
+@media (max-width: 980px) {
+ .admin-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-shell-nav,
+ .admin-card,
+ .admin-shell-rail {
+ grid-column: 1;
+ }
+
+ .users-page-toolbar-grid,
+ .users-summary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .users-page-toolbar-actions button {
+ flex: 1 1 220px;
+ }
+
+ .user-directory-bulk-panel .user-bulk-actions {
+ width: 100%;
+ }
+
+ .user-directory-bulk-panel .user-bulk-actions button {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+/* Final header account menu stacking override (must be last) */
+.page,
+.header,
+.header-left,
+.header-right,
+.header-nav,
+.header-actions,
+.signed-in-menu {
+ overflow: visible !important;
+}
+
+.header {
+ position: relative !important;
+ isolation: isolate;
+ z-index: 20 !important;
+}
+
+.header-nav,
+.header-actions {
+ position: relative;
+ z-index: 1 !important;
+}
+
+.header-actions a,
+.header-actions .header-link {
+ position: relative;
+ z-index: 1;
+}
+
+.header-right {
+ position: relative !important;
+ z-index: 4000 !important;
+}
+
+.signed-in-menu {
+ position: relative !important;
+ z-index: 4500 !important;
+}
+
+.signed-in-dropdown {
+ position: absolute !important;
+ z-index: 5000 !important;
+}
+
+/* Final width scaling */
+.page {
+ width: min(1680px, calc(100vw - 32px));
+ max-width: 1680px;
+ padding-inline: 16px;
+}
+
+@media (max-width: 1280px) {
+ .page {
+ width: min(1480px, calc(100vw - 24px));
+ max-width: 1480px;
+ padding-inline: 12px;
+ }
+
+ .admin-shell {
+ grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
+ }
+
+ .admin-shell-rail {
+ grid-column: 2;
+ position: static;
+ top: auto;
+ }
+}
+
+@media (max-width: 980px) {
+ .page {
+ width: min(100%, calc(100vw - 12px));
+ max-width: none;
+ padding-inline: 6px;
+ }
+
+ .admin-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-shell-nav,
+ .admin-card,
+ .admin-shell-rail {
+ grid-column: 1;
+ }
+}
+
+.diagnostics-page {
+ display: grid;
+ gap: 1.25rem;
+}
+
+.diagnostics-header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.diagnostics-control-panel {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.diagnostics-control-copy {
+ max-width: 52rem;
+}
+
+.diagnostics-control-actions {
+ display: flex;
+ align-items: end;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.diagnostics-email-recipient {
+ display: grid;
+ gap: 0.35rem;
+ min-width: min(100%, 20rem);
+ flex: 1 1 20rem;
+}
+
+.diagnostics-email-recipient span {
+ color: var(--ink-muted);
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+
+.diagnostics-inline-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
+ gap: 0.85rem;
+ align-items: stretch;
+}
+
+.diagnostics-inline-metric {
+ display: grid;
+ gap: 0.2rem;
+ min-width: 0;
+ padding: 0.85rem 0.95rem;
+ border-radius: 0.85rem;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.diagnostics-inline-metric span {
+ color: var(--ink-muted);
+ font-size: 0.76rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.diagnostics-inline-metric strong {
+ font-size: 1rem;
+ line-height: 1.2;
+}
+
+.diagnostics-inline-last-run {
+ grid-column: 1 / -1;
+ color: var(--ink-muted);
+ font-size: 0.9rem;
+}
+
+.diagnostics-control-actions .is-active {
+ border-color: rgba(92, 141, 255, 0.44);
+ background: rgba(92, 141, 255, 0.12);
+}
+
+.diagnostics-error {
+ color: #ffb4b4;
+ border-color: rgba(255, 118, 118, 0.32);
+ background: rgba(96, 20, 20, 0.28);
+}
+
+.diagnostics-category-panel {
+ display: grid;
+ gap: 1rem;
+}
+
+.diagnostics-category-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.diagnostics-category-header h2 {
+ margin: 0 0 0.35rem;
+}
+
+.diagnostics-category-header p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.diagnostics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
+ gap: 1rem;
+}
+
+.diagnostic-card {
+ display: grid;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)),
+ rgba(8, 12, 20, 0.82);
+}
+
+.diagnostic-card-up {
+ border-color: rgba(78, 201, 140, 0.28);
+}
+
+.diagnostic-card-down {
+ border-color: rgba(255, 116, 116, 0.26);
+}
+
+.diagnostic-card-degraded {
+ border-color: rgba(255, 194, 99, 0.24);
+}
+
+.diagnostic-card-disabled,
+.diagnostic-card-not_configured {
+ border-color: rgba(161, 173, 192, 0.2);
+}
+
+.diagnostic-card-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.diagnostic-card-copy {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.diagnostic-card-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.diagnostic-card-title-row h3 {
+ margin: 0;
+ font-size: 1.05rem;
+}
+
+.diagnostic-card-copy p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.diagnostic-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.diagnostic-meta-item {
+ display: grid;
+ gap: 0.2rem;
+ min-width: 0;
+ padding: 0.75rem;
+ border-radius: 0.85rem;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.diagnostic-meta-item span {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+}
+
+.diagnostic-meta-item strong {
+ font-size: 0.95rem;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.diagnostic-message {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ min-height: 2.8rem;
+ padding: 0.8rem 0.95rem;
+ border-radius: 0.9rem;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.diagnostic-message-up {
+ background: rgba(40, 95, 66, 0.22);
+}
+
+.diagnostic-message-down {
+ background: rgba(105, 33, 33, 0.24);
+}
+
+.diagnostic-message-degraded {
+ background: rgba(115, 82, 27, 0.2);
+}
+
+.diagnostic-message-disabled,
+.diagnostic-message-not_configured,
+.diagnostic-message-idle {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.diagnostic-detail-panel {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.diagnostic-detail-group {
+ display: grid;
+ gap: 0.6rem;
+}
+
+.diagnostic-detail-group h4 {
+ margin: 0;
+ font-size: 0.86rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.diagnostic-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+ gap: 0.7rem;
+}
+
+.diagnostic-detail-item {
+ display: grid;
+ gap: 0.2rem;
+ min-width: 0;
+ padding: 0.75rem;
+ border-radius: 0.8rem;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.diagnostic-detail-item span {
+ font-size: 0.76rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.diagnostic-detail-item strong {
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+
+.diagnostics-rail-metrics {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.diagnostics-rail-metric {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.diagnostics-rail-metric span {
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.diagnostics-rail-metric strong {
+ font-size: 1rem;
+}
+
+.diagnostics-rail-status {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.diagnostics-rail-last-run {
+ margin: 0.85rem 0 0;
+}
+
+.small-pill.is-positive {
+ border-color: rgba(78, 201, 140, 0.34);
+ color: rgba(206, 255, 227, 0.92);
+ background: rgba(31, 92, 62, 0.22);
+}
+
+.system-pill-idle,
+.system-pill-not_configured,
+.system-pill-disabled {
+ color: rgba(224, 230, 239, 0.84);
+ background: rgba(129, 141, 158, 0.18);
+ border-color: rgba(129, 141, 158, 0.26);
+}
+
+.system-disabled .system-dot {
+ background: rgba(151, 164, 184, 0.76);
+}
+
+@media (max-width: 1024px) {
+ .diagnostics-control-panel,
+ .diagnostic-card-top,
+ .diagnostics-category-header {
+ flex-direction: column;
+ }
+
+ .settings-section-actions {
+ justify-content: stretch;
+ }
+
+ .settings-section-actions > * {
+ width: 100%;
+ }
+}
+
+@media (max-width: 720px) {
+ .diagnostic-meta-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Final responsive admin shell stabilization */
+.admin-shell,
+.admin-shell-nav,
+.admin-card,
+.admin-shell-rail,
+.admin-sidebar,
+.admin-panel {
+ min-width: 0;
+}
+
+@media (max-width: 1280px) {
+ .admin-shell {
+ grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
+ grid-template-areas:
+ "nav main"
+ "nav rail";
+ align-items: start;
+ }
+
+ .admin-shell-nav {
+ grid-area: nav;
+ }
+
+ .admin-card {
+ grid-area: main;
+ grid-column: auto;
+ }
+
+ .admin-shell-rail {
+ grid-area: rail;
+ grid-column: auto;
+ position: static;
+ top: auto;
+ width: 100%;
+ }
+}
+
+@media (max-width: 1080px) {
+ .page {
+ width: min(100%, calc(100vw - 12px));
+ max-width: none;
+ padding-inline: 6px;
+ }
+
+ .card,
+ .admin-card {
+ padding: 20px;
+ }
+
+ .admin-shell {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas:
+ "nav"
+ "main"
+ "rail";
+ gap: 16px;
+ }
+
+ .admin-shell-nav,
+ .admin-card,
+ .admin-shell-rail {
+ grid-column: auto;
+ width: 100%;
+ }
+
+ .admin-shell-nav {
+ position: static;
+ top: auto;
+ }
+
+ .admin-sidebar,
+ .admin-rail-stack,
+ .admin-rail-card,
+ .maintenance-layout,
+ .maintenance-tools-panel,
+ .cache-table {
+ width: 100%;
+ }
+
+ .admin-grid,
+ .users-page-toolbar-grid,
+ .users-summary-grid,
+ .users-page-overview-grid,
+ .maintenance-action-grid,
+ .schedule-grid,
+ .diagnostics-inline-summary,
+ .diagnostics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .settings-nav,
+ .settings-links {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .settings-group {
+ min-width: 0;
+ }
+
+ .settings-links a {
+ justify-content: flex-start;
+ }
+
+ .settings-section-actions,
+ .diagnostics-control-panel,
+ .diagnostics-control-actions,
+ .log-actions {
+ display: grid;
+ width: 100%;
+ justify-content: stretch;
+ }
+
+ .settings-section-actions > *,
+ .diagnostics-control-actions > *,
+ .log-actions > * {
+ width: 100%;
+ }
+
+ .settings-section-actions .settings-action-button {
+ width: 100%;
+ min-width: 0;
+ flex-basis: auto;
+ }
+
+ .sync-meta,
+ .diagnostic-card-top,
+ .diagnostics-category-header,
+ .users-summary-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .cache-row {
+ grid-template-columns: 1fr;
+ }
+
+ .cache-row span {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ overflow-wrap: anywhere;
+ }
+}
+
+/* Final admin shell + settings section cleanup */
+.admin-shell,
+.admin-shell-nav,
+.admin-card,
+.admin-shell-rail,
+.admin-sidebar,
+.admin-panel {
+ min-width: 0;
+}
+
+.admin-shell {
+ display: grid;
+ grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
+ grid-template-areas: "nav main";
+ gap: 22px;
+ align-items: start;
+}
+
+.admin-shell.admin-shell--with-rail {
+ grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
+ grid-template-areas: "nav main rail";
+}
+
+.admin-shell-nav {
+ grid-area: nav;
+}
+
+.admin-card {
+ grid-area: main;
+}
+
+.admin-shell-rail {
+ grid-area: rail;
+ position: sticky;
+ top: 20px;
+ align-self: start;
+ display: grid;
+ gap: 10px;
+}
+
+.admin-zone-stack {
+ gap: 18px;
+}
+
+.admin-zone {
+ display: grid;
+ gap: 14px;
+ padding: 18px;
+ border-radius: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015)),
+ rgba(255, 255, 255, 0.012);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
+}
+
+[data-theme='light'] .admin-zone {
+ border-color: rgba(17, 19, 24, 0.08);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.72)),
+ rgba(17, 19, 24, 0.018);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
+}
+
+.admin-zone .section-header {
+ align-items: flex-start;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+[data-theme='light'] .admin-zone .section-header {
+ border-bottom-color: rgba(17, 19, 24, 0.08);
+}
+
+.admin-zone .section-header h2 {
+ position: relative;
+ display: inline-block;
+ padding-bottom: 8px;
+}
+
+.admin-zone .section-header h2::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: 2px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0));
+}
+
+.admin-zone .section-subtitle {
+ margin-top: -4px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+@media (max-width: 1280px) {
+ .admin-shell {
+ grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
+ grid-template-areas: "nav main";
+ }
+
+ .admin-shell.admin-shell--with-rail {
+ grid-template-areas:
+ "nav main"
+ "nav rail";
+ }
+
+ .admin-shell-rail {
+ position: static;
+ top: auto;
+ width: 100%;
+ }
+}
+
+@media (max-width: 1080px) {
+ .admin-shell,
+ .admin-shell.admin-shell--with-rail {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 16px;
+ }
+
+ .admin-shell {
+ grid-template-areas:
+ "nav"
+ "main";
+ }
+
+ .admin-shell.admin-shell--with-rail {
+ grid-template-areas:
+ "nav"
+ "main"
+ "rail";
+ }
+
+ .admin-shell-nav,
+ .admin-card,
+ .admin-shell-rail {
+ width: 100%;
+ }
+
+ .admin-shell-nav {
+ position: static;
+ top: auto;
+ }
+
+ .admin-grid,
+ .users-page-toolbar-grid,
+ .users-summary-grid,
+ .users-page-overview-grid,
+ .maintenance-action-grid,
+ .schedule-grid,
+ .diagnostics-inline-summary,
+ .diagnostics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-zone {
+ padding: 16px;
+ }
+}
+
+/* Final header action layout */
+.header-actions {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ width: 100%;
+}
+
+.header-actions .header-cta--left {
+ grid-column: 1;
+ justify-self: start;
+ margin-right: 0;
+}
+
+.header-actions-center {
+ grid-column: 2;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.header-actions-right {
+ grid-column: 3;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ justify-self: end;
+}
+
+@media (max-width: 760px) {
+ .header-actions {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+
+ .header-actions .header-cta--left {
+ grid-column: 1;
+ width: 100%;
+ }
+
+ .header-actions-center,
+ .header-actions-right {
+ display: grid;
+ grid-template-columns: 1fr;
+ width: 100%;
+ justify-self: stretch;
+ }
+
+ .header-actions-center {
+ grid-column: 1;
+ }
+
+ .header-actions-right {
+ grid-column: 1;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/* Portal */
+.portal-page {
+ display: grid;
+ gap: 16px;
+}
+
+.portal-workspace-switch {
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.portal-workspace-switch button {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel-soft);
+ color: var(--text);
+ padding: 8px 12px;
+ font-weight: 600;
+}
+
+.portal-workspace-switch button.is-active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
+ background: rgba(107, 146, 255, 0.12);
+}
+
+.portal-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.portal-overview-card {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 12px 14px;
+ display: grid;
+ gap: 4px;
+}
+
+.portal-overview-card span {
+ color: var(--muted);
+ font-size: 0.76rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.portal-overview-card strong {
+ font-size: 1.25rem;
+ color: var(--text);
+}
+
+.portal-create-panel {
+ display: grid;
+ gap: 12px;
+}
+
+.portal-discovery-panel {
+ display: grid;
+ gap: 12px;
+}
+
+.portal-discovery-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 140px;
+ gap: 10px;
+}
+
+.portal-discovery-form input {
+ width: 100%;
+}
+
+.portal-discovery-results {
+ display: grid;
+ gap: 10px;
+}
+
+.portal-discovery-item {
+ display: grid;
+ grid-template-columns: 56px minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel-soft);
+ padding: 10px;
+}
+
+.portal-discovery-media {
+ width: 56px;
+ height: 84px;
+ border-radius: 6px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--line);
+}
+
+.portal-discovery-media img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.portal-discovery-main {
+ display: grid;
+ gap: 6px;
+}
+
+.portal-discovery-title-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.portal-discovery-main p {
+ margin: 0;
+ font-size: 0.84rem;
+ color: var(--muted);
+}
+
+.portal-discovery-actions {
+ display: flex;
+ align-items: center;
+}
+
+.poster-fallback {
+ display: grid;
+ place-items: center;
+ width: 100%;
+ height: 100%;
+ color: var(--muted);
+ font-size: 0.66rem;
+ text-align: center;
+ padding: 4px;
+}
+
+.portal-form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.portal-field-span-2 {
+ grid-column: span 2;
+}
+
+.portal-toolbar {
+ display: grid;
+ grid-template-columns: 180px minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: end;
+}
+
+.portal-toolbar label span {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 0.78rem;
+ color: var(--muted);
+}
+
+.portal-search-filter input {
+ width: 100%;
+}
+
+.portal-mine-toggle {
+ align-self: center;
+ margin-top: 20px;
+}
+
+.portal-workspace {
+ display: grid;
+ grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
+ gap: 12px;
+}
+
+.portal-list-panel,
+.portal-detail-panel {
+ display: grid;
+ gap: 12px;
+ align-content: start;
+}
+
+.portal-item-list {
+ display: grid;
+ gap: 10px;
+ max-height: 900px;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.portal-item-row {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel-soft);
+ padding: 12px;
+ text-align: left;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.portal-item-row.is-active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
+}
+
+.portal-item-row-title {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.portal-item-row p {
+ margin: 8px 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.45;
+}
+
+.portal-item-row-meta {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.portal-comments-block {
+ border-top: 1px solid var(--line);
+ padding-top: 12px;
+ display: grid;
+ gap: 10px;
+}
+
+.portal-comment-list {
+ display: grid;
+ gap: 8px;
+ max-height: 420px;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.portal-comment-card {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 10px 12px;
+ background: var(--panel-soft);
+}
+
+.portal-comment-card header {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-bottom: 6px;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.portal-comment-card p {
+ margin: 0;
+ color: var(--text);
+ white-space: pre-wrap;
+ line-height: 1.45;
+}
+
+.portal-comment-form {
+ display: grid;
+ gap: 10px;
+}
+
+@media (max-width: 1200px) {
+ .portal-overview-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .portal-toolbar {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .portal-search-filter,
+ .portal-mine-toggle {
+ grid-column: span 2;
+ }
+
+ .portal-mine-toggle {
+ margin-top: 0;
+ }
+
+ .portal-workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .portal-item-list {
+ max-height: 460px;
+ }
+
+ .portal-discovery-item {
+ grid-template-columns: 56px minmax(0, 1fr);
+ }
+
+ .portal-discovery-actions {
+ grid-column: span 2;
+ justify-content: flex-end;
+ }
+}
+
+@media (max-width: 760px) {
+ .portal-form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .portal-field-span-2 {
+ grid-column: span 1;
+ }
+
+ .portal-overview-grid,
+ .portal-toolbar {
+ grid-template-columns: 1fr;
+ }
+
+ .portal-search-filter,
+ .portal-mine-toggle {
+ grid-column: span 1;
+ }
+
+ .portal-discovery-form {
+ grid-template-columns: 1fr;
+ }
+
+ .portal-discovery-item {
+ grid-template-columns: 1fr;
+ }
+
+ .portal-discovery-media {
+ width: 72px;
+ height: 108px;
+ }
+
+ .portal-discovery-actions {
+ grid-column: span 1;
+ justify-content: flex-start;
+ }
+}
diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx
new file mode 100644
index 0000000..fe8bc45
--- /dev/null
+++ b/frontend/app/how-it-works/page.tsx
@@ -0,0 +1,186 @@
+'use client'
+
+export default function HowItWorksPage() {
+ return (
+
+
+ How it works
+ How Magent works for users
+
+ Use Magent to find a request, watch it move through the pipeline, and know when it is
+ ready without constantly refreshing the page.
+
+
+
+
+ What Magent is for
+
+
+ Track requests
+
+ Search by title, year, or request number to open the request page and see where an
+ item is up to.
+
+
+
+ See live progress
+
+ Request status, timeline events, and download progress update live while you are
+ viewing the page.
+
+
+
+ Know when it is ready
+
+ When the request is fully imported and available, Magent shows it as ready and links
+ you through to Jellyfin.
+
+
+
+
+
+
+ The request pipeline
+
+
+ You request a movie or show through Seerr.
+
+
+ Magent picks up the request and shows its current state.
+
+
+ The automation stack searches and downloads it if it can find a valid
+ release.
+
+
+ The file is imported into the library .
+
+
+ Jellyfin serves it once it is ready to watch.
+
+
+
+
+
+ What the statuses usually mean
+
+
+ Pending
+ The request exists, but it is still waiting for approval or the next step.
+
+
+ Approved / Processing
+ The request has been accepted and the automation tools are working on it.
+
+
+ Downloading
+ Magent can show live progress while the content is still being downloaded.
+
+
+ Ready
+ The item has been imported and should now be available in Jellyfin.
+
+
+ Partial / Waiting
+
+ Part of the workflow completed, but the request is still waiting on another service or
+ on content becoming available.
+
+
+
+ Declined
+ The request was rejected or cannot proceed in its current form.
+
+
+
+
+
+ Live updates you can expect
+
+
+ 1
+ Recent requests refresh automatically
+
+ Your request list and landing-page activity update automatically while you are signed
+ in.
+
+
+
+ 2
+ Request pages update in real time
+
+ State changes, timeline steps, and downloader progress are pushed to the page live.
+
+
+
+ 3
+ Ready state appears as soon as the import completes
+
+ Once the content is actually available, Magent updates the request page without a hard
+ refresh.
+
+
+
+
+
+
+ User actions you may see
+
+
+ Open request
+ Jump into the full request page to inspect the current state and activity.
+
+
+ Open in Jellyfin
+ Appears when the request is ready and Magent can link you through for playback.
+
+
+ Search + auto-download
+
+ Only appears for accounts that have been granted self-service download access by the
+ admin team.
+
+
+
+ My invites
+
+ If your account is allowed to invite others, you can create and manage invite links
+ from your profile.
+
+
+
+
+
+
+ Invites and signup
+
+
+ You receive an invite link by email or directly from the person who
+ invited you.
+
+
+ You sign up through Magent and your account is linked into the media
+ stack.
+
+
+ Your account defaults apply based on the invite or your assigned
+ profile.
+
+
+ You sign in and track requests from the landing page and your request
+ pages.
+
+
+
+
+
+ If a request looks stuck
+
+ A waiting request usually means no usable release has been found yet, the download is
+ still in progress, or the import has not completed. Magent will keep updating as the
+ underlying services move forward.
+
+
+
+ )
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..e3323bb
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,45 @@
+import './globals.css'
+import './ops-redesign.css'
+import type { ReactNode } from 'react'
+import HeaderActions from './ui/HeaderActions'
+import HeaderIdentity from './ui/HeaderIdentity'
+import BrandingFavicon from './ui/BrandingFavicon'
+import BrandingLogo from './ui/BrandingLogo'
+import SiteStatus from './ui/SiteStatus'
+
+export const metadata = {
+ title: 'Magent',
+ description: 'Request timeline and AI triage for media requests',
+}
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/lib/auth.ts b/frontend/app/lib/auth.ts
new file mode 100644
index 0000000..a0c23ff
--- /dev/null
+++ b/frontend/app/lib/auth.ts
@@ -0,0 +1,100 @@
+const AUTH_STATE_COOKIE = 'magent_logged_in'
+
+export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
+
+const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
+ if (typeof document === 'undefined') return
+ document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
+}
+
+const clearCookie = (name: string) => {
+ if (typeof document === 'undefined') return
+ document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
+}
+
+export const getToken = () => {
+ if (typeof document === 'undefined') return null
+ const cookies = document.cookie.split(';').map((entry) => entry.trim())
+ const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
+ if (!marker) return null
+ const [, value] = marker.split('=', 2)
+ return value || null
+}
+
+export const setToken = (_token: string) => {
+ setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
+}
+
+export const clearToken = () => {
+ clearCookie(AUTH_STATE_COOKIE)
+ if (typeof window === 'undefined') return
+ const baseUrl = getApiBase()
+ void fetch(`${baseUrl}/auth/logout`, {
+ method: 'POST',
+ credentials: 'include',
+ keepalive: true,
+ }).catch(() => undefined)
+}
+
+export const logout = async () => {
+ const baseUrl = getApiBase()
+ clearCookie(AUTH_STATE_COOKIE)
+ await fetch(`${baseUrl}/auth/logout`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+}
+
+export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
+ const headers = new Headers(init?.headers || {})
+ return fetch(input, { ...init, headers, credentials: 'include' })
+}
+
+export const getEventStreamToken = async () => {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/stream-token`)
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || `Stream token request failed: ${response.status}`)
+ }
+ const data = await response.json()
+ const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
+ if (!token) {
+ throw new Error('Stream token not returned')
+ }
+ return token
+}
+
+export class UnauthorizedError extends Error {
+ constructor() {
+ super('Unauthorized')
+ this.name = 'UnauthorizedError'
+ }
+}
+
+export class ForbiddenError extends Error {
+ constructor() {
+ super('Forbidden')
+ this.name = 'ForbiddenError'
+ }
+}
+
+export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const response = await authFetch(input, init)
+ if (response.status === 401) {
+ clearToken()
+ throw new UnauthorizedError()
+ }
+ if (response.status === 403) {
+ throw new ForbiddenError()
+ }
+ return response
+}
+
+export const readResponseText = async (response: Response) => {
+ try {
+ return (await response.text()).trim()
+ } catch {
+ return ''
+ }
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
new file mode 100644
index 0000000..071e5b4
--- /dev/null
+++ b/frontend/app/login/page.tsx
@@ -0,0 +1,190 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { getApiBase, setToken, clearToken } from '../lib/auth'
+import BrandingLogo from '../ui/BrandingLogo'
+
+const DEFAULT_LOGIN_OPTIONS = {
+ showJellyfinLogin: true,
+ showLocalLogin: true,
+ showForgotPassword: true,
+ showSignupLink: true,
+}
+
+export default function LoginPage() {
+ const router = useRouter()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
+ const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
+ ? 'jellyfin'
+ : loginOptions.showLocalLogin
+ ? 'local'
+ : null
+
+ const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
+ event.preventDefault()
+ if (!primaryMode) {
+ setError('Login is currently disabled. Contact an administrator.')
+ return
+ }
+ setError(null)
+ setLoading(true)
+ try {
+ clearToken()
+ const baseUrl = getApiBase()
+ const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
+ const body = new URLSearchParams({ username, password })
+ const response = await fetch(`${baseUrl}${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ credentials: 'include',
+ })
+ if (!response.ok) {
+ throw new Error('Login failed')
+ }
+ const data = await response.json()
+ if (data?.authenticated) {
+ setToken('cookie')
+ if (typeof window !== 'undefined') {
+ window.location.href = '/'
+ return
+ }
+ router.push('/')
+ return
+ }
+ throw new Error('Login failed')
+ } catch (err) {
+ console.error(err)
+ setError('Invalid username or password.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ let active = true
+ const loadLoginOptions = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await fetch(`${baseUrl}/site/public`)
+ if (!response.ok) {
+ return
+ }
+ const data = await response.json()
+ const login = data?.login ?? {}
+ if (!active) return
+ setLoginOptions({
+ showJellyfinLogin: login.showJellyfinLogin !== false,
+ showLocalLogin: login.showLocalLogin !== false,
+ showForgotPassword: login.showForgotPassword !== false,
+ showSignupLink: login.showSignupLink !== false,
+ })
+ } catch (err) {
+ console.error(err)
+ }
+ }
+ void loadLoginOptions()
+ return () => {
+ active = false
+ }
+ }, [])
+
+ const loginHelpText = (() => {
+ if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
+ return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
+ }
+ if (loginOptions.showJellyfinLogin) {
+ return 'Use your Jellyfin account to sign in.'
+ }
+ if (loginOptions.showLocalLogin) {
+ return 'Use your local Magent admin account to sign in.'
+ }
+ return 'No sign-in methods are currently available. Contact an administrator.'
+ })()
+
+ return (
+
+
+
+
+
+
+
Secure access
+
Magent operational gateway
+
{loginHelpText}
+
+
+ {
+ if (!primaryMode) {
+ event.preventDefault()
+ setError('Login is currently disabled. Contact an administrator.')
+ return
+ }
+ void submit(event, primaryMode)
+ }}
+ className="auth-form auth-panel"
+ >
+
+ Username
+ setUsername(event.target.value)}
+ autoComplete="username"
+ placeholder="Enter your username"
+ />
+
+
+ Password
+ setPassword(event.target.value)}
+ autoComplete="current-password"
+ placeholder="Enter your password"
+ />
+
+ {error && {error}
}
+
+ {loginOptions.showJellyfinLogin ? (
+
+ {loading ? 'Signing in...' : 'Login with Jellyfin account'}
+
+ ) : null}
+
+ {loginOptions.showLocalLogin ? (
+ submit(event, 'local')}
+ >
+ Sign in with Magent account
+
+ ) : null}
+ {loginOptions.showForgotPassword ? (
+
+ Forgot password?
+
+ ) : null}
+ {loginOptions.showSignupLink ? (
+
+ Have an invite? Create your account (Jellyfin + Magent)
+
+ ) : null}
+ {!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
+ Login is currently disabled. Contact an administrator.
+ ) : null}
+
+
+ Beta environment
+
+
+
+ )
+}
diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css
new file mode 100644
index 0000000..109812c
--- /dev/null
+++ b/frontend/app/ops-redesign.css
@@ -0,0 +1,1709 @@
+@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
+
+:root,
+[data-theme='dark'],
+[data-theme='light'] {
+ color-scheme: dark;
+ --ops-bg: #070d1c;
+ --ops-bg-2: #0b1326;
+ --ops-panel: #10182b;
+ --ops-panel-2: #151e33;
+ --ops-panel-3: #1c263d;
+ --ops-line: #334057;
+ --ops-line-soft: rgba(176, 190, 226, 0.16);
+ --ops-text: #eff4ff;
+ --ops-muted: #aeb7ce;
+ --ops-faint: #737d96;
+ --ops-primary: #5a50f0;
+ --ops-primary-2: #c6c1ff;
+ --ops-cyan: #7ed7ff;
+ --ops-cyan-2: #0ea5e9;
+ --ops-coral: #ffb08a;
+ --ops-green: #85efac;
+ --ops-red: #ff8d8d;
+ --ops-warn: #ffd082;
+ --ops-radius-sm: 4px;
+ --ops-radius: 6px;
+ --ops-radius-lg: 8px;
+ --ink: var(--ops-text);
+ --ink-muted: var(--ops-muted);
+ --paper: var(--ops-bg);
+ --paper-strong: var(--ops-panel);
+ --accent: var(--ops-coral);
+ --accent-2: var(--ops-primary);
+ --accent-3: var(--ops-cyan);
+ --border: var(--ops-line-soft);
+ --shadow: transparent;
+ --glow: 0 0 0 1px rgba(126, 215, 255, 0.16);
+ --input-bg: rgba(255, 255, 255, 0.035);
+ --input-ink: var(--ops-text);
+ --line: var(--ops-line-soft);
+ --panel: var(--ops-panel);
+ --panel-soft: rgba(255, 255, 255, 0.035);
+ --text: var(--ops-text);
+ --muted: var(--ops-muted);
+ --error-bg: rgba(122, 36, 53, 0.44);
+ --error-ink: #ffd6d6;
+}
+
+* {
+ letter-spacing: 0 !important;
+}
+
+html {
+ background: var(--ops-bg);
+}
+
+body {
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 18% 0%, rgba(90, 80, 240, 0.2), transparent 32rem),
+ radial-gradient(circle at 88% 12%, rgba(14, 165, 233, 0.13), transparent 28rem),
+ linear-gradient(180deg, #081025 0%, #060b18 62%, #050914 100%);
+ color: var(--ops-text);
+ font-family: "Geist", "Segoe UI", Arial, sans-serif;
+ font-size: 15px;
+ line-height: 1.5;
+}
+
+a {
+ color: inherit;
+}
+
+.page {
+ width: min(100%, 1480px);
+ max-width: none;
+ margin: 0 auto;
+ padding: 0 20px 92px;
+ gap: 18px;
+}
+
+.header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ gap: 0;
+ align-items: center;
+ margin: 0 -20px;
+ padding: 14px 20px 12px;
+ border-bottom: 1px solid var(--ops-line);
+ background: rgba(8, 14, 31, 0.88);
+ backdrop-filter: blur(18px);
+}
+
+.header-left,
+.header-right {
+ min-width: 0;
+}
+
+.brand-link {
+ gap: 12px;
+ min-width: 0;
+}
+
+.brand-stack {
+ gap: 0;
+ min-width: 0;
+}
+
+.brand {
+ color: var(--ops-primary-2);
+ font-size: clamp(1.25rem, 2.5vw, 1.75rem);
+ font-weight: 800;
+ text-transform: uppercase;
+ text-shadow: 0 0 18px rgba(198, 193, 255, 0.18);
+}
+
+.tagline {
+ color: var(--ops-muted);
+ font-size: 0.78rem;
+ font-family: "JetBrains Mono", Consolas, monospace;
+}
+
+.brand-logo--header {
+ width: 34px;
+ height: 34px;
+ object-fit: contain;
+ border-radius: var(--ops-radius);
+}
+
+.branding-logo-shell {
+ position: relative;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ flex: 0 0 auto;
+ border: 1px solid rgba(126, 215, 255, 0.22);
+ background: rgba(17, 26, 51, 0.92);
+}
+
+.branding-logo-shell img,
+.branding-logo-shell svg {
+ grid-area: 1 / 1;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.branding-logo-shell img {
+ opacity: 0;
+}
+
+.branding-logo-shell img.is-loaded {
+ opacity: 1;
+}
+
+.header-right {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.beta-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 32px;
+ padding: 6px 14px;
+ border: 1px solid rgba(126, 215, 255, 0.36);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(14, 165, 233, 0.16);
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.header-nav {
+ grid-column: 1 / -1;
+ margin-top: 12px;
+}
+
+.header-actions {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+}
+
+.header-actions a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ min-height: 38px;
+ padding: 8px 12px;
+ border-radius: var(--ops-radius);
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.78rem;
+ text-decoration: none;
+ box-shadow: none;
+}
+
+.header-actions a span {
+ color: var(--ops-cyan);
+ font-size: 0.68rem;
+}
+
+.header-actions a:hover,
+.header-actions a.is-active {
+ border-color: rgba(126, 215, 255, 0.22);
+ background: rgba(14, 165, 233, 0.16);
+ color: var(--ops-text);
+}
+
+.avatar-button {
+ width: 38px;
+ height: 38px;
+ border-radius: var(--ops-radius);
+ border: 1px solid var(--ops-line-soft);
+ background: var(--ops-panel-3);
+ color: var(--ops-text);
+ box-shadow: none;
+}
+
+.signed-in-dropdown {
+ border-radius: var(--ops-radius-lg);
+ border: 1px solid var(--ops-line);
+ background: rgba(13, 20, 39, 0.98);
+ box-shadow: 0 22px 60px rgba(0, 0, 0, 0.42);
+}
+
+.signed-in-header,
+.signed-in-build {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+}
+
+.signed-in-actions a,
+.signed-in-signout {
+ border-radius: var(--ops-radius);
+ border-color: var(--ops-line-soft);
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.site-banner {
+ border-radius: var(--ops-radius);
+ border: 1px solid rgba(255, 208, 130, 0.28);
+ background: rgba(103, 75, 25, 0.38);
+ color: #ffe4b3;
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.82rem;
+}
+
+.card,
+.admin-card,
+.admin-panel,
+.main-panel,
+.summary-card,
+.portal-overview-card,
+.status-box,
+.timeline-card,
+.modal-card,
+.admin-zone,
+.admin-rail-card,
+.users-summary-card,
+.stat-card {
+ border-radius: var(--ops-radius-lg) !important;
+ border: 1px solid var(--ops-line) !important;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
+ var(--ops-panel) !important;
+ box-shadow: none !important;
+}
+
+.card {
+ width: 100%;
+ padding: 24px;
+ gap: 20px;
+}
+
+h1,
+h2,
+h3 {
+ color: var(--ops-text);
+ font-weight: 700;
+ line-height: 1.1;
+}
+
+h1 {
+ font-size: clamp(2rem, 5vw, 4rem);
+}
+
+h2 {
+ font-size: clamp(1.25rem, 2vw, 1.75rem);
+}
+
+h3 {
+ font-size: 1rem;
+}
+
+.lede,
+.section-subtitle,
+.meta,
+.helper,
+.recent-meta,
+.user-directory-subtext,
+.users-summary-meta {
+ color: var(--ops-muted) !important;
+}
+
+.section-kicker,
+.admin-sidebar-title,
+.admin-nav-title,
+.stat-label,
+.users-summary-label,
+.user-bulk-label,
+.settings-inline-field span,
+.recent-filter span,
+.label-row,
+.portal-toolbar label span,
+.admin-rail-eyebrow {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.76rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ min-height: 44px;
+ border-radius: var(--ops-radius) !important;
+ border: 1px solid var(--ops-line) !important;
+ background: rgba(8, 13, 28, 0.78) !important;
+ color: var(--ops-text) !important;
+ font-family: "Geist", "Segoe UI", Arial, sans-serif;
+ font-size: 0.96rem;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: 2px solid rgba(126, 215, 255, 0.24);
+ border-color: rgba(126, 215, 255, 0.6) !important;
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: #616b84;
+}
+
+button,
+.ghost-button,
+.settings-action-button,
+.header-link {
+ min-height: 40px;
+ border-radius: var(--ops-radius) !important;
+ border: 1px solid rgba(126, 215, 255, 0.18);
+ background: var(--ops-primary);
+ color: #f7f7ff;
+ font-family: "Geist", "Segoe UI", Arial, sans-serif;
+ font-size: 0.92rem;
+ font-weight: 700;
+ box-shadow: none !important;
+}
+
+button:hover,
+.ghost-button:hover {
+ filter: brightness(1.08);
+}
+
+button:disabled,
+.ghost-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.58;
+}
+
+.ghost-button,
+.details-toggle button,
+.admin-toolbar-actions button,
+.portal-workspace-switch button:not(.is-active),
+.settings-action-button.ghost-button {
+ background: rgba(255, 255, 255, 0.035) !important;
+ color: var(--ops-text) !important;
+}
+
+.error-banner,
+.status-banner,
+.action-message {
+ border-radius: var(--ops-radius);
+ border: 1px solid var(--ops-line-soft);
+ background: rgba(255, 255, 255, 0.035);
+ color: var(--ops-muted);
+}
+
+.error-banner {
+ border-color: rgba(255, 141, 141, 0.35);
+ background: rgba(122, 36, 53, 0.35);
+ color: var(--ops-red);
+}
+
+.loading-center {
+ min-height: 180px;
+ place-items: center;
+}
+
+.spinner {
+ border-color: rgba(126, 215, 255, 0.18);
+ border-top-color: var(--ops-cyan);
+}
+
+.loading-text {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+}
+
+.auth-screen {
+ display: grid;
+ grid-template-columns: minmax(0, 0.95fr) minmax(360px, 520px);
+ gap: 32px;
+ align-items: center;
+ min-height: calc(100vh - 210px);
+ padding: 36px 0;
+}
+
+.auth-hero {
+ display: grid;
+ gap: 28px;
+ min-height: 520px;
+ align-content: center;
+ padding: 42px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background:
+ linear-gradient(90deg, rgba(8, 13, 28, 0.94), rgba(8, 13, 28, 0.72)),
+ repeating-linear-gradient(90deg, rgba(126, 215, 255, 0.04) 0 1px, transparent 1px 56px),
+ linear-gradient(140deg, rgba(90, 80, 240, 0.16), transparent 55%);
+}
+
+.auth-mark {
+ width: 86px;
+ height: 86px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(198, 193, 255, 0.22);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.auth-mark .brand-logo--login {
+ width: 64px;
+ height: 64px;
+ margin: 0;
+}
+
+.auth-title-block {
+ display: grid;
+ gap: 14px;
+ max-width: 620px;
+}
+
+.auth-title-block h1 {
+ text-transform: capitalize;
+}
+
+.auth-title-block p {
+ color: var(--ops-muted);
+ font-size: 1.15rem;
+ max-width: 34rem;
+}
+
+.auth-panel {
+ padding: 28px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background:
+ radial-gradient(circle at top right, rgba(126, 215, 255, 0.08), transparent 20rem),
+ var(--ops-panel);
+}
+
+.auth-form label,
+.admin-form label,
+.compact-form label,
+.profile-section label,
+.filter,
+.users-page-toolbar-group,
+.user-directory-search label {
+ display: grid;
+ gap: 8px;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.76rem;
+ font-weight: 700;
+ text-align: left;
+ text-transform: uppercase;
+}
+
+.auth-footnote {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.78rem;
+}
+
+.live-dot,
+.system-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--ops-cyan);
+ box-shadow: 0 0 14px rgba(126, 215, 255, 0.64);
+}
+
+.system-dot-up,
+.system-up .system-dot {
+ background: var(--ops-green);
+}
+
+.system-dot-down,
+.system-down .system-dot {
+ background: var(--ops-red);
+}
+
+.system-dot-degraded,
+.system-degraded .system-dot {
+ background: var(--ops-warn);
+}
+
+.system-dot-not_configured,
+.system-not_configured .system-dot,
+.system-disabled .system-dot,
+.system-unknown .system-dot {
+ background: var(--ops-faint);
+ box-shadow: none;
+}
+
+.ops-metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.ops-metric-card {
+ display: grid;
+ gap: 8px;
+ min-height: 150px;
+ padding: 18px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background:
+ linear-gradient(145deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)),
+ var(--ops-panel);
+}
+
+.ops-metric-card strong {
+ color: var(--ops-primary-2);
+ font-size: clamp(2rem, 4vw, 3.25rem);
+ line-height: 1;
+}
+
+.ops-metric-card p {
+ margin: 0;
+ color: var(--ops-muted);
+}
+
+.layout-grid {
+ grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
+ align-items: start;
+}
+
+.find-panel,
+.recent.centerpiece {
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.025);
+ padding: 18px;
+}
+
+.system-status,
+.recent-header,
+.find-header,
+.section-header,
+.user-directory-panel-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.system-status-dropdown {
+ display: block;
+ margin-bottom: 16px;
+}
+
+.system-status-dropdown summary {
+ list-style: none;
+}
+
+.system-status-dropdown summary::-webkit-details-marker {
+ display: none;
+}
+
+.system-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 14px 16px;
+ cursor: pointer;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.032);
+}
+
+.system-summary-copy {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.system-summary-copy strong {
+ color: var(--ops-text);
+ font-size: 1rem;
+}
+
+.system-summary-copy span:last-child {
+ color: var(--ops-muted);
+ font-size: 0.86rem;
+}
+
+.system-summary-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ flex: 0 0 auto;
+}
+
+.system-dropdown-cue {
+ min-width: 52px;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-align: right;
+ text-transform: uppercase;
+}
+
+.system-status-dropdown[open] .system-dropdown-cue::before {
+ content: "Close";
+ font-size: 0.72rem;
+}
+
+.system-status-dropdown[open] .system-dropdown-cue {
+ font-size: 0;
+}
+
+.system-status-dropdown .system-list {
+ margin-top: 10px;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+}
+
+.system-status-dropdown .system-item {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+}
+
+.system-status-dropdown .system-actions {
+ flex-wrap: nowrap;
+}
+
+.system-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.system-list,
+.service-ecosystem,
+.recent-grid,
+.portal-item-list,
+.portal-comment-list,
+.quick-action-grid {
+ display: grid;
+ gap: 10px;
+}
+
+.system-item,
+.service-row,
+.recent-card,
+.admin-table-row,
+.user-directory-row,
+.portal-item-row,
+.portal-discovery-item,
+.connection-item {
+ border: 1px solid var(--ops-line-soft) !important;
+ border-radius: var(--ops-radius) !important;
+ background: rgba(255, 255, 255, 0.032) !important;
+ color: var(--ops-text) !important;
+ box-shadow: none !important;
+}
+
+.system-item,
+.service-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ text-decoration: none;
+}
+
+.system-meta,
+.service-row span:nth-child(2) {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.system-name,
+.service-row strong {
+ color: var(--ops-text);
+}
+
+.system-test-message,
+.service-row small {
+ color: var(--ops-muted);
+ font-size: 0.78rem;
+}
+
+.find-panel .find-header {
+ display: grid;
+ gap: 6px;
+}
+
+.find-panel .find-header h1 {
+ margin: 0;
+ font-size: clamp(1.12rem, 1.5vw, 1.38rem);
+ line-height: 1.12;
+}
+
+.find-panel h2 {
+ font-size: 1.22rem;
+}
+
+.find-panel .lede {
+ font-size: 0.9rem;
+ line-height: 1.45;
+}
+
+.system-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.system-state,
+.small-pill,
+.user-grid-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 24px;
+ padding: 4px 8px;
+ border-radius: var(--ops-radius);
+ border: 1px solid rgba(126, 215, 255, 0.22);
+ background: rgba(14, 165, 233, 0.14);
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.7rem;
+ font-weight: 700;
+ white-space: nowrap;
+ text-transform: uppercase;
+}
+
+.system-pill-up,
+.system-pill-online,
+.small-pill.is-positive {
+ border-color: rgba(133, 239, 172, 0.28);
+ background: rgba(34, 197, 94, 0.14);
+ color: var(--ops-green);
+}
+
+.system-pill-down,
+.system-pill-failed,
+.user-grid-pill.is-blocked {
+ border-color: rgba(255, 141, 141, 0.34);
+ background: rgba(239, 68, 68, 0.16);
+ color: var(--ops-red);
+}
+
+.system-pill-degraded,
+.system-pill-warning {
+ border-color: rgba(255, 208, 130, 0.34);
+ background: rgba(245, 158, 11, 0.14);
+ color: var(--ops-warn);
+}
+
+.small-pill.is-muted,
+.user-grid-pill.is-disabled,
+.system-pill-not_configured,
+.system-pill-unknown,
+.system-pill-idle {
+ border-color: rgba(174, 183, 206, 0.2);
+ background: rgba(174, 183, 206, 0.08);
+ color: var(--ops-muted);
+}
+
+.recent-card {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ text-align: left;
+}
+
+.recent-poster,
+.request-poster {
+ border-radius: var(--ops-radius) !important;
+ border: 1px solid var(--ops-line);
+}
+
+.search,
+.portal-discovery-form {
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.filters-compact,
+.portal-toolbar,
+.admin-toolbar,
+.users-page-toolbar,
+.user-directory-search-panel,
+.user-directory-bulk-panel {
+ border-radius: var(--ops-radius-lg);
+ border: 1px solid var(--ops-line);
+ background: rgba(255, 255, 255, 0.024);
+}
+
+.pill-group button {
+ background: rgba(14, 165, 233, 0.12);
+ color: var(--ops-cyan);
+ border-color: rgba(126, 215, 255, 0.2);
+}
+
+.admin-shell {
+ display: grid;
+ grid-template-columns: minmax(210px, 250px) minmax(0, 1fr);
+ grid-template-areas: "nav main";
+ gap: 18px;
+ align-items: start;
+}
+
+.admin-shell.admin-shell--with-rail {
+ grid-template-columns: minmax(210px, 250px) minmax(0, 1fr) minmax(300px, 380px);
+ grid-template-areas: "nav main rail";
+}
+
+.admin-shell-nav {
+ grid-area: nav;
+ position: sticky;
+ top: 116px;
+}
+
+.admin-card {
+ grid-area: main;
+}
+
+.admin-shell-rail {
+ grid-area: rail;
+ position: sticky;
+ top: 116px;
+}
+
+.admin-sidebar {
+ gap: 18px;
+ padding: 14px;
+ border-radius: var(--ops-radius-lg);
+ border: 1px solid var(--ops-line);
+ background: rgba(255, 255, 255, 0.026);
+}
+
+.admin-nav-links a {
+ display: flex;
+ align-items: center;
+ min-height: 36px;
+ border-radius: var(--ops-radius);
+ background: transparent;
+ color: var(--ops-muted);
+}
+
+.admin-nav-links a:hover,
+.admin-nav-links a.is-active {
+ border-color: rgba(126, 215, 255, 0.22);
+ background: rgba(14, 165, 233, 0.13);
+ color: var(--ops-text);
+}
+
+.admin-header {
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--ops-line-soft);
+}
+
+.admin-header h1 {
+ margin-top: 8px;
+}
+
+.admin-zone {
+ padding: 18px;
+}
+
+.admin-zone-stack {
+ display: grid;
+ gap: 16px;
+}
+
+.admin-table {
+ overflow-x: auto;
+}
+
+.admin-table-head,
+.admin-table-row,
+.user-directory-header,
+.user-directory-row,
+.cache-row {
+ grid-template-columns: minmax(260px, 2fr) minmax(140px, 1fr) minmax(150px, 1fr) minmax(170px, 1fr);
+}
+
+.admin-table-head,
+.user-directory-header,
+.cache-head {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.admin-table-row,
+.user-directory-row {
+ min-width: 0;
+ padding: 12px 14px;
+}
+
+.admin-table-head {
+ min-width: 0;
+}
+
+.admin-pagination {
+ color: var(--ops-muted);
+}
+
+.dashboard-activity-table .admin-table-row span:first-child {
+ font-weight: 700;
+}
+
+.dashboard-activity-table .admin-table-head,
+.dashboard-activity-table .admin-table-row {
+ grid-template-columns: minmax(150px, 1.45fr) minmax(120px, 0.9fr) minmax(72px, 0.55fr) minmax(135px, 0.9fr);
+}
+
+.dashboard-activity-table .admin-table-row span,
+.dashboard-activity-table .admin-table-head span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.quick-action-grid {
+ grid-template-columns: 1fr;
+}
+
+.quick-action-grid a {
+ padding: 10px 12px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.032);
+ color: var(--ops-text);
+ text-decoration: none;
+}
+
+.ops-status-strip {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.ops-status-strip span {
+ padding: 12px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ text-transform: uppercase;
+}
+
+.portal-page {
+ gap: 18px;
+}
+
+.portal-page > .user-directory-panel-header:first-child {
+ min-height: 190px;
+ padding: 28px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background:
+ linear-gradient(135deg, rgba(90, 80, 240, 0.2), transparent 42%),
+ var(--ops-panel);
+}
+
+.portal-workspace-switch {
+ display: flex;
+ gap: 8px;
+}
+
+.portal-workspace-switch button.is-active {
+ background: rgba(14, 165, 233, 0.18);
+ color: var(--ops-cyan);
+}
+
+.portal-overview-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.portal-overview-card {
+ min-height: 110px;
+ padding: 16px;
+}
+
+.portal-overview-card strong {
+ color: var(--ops-primary-2);
+ font-size: 2rem;
+}
+
+.portal-workspace {
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
+ gap: 14px;
+}
+
+.portal-item-row {
+ width: 100%;
+ justify-content: stretch;
+}
+
+.portal-item-row.is-active {
+ border-color: rgba(126, 215, 255, 0.62) !important;
+ background: rgba(14, 165, 233, 0.12) !important;
+}
+
+.portal-item-row-title strong,
+.portal-comment-card strong {
+ color: var(--ops-text);
+}
+
+.portal-item-row p,
+.portal-item-row-meta,
+.portal-comment-card header {
+ color: var(--ops-muted);
+}
+
+.portal-form-grid,
+.settings-grid,
+.compact-form {
+ gap: 14px;
+}
+
+.portal-comments-block {
+ border-top-color: var(--ops-line);
+}
+
+.request-detail-page {
+ gap: 22px;
+}
+
+.request-header {
+ align-items: center;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--ops-line-soft);
+}
+
+.request-header-main {
+ align-items: center;
+}
+
+.status-box {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.status-text {
+ color: var(--ops-primary-2);
+ font-size: clamp(1.5rem, 3vw, 2.8rem);
+}
+
+.pipeline-map,
+.actions,
+.history,
+.request-error-state {
+ display: grid;
+ gap: 14px;
+ padding: 18px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.024);
+}
+
+.pipeline-steps {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.pipeline-step {
+ display: grid;
+ gap: 8px;
+ justify-items: center;
+ padding: 12px 8px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ color: var(--ops-muted);
+ text-align: center;
+}
+
+.pipeline-step.is-active {
+ border-color: rgba(126, 215, 255, 0.48);
+ background: rgba(14, 165, 233, 0.14);
+ color: var(--ops-cyan);
+}
+
+.pipeline-step.is-complete {
+ color: var(--ops-green);
+}
+
+.pipeline-dot,
+.timeline-marker {
+ background: var(--ops-cyan);
+}
+
+.timeline::before {
+ background: linear-gradient(180deg, var(--ops-cyan), transparent);
+}
+
+.timeline-card pre,
+.log-viewer {
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: #050914;
+ color: #d9e4ff;
+}
+
+.timeline-title {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+}
+
+.timeline-sublist li {
+ border-bottom: 1px solid var(--ops-line-soft);
+ padding-bottom: 8px;
+}
+
+.request-error-state {
+ min-height: 340px;
+ align-content: center;
+ justify-items: start;
+}
+
+.request-error-state h1 {
+ max-width: 680px;
+}
+
+.request-error-state p {
+ max-width: 640px;
+ color: var(--ops-muted);
+}
+
+.request-error-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.settings-nav,
+.settings-links,
+.settings-section-actions,
+.user-bulk-actions,
+.users-page-toolbar-actions,
+.admin-toolbar-actions {
+ gap: 10px;
+}
+
+.settings-links a {
+ border-radius: var(--ops-radius);
+ border: 1px solid var(--ops-line-soft);
+ background: rgba(255, 255, 255, 0.028);
+}
+
+.settings-links a.is-active {
+ border-color: rgba(126, 215, 255, 0.38);
+ background: rgba(14, 165, 233, 0.13);
+ color: var(--ops-cyan);
+}
+
+.settings-section-actions {
+ padding-top: 14px;
+ border-top: 1px solid var(--ops-line-soft);
+}
+
+.service-status-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 0.82fr) minmax(520px, 1fr);
+ gap: 18px;
+ align-items: center;
+}
+
+.service-status-summary {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 14px;
+ align-items: center;
+}
+
+.service-status-summary .system-dot {
+ width: 14px;
+ height: 14px;
+}
+
+.service-status-grid {
+ display: grid;
+ grid-template-columns: minmax(88px, 0.65fr) minmax(130px, 0.85fr) minmax(190px, 1.15fr) minmax(132px, auto);
+ gap: 10px;
+ align-items: stretch;
+}
+
+.service-status-grid > div {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.service-status-grid span {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+}
+
+.service-status-grid strong {
+ overflow-wrap: normal;
+ color: var(--ops-text);
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.settings-inline-field {
+ min-width: min(100%, 280px);
+}
+
+.field-span-full {
+ grid-column: 1 / -1;
+}
+
+.users-page-toolbar-grid,
+.users-summary-grid,
+.users-page-overview-grid {
+ gap: 12px;
+}
+
+.user-directory-list {
+ display: grid;
+ gap: 8px;
+ overflow-x: auto;
+}
+
+.user-directory-row {
+ text-decoration: none;
+}
+
+.user-directory-row-chevron {
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+}
+
+.modal-backdrop {
+ background: rgba(3, 7, 18, 0.72);
+ backdrop-filter: blur(10px);
+}
+
+@media (max-width: 1180px) {
+ .ops-metric-grid,
+ .portal-overview-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .layout-grid,
+ .admin-shell,
+ .admin-shell.admin-shell--with-rail,
+ .portal-workspace {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "nav"
+ "main"
+ "rail";
+ }
+
+ .admin-shell-nav,
+ .admin-shell-rail {
+ position: static;
+ }
+
+ .admin-sidebar {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ .service-status-panel,
+ .service-status-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .service-status-grid strong {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .admin-nav-group {
+ min-width: 190px;
+ }
+
+ .side-panel {
+ position: static;
+ }
+}
+
+@media (max-width: 780px) {
+ body {
+ font-size: 14px;
+ }
+
+ .page {
+ padding: 0 14px 90px;
+ }
+
+ .header {
+ grid-template-columns: minmax(0, 1fr) auto !important;
+ margin: 0 -14px;
+ padding: 12px 14px;
+ backdrop-filter: none;
+ }
+
+ .tagline {
+ display: none;
+ }
+
+ .brand-logo--header {
+ width: 28px;
+ height: 28px;
+ }
+
+ .header-nav {
+ grid-column: auto !important;
+ grid-row: auto !important;
+ position: fixed !important;
+ left: 0;
+ right: 0;
+ top: auto !important;
+ bottom: 0;
+ z-index: 1000;
+ margin: 0;
+ padding: 8px 10px;
+ border-top: 1px solid var(--ops-line);
+ background: rgba(8, 14, 31, 0.96);
+ backdrop-filter: blur(18px);
+ }
+
+ .header-actions {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 6px;
+ }
+
+ .header-actions a {
+ display: grid;
+ gap: 3px;
+ min-height: 52px;
+ padding: 6px 4px;
+ font-size: 0.67rem;
+ }
+
+ .beta-chip {
+ min-height: 28px;
+ padding: 4px 9px;
+ }
+
+ .header-right {
+ grid-column: 2 / 3 !important;
+ grid-row: 1 / 2 !important;
+ width: auto !important;
+ justify-content: flex-end !important;
+ }
+
+ .card,
+ .admin-card {
+ padding: 16px;
+ }
+
+ .auth-screen {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ min-height: auto;
+ padding: 18px 0;
+ }
+
+ .auth-hero {
+ min-height: auto;
+ padding: 24px;
+ }
+
+ .auth-mark {
+ width: 64px;
+ height: 64px;
+ }
+
+ .auth-mark .brand-logo--login {
+ width: 44px;
+ height: 44px;
+ }
+
+ .ops-metric-grid,
+ .portal-overview-grid,
+ .status-box,
+ .history-grid,
+ .summary,
+ .ops-status-strip,
+ .pipeline-steps {
+ grid-template-columns: 1fr;
+ }
+
+ .search,
+ .portal-discovery-form,
+ .portal-toolbar,
+ .portal-form-grid,
+ .settings-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .portal-field-span-2,
+ .field-span-full {
+ grid-column: auto;
+ }
+
+ .system-status,
+ .recent-header,
+ .find-header,
+ .section-header,
+ .user-directory-panel-header,
+ .admin-header,
+ .admin-toolbar,
+ .request-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .service-status-panel,
+ .service-status-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-table-head,
+ .admin-table-row,
+ .user-directory-header,
+ .user-directory-row,
+ .cache-row {
+ min-width: 0;
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Request detail overhaul: one evidence-led status and collection journey. */
+.request-detail-page {
+ --request-green: #48e0b2;
+ --request-cyan: #7ed7ff;
+ --request-amber: #ffc56d;
+ --request-red: #ff8d9d;
+ display: grid;
+ gap: 20px;
+}
+
+.request-detail-page .request-header { margin: 0; }
+.request-detail-page .section-kicker {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.request-overview,
+.request-journey,
+.request-advanced {
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: radial-gradient(circle at 8% 0%, rgba(79, 70, 229, 0.1), transparent 32%), rgba(255, 255, 255, 0.018);
+}
+
+.request-overview {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ overflow: hidden;
+}
+
+.request-overview-block {
+ grid-column: span 6;
+ display: grid;
+ align-content: start;
+ gap: 7px;
+ min-height: 118px;
+ padding: 20px;
+ border-right: 1px solid var(--ops-line-soft);
+ border-bottom: 1px solid var(--ops-line-soft);
+}
+.request-overview-block:nth-child(even) { border-right: 0; }
+.request-overview-block p,
+.request-overview-block small { color: var(--ops-muted); line-height: 1.55; }
+.request-overview-block > strong { color: var(--ops-text); font-size: 1rem; line-height: 1.4; }
+.request-overview-status > strong {
+ max-width: 820px;
+ color: var(--request-cyan);
+ font-size: clamp(1.35rem, 2.4vw, 2.35rem);
+ letter-spacing: -0.035em;
+}
+.request-overview-label {
+ color: var(--ops-text);
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.055em;
+ text-transform: uppercase;
+}
+.request-next-step {
+ grid-column: 1 / -1;
+ min-height: 0;
+ border-right: 0;
+ border-bottom: 0;
+ background: linear-gradient(90deg, rgba(14, 165, 233, 0.08), rgba(79, 70, 229, 0.08));
+}
+
+.request-action-row,
+.request-stage-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 6px; }
+.request-action-row button,
+.request-stage-actions button,
+.request-release button {
+ min-height: 38px;
+ border: 1px solid rgba(126, 215, 255, 0.32);
+ border-radius: var(--ops-radius);
+ background: linear-gradient(115deg, rgba(79, 70, 229, 0.92), rgba(14, 165, 233, 0.88));
+ color: #fff;
+ font-size: 0.78rem;
+ font-weight: 750;
+}
+.request-action-feedback {
+ grid-column: 1 / -1;
+ padding: 13px 18px;
+ border-top: 1px solid var(--ops-line-soft);
+ color: var(--request-green);
+ background: rgba(72, 224, 178, 0.07);
+}
+.request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); }
+
+.request-journey { display: grid; gap: 18px; padding: 20px; }
+.request-journey-heading,
+.request-release-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
+.request-journey-heading h2,
+.request-release-heading h3 { font-size: clamp(1.3rem, 2.2vw, 2rem); letter-spacing: -0.03em; }
+.request-live-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 30px;
+ padding: 6px 10px;
+ border: 1px solid rgba(72, 224, 178, 0.24);
+ border-radius: 999px;
+ color: var(--request-green);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+.request-live-indicator i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 12px currentColor; }
+
+.request-stage-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 12px; }
+.request-stage {
+ position: relative;
+ grid-column: span 4;
+ display: grid;
+ align-content: start;
+ gap: 10px;
+ min-height: 170px;
+ padding: 16px;
+ overflow: hidden;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius-lg);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012));
+ color: var(--ops-text);
+ text-decoration: none;
+}
+.request-stage::before { position: absolute; inset: 0 0 auto; height: 2px; background: var(--ops-line-soft); content: ""; }
+.request-stage h3 { font-size: 1rem; }
+.request-stage p,
+.request-stage span { color: var(--ops-muted); font-size: 0.78rem; line-height: 1.48; }
+.request-stage.stage-complete { border-color: rgba(72, 224, 178, 0.25); background: linear-gradient(180deg, rgba(72, 224, 178, 0.1), rgba(72, 224, 178, 0.025)); }
+.request-stage.stage-complete::before { background: var(--request-green); box-shadow: 0 0 18px rgba(72, 224, 178, 0.7); }
+.request-stage.stage-active { border-color: rgba(126, 215, 255, 0.44); background: linear-gradient(180deg, rgba(14, 165, 233, 0.14), rgba(79, 70, 229, 0.045)); box-shadow: inset 0 0 36px rgba(14, 165, 233, 0.035); }
+.request-stage.stage-active::before { background: var(--request-cyan); box-shadow: 0 0 20px rgba(126, 215, 255, 0.78); }
+.request-stage.stage-partial { border-color: rgba(255, 197, 109, 0.35); background: linear-gradient(180deg, rgba(255, 197, 109, 0.11), rgba(255, 197, 109, 0.025)); }
+.request-stage.stage-partial::before,
+.request-stage.stage-attention::before { background: var(--request-amber); box-shadow: 0 0 18px rgba(255, 197, 109, 0.62); }
+.request-stage.stage-attention { border-color: rgba(255, 197, 109, 0.32); }
+.request-stage.is-link:hover { transform: translateY(-2px); border-color: rgba(72, 224, 178, 0.58); }
+
+.request-stage-topline,
+.request-meter-copy,
+.request-season-row,
+.request-missing-list > div,
+.request-torrent > div,
+.request-diagnostic > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.request-stage-number { color: var(--ops-cyan) !important; font-family: "JetBrains Mono", Consolas, monospace; font-weight: 800; }
+.request-stage-state {
+ padding: 3px 7px;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.6rem !important;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.request-stage-state.state-complete { color: var(--request-green); }
+.request-stage-state.state-active { color: var(--request-cyan); }
+.request-stage-state.state-partial,
+.request-stage-state.state-attention { color: var(--request-amber); }
+.request-stage-state.state-waiting { color: var(--ops-muted); }
+
+.request-availability-meter,
+.request-missing-list,
+.request-torrent { display: grid; gap: 8px; margin-top: 4px; padding-top: 10px; border-top: 1px solid var(--ops-line-soft); }
+.request-meter-track { height: 7px; overflow: hidden; border: 1px solid var(--ops-line-soft); border-radius: 999px; background: rgba(255, 255, 255, 0.06); }
+.request-meter-track > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--request-green), var(--request-cyan)); box-shadow: 0 0 14px rgba(72, 224, 178, 0.5); }
+.request-season-row,
+.request-missing-list > div { padding-top: 6px; border-top: 1px solid rgba(255, 255, 255, 0.045); }
+.request-missing-list strong,
+.request-torrent strong { overflow-wrap: anywhere; color: var(--ops-text); font-size: 0.74rem; }
+.request-stage-actions { margin-top: auto; padding-top: 6px; }
+.request-stage-actions button { width: 100%; padding-inline: 10px; font-size: 0.7rem; }
+.request-stage-link { margin-top: auto; color: var(--request-green) !important; font-weight: 750; }
+
+.request-release-picker { display: grid; gap: 14px; padding: 16px; border: 1px solid rgba(126, 215, 255, 0.28); border-radius: var(--ops-radius-lg); background: rgba(14, 165, 233, 0.06); }
+.request-release-list { display: grid; gap: 8px; }
+.request-release { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 12px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.025); }
+.request-release > div { display: grid; gap: 4px; min-width: 0; }
+.request-release strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.request-release span { color: var(--ops-muted); font-size: 0.74rem; }
+
+.request-advanced { overflow: hidden; }
+.request-advanced-toggle { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 16px 18px; border: 0; border-radius: 0; background: transparent; color: var(--ops-text); text-align: left; }
+.request-advanced-toggle > span:first-child { display: grid; gap: 3px; }
+.request-advanced-toggle small { color: var(--ops-muted); font-weight: 400; }
+.request-advanced-content { display: grid; gap: 16px; padding: 0 16px 16px; border-top: 1px solid var(--ops-line-soft); }
+.request-diagnostics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding-top: 16px; }
+.request-diagnostic { display: grid; gap: 10px; min-width: 0; padding: 12px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.025); }
+.request-diagnostic pre { max-height: 260px; margin: 0; padding: 10px; overflow: auto; border-radius: var(--ops-radius); background: #050914; color: #cfd9ee; font-size: 0.7rem; white-space: pre-wrap; }
+.request-advanced .summary-card li { display: grid; gap: 3px; }
+.request-advanced .summary-card small { color: var(--ops-muted); }
+
+@media (max-width: 980px) {
+ .request-stage { grid-column: span 6; }
+}
+
+@media (max-width: 720px) {
+ .request-overview-block,
+ .request-next-step,
+ .request-stage { grid-column: 1 / -1; }
+ .request-overview-block { min-height: 0; border-right: 0; }
+ .request-journey,
+ .request-overview-block { padding: 15px; }
+ .request-journey-heading,
+ .request-release-heading { align-items: flex-start; flex-direction: column; }
+ .request-release { grid-template-columns: 1fr; }
+ .request-live-indicator { align-self: flex-start; }
+ .request-diagnostics-grid { grid-template-columns: 1fr; }
+ .request-action-row,
+ .request-stage-actions { display: grid; }
+ .request-action-row button,
+ .request-release button { width: 100%; }
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..6959a11
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,632 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
+
+const normalizeRecentResults = (items: any[]) =>
+ items
+ .filter((item: any) => item?.id)
+ .map((item: any) => {
+ const id = item.id
+ const rawTitle = item.title
+ const placeholder =
+ typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
+ return {
+ id,
+ title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
+ year: item.year,
+ statusLabel: item.statusLabel,
+ artwork: item.artwork,
+ createdAt: item.createdAt ?? null,
+ }
+ })
+
+const REQUEST_STAGE_OPTIONS = [
+ { value: 'all', label: 'All stages' },
+ { value: 'pending', label: 'Waiting' },
+ { value: 'approved', label: 'Approved' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'working', label: 'Working' },
+ { value: 'partial', label: 'Partial' },
+ { value: 'ready', label: 'Ready' },
+ { value: 'declined', label: 'Declined' },
+]
+
+export default function HomePage() {
+ const router = useRouter()
+ const [query, setQuery] = useState('')
+ const [recent, setRecent] = useState<
+ {
+ id: number
+ title: string
+ year?: number
+ statusLabel?: string
+ artwork?: { poster_url?: string }
+ createdAt?: string | null
+ }[]
+ >([])
+ const [recentError, setRecentError] = useState(null)
+ const [recentLoading, setRecentLoading] = useState(false)
+ const [searchResults, setSearchResults] = useState<
+ {
+ title: string
+ year?: number
+ type?: string
+ requestId?: number
+ statusLabel?: string
+ requestedBy?: string | null
+ accessible?: boolean
+ }[]
+ >([])
+ const [searchError, setSearchError] = useState(null)
+ const [role, setRole] = useState(null)
+ const [recentDays, setRecentDays] = useState(90)
+ const [recentStage, setRecentStage] = useState('all')
+ const [authReady, setAuthReady] = useState(false)
+ const [servicesStatus, setServicesStatus] = useState<
+ { overall: string; services: { name: string; status: string; message?: string }[] } | null
+ >(null)
+ const [servicesLoading, setServicesLoading] = useState(false)
+ const [servicesError, setServicesError] = useState(null)
+ const [serviceTesting, setServiceTesting] = useState>({})
+ const [serviceTestResults, setServiceTestResults] = useState>({})
+ const [liveStreamConnected, setLiveStreamConnected] = useState(false)
+
+ const submit = (event: React.FormEvent) => {
+ event.preventDefault()
+ const trimmed = query.trim()
+ if (!trimmed) return
+ if (/^\d+$/.test(trimmed)) {
+ router.push(`/requests/${encodeURIComponent(trimmed)}`)
+ return
+ }
+ void runSearch(trimmed)
+ }
+
+ const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
+
+ const updateServiceStatus = (name: string, status: string, message?: string) => {
+ setServicesStatus((prev) => {
+ if (!prev) return prev
+ return {
+ ...prev,
+ services: prev.services.map((service) =>
+ service.name === name ? { ...service, status, message } : service
+ ),
+ }
+ })
+ }
+
+ const testService = async (name: string) => {
+ const slug = toServiceSlug(name)
+ setServiceTesting((prev) => ({ ...prev, [name]: true }))
+ setServiceTestResults((prev) => ({ ...prev, [name]: null }))
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || `Service test failed: ${response.status}`)
+ }
+ const data = await response.json()
+ const status = data?.status ?? 'unknown'
+ const message =
+ data?.message ||
+ (status === 'up'
+ ? 'API OK'
+ : status === 'down'
+ ? 'API unreachable'
+ : status === 'degraded'
+ ? 'Health warnings'
+ : status === 'not_configured'
+ ? 'Not configured'
+ : 'Unknown')
+ setServiceTestResults((prev) => ({ ...prev, [name]: message }))
+ updateServiceStatus(name, status, data?.message)
+ } catch (error) {
+ console.error(error)
+ setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
+ } finally {
+ setServiceTesting((prev) => ({ ...prev, [name]: false }))
+ }
+ }
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ const load = async () => {
+ setRecentLoading(true)
+ setRecentError(null)
+ try {
+ const baseUrl = getApiBase()
+ const meResponse = await authFetch(`${baseUrl}/auth/me`)
+ if (!meResponse.ok) {
+ if (meResponse.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Auth failed: ${meResponse.status}`)
+ }
+ const me = await meResponse.json()
+ const userRole = me?.role ?? null
+ setRole(userRole)
+ setAuthReady(true)
+ const take = userRole === 'admin' ? 50 : 6
+ const params = new URLSearchParams({
+ take: String(take),
+ days: String(recentDays),
+ })
+ if (recentStage !== 'all') {
+ params.set('stage', recentStage)
+ }
+ const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Recent requests failed: ${response.status}`)
+ }
+ const data = await response.json()
+ if (Array.isArray(data?.results)) {
+ setRecent(normalizeRecentResults(data.results))
+ }
+ } catch (error) {
+ console.error(error)
+ setRecentError('Recent requests are not available right now.')
+ } finally {
+ setRecentLoading(false)
+ }
+ }
+
+ load()
+ }, [recentDays, recentStage])
+
+ useEffect(() => {
+ if (!authReady) {
+ return
+ }
+ const load = async () => {
+ setServicesLoading(true)
+ setServicesError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/status/services`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Service status failed: ${response.status}`)
+ }
+ const data = await response.json()
+ setServicesStatus(data)
+ } catch (error) {
+ console.error(error)
+ setServicesError('Service status is not available right now.')
+ } finally {
+ setServicesLoading(false)
+ }
+ }
+
+ void load()
+ if (liveStreamConnected) {
+ return
+ }
+ const timer = setInterval(load, 30000)
+ return () => clearInterval(timer)
+ }, [authReady, liveStreamConnected, router])
+
+ useEffect(() => {
+ if (!authReady) {
+ setLiveStreamConnected(false)
+ return
+ }
+ if (!getToken()) {
+ setLiveStreamConnected(false)
+ return
+ }
+ const baseUrl = getApiBase()
+ let closed = false
+ let source: EventSource | null = null
+
+ const connect = async () => {
+ try {
+ const streamToken = await getEventStreamToken()
+ if (closed) return
+ const params = new URLSearchParams({
+ stream_token: streamToken,
+ recent_days: String(recentDays),
+ })
+ if (recentStage !== 'all') {
+ params.set('recent_stage', recentStage)
+ }
+ const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
+ source = new EventSource(streamUrl)
+
+ source.onopen = () => {
+ if (closed) return
+ setLiveStreamConnected(true)
+ }
+
+ source.onmessage = (event) => {
+ if (closed) return
+ setLiveStreamConnected(true)
+ try {
+ const payload = JSON.parse(event.data)
+ if (!payload || typeof payload !== 'object') {
+ return
+ }
+ if (payload.type === 'home_recent') {
+ if (Array.isArray(payload.results)) {
+ setRecent(normalizeRecentResults(payload.results))
+ setRecentError(null)
+ setRecentLoading(false)
+ } else if (typeof payload.error === 'string' && payload.error.trim()) {
+ setRecentError('Recent requests are not available right now.')
+ setRecentLoading(false)
+ }
+ return
+ }
+ if (payload.type === 'home_services') {
+ if (payload.status && typeof payload.status === 'object') {
+ setServicesStatus(payload.status)
+ setServicesError(null)
+ setServicesLoading(false)
+ } else if (typeof payload.error === 'string' && payload.error.trim()) {
+ setServicesError('Service status is not available right now.')
+ setServicesLoading(false)
+ }
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ }
+
+ source.onerror = () => {
+ if (closed) return
+ setLiveStreamConnected(false)
+ }
+ } catch (error) {
+ if (closed) return
+ console.error(error)
+ setLiveStreamConnected(false)
+ }
+ }
+
+ void connect()
+
+ return () => {
+ closed = true
+ setLiveStreamConnected(false)
+ source?.close()
+ }
+ }, [authReady, recentDays, recentStage])
+
+ const runSearch = async (term: string) => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Search failed: ${response.status}`)
+ }
+ const data = await response.json()
+ if (Array.isArray(data?.results)) {
+ setSearchResults(
+ data.results.map((item: any) => ({
+ title: item.title,
+ year: item.year,
+ type: item.type,
+ requestId: item.requestId,
+ statusLabel: item.statusLabel,
+ requestedBy: item.requestedBy ?? null,
+ accessible: Boolean(item.accessible),
+ }))
+ )
+ setSearchError(null)
+ }
+ } catch (error) {
+ console.error(error)
+ setSearchError('Search failed. Try a request ID instead.')
+ setSearchResults([])
+ }
+ }
+
+ const resolveArtworkUrl = (url?: string | null) => {
+ if (!url) return null
+ return url.startsWith('http') ? url : `${getApiBase()}${url}`
+ }
+
+ const formatRequestTime = (value?: string | null) => {
+ if (!value) return null
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+ }
+
+ const serviceItems = servicesStatus?.services ?? []
+ const serviceUpCount = serviceItems.filter((service) => service.status === 'up').length
+ const serviceAttentionCount = serviceItems.filter((service) =>
+ ['down', 'degraded', 'not_configured'].includes(service.status)
+ ).length
+ const serviceOverall = servicesStatus?.overall ?? 'unknown'
+ const serviceStatusLabel = servicesLoading
+ ? 'Checking services...'
+ : servicesError
+ ? 'Status not available yet'
+ : serviceOverall === 'up'
+ ? 'Services are up and running'
+ : serviceOverall === 'down'
+ ? 'Something is down'
+ : 'Some services need attention'
+ const serviceSummary = servicesError
+ ? 'Unable to load service status'
+ : serviceItems.length === 0
+ ? 'No services reported yet'
+ : serviceAttentionCount > 0
+ ? `${serviceAttentionCount} of ${serviceItems.length} need attention`
+ : `${serviceUpCount} of ${serviceItems.length} online`
+ const orderedServices = ['Seerr', 'Sonarr', 'Radarr', 'Prowlarr', 'qBittorrent', 'Jellyfin'].map(
+ (name) => {
+ const item = serviceItems.find((entry) => entry.name === name)
+ return { name, status: item?.status ?? 'unknown', message: item?.message }
+ }
+ )
+ const activeRecentCount = recent.filter((item) => {
+ const label = String(item.statusLabel ?? '').toLowerCase()
+ return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
+ }).length
+
+ return (
+
+
+
+
Service mesh
+
+ {serviceUpCount}/{serviceItems.length || 0}
+
+
{servicesLoading ? 'Checking services now.' : 'Configured services online.'}
+
+
+
Attention
+
{serviceAttentionCount}
+
Services reporting down, degraded, or not configured.
+
+
+
Loaded requests
+
{recent.length}
+
Returned by the live request cache.
+
+
+
Active queue
+
{activeRecentCount}
+
Loaded requests still moving through the pipeline.
+
+
+
+
+
+
+
+ System status
+ {serviceSummary}
+ {serviceStatusLabel}
+
+
+
+ {servicesLoading ? 'Checking' : serviceOverall.replaceAll('_', ' ')}
+
+ Open
+
+
+
+ {orderedServices.map(({ name, status, message }) => {
+ const testing = serviceTesting[name] ?? false
+ return (
+
+
+
+ {name}
+
+ {serviceTestResults[name] ?? message ?? 'No recent detail'}
+
+
+
+
+ {status === 'up'
+ ? 'Up'
+ : status === 'down'
+ ? 'Down'
+ : status === 'degraded'
+ ? 'Needs attention'
+ : status === 'not_configured'
+ ? 'Not configured'
+ : 'Unknown'}
+
+ void testService(name)}
+ disabled={testing}
+ >
+ {testing ? 'Testing...' : 'Test'}
+
+
+
+ )
+ })}
+
+
+
+
{role === 'admin' ? 'All requests' : 'My recent requests'}
+ {authReady && (
+
+
+ Show
+ setRecentDays(Number(event.target.value))}
+ >
+ All
+ 30 days
+ 60 days
+ 90 days
+ 180 days
+
+
+
+ Stage
+ setRecentStage(event.target.value)}
+ >
+ {REQUEST_STAGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ )}
+
+
+ {recentLoading ? (
+
+
+
Loading recent requests…
+
+ ) : recentError ? (
+
+ {recentError}
+
+ ) : recent.length === 0 ? (
+
+ No recent requests found
+
+ ) : (
+ recent.map((item) => (
+
router.push(`/requests/${item.id}`)}
+ className="recent-card"
+ >
+ {item.artwork?.poster_url && (
+
+ )}
+
+
+ {item.title || 'Untitled'}
+ {item.year ? ` (${item.year})` : ''}
+
+
+ {item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
+ {item.id}
+ {item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
Search all requests
+
+ Search any request by title + year or request number and see whether it already
+ exists in the system.
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="e.g. Dune 2021 or 1289"
+ />
+ Check status
+
+
+
+
Type
+
+ TV
+ Movie
+
+
+
+
Status
+
+ Pending
+ Approved
+ Processing
+ Failed
+ Available
+
+
+
+
+
+ Search results
+
+ {searchError ? (
+
+ {searchError}
+
+ ) : searchResults.length === 0 ? (
+
+ No matches yet
+
+ ) : (
+ searchResults.map((item, index) => (
+
+ item.requestId && router.push(`/requests/${item.requestId}`)
+ }
+ >
+ {item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
+ {!item.requestId
+ ? '- not requested'
+ : item.statusLabel
+ ? `- ${item.statusLabel}`
+ : '- already requested'}
+
+ ))
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx
new file mode 100644
index 0000000..8a81527
--- /dev/null
+++ b/frontend/app/portal/PortalClient.tsx
@@ -0,0 +1,1210 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+
+type PortalPermissions = {
+ can_edit?: boolean
+ can_comment?: boolean
+ can_moderate?: boolean
+}
+
+type PortalItem = {
+ id: number
+ kind: 'request' | 'issue' | 'feature'
+ title: string
+ description: string
+ media_type?: 'movie' | 'tv' | null
+ year?: number | null
+ external_ref?: string | null
+ source_system?: string | null
+ source_request_id?: number | null
+ status: string
+ priority: string
+ created_by_username: string
+ assignee_username?: string | null
+ created_at: string
+ updated_at: string
+ last_activity_at: string
+ permissions?: PortalPermissions
+ workflow?: {
+ request_status?: string
+ media_status?: string
+ stage_label?: string
+ is_terminal?: boolean
+ }
+ issue?: {
+ issue_type?: string
+ related_item_id?: number | null
+ is_resolved?: boolean
+ resolved_at?: string | null
+ }
+}
+
+type PortalComment = {
+ id: number
+ item_id: number
+ author_username: string
+ author_role: string
+ message: string
+ is_internal: boolean
+ created_at: string
+}
+
+type PortalOverview = {
+ overview?: {
+ total_items?: number
+ total_comments?: number
+ by_kind?: Record
+ by_status?: Record
+ }
+ my_items?: number
+}
+
+type UserProfile = {
+ username: string
+ role: string
+}
+
+type DiscoveryResult = {
+ title: string
+ year?: number | null
+ type?: 'movie' | 'tv' | null
+ tmdbId?: number | null
+ requestId?: number | null
+ statusLabel?: string | null
+ status?: number | null
+ accessible?: boolean
+ posterPath?: string | null
+ backdropPath?: string | null
+}
+
+const STATUS_OPTIONS = [
+ { value: 'new', label: 'New' },
+ { value: 'triaging', label: 'Triaging' },
+ { value: 'planned', label: 'Planned' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'blocked', label: 'Blocked' },
+ { value: 'done', label: 'Done' },
+ { value: 'pending', label: 'Pending approval' },
+ { value: 'approved', label: 'Approved' },
+ { value: 'processing', label: 'Processing' },
+ { value: 'partially_available', label: 'Partially available' },
+ { value: 'available', label: 'Available' },
+ { value: 'failed', label: 'Failed' },
+ { value: 'declined', label: 'Declined' },
+ { value: 'closed', label: 'Closed' },
+] as const
+
+const REQUEST_STATUS_OPTIONS = [
+ { value: 'pending', label: 'Pending approval' },
+ { value: 'approved', label: 'Approved' },
+ { value: 'declined', label: 'Declined' },
+] as const
+
+const MEDIA_STATUS_OPTIONS = [
+ { value: 'pending', label: 'Pending' },
+ { value: 'processing', label: 'Processing' },
+ { value: 'partially_available', label: 'Partially available' },
+ { value: 'available', label: 'Available' },
+ { value: 'failed', label: 'Failed' },
+ { value: 'unknown', label: 'Unknown' },
+] as const
+
+const PRIORITY_OPTIONS = [
+ { value: 'low', label: 'Low' },
+ { value: 'normal', label: 'Normal' },
+ { value: 'high', label: 'High' },
+ { value: 'urgent', label: 'Urgent' },
+] as const
+
+const MEDIA_TYPE_OPTIONS = [
+ { value: '', label: 'None' },
+ { value: 'movie', label: 'Movie' },
+ { value: 'tv', label: 'TV' },
+] as const
+
+const REQUEST_FILTER_STATUS_OPTIONS = [
+ { value: 'pending', label: 'Pending approval' },
+ { value: 'approved', label: 'Approved' },
+ { value: 'processing', label: 'Processing' },
+ { value: 'partially_available', label: 'Partially available' },
+ { value: 'available', label: 'Available' },
+ { value: 'failed', label: 'Failed' },
+ { value: 'declined', label: 'Declined' },
+] as const
+
+const ISSUE_FILTER_STATUS_OPTIONS = [
+ { value: 'new', label: 'New' },
+ { value: 'triaging', label: 'Triaging' },
+ { value: 'planned', label: 'Planned' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'blocked', label: 'Blocked' },
+ { value: 'done', label: 'Done' },
+ { value: 'closed', label: 'Closed' },
+] as const
+
+const formatDate = (value?: string | null) => {
+ if (!value) return 'Never'
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.valueOf())) return value
+ return parsed.toLocaleString()
+}
+
+const toPositiveInt = (value: string) => {
+ const parsed = Number.parseInt(value, 10)
+ if (Number.isNaN(parsed) || parsed <= 0) return null
+ return parsed
+}
+
+type PortalWorkspace = 'request' | 'issue'
+
+type PortalClientProps = {
+ workspace: PortalWorkspace
+}
+
+export default function PortalClient({ workspace }: PortalClientProps) {
+ const router = useRouter()
+ const [me, setMe] = useState(null)
+ const [overview, setOverview] = useState(null)
+ const [items, setItems] = useState([])
+ const [selectedItemId, setSelectedItemId] = useState(null)
+ const [selectedItem, setSelectedItem] = useState(null)
+ const [comments, setComments] = useState([])
+ const [loadingItems, setLoadingItems] = useState(true)
+ const [loadingItem, setLoadingItem] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [commenting, setCommenting] = useState(false)
+ const [error, setError] = useState(null)
+ const [status, setStatus] = useState(null)
+ const [totalItems, setTotalItems] = useState(0)
+ const [hasMore, setHasMore] = useState(false)
+
+ const filterKind = workspace
+ const [filterStatus, setFilterStatus] = useState('')
+ const [filterMine, setFilterMine] = useState(false)
+ const [filterSearch, setFilterSearch] = useState('')
+
+ const [createTitle, setCreateTitle] = useState('')
+ const [createDescription, setCreateDescription] = useState('')
+ const [createMediaType, setCreateMediaType] = useState('')
+ const [createYear, setCreateYear] = useState('')
+ const [createExternalRef, setCreateExternalRef] = useState('')
+ const [createPriority, setCreatePriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal')
+
+ const [editTitle, setEditTitle] = useState('')
+ const [editDescription, setEditDescription] = useState('')
+ const [editMediaType, setEditMediaType] = useState('')
+ const [editYear, setEditYear] = useState('')
+ const [editExternalRef, setEditExternalRef] = useState('')
+ const [editStatus, setEditStatus] = useState('new')
+ const [editRequestStatus, setEditRequestStatus] = useState('pending')
+ const [editMediaStatus, setEditMediaStatus] = useState('pending')
+ const [editPriority, setEditPriority] = useState('normal')
+ const [editAssignee, setEditAssignee] = useState('')
+
+ const [commentText, setCommentText] = useState('')
+ const [commentInternal, setCommentInternal] = useState(false)
+ const [preselectedItemId, setPreselectedItemId] = useState(null)
+ const [discoverQuery, setDiscoverQuery] = useState('')
+ const [discoverLoading, setDiscoverLoading] = useState(false)
+ const [discoverResults, setDiscoverResults] = useState([])
+ const [discoverError, setDiscoverError] = useState(null)
+ const [requestingTmdbIds, setRequestingTmdbIds] = useState>({})
+
+ const isAdmin = me?.role === 'admin'
+ const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
+ const workspaceLabel = workspace === 'request' ? 'request' : 'issue'
+ const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ const raw = new URLSearchParams(window.location.search).get('item')
+ if (!raw) {
+ setPreselectedItemId(null)
+ return
+ }
+ const parsed = Number.parseInt(raw, 10)
+ setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
+ }, [])
+
+ const loadMe = async () => {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/me`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return null
+ }
+ throw new Error(`Failed to load session (${response.status})`)
+ }
+ const data = await response.json()
+ const profile: UserProfile = {
+ username: data?.username ?? 'unknown',
+ role: data?.role ?? 'user',
+ }
+ setMe(profile)
+ return profile
+ }
+
+ const loadOverview = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/portal/overview`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Failed to load portal overview (${response.status})`)
+ }
+ const data = await response.json()
+ setOverview(data)
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ const loadItem = async (itemId: number) => {
+ setLoadingItem(true)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/portal/items/${itemId}`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (response.status === 404) {
+ setSelectedItem(null)
+ setComments([])
+ return
+ }
+ throw new Error(`Failed to load portal item (${response.status})`)
+ }
+ const data = await response.json()
+ const item = (data?.item ?? null) as PortalItem | null
+ setSelectedItem(item)
+ setComments(Array.isArray(data?.comments) ? data.comments : [])
+ } catch (err) {
+ console.error(err)
+ setError('Could not load portal item details.')
+ } finally {
+ setLoadingItem(false)
+ }
+ }
+
+ const loadItems = async (options?: { preferItemId?: number | null }) => {
+ setLoadingItems(true)
+ try {
+ const baseUrl = getApiBase()
+ const params = new URLSearchParams({
+ limit: '60',
+ offset: '0',
+ })
+ params.set('kind', filterKind)
+ if (filterStatus) params.set('status', filterStatus)
+ if (filterMine) params.set('mine', '1')
+ const trimmedSearch = filterSearch.trim()
+ if (trimmedSearch) params.set('search', trimmedSearch)
+
+ const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Failed to load portal items (${response.status})`)
+ }
+ const data = await response.json()
+ const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : []
+ setItems(loadedItems)
+ setTotalItems(Number(data?.total ?? loadedItems.length ?? 0))
+ setHasMore(Boolean(data?.has_more))
+
+ const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId
+ if (preferred && loadedItems.some((item) => item.id === preferred)) {
+ setSelectedItemId(preferred)
+ } else if (loadedItems.length > 0) {
+ setSelectedItemId(loadedItems[0].id)
+ } else {
+ setSelectedItemId(null)
+ setSelectedItem(null)
+ setComments([])
+ }
+ } catch (err) {
+ console.error(err)
+ setError('Could not load portal items.')
+ } finally {
+ setLoadingItems(false)
+ }
+ }
+
+ const resolveTmdbArtworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w185') => {
+ if (!path) return null
+ const normalized = path.startsWith('/') ? path : `/${path}`
+ return `https://image.tmdb.org/t/p/${size}${normalized}`
+ }
+
+ const runDiscoverySearch = async (event?: React.FormEvent) => {
+ if (event) event.preventDefault()
+ const query = discoverQuery.trim()
+ if (!query) {
+ setDiscoverResults([])
+ setDiscoverError('Enter a title to search.')
+ return
+ }
+ setDiscoverLoading(true)
+ setDiscoverError(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || `Search failed (${response.status})`)
+ }
+ const data = await response.json()
+ const mapped: DiscoveryResult[] = Array.isArray(data?.results)
+ ? data.results.map((item: any) => ({
+ title: item?.title ?? 'Untitled',
+ year: typeof item?.year === 'number' ? item.year : null,
+ type: item?.type === 'movie' || item?.type === 'tv' ? item.type : null,
+ tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null,
+ requestId: typeof item?.requestId === 'number' ? item.requestId : null,
+ statusLabel: item?.statusLabel ?? null,
+ status: typeof item?.status === 'number' ? item.status : null,
+ accessible: Boolean(item?.accessible),
+ posterPath: item?.posterPath ?? null,
+ backdropPath: item?.backdropPath ?? null,
+ }))
+ : []
+ setDiscoverResults(mapped)
+ } catch (err) {
+ console.error(err)
+ setDiscoverResults([])
+ setDiscoverError(err instanceof Error ? err.message : 'Search failed.')
+ } finally {
+ setDiscoverLoading(false)
+ }
+ }
+
+ const requestDiscoveryItem = async (item: DiscoveryResult) => {
+ if (!item.tmdbId || !item.type) {
+ setError('Could not request this result because required media details are missing.')
+ return
+ }
+ const key = `${item.type}:${item.tmdbId}`
+ setRequestingTmdbIds((prev) => ({ ...prev, [key]: true }))
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/requests/create`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ mediaType: item.type,
+ tmdbId: item.tmdbId,
+ }),
+ })
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || `Request failed (${response.status})`)
+ }
+ const data = await response.json()
+ const requestId = typeof data?.requestId === 'number' ? data.requestId : null
+ const statusLabel = typeof data?.statusLabel === 'string' ? data.statusLabel : item.statusLabel
+ const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : item.status
+ setDiscoverResults((prev) =>
+ prev.map((entry) =>
+ entry.tmdbId === item.tmdbId && entry.type === item.type
+ ? {
+ ...entry,
+ requestId,
+ statusLabel,
+ status: statusCode,
+ accessible: true,
+ }
+ : entry
+ )
+ )
+ if (requestId) {
+ const mode = data?.status === 'exists' ? 'already exists' : 'created'
+ setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`)
+ } else {
+ setStatus('Request submitted.')
+ }
+ await Promise.all([loadItems(), loadOverview()])
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not create request.')
+ } finally {
+ setRequestingTmdbIds((prev) => {
+ const next = { ...prev }
+ delete next[key]
+ return next
+ })
+ }
+ }
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ const bootstrap = async () => {
+ try {
+ setError(null)
+ await loadMe()
+ await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })])
+ } catch (err) {
+ console.error(err)
+ setError('Could not load request portal.')
+ }
+ }
+ void bootstrap()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [router])
+
+ useEffect(() => {
+ if (!getToken()) {
+ return
+ }
+ void loadItems({ preferItemId: preselectedItemId })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [filterStatus, filterMine, filterSearch, workspace])
+
+ useEffect(() => {
+ setFilterStatus('')
+ setCreateMediaType('')
+ setCreateYear('')
+ setSelectedItemId(null)
+ setSelectedItem(null)
+ setComments([])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [workspace])
+
+ useEffect(() => {
+ if (selectedItemId == null) return
+ void loadItem(selectedItemId)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedItemId])
+
+ useEffect(() => {
+ if (!selectedItem) return
+ setEditTitle(selectedItem.title ?? '')
+ setEditDescription(selectedItem.description ?? '')
+ setEditMediaType(selectedItem.media_type ?? '')
+ setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
+ setEditExternalRef(selectedItem.external_ref ?? '')
+ setEditStatus(selectedItem.status ?? 'new')
+ setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
+ setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
+ setEditPriority(selectedItem.priority ?? 'normal')
+ setEditAssignee(selectedItem.assignee_username ?? '')
+ }, [selectedItem])
+
+ const createItem = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setCreating(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const payload: Record = {
+ kind: workspace,
+ title: createTitle,
+ description: createDescription,
+ media_type: workspace === 'request' ? createMediaType || null : null,
+ year: workspace === 'request' && createYear.trim() ? toPositiveInt(createYear) : null,
+ external_ref: createExternalRef || null,
+ priority: createPriority,
+ }
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/portal/items`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || 'Could not create portal item.')
+ }
+ const data = await response.json()
+ const item = data?.item as PortalItem | undefined
+ setStatus(workspace === 'request' ? 'Request item created.' : 'Issue item created.')
+ setCreateTitle('')
+ setCreateDescription('')
+ setCreateMediaType('')
+ setCreateYear('')
+ setCreateExternalRef('')
+ setCreatePriority('normal')
+ await Promise.all([
+ loadItems({ preferItemId: item?.id ?? null }),
+ loadOverview(),
+ ])
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not create portal item.')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const saveItem = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!selectedItem) return
+ setSaving(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const payload: Record = {
+ title: editTitle,
+ description: editDescription,
+ media_type: editMediaType || null,
+ year: editYear.trim() ? toPositiveInt(editYear) : null,
+ external_ref: editExternalRef || null,
+ }
+ if (selectedItem.permissions?.can_moderate) {
+ if (selectedItem.kind === 'request') {
+ payload.request_status = editRequestStatus
+ payload.media_status = editMediaStatus
+ } else {
+ payload.status = editStatus
+ }
+ payload.priority = editPriority
+ payload.assignee_username = editAssignee || null
+ }
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || 'Could not update portal item.')
+ }
+ const data = await response.json()
+ setSelectedItem((data?.item ?? null) as PortalItem | null)
+ setComments(Array.isArray(data?.comments) ? data.comments : [])
+ setStatus('Portal item updated.')
+ await Promise.all([
+ loadItems({ preferItemId: selectedItem.id }),
+ loadOverview(),
+ ])
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not update portal item.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const postComment = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!selectedItem) return
+ if (!commentText.trim()) {
+ setError('Comment message is required.')
+ return
+ }
+ setCommenting(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ message: commentText,
+ is_internal: commentInternal,
+ }),
+ })
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || 'Could not add comment.')
+ }
+ setCommentText('')
+ setCommentInternal(false)
+ setStatus('Comment added.')
+ await Promise.all([
+ loadItem(selectedItem.id),
+ loadItems({ preferItemId: selectedItem.id }),
+ loadOverview(),
+ ])
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not add comment.')
+ } finally {
+ setCommenting(false)
+ }
+ }
+
+ if (loadingItems && !items.length) {
+ return Loading request portal...
+ }
+
+ return (
+
+
+
+
{workspace === 'request' ? 'Request portal' : 'Issue portal'}
+
+ {workspace === 'request'
+ ? 'Search and track content requests through the delivery pipeline.'
+ : 'Raise operational issues and manage resolution updates.'}
+
+
+
+
+
+ router.push('/portal/requests')}
+ disabled={workspace === 'request'}
+ >
+ Requests
+
+ router.push('/portal/issues')}
+ disabled={workspace === 'issue'}
+ >
+ Issues
+
+
+
+ {error && {error}
}
+ {status && {status}
}
+
+ {workspace === 'request' ? (
+
+
+
+
Search and request content
+
+ Search Seerr content directly, then submit a request in one click.
+
+
+
+
+ setDiscoverQuery(event.target.value)}
+ placeholder="Search movies or TV shows"
+ />
+
+ {discoverLoading ? 'Searching…' : 'Search'}
+
+
+ {discoverError && {discoverError}
}
+
+ {discoverLoading ? (
+
Searching Seerr…
+ ) : discoverResults.length === 0 ? (
+
No discovery results yet.
+ ) : (
+ discoverResults.map((item, index) => {
+ const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}`
+ const requesting = Boolean(requestingTmdbIds[key])
+ const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185')
+ const hasRequest = typeof item.requestId === 'number' && item.requestId > 0
+ return (
+
+
+ {poster ?
:
No artwork
}
+
+
+
+ {item.title || 'Untitled'}
+ {item.type ?? 'unknown'}
+ {item.year ? {item.year} : null}
+
+
+ {hasRequest ? (
+ <>
+ Already requested
+ {item.statusLabel ? ` · ${item.statusLabel}` : ''}
+ {item.requestId ? ` · #${item.requestId}` : ''}
+ >
+ ) : (
+ 'Not requested yet'
+ )}
+
+
+
+ {hasRequest ? (
+ router.push(`/requests/${item.requestId}`)}
+ >
+ Open request
+
+ ) : (
+ void requestDiscoveryItem(item)}
+ disabled={requesting || !item.tmdbId || !item.type}
+ >
+ {requesting ? 'Requesting…' : 'Request'}
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+ ) : (
+
+
+ Issue workspace is for reporting problems and tracking resolution separately from content requests.
+
+
+ )}
+
+
+
+ Total {workspace === 'request' ? 'requests' : 'issues'}
+ {visibleKindCount}
+
+
+ Total comments
+ {Number(overview?.overview?.total_comments ?? 0)}
+
+
+ My items
+ {Number(overview?.my_items ?? 0)}
+
+
+ Visible
+ {items.length}
+
+
+
+
+
+
+
+
+
+
+
+
{workspace === 'request' ? 'Requests' : 'Issues'}
+
+ {totalItems} total {workspaceLabelPlural}
+ {hasMore ? ' (showing first 60)' : ''}
+
+
+
+ {items.length === 0 ? (
+
+ No {workspaceLabelPlural} match this filter.
+
+ ) : (
+
+ {items.map((item) => (
+
setSelectedItemId(item.id)}
+ >
+
+
+ {item.title}
+ {item.kind}
+ {item.priority}
+
+
{item.description}
+
+ #{item.id}
+
+ Status:{' '}
+ {item.kind === 'request'
+ ? item.workflow?.stage_label ?? item.status
+ : item.status}
+
+ By: {item.created_by_username}
+ Updated: {formatDate(item.last_activity_at)}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {!selectedItemId ? (
+
+ Select a {workspaceLabel} to view details.
+
+ ) : loadingItem ? (
+ Loading details…
+ ) : !selectedItem ? (
+
+ {workspace === 'request' ? 'Request' : 'Issue'} not found.
+
+ ) : (
+ <>
+
+
+
+ {selectedItem.kind === 'request' ? 'Request' : 'Issue'} #{selectedItem.id}
+
+
+ Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
+
+ {selectedItem.kind === 'request' && (
+
+ Pipeline:{' '}
+
+ {selectedItem.workflow?.request_status ?? 'pending'} /{' '}
+ {selectedItem.workflow?.media_status ?? 'pending'}
+ {' '}
+ ({selectedItem.workflow?.stage_label ?? 'Pending'})
+
+ )}
+
+
+
+
+
+ Title
+ setEditTitle(event.target.value)}
+ disabled={!selectedItem.permissions?.can_edit}
+ />
+
+
+ Description
+ setEditDescription(event.target.value)}
+ disabled={!selectedItem.permissions?.can_edit}
+ />
+
+ {selectedItem.kind === 'request' ? (
+ <>
+
+ Media type
+ setEditMediaType(event.target.value)}
+ disabled={!selectedItem.permissions?.can_edit}
+ >
+ {MEDIA_TYPE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Year
+ setEditYear(event.target.value)}
+ inputMode="numeric"
+ disabled={!selectedItem.permissions?.can_edit}
+ />
+
+ >
+ ) : null}
+
+ External reference
+ setEditExternalRef(event.target.value)}
+ disabled={!selectedItem.permissions?.can_edit}
+ />
+
+ {selectedItem.permissions?.can_moderate && (
+ <>
+ {selectedItem.kind === 'request' ? (
+ <>
+
+ Request status
+ setEditRequestStatus(event.target.value)}
+ >
+ {REQUEST_STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Media status
+ setEditMediaStatus(event.target.value)}
+ >
+ {MEDIA_STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ >
+ ) : (
+
+ Status
+ setEditStatus(event.target.value)}>
+ {STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+ Priority
+ setEditPriority(event.target.value)}
+ >
+ {PRIORITY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Assignee username
+ setEditAssignee(event.target.value)}
+ placeholder="Optional assignee"
+ />
+
+ >
+ )}
+
+
+ {saving ? 'Saving…' : 'Save changes'}
+
+
+
+
+
+
Comments
+ {comments.length === 0 ? (
+
No comments yet.
+ ) : (
+
+ {comments.map((comment) => (
+
+
+ {comment.author_username}
+ {comment.author_role}
+ {comment.is_internal && internal }
+ {formatDate(comment.created_at)}
+
+ {comment.message}
+
+ ))}
+
+ )}
+
+
+ Add comment
+ setCommentText(event.target.value)}
+ placeholder="Add an update, troubleshooting note, or next step."
+ />
+
+ {isAdmin && (
+
+ setCommentInternal(event.target.checked)}
+ />
+ Internal comment (admin only)
+
+ )}
+
+
+ {commenting ? 'Posting…' : 'Post comment'}
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/frontend/app/portal/issues/page.tsx b/frontend/app/portal/issues/page.tsx
new file mode 100644
index 0000000..90d58fb
--- /dev/null
+++ b/frontend/app/portal/issues/page.tsx
@@ -0,0 +1,6 @@
+import PortalClient from '../PortalClient'
+
+export default function IssuePortalPage() {
+ return
+}
+
diff --git a/frontend/app/portal/page.tsx b/frontend/app/portal/page.tsx
new file mode 100644
index 0000000..cb1275f
--- /dev/null
+++ b/frontend/app/portal/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from 'next/navigation'
+
+export default function PortalIndexPage() {
+ redirect('/portal/requests')
+}
+
diff --git a/frontend/app/portal/requests/page.tsx b/frontend/app/portal/requests/page.tsx
new file mode 100644
index 0000000..704ae85
--- /dev/null
+++ b/frontend/app/portal/requests/page.tsx
@@ -0,0 +1,6 @@
+import PortalClient from '../PortalClient'
+
+export default function RequestPortalPage() {
+ return
+}
+
diff --git a/frontend/app/profile/invites/page.tsx b/frontend/app/profile/invites/page.tsx
new file mode 100644
index 0000000..e4e14db
--- /dev/null
+++ b/frontend/app/profile/invites/page.tsx
@@ -0,0 +1,638 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
+
+type ProfileInfo = {
+ username: string
+ role: string
+ auth_provider: string
+ invite_management_enabled?: boolean
+}
+
+type ProfileResponse = {
+ user: ProfileInfo
+}
+
+type OwnedInvite = {
+ id: number
+ code: string
+ label?: string | null
+ description?: string | null
+ recipient_email?: string | null
+ max_uses?: number | null
+ use_count: number
+ remaining_uses?: number | null
+ enabled: boolean
+ expires_at?: string | null
+ is_expired?: boolean
+ is_usable?: boolean
+ created_at?: string | null
+ updated_at?: string | null
+}
+
+type OwnedInvitesResponse = {
+ invites?: OwnedInvite[]
+ count?: number
+ invite_access?: {
+ enabled?: boolean
+ managed_by_master?: boolean
+ }
+ master_invite?: {
+ id: number
+ code: string
+ label?: string | null
+ description?: string | null
+ max_uses?: number | null
+ enabled?: boolean
+ expires_at?: string | null
+ is_usable?: boolean
+ } | null
+}
+
+type OwnedInviteForm = {
+ code: string
+ label: string
+ description: string
+ recipient_email: string
+ max_uses: string
+ expires_at: string
+ enabled: boolean
+ send_email: boolean
+ message: string
+}
+
+const defaultOwnedInviteForm = (): OwnedInviteForm => ({
+ code: '',
+ label: '',
+ description: '',
+ recipient_email: '',
+ max_uses: '',
+ expires_at: '',
+ enabled: true,
+ send_email: false,
+ message: '',
+})
+
+const formatDate = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
+
+export default function ProfileInvitesPage() {
+ const router = useRouter()
+ const [profile, setProfile] = useState(null)
+ const [inviteStatus, setInviteStatus] = useState(null)
+ const [inviteError, setInviteError] = useState(null)
+ const [invites, setInvites] = useState([])
+ const [inviteSaving, setInviteSaving] = useState(false)
+ const [inviteEditingId, setInviteEditingId] = useState(null)
+ const [inviteForm, setInviteForm] = useState(defaultOwnedInviteForm())
+ const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
+ const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
+ const [masterInviteTemplate, setMasterInviteTemplate] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ const signupBaseUrl = useMemo(() => {
+ if (typeof window === 'undefined') return '/signup'
+ return `${window.location.origin}/signup`
+ }, [])
+
+ const loadPage = async () => {
+ const baseUrl = getApiBase()
+ const [profileResponse, invitesResponse] = await Promise.all([
+ authFetch(`${baseUrl}/auth/profile`),
+ authFetch(`${baseUrl}/auth/profile/invites`),
+ ])
+ if (!profileResponse.ok || !invitesResponse.ok) {
+ if (profileResponse.status === 401 || invitesResponse.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error('Could not load invite tools.')
+ }
+ const [profileData, inviteData] = (await Promise.all([
+ profileResponse.json(),
+ invitesResponse.json(),
+ ])) as [ProfileResponse, OwnedInvitesResponse]
+ const user = profileData?.user ?? {}
+ setProfile({
+ username: user?.username ?? 'Unknown',
+ role: user?.role ?? 'user',
+ auth_provider: user?.auth_provider ?? 'local',
+ invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
+ })
+ setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
+ setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
+ setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
+ setMasterInviteTemplate(inviteData?.master_invite ?? null)
+ }
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ const load = async () => {
+ try {
+ await loadPage()
+ } catch (err) {
+ console.error(err)
+ setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
+ } finally {
+ setLoading(false)
+ }
+ }
+ void load()
+ }, [router])
+
+ const resetInviteEditor = () => {
+ setInviteEditingId(null)
+ setInviteForm(defaultOwnedInviteForm())
+ }
+
+ const editInvite = (invite: OwnedInvite) => {
+ setInviteEditingId(invite.id)
+ setInviteError(null)
+ setInviteStatus(null)
+ setInviteForm({
+ code: invite.code ?? '',
+ label: invite.label ?? '',
+ description: invite.description ?? '',
+ recipient_email: invite.recipient_email ?? '',
+ max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
+ expires_at: invite.expires_at ?? '',
+ enabled: invite.enabled !== false,
+ send_email: false,
+ message: '',
+ })
+ }
+
+ const reloadInvites = async () => {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/profile/invites`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ throw new Error(`Invite refresh failed: ${response.status}`)
+ }
+ const data = (await response.json()) as OwnedInvitesResponse
+ setInvites(Array.isArray(data?.invites) ? data.invites : [])
+ setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
+ setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
+ setMasterInviteTemplate(data?.master_invite ?? null)
+ }
+
+ const saveInvite = async (event: React.FormEvent) => {
+ event.preventDefault()
+ const recipientEmail = inviteForm.recipient_email.trim()
+ if (!recipientEmail) {
+ setInviteError('Recipient email is required.')
+ setInviteStatus(null)
+ return
+ }
+ if (!isValidEmail(recipientEmail)) {
+ setInviteError('Recipient email must be valid.')
+ setInviteStatus(null)
+ return
+ }
+ setInviteSaving(true)
+ setInviteError(null)
+ setInviteStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ inviteEditingId == null
+ ? `${baseUrl}/auth/profile/invites`
+ : `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
+ {
+ method: inviteEditingId == null ? 'POST' : 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ code: inviteForm.code || null,
+ label: inviteForm.label || null,
+ description: inviteForm.description || null,
+ recipient_email: recipientEmail,
+ max_uses: inviteForm.max_uses || null,
+ expires_at: inviteForm.expires_at || null,
+ enabled: inviteForm.enabled,
+ send_email: inviteForm.send_email,
+ message: inviteForm.message || null,
+ }),
+ }
+ )
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || 'Invite save failed')
+ }
+ const data = await response.json().catch(() => ({}))
+ if (data?.email?.status === 'ok') {
+ setInviteStatus(
+ `${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
+ )
+ } else if (data?.email?.status === 'error') {
+ setInviteStatus(
+ `${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
+ )
+ } else {
+ setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
+ }
+ resetInviteEditor()
+ await reloadInvites()
+ } catch (err) {
+ console.error(err)
+ setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
+ } finally {
+ setInviteSaving(false)
+ }
+ }
+
+ const deleteInvite = async (invite: OwnedInvite) => {
+ if (!window.confirm(`Delete invite "${invite.code}"?`)) return
+ setInviteError(null)
+ setInviteStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
+ method: 'DELETE',
+ })
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const text = await response.text()
+ throw new Error(text || 'Invite delete failed')
+ }
+ if (inviteEditingId === invite.id) {
+ resetInviteEditor()
+ }
+ setInviteStatus(`Deleted invite ${invite.code}.`)
+ await reloadInvites()
+ } catch (err) {
+ console.error(err)
+ setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
+ }
+ }
+
+ const copyInviteLink = async (invite: OwnedInvite) => {
+ const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(url)
+ setInviteStatus(`Copied invite link for ${invite.code}.`)
+ } else {
+ window.prompt('Copy invite link', url)
+ }
+ } catch (err) {
+ console.error(err)
+ window.prompt('Copy invite link', url)
+ }
+ }
+
+ const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
+
+ if (loading) {
+ return Loading invite tools...
+ }
+
+ return (
+
+
+
+
My invites
+
Create invite links, email them directly, and track who you have invited.
+
+
+ router.push('/profile')}>
+ Back to profile
+
+
+
+
+ {profile ? (
+
+ Signed in as {profile.username} ({profile.role}).
+
+ ) : null}
+
+
+
+ router.push('/profile')}>
+ Overview
+
+ router.push('/profile?tab=activity')}
+ >
+ Activity
+
+
+ My invites
+
+ router.push('/profile?tab=security')}
+ >
+ Security
+
+
+
+
+ {inviteError && {inviteError}
}
+ {inviteStatus && {inviteStatus}
}
+
+ {!canManageInvites ? (
+
+ Invite access is disabled
+
+ Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
+
+
+ router.push('/profile')}>
+ Return to profile
+
+
+
+ ) : (
+
+
+
+
Invite workspace
+
+ {inviteManagedByMaster
+ ? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
+ : 'Create and manage invite links you have issued. New invites use your account defaults.'}
+
+
+
+
+
+
+
{inviteEditingId == null ? 'Create invite' : 'Edit invite'}
+
+ Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
+
+ {inviteManagedByMaster && masterInviteTemplate ? (
+
+ Using master invite rule {masterInviteTemplate.code}
+ {masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
+
+ ) : null}
+
+
+
+
+
+ Description
+ Optional note shown on the signup page.
+
+
+
+ setInviteForm((current) => ({
+ ...current,
+ description: event.target.value,
+ }))
+ }
+ placeholder="Optional note shown on the signup page"
+ />
+
+
+
+
+
+
+
+
+
+ Status
+ Enable or disable this invite before sharing.
+
+
+
+
+ setInviteForm((current) => ({
+ ...current,
+ enabled: event.target.checked,
+ }))
+ }
+ disabled={inviteManagedByMaster}
+ />
+ Invite is enabled
+
+
+
+ {inviteSaving
+ ? 'Saving…'
+ : inviteEditingId == null
+ ? 'Create invite'
+ : 'Save invite'}
+
+ {inviteEditingId != null && (
+
+ Cancel edit
+
+ )}
+
+
+
+
+
+ Invite URL format: {signupBaseUrl}?code=INVITECODE
+
+
+
+
+ {invites.length === 0 ? (
+
You have not created any invites yet.
+ ) : (
+
+ {invites.map((invite) => (
+
+
+
+ {invite.code}
+
+ {invite.is_usable ? 'Usable' : 'Unavailable'}
+
+
+ {invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
+
+
+ {invite.label &&
{invite.label}
}
+ {invite.description && (
+
+ {invite.description}
+
+ )}
+
+ Recipient: {invite.recipient_email || 'Not set'}
+
+ Uses: {invite.use_count}
+ {typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
+
+ Expires: {formatDate(invite.expires_at)}
+ Created: {formatDate(invite.created_at)}
+
+
+
+ copyInviteLink(invite)}
+ >
+ Copy link
+
+ editInvite(invite)}
+ >
+ Edit
+
+ deleteInvite(invite)}>
+ Delete
+
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx
new file mode 100644
index 0000000..064802c
--- /dev/null
+++ b/frontend/app/profile/page.tsx
@@ -0,0 +1,461 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+
+type ProfileInfo = {
+ username: string
+ role: string
+ auth_provider: string
+ invite_management_enabled?: boolean
+ password_change_supported?: boolean
+ password_provider?: 'local' | 'jellyfin' | null
+}
+
+type ProfileStats = {
+ total: number
+ ready: number
+ pending: number
+ in_progress: number
+ declined: number
+ working: number
+ partial: number
+ approved: number
+ last_request_at?: string | null
+ share: number
+ global_total: number
+ most_active_user?: { username: string; total: number } | null
+}
+
+type ActivityEntry = {
+ ip: string
+ user_agent: string
+ first_seen_at: string
+ last_seen_at: string
+ hit_count: number
+}
+
+type ProfileActivity = {
+ last_ip?: string | null
+ last_user_agent?: string | null
+ last_seen_at?: string | null
+ device_count: number
+ recent: ActivityEntry[]
+}
+
+type ProfileResponse = {
+ user: ProfileInfo
+ stats: ProfileStats
+ activity: ProfileActivity
+}
+
+type ProfileTab = 'overview' | 'activity' | 'security'
+
+const normalizeProfileTab = (value?: string | null): ProfileTab => {
+ if (value === 'activity' || value === 'security') {
+ return value
+ }
+ return 'overview'
+}
+
+const formatDate = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const parseBrowser = (agent?: string | null) => {
+ if (!agent) return 'Unknown'
+ const value = agent.toLowerCase()
+ if (value.includes('edg/')) return 'Edge'
+ if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
+ if (value.includes('firefox/')) return 'Firefox'
+ if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
+ return 'Unknown'
+}
+
+export default function ProfilePage() {
+ const router = useRouter()
+ const [profile, setProfile] = useState(null)
+ const [stats, setStats] = useState(null)
+ const [activity, setActivity] = useState(null)
+ const [currentPassword, setCurrentPassword] = useState('')
+ const [newPassword, setNewPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
+ const [activeTab, setActiveTab] = useState('overview')
+ const [loading, setLoading] = useState(true)
+
+ const inviteLink = useMemo(() => '/profile/invites', [])
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ const syncTabFromLocation = () => {
+ const params = new URLSearchParams(window.location.search)
+ setActiveTab(normalizeProfileTab(params.get('tab')))
+ }
+ syncTabFromLocation()
+ window.addEventListener('popstate', syncTabFromLocation)
+ return () => window.removeEventListener('popstate', syncTabFromLocation)
+ }, [])
+
+ const selectTab = (tab: ProfileTab) => {
+ setActiveTab(tab)
+ router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
+ }
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ const load = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
+ if (!profileResponse.ok) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ const data = (await profileResponse.json()) as ProfileResponse
+ const user = data?.user ?? {}
+ setProfile({
+ username: user?.username ?? 'Unknown',
+ role: user?.role ?? 'user',
+ auth_provider: user?.auth_provider ?? 'local',
+ invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
+ password_change_supported: Boolean(user?.password_change_supported ?? false),
+ password_provider:
+ user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
+ ? user.password_provider
+ : null,
+ })
+ setStats(data?.stats ?? null)
+ setActivity(data?.activity ?? null)
+ } catch (err) {
+ console.error(err)
+ setStatus({ tone: 'error', message: 'Could not load your profile.' })
+ } finally {
+ setLoading(false)
+ }
+ }
+ void load()
+ }, [router])
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setStatus(null)
+ if (!currentPassword || !newPassword) {
+ setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
+ return
+ }
+ if (newPassword !== confirmPassword) {
+ setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
+ return
+ }
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ current_password: currentPassword,
+ new_password: newPassword,
+ }),
+ })
+ if (!response.ok) {
+ let detail = 'Update failed'
+ try {
+ const payload = await response.json()
+ if (typeof payload?.detail === 'string' && payload.detail.trim()) {
+ detail = payload.detail
+ }
+ } catch {
+ const text = await response.text().catch(() => '')
+ if (text?.trim()) detail = text
+ }
+ throw new Error(detail)
+ }
+ const data = await response.json().catch(() => ({}))
+ setCurrentPassword('')
+ setNewPassword('')
+ setConfirmPassword('')
+ setStatus({
+ tone: 'status',
+ message:
+ data?.provider === 'jellyfin'
+ ? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
+ : 'Password updated.',
+ })
+ } catch (err) {
+ console.error(err)
+ if (err instanceof Error && err.message) {
+ setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
+ } else {
+ setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
+ }
+ }
+ }
+
+ const authProvider = profile?.auth_provider ?? 'local'
+ const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
+ const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
+ const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
+ const securityHelpText =
+ passwordProvider === 'jellyfin'
+ ? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
+ : passwordProvider === 'local'
+ ? 'Change your Magent account password.'
+ : 'Password changes are not available for this sign-in provider.'
+
+ if (loading) {
+ return Loading profile...
+ }
+
+ return (
+
+
+
+
My profile
+
Review your account, activity, and security settings.
+
+ {canManageInvites || canChangePassword ? (
+
+ {canManageInvites ? (
+ router.push(inviteLink)}>
+ Open invite page
+
+ ) : null}
+ {canChangePassword ? (
+ selectTab('security')}>
+ {passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
+
+ ) : null}
+
+ ) : null}
+
+
+ {profile && (
+
+ Signed in as {profile.username} ({profile.role}). Login type:{' '}
+ {profile.auth_provider}.
+
+ )}
+
+
+
+ selectTab('overview')}
+ >
+ Overview
+
+ selectTab('activity')}
+ >
+ Activity
+
+ {canManageInvites ? (
+ router.push(inviteLink)}>
+ My invites
+
+ ) : null}
+ selectTab('security')}
+ >
+ Password
+
+
+
+
+ {activeTab === 'overview' && (
+
+ {canManageInvites ? (
+
+
+
Invite tools
+
+ Create invite links, send them by email, and track who you have invited from a dedicated page.
+
+
+
+ router.push(inviteLink)}>
+ Go to invites
+
+
+
+ ) : null}
+ {canChangePassword ? (
+
+
+
{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}
+
+ {passwordProvider === 'jellyfin'
+ ? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
+ : 'Update your Magent account password.'}
+
+
+
+ selectTab('security')}>
+ {passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
+
+
+
+ ) : null}
+ Account stats
+
+
+
Requests submitted
+
{stats?.total ?? 0}
+
+
+
Ready to watch
+
{stats?.ready ?? 0}
+
+
+
In progress
+
{stats?.in_progress ?? 0}
+
+
+
Pending approval
+
{stats?.pending ?? 0}
+
+
+
Declined
+
{stats?.declined ?? 0}
+
+
+
Working
+
{stats?.working ?? 0}
+
+
+
Partial
+
{stats?.partial ?? 0}
+
+
+
Approved
+
{stats?.approved ?? 0}
+
+
+
Last request
+
+ {formatDate(stats?.last_request_at)}
+
+
+
+
Share of all requests
+
+ {stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
+
+
+
+
Total requests (global)
+
{stats?.global_total ?? 0}
+
+ {profile?.role === 'admin' ? (
+
+
Most active user
+
+ {stats?.most_active_user
+ ? `${stats.most_active_user.username} (${stats.most_active_user.total})`
+ : 'N/A'}
+
+
+ ) : null}
+
+
+ )}
+
+ {activeTab === 'activity' && (
+
+ Connection history
+
+ Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
+
+
+ {(activity?.recent ?? []).map((entry, index) => (
+
+
+
{parseBrowser(entry.user_agent)}
+
IP: {entry.ip}
+
First seen: {formatDate(entry.first_seen_at)}
+
Last seen: {formatDate(entry.last_seen_at)}
+
+
{entry.hit_count} visits
+
+ ))}
+ {activity && activity.recent.length === 0 ? (
+
No connection history yet.
+ ) : null}
+
+
+ )}
+
+ {activeTab === 'security' && (
+
+ )}
+
+ )
+}
diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx
new file mode 100644
index 0000000..39c6b5a
--- /dev/null
+++ b/frontend/app/requests/[id]/page.tsx
@@ -0,0 +1,598 @@
+'use client'
+
+import Image from 'next/image'
+import { useParams, useRouter } from 'next/navigation'
+import { useEffect, useMemo, useState } from 'react'
+import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
+
+type TimelineHop = {
+ service: string
+ status: string
+ details?: Record
+}
+
+type RequestAction = {
+ id: string
+ label: string
+ risk: string
+ description?: string
+ requires_confirmation: boolean
+}
+
+type PipelineStage = {
+ id: string
+ label: string
+ state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
+ summary: string
+ available?: number
+ missing?: number
+ total?: number
+ seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>
+ missingEpisodes?: Record
+ actionIds?: string[]
+ visible?: boolean
+ torrents?: Array>
+ link?: string | null
+}
+
+type Snapshot = {
+ request_id: string
+ title: string
+ year?: number
+ request_type: string
+ state: string
+ state_reason?: string
+ timeline: TimelineHop[]
+ actions: RequestAction[]
+ artwork?: { poster_url?: string; backdrop_url?: string }
+ presentation?: {
+ status?: { label?: string; meaning?: string }
+ download?: {
+ visible?: boolean
+ state?: string
+ summary?: string
+ torrents?: Array>
+ lastSeenAt?: string | null
+ }
+ nextStep?: { title?: string; description?: string; actionIds?: string[] }
+ pipeline?: PipelineStage[]
+ }
+ raw?: Record
+}
+
+type ReleaseOption = {
+ title?: string
+ indexer?: string
+ indexerId?: number
+ guid?: string
+ size?: number
+ seeders?: number
+ leechers?: number
+ protocol?: string
+ publishDate?: string
+ infoUrl?: string
+ downloadUrl?: string
+}
+
+type SnapshotHistory = {
+ request_id: string
+ state: string
+ state_reason?: string
+ created_at: string
+}
+
+type ActionHistory = {
+ request_id: string
+ action_id: string
+ label: string
+ status: string
+ message?: string
+ created_at: string
+}
+
+const readApiError = async (response: Response, fallback: string) => {
+ try {
+ const contentType = response.headers.get('content-type') ?? ''
+ if (contentType.includes('application/json')) {
+ const payload = await response.json()
+ if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
+ if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message
+ } else {
+ const text = await response.text()
+ if (text.trim()) return text.trim()
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ return fallback
+}
+
+const isSnapshotPayload = (value: unknown): value is Snapshot => {
+ if (!value || typeof value !== 'object') return false
+ const snapshot = value as Partial
+ return (
+ typeof snapshot.request_id === 'string' &&
+ typeof snapshot.title === 'string' &&
+ typeof snapshot.request_type === 'string' &&
+ typeof snapshot.state === 'string' &&
+ Array.isArray(snapshot.timeline) &&
+ Array.isArray(snapshot.actions)
+ )
+}
+
+const formatBytes = (value?: number) => {
+ if (!value || Number.isNaN(value)) return 'Size unavailable'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ let size = value
+ let index = 0
+ while (size >= 1024 && index < units.length - 1) {
+ size /= 1024
+ index += 1
+ }
+ return `${size.toFixed(1)} ${units[index]}`
+}
+
+const torrentProgress = (torrent: Record) => {
+ const supplied = Number(torrent.progressPercent)
+ if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied)
+ const progress = Number(torrent.progress)
+ if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 100)
+ return null
+}
+
+const fallbackStatusLabel = (state: string) => {
+ const labels: Record = {
+ REQUESTED: 'Waiting for approval',
+ APPROVED: 'Approved — preparing collection',
+ NEEDS_ADD: 'Approved, but not yet in the library queue',
+ ADDED_TO_ARR: 'Added to library queue',
+ SEARCHING: 'Searching for a matching release',
+ GRABBED: 'Download queued',
+ DOWNLOADING: 'Download in progress',
+ IMPORTING: 'Preparing the collected media',
+ COMPLETED: 'Available to watch',
+ AVAILABLE: 'Available to watch',
+ FAILED: 'This request needs attention',
+ UNKNOWN: 'Checking request status',
+ }
+ return labels[state] ?? 'Checking request status'
+}
+
+const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
+ const approved = snapshot.state !== 'REQUESTED'
+ const complete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state)
+ return [
+ { id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
+ {
+ id: 'approved',
+ label: 'Approved',
+ state: approved ? 'complete' : 'active',
+ summary: approved ? 'Approved for collection' : 'Waiting for approval',
+ },
+ {
+ id: 'library',
+ label: 'Library collection',
+ state: complete ? 'complete' : approved ? 'active' : 'waiting',
+ summary: complete ? 'Collection complete' : 'Waiting for collector information',
+ },
+ { id: 'search', label: 'Release search', state: 'waiting', summary: 'Search state unavailable' },
+ { id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
+ {
+ id: 'available',
+ label: 'Available',
+ state: complete ? 'complete' : 'waiting',
+ summary: complete ? 'Available to watch' : 'Not available on the media server yet',
+ link: snapshot.raw?.jellyfin?.link,
+ },
+ ]
+}
+
+const formatWhen = (value?: string | null) => {
+ if (!value) return 'Time unavailable'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+export default function RequestTimelinePage() {
+ const params = useParams<{ id: string | string[] }>()
+ const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
+ const router = useRouter()
+ const [snapshot, setSnapshot] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [loadError, setLoadError] = useState(null)
+ const [showDetails, setShowDetails] = useState(false)
+ const [actionMessage, setActionMessage] = useState(null)
+ const [actionError, setActionError] = useState(null)
+ const [busyAction, setBusyAction] = useState(null)
+ const [releaseOptions, setReleaseOptions] = useState([])
+ const [historySnapshots, setHistorySnapshots] = useState([])
+ const [historyActions, setHistoryActions] = useState([])
+
+ useEffect(() => {
+ if (!requestId) return
+ const load = async () => {
+ setLoading(true)
+ setLoadError(null)
+ try {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ const baseUrl = getApiBase()
+ const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
+ authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
+ authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
+ authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
+ ])
+ if ([snapshotResponse, historyResponse, actionsResponse].some((response) => response.status === 401)) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (!snapshotResponse.ok) {
+ throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
+ }
+ const snapshotData = await snapshotResponse.json()
+ if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
+ setSnapshot(snapshotData)
+ if (historyResponse.ok) {
+ const historyData = await historyResponse.json()
+ if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
+ }
+ if (actionsResponse.ok) {
+ const actionsData = await actionsResponse.json()
+ if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
+ }
+ } catch (error) {
+ console.error(error)
+ setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
+ } finally {
+ setLoading(false)
+ }
+ }
+ void load()
+ }, [requestId, router])
+
+ useEffect(() => {
+ if (!getToken() || !requestId) return
+ const baseUrl = getApiBase()
+ let closed = false
+ let source: EventSource | null = null
+ const connect = async () => {
+ try {
+ const streamToken = await getEventStreamToken()
+ if (closed) return
+ source = new EventSource(
+ `${baseUrl}/events/requests/${encodeURIComponent(requestId)}/stream?stream_token=${encodeURIComponent(streamToken)}`
+ )
+ source.onmessage = (event) => {
+ if (closed) return
+ try {
+ const payload = JSON.parse(event.data)
+ if (payload?.type !== 'request_live' || String(payload.request_id ?? '') !== String(requestId)) return
+ if (isSnapshotPayload(payload.snapshot)) setSnapshot(payload.snapshot)
+ if (Array.isArray(payload.history)) setHistorySnapshots(payload.history)
+ if (Array.isArray(payload.actions)) setHistoryActions(payload.actions)
+ } catch (error) {
+ console.error(error)
+ }
+ }
+ } catch (error) {
+ if (!closed) console.error(error)
+ }
+ }
+ void connect()
+ return () => {
+ closed = true
+ source?.close()
+ }
+ }, [requestId])
+
+ const actionsById = useMemo(
+ () => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
+ [snapshot?.actions]
+ )
+
+ if (loading) {
+ return (
+
+
+
+
Building a clear request update…
+
+
+ )
+ }
+
+ if (loadError || !snapshot) {
+ return (
+
+
+ Request unavailable
+ We could not load this request
+ {loadError ?? 'The request API did not return a valid status.'}
+
+ window.location.reload()}>Retry
+ router.push('/')}>Back to requests
+
+
+
+ )
+ }
+
+ const presentation = snapshot.presentation ?? {}
+ const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot)
+ const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
+ const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
+ const download = presentation.download
+ const downloadVisible = Boolean(download?.visible)
+ const nextStep = presentation.nextStep ?? {
+ title: snapshot.actions[0]?.label ?? 'No action needed right now',
+ description: snapshot.actions.length ? 'Choose an option below to continue.' : 'Magent will keep checking automatically.',
+ actionIds: snapshot.actions.slice(0, 2).map((action) => action.id),
+ }
+ const recommendedActions = (nextStep.actionIds ?? [])
+ .map((actionId) => actionsById.get(actionId))
+ .filter((action): action is RequestAction => Boolean(action))
+ const posterUrl = snapshot.artwork?.poster_url
+ const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
+
+ const runAction = async (action: RequestAction) => {
+ if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
+ const actionPaths: Record = {
+ search_releases: 'actions/search',
+ search_auto: 'actions/search_auto',
+ resume_torrent: 'actions/qbit/resume',
+ readd_to_arr: 'actions/readd',
+ }
+ const path = actionPaths[action.id]
+ if (!path) {
+ setActionError('This action is not connected yet.')
+ return
+ }
+ setBusyAction(action.id)
+ setActionError(null)
+ setActionMessage(null)
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/${path}`, { method: 'POST' })
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (!response.ok) throw new Error(await readApiError(response, `${action.label} could not be completed.`))
+ const data = await response.json()
+ if (action.id === 'search_releases') {
+ const releases = Array.isArray(data.releases) ? data.releases : []
+ setReleaseOptions(releases)
+ setActionMessage(
+ releases.length
+ ? `Found ${releases.length} possible release${releases.length === 1 ? '' : 's'}. Choose one below.`
+ : 'No matching releases were found. Magent will keep the request in the search stage.'
+ )
+ } else {
+ setActionMessage(data?.message ?? `${action.label} was started successfully.`)
+ }
+ } catch (error) {
+ console.error(error)
+ setActionError(error instanceof Error ? error.message : `${action.label} could not be completed.`)
+ } finally {
+ setBusyAction(null)
+ }
+ }
+
+ const downloadRelease = async (release: ReleaseOption) => {
+ if (!release.guid || !release.indexerId) {
+ setActionError('This release is missing the details needed to start it.')
+ return
+ }
+ if (!window.confirm(`Download “${release.title ?? 'this release'}”?`)) return
+ setBusyAction(`grab:${release.guid}`)
+ setActionError(null)
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(release),
+ })
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (!response.ok) throw new Error(await readApiError(response, 'The selected release could not be started.'))
+ const data = await response.json()
+ setActionMessage(data?.message ?? 'The selected release was queued for download.')
+ setReleaseOptions([])
+ } catch (error) {
+ console.error(error)
+ setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
+ } finally {
+ setBusyAction(null)
+ }
+ }
+
+ return (
+
+
+
+ {resolvedPoster && (
+
+ )}
+
+
Request #{snapshot.request_id}
+
{snapshot.title}
+
{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}
+
+
+
+
+
+
+ Status
+ {statusLabel}
+
+
+
What this means
+
{statusMeaning}
+
+ {downloadVisible && (
+
+ Current download state
+ {download?.summary ?? 'A download attempt has been observed.'}
+ {download?.lastSeenAt && !download?.torrents?.length && Last observed {formatWhen(download.lastSeenAt)} }
+
+ )}
+
+
Next step
+
{nextStep.title}
+
{nextStep.description}
+ {recommendedActions.length > 0 && (
+
+ {recommendedActions.map((action) => (
+ void runAction(action)}>
+ {busyAction === action.id ? 'Working…' : action.label}
+
+ ))}
+
+ )}
+
+ {(actionMessage || actionError) && (
+
+ {actionError ?? actionMessage}
+
+ )}
+
+
+
+
+
+ Live collection path
+
Where your request is now
+
+
Live status
+
+
+
+ {pipeline.map((stage, index) => {
+ const stageActions = (stage.actionIds ?? [])
+ .map((actionId) => actionsById.get(actionId))
+ .filter((action): action is RequestAction => Boolean(action))
+ const content = (
+ <>
+
+ {String(index + 1).padStart(2, '0')}
+ {stage.state}
+
+
{stage.label}
+
{stage.summary}
+
+ {stage.id === 'library' && Boolean(stage.total) && (
+
+
{stage.available ?? 0} collected {stage.missing ?? 0} missing
+
+
+
+ {stage.seasons?.map((season) => (
+
Season {season.seasonNumber} {season.available} collected · {season.missing} missing
+ ))}
+
+ )}
+
+ {stage.id === 'library' && stage.missingEpisodes && Object.keys(stage.missingEpisodes).length > 0 && (
+
+ {Object.entries(stage.missingEpisodes).map(([season, episodes]) => (
+
Missing from season {season} {episodes.map((episode) => `E${episode}`).join(', ')}
+ ))}
+
+ )}
+
+ {stage.id === 'download' && stage.visible && stage.torrents?.map((torrent) => {
+ const progress = torrentProgress(torrent)
+ return (
+
+
{torrent.name ?? 'Download'} {progress === null ? 'Progress unavailable' : `${progress}% complete`}
+ {progress !== null &&
}
+
+ )
+ })}
+
+ {stageActions.length > 0 && (
+
+ {stageActions.map((action) => (
+ void runAction(action)}>{busyAction === action.id ? 'Working…' : action.label}
+ ))}
+
+ )}
+ >
+ )
+ return stage.id === 'available' && stage.link ? (
+
{content}Open on media server →
+ ) : (
+
{content}
+ )
+ })}
+
+
+ {releaseOptions.length > 0 && (
+
+
Manual selection
Choose a release setReleaseOptions([])}>Close
+
+ {releaseOptions.map((release) => (
+
+
{release.title ?? 'Unknown release'} {release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
+
void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Starting…' : 'Download this release'}
+
+ ))}
+
+
+ )}
+
+
+
+ setShowDetails((current) => !current)}>
+ Advanced details Service diagnostics, status history and recorded actions
+ {showDetails ? 'Hide' : 'Show'}
+
+ {showDetails && (
+
+
+ {snapshot.timeline.map((hop, index) => (
+
+ {hop.service} {hop.status}
+ {hop.details && {JSON.stringify(hop.details, null, 2)} }
+
+ ))}
+
+
+
+
Status changes
+
+ {historySnapshots.length === 0 ? No distinct status changes recorded yet. : historySnapshots.map((entry) => (
+ {fallbackStatusLabel(entry.state)} {entry.state_reason ?? 'No additional detail.'} · {formatWhen(entry.created_at)}
+ ))}
+
+
+
+
Recorded actions
+
+ {historyActions.length === 0 ? No actions have been run for this request. : historyActions.map((entry) => (
+ {entry.label} {entry.message ?? entry.status} · {formatWhen(entry.created_at)}
+ ))}
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx
new file mode 100644
index 0000000..4b423a6
--- /dev/null
+++ b/frontend/app/reset-password/page.tsx
@@ -0,0 +1,156 @@
+'use client'
+
+import { Suspense, useEffect, useState } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import BrandingLogo from '../ui/BrandingLogo'
+import { getApiBase } from '../lib/auth'
+
+type ResetVerification = {
+ status: string
+ recipient_hint?: string
+ auth_provider?: string
+ expires_at?: string
+}
+
+function ResetPasswordPageContent() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const token = searchParams.get('token') ?? ''
+ const [verification, setVerification] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [verifying, setVerifying] = useState(true)
+ const [password, setPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [status, setStatus] = useState(null)
+
+ useEffect(() => {
+ const verifyToken = async () => {
+ if (!token) {
+ setError('Password reset link is invalid or missing.')
+ setVerifying(false)
+ return
+ }
+ setVerifying(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await fetch(
+ `${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`,
+ )
+ const data = await response.json().catch(() => null)
+ if (!response.ok) {
+ throw new Error(typeof data?.detail === 'string' ? data.detail : 'Password reset link is invalid.')
+ }
+ setVerification(data)
+ } catch (err) {
+ console.error(err)
+ setVerification(null)
+ setError(err instanceof Error ? err.message : 'Password reset link is invalid.')
+ } finally {
+ setVerifying(false)
+ }
+ }
+
+ void verifyToken()
+ }, [token])
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!token) {
+ setError('Password reset link is invalid or missing.')
+ return
+ }
+ if (password.trim().length < 8) {
+ setError('Password must be at least 8 characters.')
+ return
+ }
+ if (password !== confirmPassword) {
+ setError('Passwords do not match.')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await fetch(`${baseUrl}/auth/password/reset`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, new_password: password }),
+ })
+ const data = await response.json().catch(() => null)
+ if (!response.ok) {
+ throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to reset password.')
+ }
+ setStatus('Password updated. You can now sign in with the new password.')
+ setPassword('')
+ setConfirmPassword('')
+ window.setTimeout(() => router.push('/login'), 1200)
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Unable to reset password.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const providerLabel =
+ verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
+
+ return (
+
+
+ Reset password
+ Choose a new password for your account.
+
+ {verifying && Checking password reset link…
}
+ {!verifying && verification && (
+
+ This reset link was sent to {verification.recipient_hint || 'your email'} and will update the password
+ used for {providerLabel}.
+
+ )}
+
+ New password
+ setPassword(event.target.value)}
+ autoComplete="new-password"
+ disabled={!verification || loading}
+ />
+
+
+ Confirm new password
+ setConfirmPassword(event.target.value)}
+ autoComplete="new-password"
+ disabled={!verification || loading}
+ />
+
+ {error && {error}
}
+ {status && {status}
}
+
+
+ {loading ? 'Updating password…' : 'Reset password'}
+
+
+ router.push('/login')} disabled={loading}>
+ Back to sign in
+
+
+
+ )
+}
+
+export default function ResetPasswordPage() {
+ return (
+ Loading password reset…}>
+
+
+ )
+}
diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx
new file mode 100644
index 0000000..8ced8f6
--- /dev/null
+++ b/frontend/app/signup/page.tsx
@@ -0,0 +1,224 @@
+'use client'
+
+import { Suspense, useEffect, useMemo, useState } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import BrandingLogo from '../ui/BrandingLogo'
+import { clearToken, getApiBase, setToken } from '../lib/auth'
+
+type InviteInfo = {
+ code: string
+ label?: string | null
+ description?: string | null
+ enabled: boolean
+ is_expired?: boolean
+ is_usable?: boolean
+ expires_at?: string | null
+ max_uses?: number | null
+ use_count?: number | null
+ remaining_uses?: number | null
+ profile?: {
+ id: number
+ name: string
+ description?: string | null
+ } | null
+}
+
+const formatDate = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+function SignupPageContent() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
+ const [invite, setInvite] = useState(null)
+ const [inviteLoading, setInviteLoading] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [status, setStatus] = useState(null)
+
+ const canSubmit = useMemo(() => {
+ return Boolean(invite?.is_usable && username.trim() && password && !loading)
+ }, [invite, username, password, loading])
+
+ const lookupInvite = async (code: string) => {
+ const trimmed = code.trim()
+ if (!trimmed) {
+ setInvite(null)
+ return
+ }
+ setInviteLoading(true)
+ setError(null)
+ setStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Invite not found')
+ }
+ const data = await response.json()
+ setInvite(data?.invite ?? null)
+ setStatus('Invite loaded.')
+ } catch (err) {
+ console.error(err)
+ setInvite(null)
+ setError('Invite code not found or unavailable.')
+ } finally {
+ setInviteLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ const initialCode = searchParams.get('code') ?? ''
+ if (initialCode) {
+ setInviteCode(initialCode)
+ void lookupInvite(initialCode)
+ }
+ }, [searchParams])
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (password !== confirmPassword) {
+ setError('Passwords do not match.')
+ return
+ }
+ if (!inviteCode.trim()) {
+ setError('Invite code is required.')
+ return
+ }
+ if (!invite?.is_usable) {
+ setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ setStatus(null)
+ try {
+ clearToken()
+ const baseUrl = getApiBase()
+ const response = await fetch(`${baseUrl}/auth/signup`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ invite_code: inviteCode,
+ username: username.trim(),
+ password,
+ }),
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Sign-up failed')
+ }
+ const data = await response.json()
+ if (data?.authenticated) {
+ setToken('cookie')
+ window.location.href = '/'
+ return
+ }
+ throw new Error('Sign-up did not complete')
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Unable to create account.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ Create account
+ Use an invite code from your admin to create your Jellyfin-backed Magent account.
+
+
+ Invite code
+
+ setInviteCode(e.target.value)}
+ placeholder="Paste your invite code"
+ autoCapitalize="characters"
+ />
+ void lookupInvite(inviteCode)}
+ >
+ {inviteLoading ? 'Checking…' : 'Check invite'}
+
+
+
+ {invite && (
+
+
+ {invite.label || invite.code}
+
+ {invite.is_usable ? 'Usable' : 'Unavailable'}
+
+
+ {invite.description &&
{invite.description}
}
+
+ Code: {invite.code}
+ Expires: {formatDate(invite.expires_at)}
+ Remaining uses: {invite.remaining_uses ?? 'Unlimited'}
+ Profile: {invite.profile?.name || 'None'}
+
+
+ )}
+
+ Username
+ setUsername(e.target.value)}
+ autoComplete="username"
+ />
+
+
+ Password
+ setPassword(e.target.value)}
+ autoComplete="new-password"
+ />
+
+
+ Confirm password
+ setConfirmPassword(e.target.value)}
+ autoComplete="new-password"
+ />
+
+ {error && {error}
}
+ {status && {status}
}
+
+
+ {loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
+
+
+ router.push('/login')}>
+ Back to sign in
+
+
+
+ )
+}
+
+export default function SignupPage() {
+ return (
+ Loading sign-up…}>
+
+
+ )
+}
diff --git a/frontend/app/ui/AdminDiagnosticsPanel.tsx b/frontend/app/ui/AdminDiagnosticsPanel.tsx
new file mode 100644
index 0000000..25b8f36
--- /dev/null
+++ b/frontend/app/ui/AdminDiagnosticsPanel.tsx
@@ -0,0 +1,517 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+
+type DiagnosticCatalogItem = {
+ key: string
+ label: string
+ category: string
+ description: string
+ live_safe: boolean
+ target: string | null
+ configured: boolean
+ config_status: string
+ config_detail: string
+}
+
+type DiagnosticResult = {
+ key: string
+ label: string
+ category: string
+ description: string
+ target: string | null
+ live_safe: boolean
+ configured: boolean
+ status: string
+ message: string
+ detail?: unknown
+ checked_at?: string
+ duration_ms?: number
+}
+
+type DiagnosticsResponse = {
+ checks: DiagnosticCatalogItem[]
+ categories: string[]
+ generated_at: string
+}
+
+type RunDiagnosticsResponse = {
+ results: DiagnosticResult[]
+ summary: {
+ total: number
+ up: number
+ down: number
+ degraded: number
+ not_configured: number
+ disabled: number
+ }
+ checked_at: string
+}
+
+type RunMode = 'safe' | 'all' | 'single'
+
+type AdminDiagnosticsPanelProps = {
+ embedded?: boolean
+}
+
+type DatabaseDiagnosticDetail = {
+ integrity_check?: string
+ database_path?: string
+ database_size_bytes?: number
+ wal_size_bytes?: number
+ shm_size_bytes?: number
+ page_size_bytes?: number
+ page_count?: number
+ freelist_pages?: number
+ allocated_bytes?: number
+ free_bytes?: number
+ row_counts?: Record
+ timings_ms?: Record
+}
+
+const REFRESH_INTERVAL_MS = 30000
+
+const STATUS_LABELS: Record = {
+ idle: 'Ready',
+ up: 'Up',
+ down: 'Down',
+ degraded: 'Degraded',
+ disabled: 'Disabled',
+ not_configured: 'Not configured',
+}
+
+function formatCheckedAt(value?: string) {
+ if (!value) return 'Not yet run'
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return value
+ return parsed.toLocaleString()
+}
+
+function formatDuration(value?: number) {
+ if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
+ return 'Pending'
+ }
+ return `${value.toFixed(1)} ms`
+}
+
+function statusLabel(status: string) {
+ return STATUS_LABELS[status] ?? status
+}
+
+function formatBytes(value?: number) {
+ if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
+ return '0 B'
+ }
+ if (value >= 1024 * 1024 * 1024) {
+ return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
+ }
+ if (value >= 1024 * 1024) {
+ return `${(value / (1024 * 1024)).toFixed(2)} MB`
+ }
+ if (value >= 1024) {
+ return `${(value / 1024).toFixed(1)} KB`
+ }
+ return `${value} B`
+}
+
+function formatDetailLabel(value: string) {
+ return value
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (character) => character.toUpperCase())
+}
+
+function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
+ return null
+ }
+ return detail as DatabaseDiagnosticDetail
+}
+
+function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
+ if (values.length === 0) {
+ return null
+ }
+ return (
+
+
{title}
+
+ {values.map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+ )
+}
+
+export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
+ const router = useRouter()
+ const [loading, setLoading] = useState(true)
+ const [authorized, setAuthorized] = useState(false)
+ const [checks, setChecks] = useState([])
+ const [resultsByKey, setResultsByKey] = useState>({})
+ const [runningKeys, setRunningKeys] = useState([])
+ const [autoRefresh, setAutoRefresh] = useState(true)
+ const [pageError, setPageError] = useState('')
+ const [lastRunAt, setLastRunAt] = useState(null)
+ const [lastRunMode, setLastRunMode] = useState(null)
+ const [emailRecipient, setEmailRecipient] = useState('')
+
+ const liveSafeKeys = checks.filter((check) => check.live_safe).map((check) => check.key)
+
+ async function runDiagnostics(keys?: string[], mode: RunMode = 'single') {
+ const baseUrl = getApiBase()
+ const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key)
+ if (effectiveKeys.length === 0) {
+ return
+ }
+ setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])))
+ setPageError('')
+ try {
+ const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ keys: effectiveKeys,
+ ...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
+ }),
+ })
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || `Diagnostics run failed: ${response.status}`)
+ }
+ const data = (await response.json()) as { status: string } & RunDiagnosticsResponse
+ const nextResults: Record = {}
+ for (const result of data.results ?? []) {
+ nextResults[result.key] = result
+ }
+ setResultsByKey((current) => ({ ...current, ...nextResults }))
+ setLastRunAt(data.checked_at ?? new Date().toISOString())
+ setLastRunMode(mode)
+ } catch (error) {
+ console.error(error)
+ setPageError(error instanceof Error ? error.message : 'Diagnostics run failed.')
+ } finally {
+ setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)))
+ }
+ }
+
+ useEffect(() => {
+ let active = true
+
+ const loadPage = async () => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ try {
+ const baseUrl = getApiBase()
+ const authResponse = await authFetch(`${baseUrl}/auth/me`)
+ if (!authResponse.ok) {
+ if (authResponse.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ router.push('/')
+ return
+ }
+ const me = await authResponse.json()
+ if (!active) return
+ if (me?.role !== 'admin') {
+ router.push('/')
+ return
+ }
+
+ const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`)
+ if (!diagnosticsResponse.ok) {
+ const text = await diagnosticsResponse.text()
+ throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`)
+ }
+ const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse
+ if (!active) return
+ setChecks(data.checks ?? [])
+ setAuthorized(true)
+ setLoading(false)
+ const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key)
+ if (safeKeys.length > 0) {
+ void runDiagnostics(safeKeys, 'safe')
+ }
+ } catch (error) {
+ console.error(error)
+ if (!active) return
+ setPageError(error instanceof Error ? error.message : 'Unable to load diagnostics.')
+ setLoading(false)
+ }
+ }
+
+ void loadPage()
+
+ return () => {
+ active = false
+ }
+ }, [router])
+
+ useEffect(() => {
+ if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
+ return
+ }
+ const interval = window.setInterval(() => {
+ void runDiagnostics(liveSafeKeys, 'safe')
+ }, REFRESH_INTERVAL_MS)
+ return () => {
+ window.clearInterval(interval)
+ }
+ }, [authorized, autoRefresh, liveSafeKeys.join('|')])
+
+ if (loading) {
+ return Loading diagnostics...
+ }
+
+ if (!authorized) {
+ return null
+ }
+
+ const orderedCategories: string[] = []
+ for (const check of checks) {
+ if (!orderedCategories.includes(check.category)) {
+ orderedCategories.push(check.category)
+ }
+ }
+
+ const mergedResults = checks.map((check) => {
+ const result = resultsByKey[check.key]
+ if (result) {
+ return result
+ }
+ return {
+ key: check.key,
+ label: check.label,
+ category: check.category,
+ description: check.description,
+ target: check.target,
+ live_safe: check.live_safe,
+ configured: check.configured,
+ status: check.configured ? 'idle' : check.config_status,
+ message: check.configured ? 'Ready to test.' : check.config_detail,
+ checked_at: undefined,
+ duration_ms: undefined,
+ } satisfies DiagnosticResult
+ })
+
+ const summary = {
+ total: mergedResults.length,
+ up: 0,
+ down: 0,
+ degraded: 0,
+ disabled: 0,
+ not_configured: 0,
+ idle: 0,
+ }
+ for (const result of mergedResults) {
+ const key = result.status as keyof typeof summary
+ if (key in summary) {
+ summary[key] += 1
+ }
+ }
+
+ return (
+
+
+
+
{embedded ? 'Connectivity diagnostics' : 'Control center'}
+
+ Use live checks for Magent and service connectivity. Use run all when you want outbound notification
+ channels to send a real ping through the configured provider.
+
+
+
+
+ Test email recipient
+ setEmailRecipient(event.target.value)}
+ />
+
+ setAutoRefresh((current) => !current)}
+ >
+ {autoRefresh ? 'Disable auto refresh' : 'Enable auto refresh'}
+
+ {
+ void runDiagnostics(liveSafeKeys, 'safe')
+ }}
+ disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
+ >
+ Run live checks
+
+ {
+ void runDiagnostics(undefined, 'all')
+ }}
+ disabled={runningKeys.length > 0 || checks.length === 0}
+ >
+ Run all tests
+
+
+ {autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
+
+ {lastRunMode ? `Last run: ${lastRunMode}` : 'No run yet'}
+
+
+
+
+
+ Total
+ {summary.total}
+
+
+ Up
+ {summary.up}
+
+
+ Degraded
+ {summary.degraded}
+
+
+ Down
+ {summary.down}
+
+
+ Disabled
+ {summary.disabled}
+
+
+ Not configured
+ {summary.not_configured}
+
+
+ Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}
+
+
+
+ {pageError ?
{pageError}
: null}
+
+ {orderedCategories.map((category) => {
+ const categoryChecks = mergedResults.filter((check) => check.category === category)
+ return (
+
+
+
+
{category}
+
{category === 'Notifications' ? 'These tests can emit real messages.' : 'Safe live health checks.'}
+
+
{categoryChecks.length} checks
+
+
+
+ {categoryChecks.map((check) => {
+ const isRunning = runningKeys.includes(check.key)
+ return (
+
+
+
+
+
{check.label}
+ {statusLabel(check.status)}
+
+
{check.description}
+
+
{
+ void runDiagnostics([check.key], 'single')
+ }}
+ disabled={isRunning}
+ >
+ {check.live_safe ? 'Ping' : 'Send test'}
+
+
+
+
+
+ Target
+ {check.target || 'Not set'}
+
+
+ Latency
+ {formatDuration(check.duration_ms)}
+
+
+ Mode
+ {check.live_safe ? 'Live safe' : 'Manual only'}
+
+
+ Last checked
+ {formatCheckedAt(check.checked_at)}
+
+
+
+
+
+ {isRunning ? 'Running diagnostic...' : check.message}
+
+
+ {check.key === 'database'
+ ? (() => {
+ const detail = asDatabaseDiagnosticDetail(check.detail)
+ if (!detail) {
+ return null
+ }
+ return (
+
+ {renderDatabaseMetricGroup('Storage', [
+ ['Database file', formatBytes(detail.database_size_bytes)],
+ ['WAL file', formatBytes(detail.wal_size_bytes)],
+ ['Shared memory', formatBytes(detail.shm_size_bytes)],
+ ['Allocated bytes', formatBytes(detail.allocated_bytes)],
+ ['Free bytes', formatBytes(detail.free_bytes)],
+ ['Page size', formatBytes(detail.page_size_bytes)],
+ ['Page count', `${detail.page_count?.toLocaleString() ?? 0}`],
+ ['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`],
+ ])}
+ {renderDatabaseMetricGroup(
+ 'Tables',
+ Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
+ formatDetailLabel(key),
+ value.toLocaleString(),
+ ]),
+ )}
+ {renderDatabaseMetricGroup(
+ 'Timings',
+ Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
+ formatDetailLabel(key),
+ `${value.toFixed(1)} ms`,
+ ]),
+ )}
+
+ )
+ })()
+ : null}
+
+ )
+ })}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/app/ui/AdminShell.tsx b/frontend/app/ui/AdminShell.tsx
new file mode 100644
index 0000000..bb4c72b
--- /dev/null
+++ b/frontend/app/ui/AdminShell.tsx
@@ -0,0 +1,36 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import AdminSidebar from './AdminSidebar'
+
+type AdminShellProps = {
+ title: string
+ subtitle?: string
+ actions?: ReactNode
+ rail?: ReactNode
+ children: ReactNode
+}
+
+export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
+ const hasRail = Boolean(rail)
+
+ return (
+
+
+
+
+
+
Beta stream
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {actions}
+
+ {children}
+
+ {hasRail ?
: null}
+
+ )
+}
diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx
new file mode 100644
index 0000000..096110c
--- /dev/null
+++ b/frontend/app/ui/AdminSidebar.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { usePathname } from 'next/navigation'
+
+const NAV_GROUPS = [
+ {
+ title: 'Operations',
+ items: [
+ { href: '/admin', label: 'Overview' },
+ { href: '/', label: 'Health' },
+ { href: '/portal/requests', label: 'Request portal' },
+ { href: '/admin/issues', label: 'Issue tracking' },
+ ],
+ },
+ {
+ title: 'Services',
+ items: [
+ { href: '/admin/general', label: 'General' },
+ { href: '/admin/seerr', label: 'Seerr' },
+ { href: '/admin/jellyfin', label: 'Jellyfin' },
+ { href: '/admin/sonarr', label: 'Sonarr' },
+ { href: '/admin/radarr', label: 'Radarr' },
+ { href: '/admin/prowlarr', label: 'Prowlarr' },
+ { href: '/admin/qbittorrent', label: 'qBittorrent' },
+ ],
+ },
+ {
+ title: 'Requests',
+ items: [
+ { href: '/admin/requests', label: 'Request sync' },
+ { href: '/admin/requests-all', label: 'All requests' },
+ { href: '/admin/cache', label: 'Cache Control' },
+ { href: '/admin/artwork', label: 'Artwork cache' },
+ ],
+ },
+ {
+ title: 'Admin',
+ items: [
+ { href: '/admin/notifications', label: 'Notifications' },
+ { href: '/admin/system', label: 'How it works' },
+ { href: '/admin/site', label: 'Site' },
+ { href: '/users', label: 'Users' },
+ { href: '/admin/invites', label: 'Invite management' },
+ { href: '/admin/logs', label: 'Activity log' },
+ { href: '/admin/maintenance', label: 'Maintenance' },
+ ],
+ },
+]
+
+export default function AdminSidebar() {
+ const pathname = usePathname()
+ return (
+
+ Settings
+ {NAV_GROUPS.map((group) => (
+
+
{group.title}
+
+ {group.items.map((item) => {
+ const isActive =
+ pathname === item.href ||
+ (item.href !== '/' && pathname.startsWith(item.href))
+ return (
+
+ {item.label}
+
+ )
+ })}
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/app/ui/BrandingFavicon.tsx b/frontend/app/ui/BrandingFavicon.tsx
new file mode 100644
index 0000000..f4eb6ae
--- /dev/null
+++ b/frontend/app/ui/BrandingFavicon.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import { useEffect } from 'react'
+
+export default function BrandingFavicon() {
+ useEffect(() => {
+ const href = '/api/branding/favicon.ico'
+ let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
+ if (!link) {
+ link = document.createElement('link')
+ link.rel = 'icon'
+ document.head.appendChild(link)
+ }
+ link.href = href
+ }, [])
+
+ return null
+}
diff --git a/frontend/app/ui/BrandingLogo.tsx b/frontend/app/ui/BrandingLogo.tsx
new file mode 100644
index 0000000..10360ec
--- /dev/null
+++ b/frontend/app/ui/BrandingLogo.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+import { useState } from 'react'
+
+type BrandingLogoProps = {
+ className?: string
+ alt?: string
+}
+
+export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
+ const [loaded, setLoaded] = useState(false)
+ const [failed, setFailed] = useState(false)
+
+ return (
+
+ {!failed ? (
+ setLoaded(true)}
+ onError={() => setFailed(true)}
+ />
+ ) : null}
+ {!loaded ? (
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx
new file mode 100644
index 0000000..dacd974
--- /dev/null
+++ b/frontend/app/ui/HeaderActions.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { usePathname } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+
+export default function HeaderActions() {
+ const [signedIn, setSignedIn] = useState(false)
+ const [role, setRole] = useState(null)
+ const [showRequestsNav, setShowRequestsNav] = useState(true)
+ const pathname = usePathname()
+
+ useEffect(() => {
+ const token = getToken()
+ setSignedIn(Boolean(token))
+ if (!token) {
+ setShowRequestsNav(true)
+ return
+ }
+ const load = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const [response, siteResponse] = await Promise.all([
+ authFetch(`${baseUrl}/auth/me`),
+ fetch(`${baseUrl}/site/public`).catch(() => null),
+ ])
+ if (!response.ok) {
+ clearToken()
+ setSignedIn(false)
+ setRole(null)
+ return
+ }
+ const data = await response.json()
+ setRole(data?.role ?? null)
+ if (siteResponse?.ok) {
+ const siteData = await siteResponse.json()
+ setShowRequestsNav(siteData?.navigation?.showRequests !== false)
+ } else {
+ setShowRequestsNav(true)
+ }
+ } catch (err) {
+ console.error(err)
+ setShowRequestsNav(true)
+ }
+ }
+ void load()
+ }, [])
+
+ if (!signedIn) {
+ return null
+ }
+
+ const roleItems =
+ role === null
+ ? []
+ : role === 'admin'
+ ? [
+ {
+ href: '/admin',
+ label: 'Config',
+ match: (path: string) => path.startsWith('/admin'),
+ },
+ ]
+ : [
+ {
+ href: '/profile',
+ label: 'Profile',
+ match: (path: string) => path.startsWith('/profile') && !path.startsWith('/profile/invites'),
+ },
+ {
+ href: '/profile/invites',
+ label: 'Invites',
+ match: (path: string) => path.startsWith('/profile/invites'),
+ },
+ ]
+
+ const commonItems = [
+ { href: '/', label: 'Health', match: (path: string) => path === '/' },
+ ...(showRequestsNav
+ ? [
+ {
+ href: '/portal/requests',
+ label: 'Requests',
+ match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
+ },
+ ]
+ : []),
+ {
+ href: '/portal/issues',
+ label: 'Issues',
+ match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
+ },
+ ]
+
+ const items = [
+ ...commonItems,
+ ...roleItems,
+ ]
+
+ return (
+
+ {items.map((item, index) => {
+ const active = item.match(pathname)
+ return (
+
+ {String(index + 1).padStart(2, '0')}
+ {item.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/app/ui/HeaderIdentity.tsx b/frontend/app/ui/HeaderIdentity.tsx
new file mode 100644
index 0000000..0ba8025
--- /dev/null
+++ b/frontend/app/ui/HeaderIdentity.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
+
+export default function HeaderIdentity() {
+ const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
+ const [buildNumber, setBuildNumber] = useState(null)
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ const token = getToken()
+ if (!token) {
+ setIdentity(null)
+ setBuildNumber(null)
+ return
+ }
+ const load = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/auth/me`)
+ if (!response.ok) {
+ clearToken()
+ setIdentity(null)
+ return
+ }
+ const data = await response.json()
+ if (data?.username) {
+ setIdentity({ username: data.username, role: data.role })
+ }
+ const siteResponse = await fetch(`${baseUrl}/site/public`)
+ if (siteResponse.ok) {
+ const siteInfo = await siteResponse.json()
+ if (siteInfo?.buildNumber) {
+ setBuildNumber(siteInfo.buildNumber)
+ }
+ }
+ } catch (err) {
+ console.error(err)
+ setIdentity(null)
+ }
+ }
+ void load()
+ }, [])
+
+ if (!identity) {
+ return null
+ }
+
+ const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
+ const initial = identity.username.slice(0, 1).toUpperCase()
+ const signOut = async () => {
+ await logout().catch(() => undefined)
+ clearToken()
+ if (typeof window !== 'undefined') {
+ window.location.href = '/login'
+ }
+ }
+
+ return (
+
+
setOpen((prev) => !prev)}
+ aria-haspopup="true"
+ aria-expanded={open}
+ title={label}
+ >
+ {initial}
+
+ {open && (
+
+
Signed in as {label}
+
+ {buildNumber ?
Build {buildNumber}
: null}
+
+ )}
+
+ )
+}
diff --git a/frontend/app/ui/SiteStatus.tsx b/frontend/app/ui/SiteStatus.tsx
new file mode 100644
index 0000000..323e520
--- /dev/null
+++ b/frontend/app/ui/SiteStatus.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+
+type BannerInfo = {
+ enabled: boolean
+ message: string
+ tone?: string
+}
+
+type SiteInfo = {
+ buildNumber?: string
+ banner?: BannerInfo
+}
+
+const buildRequest = () => {
+ const token = getToken()
+ const baseUrl = getApiBase()
+ const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
+ const fetcher = token ? authFetch : fetch
+ return { token, url, fetcher }
+}
+
+export default function SiteStatus() {
+ const [info, setInfo] = useState(null)
+
+ useEffect(() => {
+ let active = true
+ const load = async () => {
+ try {
+ const { token, url, fetcher } = buildRequest()
+ const response = await fetcher(url)
+ if (!response.ok) {
+ if (response.status === 401 && token) {
+ clearToken()
+ }
+ return
+ }
+ const data = await response.json()
+ if (!active) return
+ setInfo(data)
+ } catch (err) {
+ console.error(err)
+ }
+ }
+ void load()
+ return () => {
+ active = false
+ }
+ }, [])
+
+ const banner = info?.banner
+ const tone = banner?.tone || 'info'
+ return (
+ <>
+ {banner?.enabled && banner.message ? (
+ {banner.message}
+ ) : null}
+ >
+ )
+}
diff --git a/frontend/app/ui/ThemeToggle.tsx b/frontend/app/ui/ThemeToggle.tsx
new file mode 100644
index 0000000..2b8ad8b
--- /dev/null
+++ b/frontend/app/ui/ThemeToggle.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+const STORAGE_KEY = 'magent_theme'
+
+const getPreferredTheme = () => {
+ if (typeof window === 'undefined') return 'dark'
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ if (stored === 'light' || stored === 'dark') {
+ return stored
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+}
+
+const applyTheme = (theme: string) => {
+ if (typeof document === 'undefined') return
+ document.documentElement.setAttribute('data-theme', theme)
+}
+
+export default function ThemeToggle() {
+ const [theme, setTheme] = useState<'light' | 'dark'>('dark')
+
+ useEffect(() => {
+ const preferred = getPreferredTheme()
+ setTheme(preferred)
+ applyTheme(preferred)
+ }, [])
+
+ const toggle = () => {
+ const next = theme === 'dark' ? 'light' : 'dark'
+ setTheme(next)
+ applyTheme(next)
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(STORAGE_KEY, next)
+ }
+ }
+
+ return (
+
+ {theme === 'dark' ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/app/users/[id]/page.tsx b/frontend/app/users/[id]/page.tsx
new file mode 100644
index 0000000..d1fed33
--- /dev/null
+++ b/frontend/app/users/[id]/page.tsx
@@ -0,0 +1,696 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useParams, useRouter } from 'next/navigation'
+import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
+import AdminShell from '../../ui/AdminShell'
+
+type UserStats = {
+ total: number
+ ready: number
+ pending: number
+ approved: number
+ working: number
+ partial: number
+ declined: number
+ in_progress: number
+ last_request_at?: string | null
+}
+
+type AdminUser = {
+ id?: number
+ username: string
+ email?: string | null
+ role: string
+ auth_provider?: string | null
+ last_login_at?: string | null
+ is_blocked?: boolean
+ auto_search_enabled?: boolean
+ invite_management_enabled?: boolean
+ jellyseerr_user_id?: number | null
+ profile_id?: number | null
+ expires_at?: string | null
+ is_expired?: boolean
+ invited_by_code?: string | null
+ invited_at?: string | null
+}
+
+type UserLineage = {
+ invite_code?: string | null
+ invited_by?: string | null
+ invite?: {
+ id?: number
+ code?: string
+ label?: string | null
+ created_by?: string | null
+ created_at?: string | null
+ enabled?: boolean
+ is_usable?: boolean
+ } | null
+} | null
+
+type UserProfileOption = {
+ id: number
+ name: string
+ is_active?: boolean
+}
+
+const formatDateTime = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const toLocalDateTimeInput = (value?: string | null) => {
+ if (!value) return ''
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return ''
+ const offsetMs = date.getTimezoneOffset() * 60_000
+ const local = new Date(date.getTime() - offsetMs)
+ return local.toISOString().slice(0, 16)
+}
+
+const fromLocalDateTimeInput = (value: string) => {
+ if (!value.trim()) return null
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return null
+ return date.toISOString()
+}
+
+const normalizeStats = (stats: any): UserStats => ({
+ total: Number(stats?.total ?? 0),
+ ready: Number(stats?.ready ?? 0),
+ pending: Number(stats?.pending ?? 0),
+ approved: Number(stats?.approved ?? 0),
+ working: Number(stats?.working ?? 0),
+ partial: Number(stats?.partial ?? 0),
+ declined: Number(stats?.declined ?? 0),
+ in_progress: Number(stats?.in_progress ?? 0),
+ last_request_at: stats?.last_request_at ?? null,
+})
+
+export default function UserDetailPage() {
+ const params = useParams()
+ const router = useRouter()
+ const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
+ const [user, setUser] = useState(null)
+ const [stats, setStats] = useState(null)
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [profiles, setProfiles] = useState([])
+ const [profileSelection, setProfileSelection] = useState('')
+ const [expiryInput, setExpiryInput] = useState('')
+ const [savingProfile, setSavingProfile] = useState(false)
+ const [savingExpiry, setSavingExpiry] = useState(false)
+ const [systemActionBusy, setSystemActionBusy] = useState(false)
+ const [actionStatus, setActionStatus] = useState(null)
+ const [lineage, setLineage] = useState(null)
+
+ const loadProfiles = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/profiles`)
+ if (!response.ok) {
+ return
+ }
+ const data = await response.json()
+ if (!Array.isArray(data?.profiles)) {
+ setProfiles([])
+ return
+ }
+ setProfiles(
+ data.profiles.map((profile: any) => ({
+ id: Number(profile.id ?? 0),
+ name: String(profile.name ?? 'Unnamed profile'),
+ is_active: Boolean(profile.is_active ?? true),
+ }))
+ )
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ const loadUser = async () => {
+ if (!idParam) return
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
+ )
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (response.status === 403) {
+ router.push('/')
+ return
+ }
+ if (response.status === 404) {
+ setError('User not found.')
+ return
+ }
+ throw new Error('Could not load user.')
+ }
+ const data = await response.json()
+ const nextUser = data?.user ?? null
+ setUser(nextUser)
+ setStats(normalizeStats(data?.stats))
+ setLineage((data?.lineage ?? null) as UserLineage)
+ setProfileSelection(
+ nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
+ ? ''
+ : String(nextUser.profile_id)
+ )
+ setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
+ setError(null)
+ } catch (err) {
+ console.error(err)
+ setError('Could not load user.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const toggleUserBlock = async (blocked: boolean) => {
+ if (!user) return
+ try {
+ setActionStatus(null)
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
+ { method: 'POST' }
+ )
+ if (!response.ok) {
+ throw new Error('Update failed')
+ }
+ await loadUser()
+ setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
+ } catch (err) {
+ console.error(err)
+ setError('Could not update user access.')
+ }
+ }
+
+ const updateUserRole = async (role: string) => {
+ if (!user) return
+ try {
+ setActionStatus(null)
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role }),
+ }
+ )
+ if (!response.ok) {
+ throw new Error('Update failed')
+ }
+ await loadUser()
+ setActionStatus(`Role updated to ${role}.`)
+ } catch (err) {
+ console.error(err)
+ setError('Could not update user role.')
+ }
+ }
+
+ const updateAutoSearchEnabled = async (enabled: boolean) => {
+ if (!user) return
+ try {
+ setActionStatus(null)
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ }
+ )
+ if (!response.ok) {
+ throw new Error('Update failed')
+ }
+ await loadUser()
+ setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
+ } catch (err) {
+ console.error(err)
+ setError('Could not update auto search access.')
+ }
+ }
+
+ const updateInviteManagementEnabled = async (enabled: boolean) => {
+ if (!user) return
+ try {
+ setActionStatus(null)
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ }
+ )
+ if (!response.ok) {
+ throw new Error('Update failed')
+ }
+ await loadUser()
+ setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
+ } catch (err) {
+ console.error(err)
+ setError('Could not update invite access.')
+ }
+ }
+
+ const applyProfileToUser = async (profileOverride?: string | null) => {
+ if (!user) return
+ const profileValue = profileOverride ?? profileSelection
+ setSavingProfile(true)
+ setError(null)
+ setActionStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ profile_id: profileValue || null }),
+ }
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Profile update failed')
+ }
+ await loadUser()
+ setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
+ } catch (err) {
+ console.error(err)
+ setError('Could not update user profile.')
+ } finally {
+ setSavingProfile(false)
+ }
+ }
+
+ const saveUserExpiry = async () => {
+ if (!user) return
+ const expiresAt = fromLocalDateTimeInput(expiryInput)
+ if (expiryInput.trim() && !expiresAt) {
+ setError('Invalid expiry date/time.')
+ return
+ }
+ setSavingExpiry(true)
+ setError(null)
+ setActionStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ expires_at: expiresAt }),
+ }
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Expiry update failed')
+ }
+ await loadUser()
+ setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
+ } catch (err) {
+ console.error(err)
+ setError('Could not update user expiry.')
+ } finally {
+ setSavingExpiry(false)
+ }
+ }
+
+ const clearUserExpiry = async () => {
+ if (!user) return
+ setSavingExpiry(true)
+ setError(null)
+ setActionStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ clear: true }),
+ }
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Expiry clear failed')
+ }
+ setExpiryInput('')
+ await loadUser()
+ setActionStatus('User expiry cleared.')
+ } catch (err) {
+ console.error(err)
+ setError('Could not clear user expiry.')
+ } finally {
+ setSavingExpiry(false)
+ }
+ }
+
+ const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
+ if (!user) return
+ if (action === 'remove') {
+ const confirmed = window.confirm(
+ `Remove ${user.username} from Magent and external systems? This is destructive.`
+ )
+ if (!confirmed) return
+ }
+ if (action === 'ban') {
+ const confirmed = window.confirm(
+ `Ban ${user.username} across systems and disable invites they created?`
+ )
+ if (!confirmed) return
+ }
+ setSystemActionBusy(true)
+ setError(null)
+ setActionStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action }),
+ }
+ )
+ const text = await response.text()
+ let data: any = null
+ try {
+ data = text ? JSON.parse(text) : null
+ } catch {
+ data = null
+ }
+ if (!response.ok) {
+ throw new Error(data?.detail || text || 'Cross-system action failed')
+ }
+ const state = data?.status === 'partial' ? 'partial' : 'complete'
+ if (action === 'remove') {
+ setActionStatus(`User removed (${state}).`)
+ router.push('/users')
+ return
+ }
+ await loadUser()
+ setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
+ } finally {
+ setSystemActionBusy(false)
+ }
+ }
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ void loadUser()
+ void loadProfiles()
+ }, [router, idParam])
+
+ if (loading) {
+ return Loading user...
+ }
+
+ return (
+ router.push('/users')}>
+ Back to users
+
+ }
+ >
+
+ {error && {error}
}
+ {actionStatus && {actionStatus}
}
+ {!user ? (
+ No user data found.
+ ) : (
+
+
+
+
+
+ {user.username}
+
+ {user.is_blocked ? 'Blocked' : 'Active'}
+
+
+ {user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
+
+
+
+ User identity, access state, and request history for this account.
+
+
+
+
+ Email
+ {user.email || 'Not set'}
+
+
+ Seerr ID
+ {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}
+
+
+ Role
+ {user.role}
+
+
+ Login type
+ {user.auth_provider || 'local'}
+
+
+ Assigned profile
+ {user.profile_id ?? 'None'}
+
+
+ Invited by
+ {lineage?.invited_by || 'Direct / unknown'}
+
+
+ Invite code used
+ {lineage?.invite_code || user.invited_by_code || 'None'}
+
+
+ Last login
+ {formatDateTime(user.last_login_at)}
+
+
+ Account expiry
+ {user.expires_at ? formatDateTime(user.expires_at) : 'Never'}
+
+
+
+
+
+
+
Request statistics
+
Snapshot of request states and recent activity for this user.
+
+
+
+ Total
+ {stats?.total ?? 0}
+
+
+ Ready
+ {stats?.ready ?? 0}
+
+
+ Pending
+ {stats?.pending ?? 0}
+
+
+ Approved
+ {stats?.approved ?? 0}
+
+
+ Working
+ {stats?.working ?? 0}
+
+
+ Partial
+ {stats?.partial ?? 0}
+
+
+ Declined
+ {stats?.declined ?? 0}
+
+
+ In progress
+ {stats?.in_progress ?? 0}
+
+
+ Last request
+ {formatDateTime(stats?.last_request_at)}
+
+
+
+
+
+
+
+
+
Access controls
+
Role, login access, and auto-download behavior.
+
+
+
+ updateUserRole(event.target.checked ? 'admin' : 'user')}
+ />
+ Make admin
+
+
+ updateAutoSearchEnabled(event.target.checked)}
+ />
+ Allow auto search/download
+
+
+ updateInviteManagementEnabled(event.target.checked)}
+ />
+ Allow self-service invites
+
+
toggleUserBlock(!user.is_blocked)}
+ disabled={systemActionBusy}
+ >
+ {user.is_blocked ? 'Allow access' : 'Block access'}
+
+
+ void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
+ disabled={systemActionBusy}
+ >
+ {systemActionBusy
+ ? 'Working...'
+ : user.is_blocked
+ ? 'Unban everywhere'
+ : 'Ban everywhere'}
+
+ void runSystemAction('remove')}
+ disabled={systemActionBusy}
+ >
+ Remove everywhere
+
+
+ {user.role === 'admin' && (
+
+ Admins always have auto search/download and invite-management access.
+
+ )}
+
+
+
+
+
+
Profile defaults
+
Assign or clear an invite profile for this user.
+
+
+
+ Assigned profile
+ setProfileSelection(event.target.value)}
+ disabled={savingProfile}
+ >
+ None
+ {profiles.map((profile) => (
+
+ {profile.name}
+ {profile.is_active === false ? ' (disabled)' : ''}
+
+ ))}
+
+
+
+ void applyProfileToUser()} disabled={savingProfile}>
+ {savingProfile ? 'Applying...' : 'Apply profile defaults'}
+
+ {
+ setProfileSelection('')
+ void applyProfileToUser('')
+ }}
+ disabled={savingProfile}
+ >
+ Clear profile
+
+
+
+
+
+
+
+
Account expiry
+
Set a specific expiry date/time for this user account.
+
+
+
+ Account expiry
+ setExpiryInput(event.target.value)}
+ disabled={savingExpiry}
+ />
+
+
+
+ {savingExpiry ? 'Saving...' : 'Save expiry'}
+
+
+ Clear expiry
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx
new file mode 100644
index 0000000..1fc060a
--- /dev/null
+++ b/frontend/app/users/page.tsx
@@ -0,0 +1,476 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import Link from 'next/link'
+import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+import AdminShell from '../ui/AdminShell'
+
+type AdminUser = {
+ id: number
+ username: string
+ email?: string | null
+ role: string
+ authProvider?: string | null
+ lastLoginAt?: string | null
+ isBlocked?: boolean
+ autoSearchEnabled?: boolean
+ profileId?: number | null
+ expiresAt?: string | null
+ isExpired?: boolean
+ stats?: UserStats
+}
+
+type UserStats = {
+ total: number
+ ready: number
+ pending: number
+ approved: number
+ working: number
+ partial: number
+ declined: number
+ in_progress: number
+ last_request_at?: string | null
+}
+
+const formatLastLogin = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const formatLastRequest = (value?: string | null) => {
+ if (!value) return '—'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const formatExpiry = (value?: string | null) => {
+ if (!value) return 'Never'
+ const date = new Date(value)
+ if (Number.isNaN(date.valueOf())) return value
+ return date.toLocaleString()
+}
+
+const emptyStats: UserStats = {
+ total: 0,
+ ready: 0,
+ pending: 0,
+ approved: 0,
+ working: 0,
+ partial: 0,
+ declined: 0,
+ in_progress: 0,
+ last_request_at: null,
+}
+
+const normalizeStats = (stats: any): UserStats => ({
+ total: Number(stats?.total ?? 0),
+ ready: Number(stats?.ready ?? 0),
+ pending: Number(stats?.pending ?? 0),
+ approved: Number(stats?.approved ?? 0),
+ working: Number(stats?.working ?? 0),
+ partial: Number(stats?.partial ?? 0),
+ declined: Number(stats?.declined ?? 0),
+ in_progress: Number(stats?.in_progress ?? 0),
+ last_request_at: stats?.last_request_at ?? null,
+})
+
+export default function UsersPage() {
+ const router = useRouter()
+ const [users, setUsers] = useState([])
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [query, setQuery] = useState('')
+ const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState(null)
+ const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
+ const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
+ const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
+
+ const loadUsers = async () => {
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/users/summary`)
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken()
+ router.push('/login')
+ return
+ }
+ if (response.status === 403) {
+ router.push('/')
+ return
+ }
+ throw new Error('Could not load users.')
+ }
+ const data = await response.json()
+ if (Array.isArray(data?.users)) {
+ setUsers(
+ data.users.map((user: any) => ({
+ username: user.username ?? 'Unknown',
+ email: user.email ?? null,
+ role: user.role ?? 'user',
+ authProvider: user.auth_provider ?? 'local',
+ lastLoginAt: user.last_login_at ?? null,
+ isBlocked: Boolean(user.is_blocked),
+ autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
+ profileId:
+ user.profile_id == null || Number.isNaN(Number(user.profile_id))
+ ? null
+ : Number(user.profile_id),
+ expiresAt: user.expires_at ?? null,
+ isExpired: Boolean(user.is_expired),
+ id: Number(user.id ?? 0),
+ stats: normalizeStats(user.stats ?? emptyStats),
+ }))
+ )
+ } else {
+ setUsers([])
+ }
+ setError(null)
+ } catch (err) {
+ console.error(err)
+ setError('Could not load user list.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const syncJellyseerrUsers = async () => {
+ setJellyseerrSyncStatus(null)
+ setJellyseerrSyncBusy(true)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Sync failed')
+ }
+ const data = await response.json()
+ setJellyseerrSyncStatus(
+ `Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
+ )
+ await loadUsers()
+ } catch (err) {
+ console.error(err)
+ setJellyseerrSyncStatus('Could not sync Seerr users.')
+ } finally {
+ setJellyseerrSyncBusy(false)
+ }
+ }
+
+ const resyncJellyseerrUsers = async () => {
+ const confirmed = window.confirm(
+ 'This will remove all non-admin users and re-import from Seerr. Continue?'
+ )
+ if (!confirmed) return
+ setJellyseerrSyncStatus(null)
+ setJellyseerrResyncBusy(true)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Resync failed')
+ }
+ const data = await response.json()
+ setJellyseerrSyncStatus(
+ `Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
+ )
+ await loadUsers()
+ } catch (err) {
+ console.error(err)
+ setJellyseerrSyncStatus('Could not resync Seerr users.')
+ } finally {
+ setJellyseerrResyncBusy(false)
+ }
+ }
+
+ const bulkUpdateAutoSearch = async (enabled: boolean) => {
+ setBulkAutoSearchBusy(true)
+ setJellyseerrSyncStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Bulk update failed')
+ }
+ const data = await response.json()
+ setJellyseerrSyncStatus(
+ `${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
+ )
+ await loadUsers()
+ } catch (err) {
+ console.error(err)
+ setError('Could not update auto search/download for all users.')
+ } finally {
+ setBulkAutoSearchBusy(false)
+ }
+ }
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push('/login')
+ return
+ }
+ void loadUsers()
+ }, [router])
+
+ if (loading) {
+ return Loading users...
+ }
+
+ const nonAdminUsers = users.filter((user) => user.role !== 'admin')
+ const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
+ const blockedCount = users.filter((user) => user.isBlocked).length
+ const expiredCount = users.filter((user) => user.isExpired).length
+ const adminCount = users.filter((user) => user.role === 'admin').length
+ const normalizedQuery = query.trim().toLowerCase()
+ const filteredUsers = normalizedQuery
+ ? users.filter((user) => {
+ const fields = [
+ user.username,
+ user.email || '',
+ user.role,
+ user.authProvider || '',
+ user.profileId != null ? String(user.profileId) : '',
+ ]
+ return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
+ })
+ : users
+ const filteredCountLabel =
+ filteredUsers.length === users.length
+ ? `${users.length} users`
+ : `${filteredUsers.length} of ${users.length} users`
+ const usersRail = (
+
+
+
+
+
Directory summary
+
A quick view of user access and account state.
+
+
+
+
+
+ Total users
+ {users.length}
+
+
{adminCount} admin accounts
+
+
+
+ Auto search
+ {autoSearchEnabledCount}
+
+
of {nonAdminUsers.length} non-admin users enabled
+
+
+
+ Blocked
+ {blockedCount}
+
+
+ {blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
+
+
+
+
+ Expired
+ {expiredCount}
+
+
+ {expiredCount ? 'Accounts with expired access' : 'No expiries'}
+
+
+
+
+
+ )
+
+ return (
+
+
+
+
+
+
Directory actions
+
+ router.push('/admin/invites')}
+ >
+ Invite management
+
+
+ Reload list
+
+
+
+
+
Seerr sync
+
+
+ {jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
+
+
+ {jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
+
+
+
+
+
+ {error && {error}
}
+ {jellyseerrSyncStatus && {jellyseerrSyncStatus}
}
+
+
+
+
Bulk controls
+
+ Auto search/download can be enabled or disabled for all non-admin users.
+
+
+
+
+
+ Auto search/download
+
+ {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
+
+
+
+ bulkUpdateAutoSearch(true)}
+ disabled={bulkAutoSearchBusy}
+ >
+ {bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
+
+ bulkUpdateAutoSearch(false)}
+ disabled={bulkAutoSearchBusy}
+ >
+ {bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
+
+
+
+
+
+
+
+
Directory search
+
+ Filter by username, role, login provider, or assigned profile.
+
+
+
{filteredCountLabel}
+
+
+
+
+ Search users
+ setQuery(event.target.value)}
+ placeholder="Search username, login type, role, profile…"
+ />
+
+
+
+
+ {filteredUsers.length === 0 ? (
+ No users found yet.
+ ) : (
+
+
+ User
+ Access
+ Requests
+ Activity
+
+ {filteredUsers.map((user) => (
+
+
+
+ {user.username}
+ {user.role}
+
+
+ {user.email || 'No email on file'}
+
+
+ Login: {user.authProvider || 'local'} • Profile: {user.profileId ?? 'None'}
+
+
+
+
+
+ {user.isBlocked ? 'Blocked' : 'Active'}
+
+
+ Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
+
+
+ {user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
+
+
+
+ {user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
+
+
+
+
+ {user.stats?.total ?? 0} total
+ {user.stats?.ready ?? 0} ready
+ {user.stats?.pending ?? 0} pending
+ {user.stats?.in_progress ?? 0} in progress
+
+
+
+
+ Last login: {formatLastLogin(user.lastLoginAt)}
+
+
+ Last request: {formatLastRequest(user.stats?.last_request_at)}
+
+
+
+ Open
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/biome.json b/frontend/biome.json
new file mode 100644
index 0000000..ac2d194
--- /dev/null
+++ b/frontend/biome.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
+ "files": {
+ "includes": [
+ "app/**/*.{ts,tsx}",
+ "next.config.js",
+ "!node_modules",
+ "!.next"
+ ]
+ },
+ "formatter": {
+ "enabled": false
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "correctness": {
+ "useExhaustiveDependencies": "off"
+ },
+ "performance": {
+ "noImgElement": "off"
+ },
+ "suspicious": {
+ "noArrayIndexKey": "off",
+ "noDocumentCookie": "off",
+ "noExplicitAny": "off"
+ }
+ }
+ }
+}
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/next.config.js b/frontend/next.config.js
new file mode 100644
index 0000000..c5e687e
--- /dev/null
+++ b/frontend/next.config.js
@@ -0,0 +1,15 @@
+const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000'
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ async rewrites() {
+ return [
+ {
+ source: '/api/:path*',
+ destination: `${backendUrl}/:path*`,
+ },
+ ]
+ },
+}
+
+module.exports = nextConfig
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..8df97c1
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1252 @@
+{
+ "name": "magent-frontend",
+ "version": "0803262237",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "magent-frontend",
+ "version": "0803262237",
+ "dependencies": {
+ "next": "16.2.12",
+ "react": "19.2.4",
+ "react-dom": "19.2.4"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.5.6",
+ "@types/node": "24.11.0",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "typescript": "5.9.3"
+ }
+ },
+ "node_modules/@biomejs/biome": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz",
+ "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "2.5.6",
+ "@biomejs/cli-darwin-x64": "2.5.6",
+ "@biomejs/cli-linux-arm64": "2.5.6",
+ "@biomejs/cli-linux-arm64-musl": "2.5.6",
+ "@biomejs/cli-linux-x64": "2.5.6",
+ "@biomejs/cli-linux-x64-musl": "2.5.6",
+ "@biomejs/cli-win32-arm64": "2.5.6",
+ "@biomejs/cli-win32-x64": "2.5.6"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz",
+ "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz",
+ "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz",
+ "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz",
+ "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz",
+ "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz",
+ "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz",
+ "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz",
+ "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz",
+ "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz",
+ "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz",
+ "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz",
+ "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz",
+ "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz",
+ "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz",
+ "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz",
+ "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz",
+ "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.11.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
+ "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz",
+ "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "16.2.12",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz",
+ "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.2.12",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.9.19",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.2.12",
+ "@next/swc-darwin-x64": "16.2.12",
+ "@next/swc-linux-arm64-gnu": "16.2.12",
+ "@next/swc-linux-arm64-musl": "16.2.12",
+ "@next/swc-linux-x64-gnu": "16.2.12",
+ "@next/swc-linux-x64-musl": "16.2.12",
+ "@next/swc-win32-arm64-msvc": "16.2.12",
+ "@next/swc-win32-x64-msvc": "16.2.12",
+ "sharp": "^0.34.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..4123231
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "magent-frontend",
+ "private": true,
+ "version": "0803262237",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "biome lint ."
+ },
+ "dependencies": {
+ "next": "16.2.12",
+ "react": "19.2.4",
+ "react-dom": "19.2.4"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.5.6",
+ "@types/node": "24.11.0",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "typescript": "5.9.3"
+ },
+ "overrides": {
+ "nanoid": "3.3.18",
+ "postcss": "8.5.25",
+ "sharp": "0.35.3"
+ }
+}
diff --git a/frontend/public/branding-icon.svg b/frontend/public/branding-icon.svg
new file mode 100644
index 0000000..a21a07f
--- /dev/null
+++ b/frontend/public/branding-icon.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/branding-logo.svg b/frontend/public/branding-logo.svg
new file mode 100644
index 0000000..802dca7
--- /dev/null
+++ b/frontend/public/branding-logo.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..3a213e9
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1
new file mode 100644
index 0000000..ae0419e
--- /dev/null
+++ b/scripts/build_release.ps1
@@ -0,0 +1,31 @@
+$ErrorActionPreference = "Stop"
+
+$repoRoot = Resolve-Path "$PSScriptRoot\\.."
+Set-Location $repoRoot
+
+powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
+if ($LASTEXITCODE -ne 0) {
+ throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
+}
+
+$now = Get-Date
+$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
+
+Write-Host "Build number: $buildNumber"
+
+git tag $buildNumber
+git push origin $buildNumber
+
+$backendImage = "rephl3xnz/magent-backend:$buildNumber"
+$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
+
+docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
+docker build -f frontend/Dockerfile -t $frontendImage frontend
+
+docker tag $backendImage rephl3xnz/magent-backend:latest
+docker tag $frontendImage rephl3xnz/magent-frontend:latest
+
+docker push $backendImage
+docker push $frontendImage
+docker push rephl3xnz/magent-backend:latest
+docker push rephl3xnz/magent-frontend:latest
diff --git a/scripts/ci_backend_quality_gate.sh b/scripts/ci_backend_quality_gate.sh
new file mode 100644
index 0000000..3b0a1ce
--- /dev/null
+++ b/scripts/ci_backend_quality_gate.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$repo_root"
+
+python_bin="${PYTHON_BIN:-python3}"
+
+echo "Installing backend Python requirements"
+"$python_bin" -m pip install -r backend/requirements.txt
+
+echo "Running Python dependency integrity check"
+"$python_bin" -m pip check
+
+echo "Running backend unit tests"
+"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
+
+echo "Backend quality gate passed"
diff --git a/scripts/deploy_ams_dev01.sh b/scripts/deploy_ams_dev01.sh
new file mode 100644
index 0000000..ea52e0f
--- /dev/null
+++ b/scripts/deploy_ams_dev01.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$repo_root"
+
+deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
+deploy_user="${DEPLOY_USER:-zak}"
+deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
+ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
+timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
+
+remote="${deploy_user}@${deploy_host}"
+
+echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
+
+git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
+ set -e
+ mkdir -p '${deploy_path}'
+ backup_root=\"\${HOME}/magent-backups/${timestamp}\"
+ mkdir -p \"\${backup_root}\"
+ cd '${deploy_path}'
+ for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
+ if [ -e \"\$path\" ]; then
+ cp -a \"\$path\" \"\${backup_root}/\"
+ fi
+ done
+ tar -xf - -C '${deploy_path}'
+ docker compose up -d --build
+"
+
+echo "Running remote smoke checks"
+ssh ${ssh_opts} "${remote}" "
+ set -e
+ python3 - <<'PY'
+from urllib import request
+
+checks = [
+ ('http://127.0.0.1:8000/health', 200),
+ ('http://127.0.0.1:3000/login', 200),
+]
+
+for url, expected in checks:
+ with request.urlopen(url, timeout=20) as response:
+ if response.status != expected:
+ raise SystemExit(f'{url} returned {response.status}, expected {expected}')
+ print(url, response.status)
+PY
+"
+
+echo "Deployment completed successfully"
diff --git a/scripts/deploy_beta_ams_dev01.sh b/scripts/deploy_beta_ams_dev01.sh
new file mode 100755
index 0000000..dbd4a98
--- /dev/null
+++ b/scripts/deploy_beta_ams_dev01.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$repo_root"
+
+deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
+deploy_user="${DEPLOY_USER:-zak}"
+prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
+deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
+beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
+ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
+timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
+
+remote="${deploy_user}@${deploy_host}"
+
+echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
+
+git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
+ set -e
+ mkdir -p '${deploy_path}'
+ backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
+ mkdir -p \"\${backup_root}\"
+ cd '${deploy_path}'
+ for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
+ if [ -e \"\$path\" ]; then
+ cp -a \"\$path\" \"\${backup_root}/\"
+ fi
+ done
+ tar -xf - -C '${deploy_path}'
+
+ if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
+ cp '${prod_path}/.env' '${deploy_path}/.env'
+ fi
+
+ mkdir -p '${deploy_path}/data'
+ if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
+ cp -a '${prod_path}/data/.' '${deploy_path}/data/'
+ fi
+
+ cd '${deploy_path}'
+ docker compose -p magent-beta -f docker-compose.beta.yml build
+ docker compose -p magent-beta -f docker-compose.beta.yml up -d
+"
+
+echo "Running remote beta smoke checks"
+ssh ${ssh_opts} "${remote}" "
+ set -e
+ python3 - <<'PY'
+from urllib import request
+
+checks = [
+ ('http://127.0.0.1:8100/health', 200),
+ ('http://${beta_frontend_bind}:3100/login', 200),
+]
+
+for url, expected in checks:
+ with request.urlopen(url, timeout=20) as response:
+ if response.status != expected:
+ raise SystemExit(f'{url} returned {response.status}, expected {expected}')
+ print(url, response.status)
+PY
+"
+
+echo "Beta deployment completed successfully"
diff --git a/scripts/import_user_emails.py b/scripts/import_user_emails.py
new file mode 100644
index 0000000..898ac88
--- /dev/null
+++ b/scripts/import_user_emails.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import sqlite3
+from collections import Counter
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
+DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
+
+
+def _normalize_email(value: object) -> str | None:
+ if not isinstance(value, str):
+ return None
+ candidate = value.strip()
+ if not candidate or "@" not in candidate:
+ return None
+ return candidate
+
+
+def _load_rows(csv_path: Path) -> list[dict[str, str]]:
+ with csv_path.open("r", encoding="utf-8", newline="") as handle:
+ return [dict(row) for row in csv.DictReader(handle)]
+
+
+def _ensure_email_column(conn: sqlite3.Connection) -> None:
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
+ except sqlite3.OperationalError:
+ pass
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_email_nocase
+ ON users (email COLLATE NOCASE)
+ """
+ )
+
+
+def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
+ return conn.execute(
+ """
+ SELECT id, username, email
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ ORDER BY
+ CASE WHEN username = ? THEN 0 ELSE 1 END,
+ id ASC
+ """,
+ (username, username),
+ ).fetchall()
+
+
+def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
+ rows = _load_rows(csv_path)
+ username_counts = Counter(
+ str(row.get("Username") or "").strip().lower()
+ for row in rows
+ if str(row.get("Username") or "").strip()
+ )
+ duplicate_usernames = {
+ username for username, count in username_counts.items() if username and count > 1
+ }
+
+ summary: dict[str, object] = {
+ "csv_path": str(csv_path),
+ "db_path": str(db_path),
+ "source_rows": len(rows),
+ "updated": 0,
+ "unchanged": 0,
+ "missing_email": [],
+ "missing_user": [],
+ "duplicate_source_username": [],
+ }
+
+ with sqlite3.connect(db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ _ensure_email_column(conn)
+
+ for row in rows:
+ username = str(row.get("Username") or "").strip()
+ if not username:
+ continue
+ username_key = username.lower()
+ if username_key in duplicate_usernames:
+ cast_list = summary["duplicate_source_username"]
+ assert isinstance(cast_list, list)
+ if username not in cast_list:
+ cast_list.append(username)
+ continue
+
+ email = _normalize_email(row.get("Email"))
+ if not email:
+ cast_list = summary["missing_email"]
+ assert isinstance(cast_list, list)
+ cast_list.append(username)
+ continue
+
+ matches = _lookup_user(conn, username)
+ if not matches:
+ cast_list = summary["missing_user"]
+ assert isinstance(cast_list, list)
+ cast_list.append(username)
+ continue
+
+ current_emails = {
+ normalized.lower()
+ for normalized in (_normalize_email(row["email"]) for row in matches)
+ if normalized
+ }
+ if current_emails == {email.lower()}:
+ summary["unchanged"] = int(summary["unchanged"]) + 1
+ continue
+
+ conn.execute(
+ """
+ UPDATE users
+ SET email = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (email, username),
+ )
+ summary["updated"] = int(summary["updated"]) + 1
+
+ summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
+ summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
+ summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
+ return summary
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
+ parser.add_argument(
+ "csv_path",
+ nargs="?",
+ default=str(DEFAULT_CSV_PATH),
+ help="CSV file containing Username and Email columns",
+ )
+ parser.add_argument(
+ "--db-path",
+ default=str(DEFAULT_DB_PATH),
+ help="Path to the Magent SQLite database",
+ )
+ args = parser.parse_args()
+ summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
+ print(json.dumps(summary, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/process1.ps1 b/scripts/process1.ps1
new file mode 100644
index 0000000..237be2c
--- /dev/null
+++ b/scripts/process1.ps1
@@ -0,0 +1,316 @@
+param(
+ [string]$CommitMessage,
+ [switch]$SkipCommit,
+ [switch]$SkipDiscord
+)
+
+$ErrorActionPreference = "Stop"
+
+$repoRoot = Resolve-Path "$PSScriptRoot\.."
+Set-Location $repoRoot
+
+$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+$script:CurrentStep = "initializing"
+
+function Write-TextFile {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Content
+ )
+
+ $fullPath = Join-Path $repoRoot $Path
+ $normalized = $Content -replace "`r`n", "`n"
+ [System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom)
+}
+
+function Assert-LastExitCode {
+ param([Parameter(Mandatory = $true)][string]$CommandName)
+
+ if ($LASTEXITCODE -ne 0) {
+ throw "$CommandName failed with exit code $LASTEXITCODE."
+ }
+}
+
+function Read-TextFile {
+ param([Parameter(Mandatory = $true)][string]$Path)
+
+ $fullPath = Join-Path $repoRoot $Path
+ return [System.IO.File]::ReadAllText($fullPath)
+}
+
+function Get-EnvFileValue {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Name
+ )
+
+ if (-not (Test-Path $Path)) {
+ return $null
+ }
+
+ $match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1
+ if (-not $match) {
+ return $null
+ }
+
+ return $match.Matches[0].Groups[1].Value.Trim()
+}
+
+function Get-DiscordWebhookUrl {
+ $candidateNames = @(
+ "PROCESS_DISCORD_WEBHOOK_URL",
+ "MAGENT_NOTIFY_DISCORD_WEBHOOK_URL",
+ "DISCORD_WEBHOOK_URL"
+ )
+
+ foreach ($name in $candidateNames) {
+ $value = [System.Environment]::GetEnvironmentVariable($name)
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ return $value.Trim()
+ }
+ }
+
+ foreach ($name in $candidateNames) {
+ $value = Get-EnvFileValue -Path ".env" -Name $name
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ return $value.Trim()
+ }
+ }
+
+ $configPath = Join-Path $repoRoot "backend/app/config.py"
+ if (Test-Path $configPath) {
+ $configContent = Read-TextFile -Path "backend/app/config.py"
+ $match = [regex]::Match(
+ $configContent,
+ 'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"',
+ [System.Text.RegularExpressions.RegexOptions]::Singleline
+ )
+ if ($match.Success) {
+ return $match.Groups[1].Value.Trim()
+ }
+ }
+
+ return $null
+}
+
+function Send-DiscordUpdate {
+ param(
+ [Parameter(Mandatory = $true)][string]$Title,
+ [Parameter(Mandatory = $true)][string]$Body
+ )
+
+ if ($SkipDiscord) {
+ Write-Host "Skipping Discord notification."
+ return
+ }
+
+ $webhookUrl = Get-DiscordWebhookUrl
+ if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
+ Write-Warning "Discord webhook not configured for Process 1."
+ return
+ }
+
+ $content = "**$Title**`n$Body"
+ Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null
+}
+
+function Get-BuildNumber {
+ $current = ""
+ if (Test-Path ".build_number") {
+ $current = (Get-Content ".build_number" -Raw).Trim()
+ }
+
+ $candidate = Get-Date
+ $buildNumber = $candidate.ToString("ddMMyyHHmm")
+ if ($buildNumber -eq $current) {
+ $buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm")
+ }
+
+ return $buildNumber
+}
+
+function Wait-ForHttp {
+ param(
+ [Parameter(Mandatory = $true)][string]$Url,
+ [int]$Attempts = 30,
+ [int]$DelaySeconds = 2
+ )
+
+ $lastError = $null
+ for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
+ try {
+ return Invoke-RestMethod -Uri $Url -TimeoutSec 10
+ } catch {
+ $lastError = $_
+ Start-Sleep -Seconds $DelaySeconds
+ }
+ }
+
+ throw $lastError
+}
+
+function Get-GitChangelogLiteral {
+ $scriptPath = Join-Path $repoRoot "scripts/render_git_changelog.py"
+ $literal = python $scriptPath --python-literal
+ Assert-LastExitCode -CommandName "python scripts/render_git_changelog.py --python-literal"
+ return ($literal | Out-String).Trim()
+}
+
+function Update-BuildFiles {
+ param([Parameter(Mandatory = $true)][string]$BuildNumber)
+
+ Write-TextFile -Path ".build_number" -Content "$BuildNumber`n"
+
+ $changelogLiteral = Get-GitChangelogLiteral
+ $buildInfoContent = @(
+ "BUILD_NUMBER = `"$BuildNumber`""
+ "CHANGELOG = $changelogLiteral"
+ ""
+ ) -join "`n"
+ Write-TextFile -Path "backend/app/build_info.py" -Content $buildInfoContent
+
+ $envPath = Join-Path $repoRoot ".env"
+ if (Test-Path $envPath) {
+ $envContent = Read-TextFile -Path ".env"
+ if ($envContent -match '^BUILD_NUMBER=.*$') {
+ $updatedEnv = [regex]::Replace(
+ $envContent,
+ '^BUILD_NUMBER=.*$',
+ "BUILD_NUMBER=$BuildNumber",
+ [System.Text.RegularExpressions.RegexOptions]::Multiline
+ )
+ } else {
+ $updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
+ }
+ Write-TextFile -Path ".env" -Content $updatedEnv
+ }
+
+ $packageJson = Read-TextFile -Path "frontend/package.json"
+ $packageJsonRegex = [regex]::new('"version"\s*:\s*"\d+"')
+ $updatedPackageJson = $packageJsonRegex.Replace(
+ $packageJson,
+ "`"version`": `"$BuildNumber`"",
+ 1
+ )
+ Write-TextFile -Path "frontend/package.json" -Content $updatedPackageJson
+
+ $packageLock = Read-TextFile -Path "frontend/package-lock.json"
+ $packageLockVersionRegex = [regex]::new('"version"\s*:\s*"\d+"')
+ $updatedPackageLock = $packageLockVersionRegex.Replace(
+ $packageLock,
+ "`"version`": `"$BuildNumber`"",
+ 1
+ )
+ $packageLockRootRegex = [regex]::new(
+ '(""\s*:\s*\{\s*"name"\s*:\s*"magent-frontend"\s*,\s*"version"\s*:\s*)"\d+"',
+ [System.Text.RegularExpressions.RegexOptions]::Singleline
+ )
+ $updatedPackageLock = $packageLockRootRegex.Replace(
+ $updatedPackageLock,
+ '$1"' + $BuildNumber + '"',
+ 1
+ )
+ Write-TextFile -Path "frontend/package-lock.json" -Content $updatedPackageLock
+}
+
+function Get-ChangedFilesSummary {
+ $files = git diff --cached --name-only
+ if (-not $files) {
+ return "No staged files"
+ }
+
+ $count = ($files | Measure-Object).Count
+ $sample = $files | Select-Object -First 8
+ $summary = ($sample -join ", ")
+ if ($count -gt $sample.Count) {
+ $summary = "$summary, +$($count - $sample.Count) more"
+ }
+
+ return "$count files: $summary"
+}
+
+$buildNumber = $null
+$branch = $null
+$commit = $null
+$publicInfo = $null
+$changedFiles = "No staged files"
+
+try {
+ $branch = (git rev-parse --abbrev-ref HEAD).Trim()
+ $buildNumber = Get-BuildNumber
+ Write-Host "Process 1 build number: $buildNumber"
+
+ $script:CurrentStep = "updating build metadata"
+ Update-BuildFiles -BuildNumber $buildNumber
+
+ $script:CurrentStep = "running backend quality gate"
+ powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
+ Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1"
+
+ $script:CurrentStep = "rebuilding local docker stack"
+ docker compose up -d --build
+ Assert-LastExitCode -CommandName "docker compose up -d --build"
+
+ $script:CurrentStep = "verifying backend health"
+ $health = Wait-ForHttp -Url "http://127.0.0.1:8000/health"
+ if ($health.status -ne "ok") {
+ throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)"
+ }
+
+ $script:CurrentStep = "verifying public build metadata"
+ $publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public"
+ if ($publicInfo.buildNumber -ne $buildNumber) {
+ throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)."
+ }
+
+ $script:CurrentStep = "committing changes"
+ git add -A
+ Assert-LastExitCode -CommandName "git add -A"
+ $changedFiles = Get-ChangedFilesSummary
+ if ((git status --short).Trim()) {
+ if (-not $SkipCommit) {
+ if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
+ $CommitMessage = "Process 1 build $buildNumber"
+ }
+ git commit -m $CommitMessage
+ Assert-LastExitCode -CommandName "git commit"
+ }
+ }
+
+ $commit = (git rev-parse --short HEAD).Trim()
+
+ $body = @(
+ "Build: $buildNumber"
+ "Branch: $branch"
+ "Commit: $commit"
+ "Health: ok"
+ "Public build: $($publicInfo.buildNumber)"
+ "Changes: $changedFiles"
+ ) -join "`n"
+ Send-DiscordUpdate -Title "Process 1 complete" -Body $body
+
+ Write-Host "Process 1 completed successfully."
+} catch {
+ $failureCommit = ""
+ try {
+ $failureCommit = (git rev-parse --short HEAD).Trim()
+ } catch {
+ $failureCommit = "unknown"
+ }
+
+ $failureBody = @(
+ "Build: $buildNumber"
+ "Branch: $branch"
+ "Commit: $failureCommit"
+ "Step: $script:CurrentStep"
+ "Error: $($_.Exception.Message)"
+ ) -join "`n"
+
+ try {
+ Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody
+ } catch {
+ Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)"
+ }
+
+ throw
+}
diff --git a/scripts/render_git_changelog.py b/scripts/render_git_changelog.py
new file mode 100644
index 0000000..a452bd6
--- /dev/null
+++ b/scripts/render_git_changelog.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path
+
+
+def build_git_changelog(repo_root: Path, max_count: int) -> str:
+ result = subprocess.run(
+ [
+ "git",
+ "log",
+ f"--max-count={max_count}",
+ "--date=short",
+ "--pretty=format:%cs|%s",
+ "--",
+ ".",
+ ],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
+ return "\n".join(lines)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--max-count", type=int, default=200)
+ parser.add_argument("--python-literal", action="store_true")
+ args = parser.parse_args()
+
+ repo_root = Path(__file__).resolve().parents[1]
+ changelog = build_git_changelog(repo_root, max_count=args.max_count)
+ if args.python_literal:
+ print(repr(changelog))
+ else:
+ sys.stdout.write(changelog)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/run_backend_quality_gate.ps1 b/scripts/run_backend_quality_gate.ps1
new file mode 100644
index 0000000..7558df8
--- /dev/null
+++ b/scripts/run_backend_quality_gate.ps1
@@ -0,0 +1,60 @@
+$ErrorActionPreference = "Stop"
+
+$repoRoot = Resolve-Path "$PSScriptRoot\.."
+Set-Location $repoRoot
+$env:PYTHONIOENCODING = "utf-8"
+
+function Assert-LastExitCode {
+ param([Parameter(Mandatory = $true)][string]$CommandName)
+
+ if ($LASTEXITCODE -ne 0) {
+ throw "$CommandName failed with exit code $LASTEXITCODE."
+ }
+}
+
+function Get-PythonCommand {
+ $venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
+ if (Test-Path $venvPython) {
+ return $venvPython
+ }
+ return "python"
+}
+
+function Ensure-PythonModule {
+ param(
+ [Parameter(Mandatory = $true)][string]$PythonExe,
+ [Parameter(Mandatory = $true)][string]$ModuleName,
+ [Parameter(Mandatory = $true)][string]$PackageName
+ )
+
+ & $PythonExe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)"
+ if ($LASTEXITCODE -eq 0) {
+ return
+ }
+
+ Write-Host "Installing missing Python package: $PackageName"
+ & $PythonExe -m pip install $PackageName
+ Assert-LastExitCode -CommandName "python -m pip install $PackageName"
+}
+
+$pythonExe = Get-PythonCommand
+
+Write-Host "Installing backend Python requirements"
+& $pythonExe -m pip install -r (Join-Path $repoRoot "backend\requirements.txt")
+Assert-LastExitCode -CommandName "python -m pip install -r backend/requirements.txt"
+
+Write-Host "Running Python dependency integrity check"
+& $pythonExe -m pip check
+Assert-LastExitCode -CommandName "python -m pip check"
+
+Ensure-PythonModule -PythonExe $pythonExe -ModuleName "pip_audit" -PackageName "pip-audit"
+
+Write-Host "Running Python vulnerability scan"
+& $pythonExe -m pip_audit -r (Join-Path $repoRoot "backend\requirements.txt") --progress-spinner off --desc
+Assert-LastExitCode -CommandName "python -m pip_audit"
+
+Write-Host "Running backend unit tests"
+& $pythonExe -m unittest discover -s backend/tests -p "test_*.py" -v
+Assert-LastExitCode -CommandName "python -m unittest discover"
+
+Write-Host "Backend quality gate passed"