commit 5fa5d4553502c575ebec8788d89656b8a1a4ecdb
Author: Magent release tooling
Date: Sat Sep 19 16:58:12 2026 +1200
feat(release): publish minimal self-contained Magent source
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..faa48c8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,44 @@
+# Release builds accept only application sources and explicit build inputs.
+# Local configuration, databases, backups, Git metadata and tool caches must
+# never be sent to the builder, even if new directories are added to the repo.
+**
+!Dockerfile
+!.dockerignore
+!LICENSE
+!backend/
+!backend/requirements.txt
+!backend/app/
+!backend/app/**
+!frontend/
+!frontend/package.json
+!frontend/package-lock.json
+!frontend/next-env.d.ts
+!frontend/next.config.js
+!frontend/proxy.ts
+!frontend/tsconfig.json
+!frontend/app/
+!frontend/app/**
+!frontend/public/
+!frontend/public/**
+!docker/
+!docker/supervisord.conf
+!docker/requirements-runtime.txt
+!data/
+!data/branding/
+!data/branding/**
+
+# Defense in depth for accidental private/generated files under allowed paths.
+**/.env
+**/.env.*
+**/__pycache__
+**/*.pyc
+**/*.log
+**/*.db
+**/*.db-*
+**/*.sqlite
+**/*.sqlite3
+**/bootstrap-admin.json
+**/bootstrap-secrets.json
+**/.magent-secrets-*
+**/node_modules
+**/.next
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5abe2ec
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,37 @@
+# Copy to .env for a fresh install; never replace an existing deployment's keys.
+# See docs/PUBLIC_RELEASE.md. The localhost settings below are for local HTTP only.
+# Never deploy the example secret placeholders.
+APP_NAME=Magent
+
+# Public Docker Hub template: choose a published prod- tag or sha256 digest.
+# Intentionally no default: do not silently pull a mutable or incompatible image.
+MAGENT_IMAGE=
+MAGENT_BIND_ADDRESS=127.0.0.1
+MAGENT_HTTP_PORT=3000
+
+# For public hosting set BOTH URLs to your exact HTTPS origin (no trailing slash),
+# for example https://magent.example.com, and AUTH_COOKIE_SECURE=true below.
+CORS_ALLOW_ORIGIN=http://localhost:3000
+MAGENT_APPLICATION_URL=http://localhost:3000
+# Backend address is internal to the combined container, not a browser endpoint.
+MAGENT_API_URL=http://127.0.0.1:8000
+SQLITE_PATH=/app/data/magent.db
+LOG_FILE=/app/data/magent.log
+LOG_FORMAT=text
+
+# Generate independent values as documented in docs/PUBLIC_RELEASE.md.
+# Keep both unchanged when upgrading or restoring an offline data-volume backup.
+JWT_SECRET=replace-with-at-least-32-random-characters
+SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
+ADMIN_USERNAME=admin
+# Recommended fresh install: generate a separate random setup token. Open /setup
+# to create the administrator and connect your apps; remove this after finishing.
+SETUP_TOKEN=replace-with-a-separate-random-setup-token
+# Alternatively pre-create the first admin with a unique password (12+ chars).
+# Leave blank to create the account using the setup wizard and SETUP_TOKEN.
+ADMIN_PASSWORD=
+
+# false is ONLY for local HTTP; public HTTPS deployments must use true.
+AUTH_COOKIE_SECURE=false
+AUTH_COOKIE_SAMESITE=strict
+API_DOCS_ENABLED=false
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/.gitignore b/.gitignore
new file mode 100644
index 0000000..ac87aa7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+.env
+.env.*
+!.env.example
+.venv/
+**/__pycache__/
+*.pyc
+**/.pytest_cache/
+.coverage
+coverage.xml
+htmlcov/
+frontend/node_modules/
+frontend/.next/
+*.tsbuildinfo
+*.log
+*.db
+*.db-*
+*.sqlite*
+*.magent-backup
+bootstrap-admin.json
+bootstrap-secrets.json
+.magent-secrets-*
+data/*
+!data/branding/
+*.tar
+*.tar.gz
+*.zip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..25adbb5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,96 @@
+FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS frontend-builder
+
+WORKDIR /frontend
+
+# GNU cp is needed only to collect third-party notices in the builder.
+RUN apk add --no-cache coreutils
+
+ENV NODE_ENV=production \
+ NEXT_TELEMETRY_DISABLED=1 \
+ 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/proxy.ts ./proxy.ts
+COPY frontend/tsconfig.json ./tsconfig.json
+
+# Keep dependency notices outside the traced bundle: file tracing deliberately
+# omits many license files that still need to accompany redistributed packages.
+RUN npm run build \
+ && npm prune --omit=dev \
+ && mkdir /licenses \
+ && npm ls --omit=dev --all --json > /licenses/dependencies.json \
+ && find node_modules -type f \
+ \( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'copyright*' \) \
+ -exec cp --parents -t /licenses {} +
+
+FROM python:3.14-alpine@sha256:016508ba505da24f7139765bc4bb669df4e88eb2f12eeadd571bf2f88d7533df AS runtime
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ MAGENT_MANAGED_SECRETS=auto \
+ SQLITE_PATH=/app/data/magent.db \
+ API_DOCS_ENABLED=false \
+ NODE_ENV=production \
+ NEXT_TELEMETRY_DISABLED=1
+
+# Keep curl for existing deployments that override the image healthcheck.
+# Copy only Node's runtime binary: npm, headers and the NodeSource installer
+# are build tools, not dependencies of the standalone frontend server.
+RUN apk upgrade --no-cache \
+ && apk add --no-cache curl libstdc++
+
+COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
+COPY --from=frontend-builder /usr/local/LICENSE /usr/local/share/doc/nodejs/LICENSE
+RUN node --version
+
+ARG MAGENT_UID=1000
+ARG MAGENT_GID=1000
+RUN addgroup -g ${MAGENT_GID} magent \
+ && adduser -D -u ${MAGENT_UID} -G magent -s /sbin/nologin magent \
+ && install -d -o magent -g magent -m 0700 /app/data \
+ && install -d -o magent -g magent -m 0755 /app/frontend/.next/cache
+
+COPY backend/requirements.txt docker/requirements-runtime.txt /tmp/requirements/
+RUN pip install --no-cache-dir --no-compile \
+ -r /tmp/requirements/requirements.txt \
+ -r /tmp/requirements/requirements-runtime.txt \
+ && pip uninstall -y pip \
+ && rm /tmp/requirements/requirements.txt /tmp/requirements/requirements-runtime.txt \
+ && rmdir /tmp/requirements
+
+COPY --chown=magent:magent backend/app ./app
+COPY --chown=magent:magent data/branding /app/data/branding
+
+# Next's traced standalone output excludes the full dev/build dependency tree.
+COPY --chown=magent:magent --from=frontend-builder /frontend/.next/standalone /app/frontend
+COPY --chown=magent:magent --from=frontend-builder /frontend/.next/static /app/frontend/.next/static
+COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
+
+COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
+COPY LICENSE /usr/share/licenses/magent/LICENSE
+COPY --from=frontend-builder /licenses /usr/share/licenses/magent/frontend
+
+LABEL org.opencontainers.image.title="Magent" \
+ org.opencontainers.image.description="Self-hosted media requests, issues and viewing insights" \
+ org.opencontainers.image.licenses="MIT"
+
+USER magent:magent
+
+EXPOSE 3000 8000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
+ CMD curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8000/health >/dev/null \
+ && curl --fail --silent --show-error --max-time 2 http://127.0.0.1:3000/login >/dev/null \
+ || exit 1
+
+ENTRYPOINT ["python", "-m", "app.container_bootstrap"]
+CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..27975b5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Magent contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0160216
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+# Magent
+
+Self-hosted media requests, viewing stats and issue management for Jellyfin,
+Seerr, Sonarr, Radarr and related services. Magent combines a Python/FastAPI API,
+a Next.js frontend and SQLite in one non-root container.
+
+## Install
+
+Paste [compose.yml](compose.yml) into a Portainer **Docker Standalone** stack.
+It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no
+environment variables or Dockerfile on the user's machine.
+
+**Image availability:** this source snapshot has not yet been published to
+Docker Hub. The current `latest` image may not include this setup flow. Until a
+compatible image is published, use the source-build command below for testing.
+Only Linux/amd64 has been validated.
+
+1. Deploy the stack and wait for the container to become healthy.
+2. In its console, select `/bin/ash` and user `magent`, then run:
+
+ ```sh
+ python -m app.container_bootstrap setup-token
+ ```
+
+3. Open the Docker host's address on port 3000. Confirm the browser-facing URL
+ in setup and use the token to create the first administrator.
+4. Connect your apps, choose preferences and finish setup. Optional apps can
+ be skipped. Save an encrypted backup afterwards.
+
+Keep the Compose security block unchanged. Database storage is fixed at
+`/app/data/magent.db` and API docs are disabled in managed installs. CORS and
+cookie security follow the confirmed URL. Use HTTPS before public access.
+
+See [Portainer setup](docs/PORTAINER.md),
+[all environment options](docs/ENVIRONMENT.md),
+[backup and restore](docs/installation-and-recovery.md) and
+[advanced installation/upgrades](docs/PUBLIC_RELEASE.md).
+Existing installations must retain their original data volume and signing/
+encryption keys; this fresh-install template is not an automatic migration.
+
+## Build and test
+
+The source tree contains everything needed to build the application:
+
+```sh
+docker compose -f compose.yml -f compose.build.yml up -d --build
+```
+
+For a disposable verification run, without touching an existing installation:
+
+```sh
+docker build -t magent:review .
+bash scripts/ci_container_smoke.sh magent:review
+MAGENT_SMOKE_MANAGED=true bash scripts/ci_container_smoke.sh magent:review
+```
+
+Unit checks require Python 3.14 and Node 24:
+
+```sh
+python -m venv .venv
+. .venv/bin/activate
+pip install -r backend/requirements-dev.txt
+python -m unittest discover -s backend/tests -p 'test_*.py'
+python scripts/check_environment_docs.py
+cd frontend
+npm ci
+npm test
+npm run lint
+npm run format:check
+npm run typecheck
+```
+
+On Windows, activate `.venv\Scripts\Activate.ps1` instead. Do not point tests
+at live services or use production credentials.
+
+## How it is organised
+
+- `backend/app/routers/`: authenticated API endpoints and administration.
+- `backend/app/clients/`: media-service clients; `services/`: request states,
+ synchronisation, notifications, setup and encrypted backups.
+- `backend/app/db.py` and `schema_migrations.py`: SQLite persistence/migrations.
+- `frontend/app/`: pages and shared interface components; `frontend/proxy.ts`:
+ browser security headers and request nonces.
+- `backend/tests/` and frontend `*.test.*`: synthetic regression tests.
+- `Dockerfile` and `docker/`: multi-stage build and process supervision.
+- `compose.yml`: prebuilt-image install; `compose.build.yml`: source override.
+
+Requests are cached from Seerr, joined to collector/download/library evidence,
+normalised into a user-facing state and displayed by the frontend. App settings
+are stored in SQLite; sensitive settings are encrypted with installation-specific
+keys. Integrations are optional and are configured through the setup wizard.
+
+This `release` branch intentionally excludes internal deployment scripts,
+environment files, runtime data, development reports and prior Git history.
+It contains no workflow that automatically deploys or publishes an image.
+
+## Contributing and security
+
+Keep changes focused, add regression tests and run the checks above. Never
+commit tokens, database exports, backups or real user information.
+See [SECURITY.md](SECURITY.md) for reporting guidance and deployment precautions.
+
+Licensed under [MIT](LICENSE). Third-party dependency licences remain applicable.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..6b0003f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,28 @@
+# Security
+
+## Reporting a vulnerability
+
+Do not post passwords, access tokens, encryption keys, database exports, backup
+files or live exploit details in public issues, discussions or container logs.
+Use the repository hosting platform's private vulnerability-reporting feature
+if the release owner has enabled it. Otherwise contact the maintainer privately
+through the platform where you obtained this release before sending sensitive
+details. This repository does not currently advertise a dedicated reporting
+address; the release owner must establish one before a broad public launch.
+
+Include the image tag/digest, affected version, a minimal reproduction using
+synthetic data, and the security impact. Remove deployment credentials and
+personal data from attachments. Do not test against systems you do not own or
+have permission to assess.
+
+## Deployment precautions
+
+Follow [the public installation guide](docs/PUBLIC_RELEASE.md): use HTTPS for
+public access, independent random secrets, a protected persistent data volume,
+and the exact browser-facing origin. Keep the original signing/encryption keys
+when upgrading. Do not disable origin checks or run as root to work around a
+deployment failure.
+
+Use a reviewed immutable release image and retain a tested backup. Check the
+release's declared architecture support and migration notes. The project has
+not declared an LTS support window or a guaranteed security-response SLA.
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/api_models.py b/backend/app/api_models.py
new file mode 100644
index 0000000..2578834
--- /dev/null
+++ b/backend/app/api_models.py
@@ -0,0 +1,58 @@
+"""Shared HTTP request and error contracts."""
+
+from typing import Any, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class StrictRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class ErrorResponse(BaseModel):
+ detail: str
+
+
+COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
+ 400: {"model": ErrorResponse, "description": "Invalid request"},
+ 401: {"model": ErrorResponse, "description": "Authentication required"},
+ 403: {"model": ErrorResponse, "description": "Permission denied"},
+ 404: {"model": ErrorResponse, "description": "Resource not found"},
+ 409: {"model": ErrorResponse, "description": "Request conflict"},
+ 429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
+ 500: {"model": ErrorResponse, "description": "Unexpected server error"},
+ 502: {"model": ErrorResponse, "description": "Upstream service error"},
+ 503: {"model": ErrorResponse, "description": "Service unavailable"},
+}
+
+
+class SignupRequest(StrictRequest):
+ invite_code: str = Field(min_length=1, max_length=256)
+ username: str = Field(min_length=1, max_length=100)
+ password: str = Field(min_length=1, max_length=1024)
+ email: Optional[str] = Field(default=None, max_length=320)
+
+
+class ForgotPasswordRequest(StrictRequest):
+ identifier: Optional[str] = Field(default=None, max_length=320)
+ username: Optional[str] = Field(default=None, max_length=100)
+ email: Optional[str] = Field(default=None, max_length=320)
+
+
+class PasswordResetRequest(StrictRequest):
+ token: str = Field(min_length=1, max_length=512)
+ new_password: str = Field(min_length=1, max_length=1024)
+
+
+class ProfileEmailUpdateRequest(StrictRequest):
+ email: Optional[str] = Field(default=None, max_length=320)
+
+
+class ChangePasswordRequest(StrictRequest):
+ current_password: str = Field(min_length=1, max_length=1024)
+ new_password: str = Field(min_length=1, max_length=1024)
+
+
+def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
+ """Keep direct service-level tests compatible while FastAPI validates HTTP input."""
+ return payload if isinstance(payload, dict) else payload.model_dump()
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..76d907f
--- /dev/null
+++ b/backend/app/auth.py
@@ -0,0 +1,240 @@
+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 .installation_origin import managed_runtime
+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"
+ secure = bool(settings.auth_cookie_secure)
+ if managed_runtime():
+ from .services.public_urls import magent_public_url
+ # Follow the persisted operator-selected URL immediately, including
+ # first login after setup; a restart is not required to protect cookies.
+ secure = magent_public_url().startswith("https://")
+ return {
+ "secure": 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")
+ token_version = payload.get("ver")
+ if not isinstance(token_version, int) or token_version != int(user.get("auth_version") or 1):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked")
+
+ user = normalize_user_auth_provider(user)
+ from .feature_access import permissions
+ features = permissions(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 {
+ "features": features,
+ "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"),
+ "auth_version": int(user.get("auth_version") or 1),
+ }
+
+
+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..8593f44
--- /dev/null
+++ b/backend/app/build_info.py
@@ -0,0 +1,2 @@
+BUILD_NUMBER = "0803262237"
+CHANGELOG = '2026-09-19|Initial minimal release source snapshot'
diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py
new file mode 100644
index 0000000..c8d5b8e
--- /dev/null
+++ b/backend/app/clients/base.py
@@ -0,0 +1,419 @@
+from typing import Any, Dict, Optional
+import logging
+import time
+import httpx
+
+from ..logging_config import sanitize_headers, sanitize_value
+from ..services.operation_progress import finish_remote_call, start_remote_call
+from ..metrics import record_remote
+
+
+_SERVICE_NAMES = {
+ "JellyseerrClient": "Seerr",
+ "SonarrClient": "Sonarr",
+ "RadarrClient": "Radarr",
+ "BazarrClient": "Bazarr",
+ "ProwlarrClient": "Prowlarr",
+ "JellyfinClient": "Jellyfin",
+ "QBittorrentClient": "qBittorrent",
+}
+
+
+def _result_items(result: Any, *keys: str) -> list[Any]:
+ if isinstance(result, list):
+ return result
+ if not isinstance(result, dict):
+ return []
+ for key in keys:
+ value = result.get(key)
+ if isinstance(value, list):
+ return value
+ return []
+
+
+def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ candidates = result if isinstance(result, list) else [result]
+ for candidate in candidates:
+ if not isinstance(candidate, dict):
+ continue
+ title = str(candidate.get("title") or candidate.get("name") or "").strip()
+ if title:
+ return title
+ if isinstance(payload, dict):
+ title = str(payload.get("title") or payload.get("name") or "").strip()
+ if title:
+ return title
+ return None
+
+
+def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
+ noun = singular if count == 1 else (plural or f"{singular}s")
+ return f"{count} {noun}"
+
+
+def _queue_result_message(service: str, result: Any) -> str:
+ records = _result_items(result, "records", "items")
+ total = result.get("totalRecords") if isinstance(result, dict) else None
+ count = int(total) if isinstance(total, int) else len(records)
+ if count == 0:
+ return f"{service} has no matching downloads in its queue."
+ first = next((item for item in records if isinstance(item, dict)), None)
+ progress_text = ""
+ if first:
+ size = first.get("size")
+ size_left = first.get("sizeleft")
+ if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
+ progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
+ progress_text = f" The first is {progress}% complete."
+ return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
+
+
+def _command_name(payload: Optional[Dict[str, Any]]) -> str:
+ raw_name = str((payload or {}).get("name") or "").strip()
+ names = {
+ "MoviesSearch": "movie search",
+ "SeriesSearch": "series search",
+ "EpisodeSearch": "episode search",
+ "DownloadRelease": "release download",
+ "RefreshMovie": "movie refresh",
+ "RescanMovie": "movie rescan",
+ "RefreshSeries": "series refresh",
+ "RescanSeries": "series rescan",
+ }
+ return names.get(raw_name, "command")
+
+
+def _operation_result_message(
+ service: str,
+ method: str,
+ path: str,
+ result: Any,
+ *,
+ params: Optional[Dict[str, Any]] = None,
+ payload: Optional[Dict[str, Any]] = None,
+) -> str:
+ normalized_path = path.lower().split("?", 1)[0].rstrip("/")
+ normalized_method = method.upper()
+ title = _result_title(result, payload)
+ title_text = f' "{title}"' if title else ""
+
+ if service == "Seerr":
+ if normalized_path.endswith("/request") and normalized_method == "POST":
+ request_id = result.get("id") if isinstance(result, dict) else None
+ suffix = f" #{request_id}" if isinstance(request_id, int) else ""
+ return f"Seerr created the request{suffix} and passed it into the collection workflow."
+ if "/request/" in normalized_path and normalized_method == "GET":
+ status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
+ status = result.get("status") if isinstance(result, dict) else None
+ status_text = status_names.get(status)
+ return (
+ f"Seerr found the request; it is currently {status_text}."
+ if status_text
+ else "Seerr found the request and returned its current status."
+ )
+
+ if service in {"Radarr", "Sonarr"}:
+ media_name = "movie" if service == "Radarr" else "series"
+ media_path = "/movie" if service == "Radarr" else "/series"
+ if "/queue" in normalized_path and normalized_method == "GET":
+ return _queue_result_message(service, result)
+ if "/command" in normalized_path and normalized_method == "POST":
+ return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started."
+ if "/release" in normalized_path:
+ if normalized_method == "GET":
+ count = len(_result_items(result, "records", "items"))
+ return (
+ f"{service} found {_count_message(count, 'download option')}."
+ if count
+ else f"{service} could not find a suitable download option."
+ )
+ return f"{service} accepted the selected release and sent it to the download client."
+ if "/qualityprofile" in normalized_path and normalized_method == "GET":
+ count = len(_result_items(result))
+ return f"{service} returned {_count_message(count, 'download quality setting')}."
+ if "/rootfolder" in normalized_path and normalized_method == "GET":
+ count = len(_result_items(result))
+ return f"{service} returned {_count_message(count, 'library folder')}."
+ if "/indexer" in normalized_path and normalized_method == "GET":
+ count = len(_result_items(result))
+ return f"{service} reports {_count_message(count, 'configured search source')}."
+ if service == "Sonarr" and "/episodefile" in normalized_path:
+ if normalized_method == "DELETE":
+ return "Sonarr removed the existing episode file so it can be replaced."
+ count = len(_result_items(result))
+ return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
+ if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT":
+ return "Sonarr marked the selected episodes as wanted."
+ if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
+ episodes = _result_items(result)
+ available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
+ return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
+ if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
+ return "Radarr removed the existing movie file so it can be replaced."
+ is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
+ if is_media_endpoint:
+ if normalized_method == "GET":
+ found = bool(result) if not isinstance(result, list) else len(result) > 0
+ return (
+ f"{service} found{title_text} in its library list."
+ if found
+ else f"This {media_name} is not currently in {service}."
+ )
+ if normalized_method == "POST":
+ search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
+ search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
+ search_text = " and started looking for a download" if search_requested else ""
+ subject = title_text or f" the {media_name}"
+ return f"{service} added{subject}{search_text}."
+ if normalized_method == "PUT":
+ return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
+ if "/system/status" in normalized_path:
+ version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
+ return f"Connected to {service}{f' version {version}' if version else ''}."
+
+ if service == "Prowlarr":
+ if "/health" in normalized_path:
+ issues = _result_items(result)
+ if not issues:
+ return "The download search sources are working normally."
+ first = next((item for item in issues if isinstance(item, dict)), {})
+ detail = str(first.get("message") or first.get("source") or "").strip()
+ suffix = f" First issue: {detail}" if detail else ""
+ return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
+ if "/search" in normalized_path:
+ results = _result_items(result, "results", "records")
+ return (
+ f"Prowlarr found {_count_message(len(results), 'possible download')}."
+ if results
+ else "Prowlarr did not find any possible downloads."
+ )
+
+ if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
+ target = "movie" if "/movies/" in normalized_path else "selected episode"
+ language = str((params or {}).get("language") or "the requested language").upper()
+ return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
+
+ if normalized_method == "GET":
+ return f"{service} finished this check without reporting a problem."
+ if normalized_method == "POST":
+ return f"{service} received the request. Its result will be checked separately."
+ if normalized_method == "PUT":
+ return f"{service} saved the requested changes."
+ if normalized_method == "DELETE":
+ return f"{service} confirmed the item was removed."
+ return f"{service} completed the request successfully."
+
+
+def _operation_error_message(service: str, status_code: Optional[int]) -> str:
+ explanations = {
+ 400: "rejected the request because some details were invalid",
+ 401: "rejected Magent's login details",
+ 403: "refused permission for this action",
+ 404: "could not find the requested item",
+ 409: "reported a conflict, usually because the item already exists",
+ 422: "could not use the details Magent supplied",
+ 429: "is busy and asked Magent to try again later",
+ 500: "encountered an internal error while processing the request",
+ 502: "could not reach one of its own dependent services",
+ 503: "is temporarily unavailable",
+ 504: "did not finish before the request timed out",
+ }
+ explanation = explanations.get(status_code)
+ if explanation:
+ return f"{service} {explanation}."
+ if status_code:
+ return f"{service} could not complete the request (response code {status_code})."
+ return f"Magent could not get a usable response from {service}."
+
+
+def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
+ normalized_path = path.lower()
+ normalized_method = method.upper()
+ if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
+ return "Reading the request from Seerr…", "Seerr returned the current request record"
+ if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
+ return "Checking Radarr for the movie…", "Radarr returned the movie record"
+ if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
+ return "Checking Sonarr for the series…", "Sonarr returned the series record"
+ if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
+ return f"Checking {service}'s download queue…", f"{service} returned its queue state"
+ if service == "Sonarr" and "/episode" in normalized_path:
+ return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
+ if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
+ return f"Checking releases through {service}…", f"{service} returned release information"
+ if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
+ if normalized_method == "GET":
+ return f"Checking {service}'s search activity…", f"{service} returned its current activity"
+ return f"Sending a command to {service}…", f"{service} accepted the command"
+ if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
+ return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
+ if service == "Prowlarr" and "/health" in normalized_path:
+ return "Checking whether the download search sources are working…", "Prowlarr returned its indexer health"
+ return f"Contacting {service}…", f"{service} responded"
+
+
+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 _send_request(
+ self,
+ client: httpx.AsyncClient,
+ method: str,
+ url: str,
+ *,
+ headers: Dict[str, str],
+ params: Optional[Dict[str, Any]],
+ payload: Optional[Dict[str, Any]],
+ ) -> httpx.Response:
+ return await client.request(
+ method,
+ url,
+ headers=headers,
+ params=params,
+ json=payload,
+ )
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: Optional[Dict[str, Any]] = None,
+ payload: Optional[Dict[str, Any]] = None,
+ timeout_seconds: float = 10.0,
+ ) -> 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()
+ service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
+ active_message, _ = _operation_messages(service_name, method, path)
+ operation_event_id = start_remote_call(service_name, active_message)
+ metric_status = 'error'
+ 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=timeout_seconds) as client:
+ response = await self._send_request(
+ client,
+ method,
+ url,
+ headers=self.headers(),
+ params=params,
+ payload=payload,
+ )
+ metric_status = str(response.status_code)
+ 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,
+ )
+ result = response.json() if response.content else None
+ finish_remote_call(
+ operation_event_id,
+ success=True,
+ status_code=response.status_code,
+ message=_operation_result_message(
+ service_name,
+ method,
+ path,
+ result,
+ params=params,
+ payload=payload,
+ ),
+ )
+ return result
+ 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),
+ )
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ status_code=status if isinstance(status, int) else None,
+ message=_operation_error_message(
+ service_name,
+ status if isinstance(status, int) else None,
+ ),
+ )
+ 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,
+ )
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ message=_operation_error_message(service_name, None),
+ )
+ raise
+
+ finally:
+ record_remote(service_name, method, metric_status, time.perf_counter() - started_at)
+
+ async def get(
+ self,
+ path: str,
+ params: Optional[Dict[str, Any]] = None,
+ timeout_seconds: float = 10.0,
+ ) -> Optional[Any]:
+ return await self._request(
+ "GET", path, params=params, timeout_seconds=timeout_seconds
+ )
+
+ 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,
+ params: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Any]:
+ return await self._request("DELETE", path, params=params)
diff --git a/backend/app/clients/bazarr.py b/backend/app/clients/bazarr.py
new file mode 100644
index 0000000..703ff10
--- /dev/null
+++ b/backend/app/clients/bazarr.py
@@ -0,0 +1,48 @@
+from typing import Any, Optional
+
+from .base import ApiClient
+
+
+class BazarrClient(ApiClient):
+ async def get_system_status(self) -> Optional[Any]:
+ return await self._request("GET", "/api/system/status")
+
+ async def search_movie_subtitles(
+ self,
+ radarr_id: int,
+ *,
+ language: str,
+ forced: bool = False,
+ ) -> Optional[Any]:
+ return await self._request(
+ "PATCH",
+ "/api/movies/subtitles",
+ params={
+ "radarrid": radarr_id,
+ "language": language,
+ "forced": str(forced).lower(),
+ "hi": "false",
+ },
+ timeout_seconds=90.0,
+ )
+
+ async def search_episode_subtitles(
+ self,
+ series_id: int,
+ episode_id: int,
+ *,
+ language: str,
+ forced: bool = False,
+ ) -> Optional[Any]:
+ return await self._request(
+ "PATCH",
+ "/api/episodes/subtitles",
+ params={
+ "seriesid": series_id,
+ "episodeid": episode_id,
+ "language": language,
+ "forced": str(forced).lower(),
+ "hi": "false",
+ },
+ timeout_seconds=90.0,
+ )
diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py
new file mode 100644
index 0000000..8efcc2f
--- /dev/null
+++ b/backend/app/clients/jellyfin.py
@@ -0,0 +1,298 @@
+import re
+from typing import Any, Dict, Optional
+import httpx
+from .base import ApiClient, _operation_error_message
+from ..services.operation_progress import finish_remote_call, start_remote_call
+
+
+def _availability_message(result: Any) -> str:
+ if not isinstance(result, dict):
+ return "Jellyfin did not return any matching library items."
+ total = result.get("TotalRecordCount")
+ items = result.get("Items")
+ available = (
+ (isinstance(total, int) and total > 0)
+ or (isinstance(items, list) and len(items) > 0)
+ )
+ return (
+ "Jellyfin returned possible matches. Magent still needs to check the exact title and file."
+ if available
+ else "Jellyfin did not find this title in its library search."
+ )
+
+
+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
+ operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
+ url = f"{self.base_url}/Items"
+ params = {
+ "SearchTerm": term,
+ "IncludeItemTypes": ",".join(item_types or []),
+ "Recursive": "true",
+ "Fields": "Path,MediaSources,ProviderIds,OriginalTitle,SortName",
+ "Limit": limit,
+ }
+ headers = self._emby_headers()
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ normalized = ' '.join(re.sub(r"[^\w\s]", ' ', term, flags=re.UNICODE).split())
+ terms = list(dict.fromkeys([term, normalized]))
+ if normalized != term and normalized.split():
+ terms.append(max(normalized.split(), key=len))
+ items = {}
+ for search_term in dict.fromkeys(terms):
+ if not search_term:
+ continue
+ response = await client.get(url, headers=headers, params={**params, "SearchTerm": search_term})
+ response.raise_for_status()
+ payload = response.json()
+ for item in payload.get('Items', []):
+ if isinstance(item, dict) and item.get('Id'):
+ items[item['Id']] = item
+ result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
+ finish_remote_call(
+ operation_event_id,
+ success=True,
+ status_code=response.status_code,
+ message=_availability_message(result),
+ )
+ return result
+ except Exception as exc:
+ status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ status_code=status_code,
+ message=_operation_error_message("Jellyfin", status_code),
+ )
+ raise
+
+ async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
+ if not self.base_url or not self.api_key or not str(series_id).strip():
+ return []
+ url = f"{self.base_url}/Items"
+ params = {
+ "ParentId": str(series_id).strip(),
+ "IncludeItemTypes": "Episode",
+ "Recursive": "true",
+ "Fields": "Path,ProviderIds,MediaSources",
+ "Limit": 10000,
+ }
+ async with httpx.AsyncClient(timeout=20.0) as client:
+ response = await client.get(url, headers=self._emby_headers(), params=params)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict):
+ return []
+ items = payload.get("Items") or payload.get("items") or []
+ return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
+
+ 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 get_sessions(self) -> Optional[list[Dict[str, Any]]]:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Sessions"
+ headers = self._emby_headers()
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(url, headers=headers)
+ response.raise_for_status()
+ payload = response.json()
+ return payload if isinstance(payload, list) else []
+
+ async def refresh_library(self, recursive: bool = True) -> None:
+ if not self.base_url or not self.api_key:
+ return None
+ operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
+ url = f"{self.base_url}/Library/Refresh"
+ headers = self._emby_headers()
+ params = {"Recursive": "true" if recursive else "false"}
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers=headers, params=params)
+ response.raise_for_status()
+ finish_remote_call(
+ operation_event_id,
+ success=True,
+ status_code=response.status_code,
+ message="Jellyfin accepted the library refresh and is scanning for new media.",
+ )
+ except Exception as exc:
+ status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ status_code=status_code,
+ message=_operation_error_message("Jellyfin", status_code),
+ )
+ raise
diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py
new file mode 100644
index 0000000..84b3956
--- /dev/null
+++ b/backend/app/clients/jellyseerr.py
@@ -0,0 +1,125 @@
+from typing import Any, Dict, Optional
+from urllib.parse import quote, unquote, urlsplit
+import httpx
+from .base import ApiClient
+
+
+class JellyseerrClient(ApiClient):
+ async def _send_request(
+ self,
+ client: httpx.AsyncClient,
+ method: str,
+ url: str,
+ *,
+ headers: Dict[str, str],
+ params: Optional[Dict[str, Any]],
+ payload: Optional[Dict[str, Any]],
+ ) -> httpx.Response:
+ request_headers = dict(headers)
+ if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
+ # Seerr's optional CSRF protection also applies to API-key writes.
+ # Seed its secret/token cookie pair, then echo the readable token in
+ # the header Seerr's own web client uses.
+ csrf_response = await client.get(
+ f"{self.base_url}/api/v1/auth/me",
+ headers=self.headers(),
+ )
+ csrf_response.raise_for_status()
+ csrf_token = client.cookies.get("XSRF-TOKEN")
+ if csrf_token:
+ request_headers["XSRF-TOKEN"] = unquote(csrf_token)
+ parsed_base = urlsplit(self.base_url)
+ request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
+ return await super()._send_request(
+ client,
+ method,
+ url,
+ headers=request_headers,
+ params=params,
+ payload=payload,
+ )
+
+ 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]]:
+ # Seerr rejects the `+` encoding that standard query builders use for
+ # spaces. Build this query explicitly so multi-word titles are sent as
+ # percent-encoded values.
+ encoded_query = quote(query, safe="")
+ return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
+
+ async def get_service_settings(self, media_type: str) -> Optional[Any]:
+ service = "sonarr" if media_type == "tv" else "radarr"
+ return await self.get(f"/api/v1/settings/{service}")
+
+ async def create_request(
+ self,
+ *,
+ media_type: str,
+ media_id: int,
+ seasons: Optional[list[int]] = None,
+ is_4k: Optional[bool] = None,
+ server_id: Optional[int] = None,
+ profile_id: Optional[int] = None,
+ root_folder: Optional[str] = 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
+ if isinstance(server_id, int):
+ payload["serverId"] = server_id
+ if isinstance(profile_id, int):
+ payload["profileId"] = profile_id
+ if isinstance(root_folder, str) and root_folder.strip():
+ payload["rootFolder"] = root_folder.strip()
+ 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/jellystat.py b/backend/app/clients/jellystat.py
new file mode 100644
index 0000000..e388ed8
--- /dev/null
+++ b/backend/app/clients/jellystat.py
@@ -0,0 +1,118 @@
+"""Jellystat API adapter. Credentials and raw history never leave the backend."""
+
+import asyncio
+import json
+import re
+from datetime import datetime
+
+import httpx
+
+from .base import ApiClient
+
+
+class JellystatError(Exception):
+ pass
+
+
+class HistoryLimitError(JellystatError):
+ pass
+
+
+def same_user_id(left, right) -> bool:
+ return bool(left and right) and str(left).replace("-", "").lower() == str(right).replace("-", "").lower()
+
+
+class JellystatClient(ApiClient):
+ PAGE_SIZE = 200
+ MAX_PAGES = 50
+
+ def configured(self) -> bool:
+ return bool(self.base_url and self.api_key)
+
+ async def _read(self, client: httpx.AsyncClient, method: str, path: str, **kwargs):
+ try:
+ response = await client.request(method, f"{self.base_url}{path}",
+ headers={"x-api-token": self.api_key}, **kwargs)
+ response.raise_for_status()
+ return response.json()
+ except (httpx.HTTPError, ValueError) as exc:
+ raise JellystatError("Jellystat did not return a valid response") from exc
+
+ async def test_connection(self) -> dict:
+ # This protected endpoint confirms API authentication without returning user data.
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ result = await self._read(client, "GET", "/api/getLibraries")
+ if not isinstance(result, list):
+ raise JellystatError("Jellystat returned an unexpected library response")
+ return {"connected": True}
+
+ async def check_user_ids(self, user_ids: list[str]) -> dict:
+ """Read metadata for known identities; never scan everyone's playback history."""
+ if not self.configured():
+ return {user_id: {"state": "not_configured"} for user_id in user_ids}
+ results = {user_id: {"state": "unavailable"} for user_id in user_ids}
+ semaphore = asyncio.Semaphore(6)
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ async def check(user_id):
+ if not re.fullmatch(r"[a-f0-9]{32}", user_id):
+ return
+ async with semaphore:
+ try:
+ response = await client.post(f"{self.base_url}/api/getUserDetails",
+ headers={"x-api-token": self.api_key}, json={"userid": user_id})
+ if response.status_code == 404 or (response.status_code == 200 and not response.content.strip()):
+ results[user_id] = {"state": "missing"}
+ return
+ response.raise_for_status()
+ row = response.json()
+ if row is None:
+ results[user_id] = {"state": "missing"}
+ elif isinstance(row, dict) and same_user_id(row.get("Id"), user_id):
+ results[user_id] = {"state": "matched", "id": user_id, "name": str(row.get("Name") or "")[:200]}
+ except (httpx.HTTPError, ValueError):
+ pass
+ try:
+ async with asyncio.timeout(25):
+ await asyncio.gather(*(check(user_id) for user_id in user_ids))
+ except TimeoutError:
+ pass
+ return results
+
+ async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
+ raise JellystatError("Invalid linked Jellyfin identity")
+ # Only fixed, user-scoped endpoints are used. Never pass browser search/filters through.
+ filters = json.dumps([{"field": "ActivityDateInserted", "min": start.isoformat(), "max": end.isoformat()}])
+ try:
+ async with asyncio.timeout(30):
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ libraries = await self._read(client, "GET", "/api/getLibraries")
+ if not isinstance(libraries, list) or any(not isinstance(row, dict) for row in libraries):
+ raise JellystatError("Jellystat returned an unexpected library response")
+ history = []
+ for page in range(1, self.MAX_PAGES + 1):
+ payload = await self._read(client, "POST", "/api/getUserHistory",
+ json={"userid": user_id}, params={"page": page, "size": self.PAGE_SIZE,
+ "sort": "ActivityDateInserted", "desc": "true", "filters": filters})
+ if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
+ raise JellystatError("Jellystat returned an unexpected history response")
+ rows = payload["results"]
+ try:
+ pages = int(payload["pages"])
+ except (KeyError, TypeError, ValueError) as exc:
+ raise JellystatError("Jellystat did not return history pagination") from exc
+ if pages < 0 or (pages == 0 and rows) or len(rows) > self.PAGE_SIZE:
+ raise JellystatError("Jellystat returned invalid history pagination")
+ if pages > self.MAX_PAGES:
+ raise HistoryLimitError("Select a shorter period to view this history")
+ for row in rows:
+ if not isinstance(row, dict) or not same_user_id(row.get("UserId"), user_id):
+ raise JellystatError("Jellystat returned history for an unexpected account")
+ history.extend(rows)
+ if page >= pages:
+ return history, libraries
+ if not rows:
+ raise JellystatError("Jellystat returned incomplete history")
+ except TimeoutError as exc:
+ raise JellystatError("Jellystat took too long to return history") from exc
+ raise HistoryLimitError("Select a shorter period to view this history")
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..f576554
--- /dev/null
+++ b/backend/app/clients/qbittorrent.py
@@ -0,0 +1,218 @@
+from typing import Any, Dict, Optional
+import httpx
+import logging
+from .base import ApiClient, _operation_error_message
+from ..services.operation_progress import finish_remote_call, start_remote_call
+
+
+def _torrent_state_text(state: Any) -> str:
+ normalized = str(state or "").strip().lower()
+ if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
+ return "finished"
+ if "pause" in normalized or normalized == "stoppeddl":
+ return "paused"
+ if "stall" in normalized:
+ return "waiting for data"
+ if normalized.startswith("queued"):
+ return "waiting in the queue"
+ if normalized == "metadl":
+ return "getting the download details"
+ if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
+ return "checking the downloaded files"
+ if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
+ return "downloading"
+ if "upload" in normalized:
+ return "downloaded and sharing with others"
+ if normalized in {"completed", "missingfiles"}:
+ return "finished" if normalized == "completed" else "missing files"
+ if "error" in normalized:
+ return "unable to continue"
+ return "present"
+
+
+def _torrent_result_message(result: Any) -> str:
+ torrents = result if isinstance(result, list) else []
+ if not torrents:
+ return "qBittorrent found no matching downloads."
+ first = next((item for item in torrents if isinstance(item, dict)), {})
+ if len(torrents) == 1:
+ progress = first.get("progress")
+ progress_text = (
+ f" — {max(0, min(100, round(progress * 100)))}% complete"
+ if isinstance(progress, (int, float))
+ else ""
+ )
+ state_text = _torrent_state_text(first.get("state"))
+ return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.'
+ active = sum(
+ 1
+ for item in torrents
+ if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
+ )
+ return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
+
+
+def _torrent_action_message(path: str) -> str:
+ normalized_path = path.lower()
+ if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
+ return "qBittorrent accepted the request to resume the download."
+ if normalized_path.endswith("/add"):
+ return "qBittorrent accepted the release and added it to the download queue."
+ return "qBittorrent accepted the requested download action."
+
+
+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
+ operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
+ try:
+ 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()
+ result = response.json()
+ finish_remote_call(
+ operation_event_id,
+ success=True,
+ status_code=response.status_code,
+ message=_torrent_result_message(result),
+ )
+ return result
+ except Exception as exc:
+ status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ status_code=status_code,
+ message=_operation_error_message("qBittorrent", status_code),
+ )
+ raise
+
+ async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ if not self.base_url:
+ return None
+ operation_event_id = start_remote_call("qBittorrent")
+ try:
+ 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()
+ result = response.text.strip()
+ finish_remote_call(
+ operation_event_id,
+ success=True,
+ status_code=response.status_code,
+ message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
+ )
+ return result
+ except Exception as exc:
+ status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ status_code=status_code,
+ message=_operation_error_message("qBittorrent", status_code),
+ )
+ raise
+
+ async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
+ if not self.base_url:
+ return None
+ operation_event_id = start_remote_call("qBittorrent")
+ try:
+ 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()
+ finish_remote_call(
+ operation_event_id,
+ success=True,
+ status_code=response.status_code,
+ message=_torrent_action_message(path),
+ )
+ except Exception as exc:
+ status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
+ finish_remote_call(
+ operation_event_id,
+ success=False,
+ status_code=status_code,
+ message=_operation_error_message("qBittorrent", status_code),
+ )
+ raise
+
+ 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..6799e1b
--- /dev/null
+++ b/backend/app/clients/radarr.py
@@ -0,0 +1,93 @@
+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 lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
+ result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
+ return result if isinstance(result, dict) else None
+
+ 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={"movieIds": movie_id, "pageSize": 1000})
+
+ async def search_releases(self, movie_id: int) -> Optional[Any]:
+ return await self.get(
+ "/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
+ )
+
+ 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 monitor_movie(
+ self, movie_id: int, monitored: bool = True
+ ) -> Optional[Dict[str, Any]]:
+ movie = await self.get_movie(movie_id)
+ if not isinstance(movie, dict):
+ raise ValueError("Radarr did not return the movie before updating its monitored state")
+ movie["monitored"] = monitored
+ return await self.update_movie(movie)
+
+ async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
+ return await self.delete(
+ f"/api/v3/moviefile/{movie_file_id}",
+ params={"deleteFromClient": "true"},
+ )
+
+ async def add_movie(
+ self,
+ tmdb_id: int,
+ quality_profile_id: int,
+ root_folder: str,
+ monitored: bool = True,
+ search_for_movie: bool = True,
+ title: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
+ resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
+ if not resolved_title:
+ raise ValueError("Radarr could not resolve a title for this TMDB ID")
+ payload = {
+ "tmdbId": tmdb_id,
+ "title": resolved_title,
+ "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..9a0121b
--- /dev/null
+++ b/backend/app/clients/sonarr.py
@@ -0,0 +1,129 @@
+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 lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
+ result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
+ if not isinstance(result, list):
+ return None
+ for item in result:
+ if not isinstance(item, dict):
+ continue
+ try:
+ if int(item.get("tvdbId")) == tvdb_id:
+ return item
+ except (TypeError, ValueError):
+ continue
+ return next((item for item in result if isinstance(item, dict)), None)
+
+ 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]]:
+ records = []
+ page = 1
+ while True:
+ result = await self.get("/api/v3/queue", params={
+ "seriesIds": series_id, "includeEpisode": "true",
+ "page": page, "pageSize": 100,
+ })
+ if not isinstance(result, dict) or not isinstance(result.get("records"), list):
+ raise ValueError("Sonarr returned an invalid queue")
+ batch = result["records"]
+ records.extend(batch)
+ if not batch or len(records) >= int(result.get("totalRecords", len(records))):
+ return {**result, "records": records, "totalRecords": len(records)}
+ page += 1
+ if page > 100:
+ raise ValueError("Sonarr queue exceeded the safe paging limit")
+
+ 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 get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
+
+ async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
+ return await self.get(
+ "/api/v3/release",
+ params={"seriesId": series_id, "seasonNumber": season_number},
+ timeout_seconds=90.0,
+ )
+
+ async def search_episode_releases(self, episode_id: int) -> Optional[Any]:
+ return await self.get('/api/v3/release', params={'episodeId': episode_id}, timeout_seconds=90.0)
+
+ 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 monitor_episodes(
+ self, episode_ids: list[int], monitored: bool = True
+ ) -> Optional[Dict[str, Any]]:
+ return await self.put(
+ "/api/v3/episode/monitor",
+ payload={"episodeIds": episode_ids, "monitored": monitored},
+ )
+
+ async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
+ return await self.delete(
+ f"/api/v3/episodefile/{episode_file_id}",
+ params={"deleteFromClient": "true"},
+ )
+
+ 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]]:
+ lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
+ resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
+ if not resolved_title:
+ raise ValueError("Sonarr could not resolve a title for this TVDB ID")
+ payload = {
+ "tvdbId": tvdb_id,
+ "title": resolved_title,
+ "qualityProfileId": quality_profile_id,
+ "rootFolderPath": root_folder,
+ "monitored": monitored,
+ "seasonFolder": True,
+ "addOptions": {"searchForMissingEpisodes": search_missing},
+ }
+ 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..16ca779
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,374 @@
+import re
+from typing import Optional
+
+from pydantic import AliasChoices, Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from .build_info import BUILD_NUMBER, CHANGELOG
+
+
+_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
+
+
+def normalize_banner_color(value: object) -> Optional[str]:
+ color = str(value or "").strip().lower()
+ return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
+
+
+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=120, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
+ jwt_issuer: str = Field(default="magent", validation_alias=AliasChoices("JWT_ISSUER"))
+ jwt_audience: str = Field(default="magent-web", validation_alias=AliasChoices("JWT_AUDIENCE"))
+ settings_encryption_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SETTINGS_ENCRYPTION_KEY")
+ )
+ 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"))
+ setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN"))
+ 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="strict", 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_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
+ 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_stage_refresh_minutes: int = Field(default=15, ge=1, le=1440, validation_alias=AliasChoices("REQUESTS_STAGE_REFRESH_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")
+ )
+ issue_confirmation_contact_attempts: int = Field(
+ default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
+ )
+ issue_confirmation_interval_value: int = Field(
+ default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
+ )
+ issue_confirmation_interval_unit: str = Field(
+ default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
+ )
+ 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_banner_background_color: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
+ )
+ site_banner_border_color: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
+ )
+ site_login_message: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
+ )
+ 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")
+ )
+ jellystat_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
+ )
+ jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_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"),
+ )
+
+ bazarr_base_url: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
+ )
+ bazarr_api_key: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
+ )
+ bazarr_default_language: str = Field(
+ default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
+ )
+
+ 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/container_bootstrap.py b/backend/app/container_bootstrap.py
new file mode 100644
index 0000000..9e2ee07
--- /dev/null
+++ b/backend/app/container_bootstrap.py
@@ -0,0 +1,258 @@
+"""Persistent secrets for fresh image-only container installations.
+
+Runs before importing application settings. Existing environment-managed
+deployments are unchanged. Secrets are never printed during normal startup.
+"""
+
+import base64
+import binascii
+from contextlib import closing
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import sqlite3
+import stat
+import sys
+import tempfile
+from urllib.parse import urlsplit
+
+from .installation_origin import normalize_application_origin
+
+
+DATA_DIRECTORY = Path("/app/data")
+STATE_FILENAME = "bootstrap-secrets.json"
+SECRET_NAMES = ("JWT_SECRET", "SETTINGS_ENCRYPTION_KEY", "SETUP_TOKEN")
+MAX_STATE_BYTES = 4096
+
+
+class BootstrapError(ValueError):
+ """An operator-actionable error that never includes a secret value."""
+
+
+def managed_mode(environment: dict) -> bool:
+ value = environment.get("MAGENT_MANAGED_SECRETS", "false").strip().lower()
+ if value == "auto":
+ # Existing explicitly keyed installations retain their environment and
+ # JWT-derived encryption behaviour. Fresh image-only installs opt in.
+ return not bool(environment.get("JWT_SECRET", "").strip())
+ if value not in {"true", "false", "1", "0", "yes", "no", ""}:
+ raise BootstrapError("MAGENT_MANAGED_SECRETS must be auto, true or false.")
+ return value in {"true", "1", "yes"}
+
+
+def _data_paths(environment: dict, directory: Path) -> tuple[Path, Path]:
+ directory = directory.absolute()
+ if not directory.is_dir() or any(part.is_symlink() for part in (directory, *directory.parents)):
+ raise BootstrapError("Managed installation requires a real, writable /app/data volume; symlinks are not allowed.")
+ if os.name == "posix":
+ metadata = directory.stat()
+ if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) & 0o022:
+ raise BootstrapError("Managed data volume must belong to the runtime user and not be writable by other users.")
+ database = directory / "magent.db"
+ configured = Path(environment.get("SQLITE_PATH") or str(database)).absolute()
+ if configured != database:
+ raise BootstrapError("Managed installation requires SQLITE_PATH=/app/data/magent.db; retain manual keys for custom paths.")
+ if os.path.lexists(database) and (database.is_symlink() or not database.is_file()):
+ raise BootstrapError("Managed database must be a regular file, not a symlink or directory.")
+ return directory / STATE_FILENAME, database
+
+
+def _read_state(path: Path) -> dict:
+ try:
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
+ with os.fdopen(descriptor, "rb") as handle:
+ metadata = os.fstat(handle.fileno())
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_STATE_BYTES:
+ raise BootstrapError("Managed secrets file must be a small regular file.")
+ if os.name == "posix" and (
+ metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o600
+ ):
+ raise BootstrapError("Managed secrets file must belong to the runtime user with permissions 0600.")
+ state = json.loads(handle.read(MAX_STATE_BYTES + 1))
+ except FileNotFoundError:
+ raise
+ except (OSError, ValueError, UnicodeError) as exc:
+ if isinstance(exc, BootstrapError):
+ raise
+ raise BootstrapError("Cannot read managed secrets. Restore the original file; keys will not be regenerated.") from None
+ if not isinstance(state, dict) or set(state) != {"version", *SECRET_NAMES} or type(state["version"]) is not int or state["version"] != 1:
+ raise BootstrapError("Invalid managed secrets format. Restore the original file; keys will not be regenerated.")
+ for key in SECRET_NAMES:
+ if not isinstance(state[key], str):
+ raise BootstrapError("Invalid managed secret values. Restore the original file.")
+ for key in ("JWT_SECRET", "SETUP_TOKEN"):
+ if not re.fullmatch(r"[A-Za-z0-9_-]{64}", state[key]) or len(set(state[key])) < 2:
+ raise BootstrapError("Invalid managed token. Restore the original file.")
+ try:
+ decoded = base64.b64decode(state["SETTINGS_ENCRYPTION_KEY"], altchars=b"-_", validate=True)
+ except (ValueError, binascii.Error):
+ raise BootstrapError("Invalid managed encryption key. Restore the original file.") from None
+ if len(decoded) != 32 or base64.urlsafe_b64encode(decoded).decode() != state["SETTINGS_ENCRYPTION_KEY"]:
+ raise BootstrapError("Invalid managed encryption key. Restore the original file.")
+ if state["JWT_SECRET"] == state["SETUP_TOKEN"]:
+ raise BootstrapError("Managed signing and setup tokens must be independent.")
+ return state
+
+
+def _sync_directory(directory: Path) -> None:
+ if os.name == "posix":
+ descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
+
+
+def _create_state(path: Path) -> dict:
+ state = {
+ "version": 1,
+ "JWT_SECRET": secrets.token_urlsafe(48),
+ "SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(secrets.token_bytes(32)).decode(),
+ "SETUP_TOKEN": secrets.token_urlsafe(48),
+ }
+ descriptor, temporary_name = tempfile.mkstemp(prefix=".magent-secrets-", dir=path.parent)
+ temporary = Path(temporary_name)
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ json.dump(state, handle, separators=(",", ":"))
+ handle.flush()
+ os.fsync(handle.fileno())
+ try:
+ # Publish an entirely written file without replacing another
+ # initializer's state. Both callers subsequently read the winner.
+ os.link(temporary, path)
+ _sync_directory(path.parent)
+ except FileExistsError:
+ pass
+ finally:
+ temporary.unlink(missing_ok=True)
+ return _read_state(path)
+
+
+def _saved_origin(database: Path) -> str:
+ if not database.exists():
+ return ""
+ try:
+ with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
+ if not connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'").fetchone():
+ return ""
+ row = connection.execute("SELECT value FROM settings WHERE key='magent_application_url'").fetchone()
+ return str(row[0] or "") if row else ""
+ except sqlite3.Error:
+ raise BootstrapError("Cannot read the saved application address. Check the existing database; no keys were changed.") from None
+
+
+def _configure_origin(environment: dict, database: Path) -> None:
+ value = environment.get("MAGENT_APPLICATION_URL", "")
+ saved = _saved_origin(database)
+ if saved:
+ value = saved
+ if not value:
+ # No network address is trusted automatically. The token-authorized
+ # first-admin transaction will save the explicitly confirmed origin.
+ environment.setdefault("CORS_ALLOW_ORIGIN", "http://localhost:3000")
+ environment.setdefault("AUTH_COOKIE_SECURE", "false")
+ return
+ try:
+ parsed = urlsplit(value)
+ valid = (
+ bool(value) and not any(c.isspace() or ord(c) < 33 or ord(c) == 127 for c in value)
+ and parsed.scheme in {"http", "https"} and parsed.hostname
+ and parsed.username is None and parsed.password is None and not parsed.path
+ and "?" not in value and "#" not in value and "\\" not in value and "*" not in value
+ and (parsed.port is None or 1 <= parsed.port <= 65535)
+ )
+ except ValueError:
+ valid = False
+ if not valid:
+ raise BootstrapError("Set MAGENT_APPLICATION_URL to the exact http(s) browser origin, with no path or trailing slash.")
+ if not saved and environment.get("CORS_ALLOW_ORIGIN") not in (None, "", value):
+ raise BootstrapError("CORS_ALLOW_ORIGIN must match MAGENT_APPLICATION_URL for a managed install.")
+ value = normalize_application_origin(value)
+ environment["MAGENT_APPLICATION_URL"] = value
+ environment["CORS_ALLOW_ORIGIN"] = value
+ secure = environment.get("AUTH_COOKIE_SECURE", "").strip().lower()
+ if not secure:
+ environment["AUTH_COOKIE_SECURE"] = str(parsed.scheme == "https").lower()
+ elif secure not in {"true", "false", "1", "0"}:
+ raise BootstrapError("AUTH_COOKIE_SECURE must be true or false.")
+ elif parsed.scheme == "https" and secure in {"false", "0"}:
+ raise BootstrapError("HTTPS managed installations require AUTH_COOKIE_SECURE=true.")
+ elif parsed.scheme == "http" and secure in {"true", "1"}:
+ raise BootstrapError("Secure cookies require an HTTPS application URL.")
+
+
+def prepare_environment(environment: dict, directory: Path = DATA_DIRECTORY) -> dict:
+ prepared = dict(environment)
+ if not managed_mode(prepared):
+ return prepared
+ if not prepared.get("JWT_SECRET", "").strip():
+ prepared.pop("JWT_SECRET", None)
+ path, database = _data_paths(prepared, directory)
+ _configure_origin(prepared, database)
+ if prepared.get("API_DOCS_ENABLED", "false").strip().lower() not in {"", "false", "0"}:
+ raise BootstrapError("API_DOCS_ENABLED is fixed to false for managed installations.")
+ try:
+ state = _read_state(path)
+ except FileNotFoundError:
+ # Never add independent encryption to an existing JWT-derived database
+ # or invent replacement keys after a lost secrets file.
+ if any(os.path.lexists(str(database) + suffix) for suffix in ("", "-wal", "-shm", "-journal")):
+ raise BootstrapError("Existing database has no managed secrets file. Restore its original keys or use the existing manual deployment.") from None
+ if any(prepared.get(key) for key in SECRET_NAMES):
+ raise BootstrapError("Fresh managed installs generate their own keys. Remove manual key variables or disable managed mode.") from None
+ state = _create_state(path)
+ for key in SECRET_NAMES:
+ if prepared.get(key) and prepared[key] != state[key]:
+ raise BootstrapError(f"{key} conflicts with the persistent managed value. Keys will not be replaced.")
+ prepared[key] = state[key]
+ prepared["SQLITE_PATH"] = str(database)
+ prepared["API_DOCS_ENABLED"] = "false"
+ prepared["MAGENT_MANAGED_SECRETS"] = "true"
+ prepared["MAGENT_RUNTIME_MANAGED"] = "1"
+ return prepared
+
+
+def setup_token(environment: dict, directory: Path = DATA_DIRECTORY) -> str:
+ if not managed_mode(environment):
+ raise BootstrapError("Managed secrets are disabled. Use the SETUP_TOKEN from your deployment configuration.")
+ path, database = _data_paths(environment, directory)
+ state = _read_state(path) # This read-only command never generates keys.
+ if database.is_symlink() or not database.is_file():
+ raise BootstrapError("Database is not initialized. Wait for the container to become healthy.")
+ try:
+ with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
+ row = connection.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
+ admin = connection.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
+ except sqlite3.Error:
+ raise BootstrapError("Cannot verify setup state. No setup token will be displayed.") from None
+ if row is None or row[0] != 0 or admin is not None:
+ raise BootstrapError("Initial administrator setup is no longer available. Sign in with the existing administrator.")
+ return state["SETUP_TOKEN"]
+
+
+def main() -> int:
+ try:
+ if sys.argv[1:] == ["setup-token"]:
+ print(setup_token(dict(os.environ)))
+ return 0
+ if len(sys.argv) < 2:
+ raise BootstrapError("Pass the container startup command, or setup-token from the operator console.")
+ environment = prepare_environment(dict(os.environ))
+ if managed_mode(environment):
+ print("Managed installation secrets loaded. For first setup, run in the container console: "
+ "python -m app.container_bootstrap setup-token", flush=True)
+ os.execvpe(sys.argv[1], sys.argv[1:], environment)
+ except (BootstrapError, OSError):
+ # Never include unexpected I/O details or environment values in logs.
+ error = sys.exc_info()[1]
+ message = str(error) if isinstance(error, BootstrapError) else "Cannot access managed installation files or start the runtime. Check volume permissions and original keys."
+ print(f"Magent startup: {message}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/app/db.py b/backend/app/db.py
new file mode 100644
index 0000000..a71203a
--- /dev/null
+++ b/backend/app/db.py
@@ -0,0 +1,4301 @@
+import json
+import hmac
+import os
+import sqlite3
+import logging
+from contextlib import suppress
+from hashlib import sha256
+from datetime import datetime, timezone, timedelta
+from time import perf_counter, time as unix_time
+from typing import Any, Dict, Optional
+
+from .config import settings
+from .models import Snapshot
+from .security import hash_password, verify_and_update_password, verify_password
+from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
+from .schema_migrations import run_schema_migrations
+
+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)
+ directory = os.path.dirname(path)
+ os.makedirs(directory, exist_ok=True)
+ with suppress(OSError):
+ os.chmod(directory, 0o700)
+ 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)
+
+
+class _ClosingConnection(sqlite3.Connection):
+ def __exit__(self, exc_type, exc_value, traceback) -> bool:
+ try:
+ return super().__exit__(exc_type, exc_value, traceback)
+ finally:
+ self.close()
+
+
+def _connect() -> sqlite3.Connection:
+ conn = sqlite3.connect(
+ _db_path(),
+ timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
+ cached_statements=512,
+ factory=_ClosingConnection,
+ )
+ with suppress(OSError):
+ os.chmod(_db_path(), 0o600)
+ _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)
+
+
+_INVITE_HASH_PREFIX = "sha256:"
+
+
+def _normalize_invite_secret(value: str) -> str:
+ return "".join(character for character in str(value or "").strip().upper() if character.isalnum())
+
+
+def _hash_signup_invite_code(value: str) -> str:
+ normalized = _normalize_invite_secret(value)
+ return _INVITE_HASH_PREFIX + sha256(normalized.encode("utf-8")).hexdigest()
+
+
+def _invite_code_hint(value: str) -> str:
+ normalized = _normalize_invite_secret(value)
+ return normalized[-4:] if normalized else ""
+
+
+def _masked_invite_code(hint: Optional[str]) -> str:
+ return f"••••{str(hint or '').upper()}" if hint else "Protected invite"
+
+
+def _protect_legacy_signup_invite_codes(conn: sqlite3.Connection) -> None:
+ rows = conn.execute(
+ "SELECT id, code, code_hint FROM signup_invites ORDER BY id"
+ ).fetchall()
+ for invite_id, stored_code, stored_hint in rows:
+ if not isinstance(stored_code, str) or stored_code.startswith(_INVITE_HASH_PREFIX):
+ continue
+ code_hash = _hash_signup_invite_code(stored_code)
+ duplicate = conn.execute(
+ "SELECT id FROM signup_invites WHERE code = ? AND id != ?",
+ (code_hash, invite_id),
+ ).fetchone()
+ if duplicate:
+ code_hash = _INVITE_HASH_PREFIX + sha256(
+ f"duplicate:{invite_id}:{stored_code}".encode("utf-8")
+ ).hexdigest()
+ conn.execute(
+ "UPDATE users SET invited_by_code = ? WHERE invited_by_code = ? COLLATE NOCASE",
+ (f"invite:{invite_id}", stored_code),
+ )
+ conn.execute(
+ "UPDATE signup_invites SET code = ?, code_hint = ? WHERE id = ?",
+ (code_hash, stored_hint or _invite_code_hint(stored_code), invite_id),
+ )
+
+
+def _encrypt_legacy_sensitive_settings(conn: sqlite3.Connection) -> None:
+ rows = conn.execute("SELECT key, value FROM settings").fetchall()
+ for key, value in rows:
+ if value is None or not is_sensitive_setting(str(key)):
+ continue
+ encrypted = encrypt_setting_value(str(key), str(value))
+ if encrypted != value:
+ conn.execute("UPDATE settings SET value = ? WHERE key = ?", (encrypted, key))
+
+
+def init_db() -> None:
+ with _connect() as conn:
+ conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)")
+ conn.execute("""CREATE TABLE IF NOT EXISTS user_duplicate_repairs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, kept_user_id INTEGER NOT NULL,
+ archive_json TEXT NOT NULL, repaired_by TEXT NOT NULL, repaired_at TEXT NOT NULL)""")
+ conn.execute("""CREATE TABLE IF NOT EXISTS user_feature_permissions (
+ user_id INTEGER NOT NULL, feature TEXT NOT NULL, enabled INTEGER NOT NULL,
+ PRIMARY KEY(user_id, feature))""")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS jellyfin_user_links (
+ source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL,
+ PRIMARY KEY (source, local_user_id), UNIQUE (source, jellyfin_user_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS user_identity_confirmations (
+ local_user_id INTEGER PRIMARY KEY,
+ jellyfin_server_id TEXT NOT NULL,
+ jellyfin_user_id TEXT NOT NULL,
+ jellyfin_source TEXT NOT NULL,
+ seerr_source TEXT NOT NULL,
+ seerr_user_id INTEGER NOT NULL,
+ confirmed_at TEXT NOT NULL,
+ confirmed_by TEXT NOT NULL,
+ UNIQUE (jellyfin_server_id, jellyfin_user_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS user_identity_repairs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ local_user_id INTEGER NOT NULL,
+ before_json TEXT NOT NULL,
+ after_json TEXT NOT NULL,
+ repaired_at TEXT NOT NULL,
+ repaired_by TEXT NOT NULL
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS request_repairs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ request_id TEXT NOT NULL,
+ started_at TEXT NOT NULL,
+ tracking_json TEXT NOT NULL,
+ completed_at TEXT,
+ UNIQUE(request_id, started_at)
+ )
+ """)
+ 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,
+ auth_version INTEGER NOT NULL DEFAULT 1
+ )
+ """
+ )
+ 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 auth_rate_limits (
+ scope TEXT NOT NULL,
+ key_hash TEXT NOT NULL,
+ occurred_at REAL NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_auth_rate_limits_lookup ON auth_rate_limits (scope, key_hash, occurred_at)"
+ )
+ 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 TABLE IF NOT EXISTS portal_item_activity (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ item_id INTEGER NOT NULL,
+ event_type TEXT NOT NULL,
+ actor_username TEXT NOT NULL,
+ actor_role TEXT NOT NULL,
+ message TEXT NOT NULL,
+ metadata_json TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item
+ ON portal_item_activity (item_id, created_at ASC, id ASC)
+ """
+ )
+ 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)
+ """
+ )
+ run_schema_migrations(conn)
+ _protect_legacy_signup_invite_codes(conn)
+ _encrypt_legacy_sensitive_settings(conn)
+ try:
+ conn.execute("PRAGMA optimize")
+ except sqlite3.OperationalError:
+ pass
+ from .services.recap_store import init_schema as init_recap_schema
+ init_recap_schema(conn)
+ from .services.newsletter_store import init_schema as init_newsletter_schema
+ init_newsletter_schema(conn)
+ conn.execute("""CREATE TRIGGER IF NOT EXISTS delete_user_feature_permissions
+ AFTER DELETE ON users BEGIN
+ DELETE FROM user_feature_permissions WHERE user_id = OLD.id;
+ END""")
+ _backfill_auth_providers()
+ ensure_admin_user()
+ _backfill_request_repairs()
+
+
+
+def start_request_repair(tracking: Dict[str, Any]) -> None:
+ """Persist the new collection cycle before a managed file is removed."""
+ with _connect() as conn:
+ conn.execute(
+ "INSERT OR IGNORE INTO request_repairs (request_id, started_at, tracking_json) VALUES (?, ?, ?)",
+ (str(tracking["requestId"]), tracking["startedAt"], json.dumps(tracking)),
+ )
+
+
+def _backfill_request_repairs() -> None:
+ # Carry existing issue repairs forward once, without depending on the ticket's
+ # lifetime. Deleting/closing an issue must not restore stale availability.
+ with _connect() as conn:
+ rows = conn.execute("""
+ SELECT a.metadata_json FROM portal_item_activity a
+ JOIN portal_items p ON p.id = a.item_id
+ WHERE p.kind = 'issue' AND p.status IN ('in_progress', 'blocked')
+ AND a.event_type IN ('replacement_started', 'missing_search_started')
+ AND a.id = (SELECT MAX(b.id) FROM portal_item_activity b
+ WHERE b.item_id = a.item_id
+ AND b.event_type IN ('replacement_started', 'missing_search_started'))
+ """).fetchall()
+ for (raw,) in rows:
+ try:
+ tracking = json.loads(raw or "{}").get("repairTracking")
+ if isinstance(tracking, dict) and tracking.get("requestId") and tracking.get("startedAt"):
+ start_request_repair(tracking)
+ except (TypeError, ValueError):
+ continue
+
+
+def get_request_repairs(request_id: str, *, active_only: bool = True) -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ "SELECT id, tracking_json, completed_at FROM request_repairs WHERE request_id = ?"
+ + (" AND completed_at IS NULL" if active_only else "") + " ORDER BY id",
+ (str(request_id),),
+ ).fetchall()
+ return [{"id": row[0], **json.loads(row[1]), "completedAt": row[2]} for row in rows]
+
+
+def complete_request_repair(repair_id: int) -> None:
+ with _connect() as conn:
+ conn.execute("UPDATE request_repairs SET completed_at = ? WHERE id = ? AND completed_at IS NULL",
+ (datetime.now(timezone.utc).isoformat(), repair_id))
+
+
+def active_repair_request_ids() -> set[str]:
+ with _connect() as conn:
+ return {row[0] for row in conn.execute("SELECT DISTINCT request_id FROM request_repairs WHERE completed_at IS NULL")}
+
+
+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('UPDATE request_stage_cache SET checked_at = 0 WHERE request_id = ?', (request_id,))
+ 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:
+ cycle = conn.execute("SELECT MAX(started_at) FROM request_repairs WHERE request_id = ?",
+ (str(request_id),)).fetchone()[0]
+ rows = conn.execute(
+ """
+ SELECT created_at, payload_json
+ FROM snapshots
+ WHERE request_id = ? AND (? IS NULL OR created_at >= ?)
+ ORDER BY id DESC
+ LIMIT ?
+ """,
+ (request_id, cycle, cycle, 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
+ # A poll begun before deletion may finish afterwards. Its wall-clock save
+ # time alone is not evidence that it belongs to the replacement cycle.
+ if cycle and (payload.get("raw", {}).get("repairCycle") or "") < cycle:
+ 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
+ # Environment credentials bootstrap only the first administrator. In
+ # particular, do not inject a destination host's account into a restored DB.
+ if has_admin_user():
+ 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:
+ username = str(username).strip()
+ created_at = datetime.now(timezone.utc).isoformat()
+ password_hash = hash_password(password)
+ normalized_email = _normalize_stored_email(email)
+ with _connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ if any(str(row[0]).strip().casefold() == username.casefold()
+ for row in conn.execute("SELECT username FROM users")):
+ raise sqlite3.IntegrityError("A normalized username already exists")
+ 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:
+ username = str(username).strip()
+ created_at = datetime.now(timezone.utc).isoformat()
+ password_hash = hash_password(password)
+ normalized_email = _normalize_stored_email(email)
+ with _connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ if any(str(row[0]).strip().casefold() == username.casefold()
+ for row in conn.execute("SELECT username FROM users")):
+ return False
+ if jellyseerr_user_id is not None and conn.execute(
+ "SELECT 1 FROM users WHERE jellyseerr_user_id=?", (jellyseerr_user_id,)
+ ).fetchone():
+ return False
+ 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, auth_version
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ ORDER BY id
+ """,
+ (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],
+ "auth_version": int(row[18] or 1),
+ }
+
+
+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, auth_version
+ 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],
+ "auth_version": int(row[18] or 1),
+ }
+
+
+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, auth_version
+ 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],
+ "auth_version": int(row[18] or 1),
+ }
+
+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, auth_version
+ 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],
+ "auth_version": int(row[15] or 1),
+ "is_expired": _is_datetime_in_past(row[12]),
+ }
+ )
+ # Imported Seerr accounts must remain manageable. Prefer a Jellyfin/local
+ # account when a linked duplicate exists, without hiding Seerr-only users.
+ 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 = all_rows
+
+ 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
+ AND id NOT IN (SELECT local_user_id FROM user_identity_confirmations)
+ """,
+ (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 = ?, auth_version = auth_version + 1 WHERE username = ?
+ """,
+ (1 if blocked else 0, username),
+ )
+ logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
+
+
+def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
+ row = conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
+ (table_name,),
+ ).fetchone()
+ return bool(row)
+
+
+def _redact_user_json(value: Any, identifiers: set[str]) -> Any:
+ if isinstance(value, dict):
+ return {key: _redact_user_json(item, identifiers) for key, item in value.items()}
+ if isinstance(value, list):
+ return [_redact_user_json(item, identifiers) for item in value]
+ if isinstance(value, str) and value.strip().casefold() in identifiers:
+ return "Deleted user"
+ return value
+
+
+def delete_user_data_by_username(username: str) -> Dict[str, int | bool]:
+ with _connect() as conn:
+ user = conn.execute(
+ "SELECT id, username, email FROM users WHERE username = ? COLLATE NOCASE",
+ (username,),
+ ).fetchone()
+ if not user:
+ return {"deleted": False}
+ user_id, canonical_username, email = int(user[0]), str(user[1]), user[2]
+ pseudonym = f"deleted-user-{user_id}"
+ identifiers = {canonical_username.casefold()}
+ if isinstance(email, str) and email.strip():
+ identifiers.add(email.strip().casefold())
+
+ counts: Dict[str, int | bool] = {"deleted": False}
+ request_rows = conn.execute(
+ """
+ SELECT request_id, payload_json FROM requests_cache
+ WHERE requested_by_id = ? OR requested_by_norm = ? OR requested_by = ? COLLATE NOCASE
+ """,
+ (user_id, canonical_username.casefold(), canonical_username),
+ ).fetchall()
+ for request_id, payload_json in request_rows:
+ try:
+ payload = _redact_user_json(json.loads(payload_json), identifiers)
+ sanitized_payload = json.dumps(payload, separators=(",", ":"))
+ except (TypeError, json.JSONDecodeError):
+ sanitized_payload = "{}"
+ conn.execute(
+ """
+ UPDATE requests_cache
+ SET requested_by = 'Deleted user', requested_by_norm = NULL,
+ requested_by_id = NULL, payload_json = ?
+ WHERE request_id = ?
+ """,
+ (sanitized_payload, request_id),
+ )
+ snapshot_rows = conn.execute(
+ "SELECT id, payload_json FROM snapshots WHERE request_id = ?",
+ (str(request_id),),
+ ).fetchall()
+ for snapshot_id, snapshot_json in snapshot_rows:
+ try:
+ snapshot_payload = _redact_user_json(
+ json.loads(snapshot_json), identifiers
+ )
+ sanitized_snapshot = json.dumps(
+ snapshot_payload, separators=(",", ":")
+ )
+ except (TypeError, json.JSONDecodeError):
+ sanitized_snapshot = "{}"
+ conn.execute(
+ "UPDATE snapshots SET payload_json = ? WHERE id = ?",
+ (sanitized_snapshot, snapshot_id),
+ )
+ conn.execute(
+ "UPDATE actions SET message = REPLACE(message, ?, 'Deleted user') WHERE request_id = ? AND message IS NOT NULL",
+ (canonical_username, str(request_id)),
+ )
+ if email:
+ conn.execute(
+ "UPDATE actions SET message = REPLACE(message, ?, '[deleted email]') WHERE request_id = ? AND message IS NOT NULL",
+ (email, str(request_id)),
+ )
+ counts["requests_anonymized"] = len(request_rows)
+
+ direct_operations = (
+ ("DELETE FROM user_activity WHERE username = ? COLLATE NOCASE", (canonical_username,), "activity_deleted"),
+ ("DELETE FROM password_reset_tokens WHERE username = ? COLLATE NOCASE", (canonical_username,), "reset_tokens_deleted"),
+ ("DELETE FROM user_feature_permissions WHERE user_id = ?", (user_id,), "feature_rows_deleted"),
+ ("DELETE FROM jellyfin_user_links WHERE local_user_id = ?", (user_id,), "identity_links_deleted"),
+ ("DELETE FROM user_identity_confirmations WHERE local_user_id = ?", (user_id,), "identity_confirmations_deleted"),
+ ("DELETE FROM user_identity_repairs WHERE local_user_id = ?", (user_id,), "identity_repairs_deleted"),
+ ("DELETE FROM user_duplicate_repairs WHERE kept_user_id = ?", (user_id,), "duplicate_repairs_deleted"),
+ )
+ for sql, params, label in direct_operations:
+ counts[label] = int(conn.execute(sql, params).rowcount or 0)
+
+ conn.execute(
+ "UPDATE signup_invites SET enabled = 0, created_by = ? WHERE created_by = ? COLLATE NOCASE",
+ (pseudonym, canonical_username),
+ )
+ if email:
+ conn.execute(
+ "UPDATE signup_invites SET recipient_email = NULL WHERE recipient_email = ? COLLATE NOCASE",
+ (email,),
+ )
+ conn.execute(
+ "UPDATE portal_items SET created_by_username = ?, created_by_id = NULL WHERE created_by_id = ? OR created_by_username = ? COLLATE NOCASE",
+ (pseudonym, user_id, canonical_username),
+ )
+ conn.execute(
+ "UPDATE portal_items SET assignee_username = NULL WHERE assignee_username = ? COLLATE NOCASE",
+ (canonical_username,),
+ )
+ conn.execute(
+ "UPDATE portal_comments SET author_username = ? WHERE author_username = ? COLLATE NOCASE",
+ (pseudonym, canonical_username),
+ )
+ conn.execute(
+ "UPDATE portal_item_activity SET actor_username = ? WHERE actor_username = ? COLLATE NOCASE",
+ (pseudonym, canonical_username),
+ )
+ conn.execute(
+ "UPDATE user_identity_confirmations SET confirmed_by = ? WHERE confirmed_by = ? COLLATE NOCASE",
+ (pseudonym, canonical_username),
+ )
+ conn.execute(
+ "UPDATE user_identity_repairs SET repaired_by = ? WHERE repaired_by = ? COLLATE NOCASE",
+ (pseudonym, canonical_username),
+ )
+ duplicate_rows = conn.execute(
+ "SELECT id, archive_json FROM user_duplicate_repairs"
+ ).fetchall()
+ for repair_id, archive_json in duplicate_rows:
+ try:
+ archive_payload = _redact_user_json(
+ json.loads(archive_json), identifiers
+ )
+ except (TypeError, json.JSONDecodeError):
+ continue
+ conn.execute(
+ "UPDATE user_duplicate_repairs SET archive_json = ?, repaired_by = CASE WHEN repaired_by = ? COLLATE NOCASE THEN ? ELSE repaired_by END WHERE id = ?",
+ (
+ json.dumps(archive_payload, separators=(",", ":")),
+ canonical_username,
+ pseudonym,
+ repair_id,
+ ),
+ )
+
+ for table in ("email_recap_subscriptions", "email_recap_deliveries", "newsletter_subscriptions", "newsletter_deliveries"):
+ if _table_exists(conn, table):
+ counts[f"{table}_deleted"] = int(
+ conn.execute(f"DELETE FROM {table} WHERE user_id = ?", (user_id,)).rowcount or 0
+ )
+ if _table_exists(conn, "newsletter_editions"):
+ conn.execute(
+ "UPDATE newsletter_editions SET created_by = ? WHERE created_by = ? COLLATE NOCASE",
+ (pseudonym, canonical_username),
+ )
+
+ deleted = conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount > 0
+ counts["deleted"] = deleted
+ logger.warning("user data deleted user_id=%s deleted=%s", user_id, deleted)
+ return counts
+
+
+def delete_user_by_username(username: str) -> bool:
+ return bool(delete_user_data_by_username(username).get("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 = ?, auth_version = auth_version + 1 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 = 1 if row[11] else row[7]
+ use_count = int(row[8] or 0)
+ expires_at = row[10]
+ 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": _masked_invite_code(row[2]),
+ "code_hint": row[2],
+ "code_available": False,
+ "label": row[3],
+ "description": row[4],
+ "profile_id": row[5],
+ "role": row[6],
+ "max_uses": max_uses,
+ "use_count": use_count,
+ "enabled": bool(row[9]),
+ "expires_at": expires_at,
+ "recipient_email": row[11],
+ "created_by": row[12],
+ "created_at": row[13],
+ "updated_at": row[14],
+ "is_expired": is_expired,
+ "remaining_uses": remaining_uses,
+ "is_usable": bool(row[9]) 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, code_hint, 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, code_hint, 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, code_hint, 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 = ?
+ """,
+ (_hash_signup_invite_code(code),),
+ ).fetchone()
+ if not row:
+ return None
+ invite = _row_to_signup_invite(row)
+ invite["code"] = _normalize_invite_secret(code)
+ invite["code_available"] = True
+ return invite
+
+
+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]:
+ normalized_code = _normalize_invite_secret(code)
+ if not normalized_code:
+ raise ValueError("Invite code is required")
+ if recipient_email:
+ max_uses = 1
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO signup_invites (
+ code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ _hash_signup_invite_code(normalized_code),
+ _invite_code_hint(normalized_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 role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s has_recipient=%s created_by=%s",
+ invite_id,
+ role,
+ profile_id,
+ max_uses,
+ enabled,
+ expires_at,
+ bool(recipient_email),
+ created_by,
+ )
+ invite = get_signup_invite_by_id(invite_id)
+ if not invite:
+ raise RuntimeError("Invite creation failed")
+ invite["code"] = normalized_code
+ invite["code_available"] = True
+ 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]]:
+ existing = get_signup_invite_by_id(invite_id)
+ if recipient_email or (existing and existing.get('recipient_email')):
+ max_uses = 1
+ if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
+ raise ValueError('A used email invitation cannot be reassigned.')
+ timestamp = datetime.now(timezone.utc).isoformat()
+ requested_code = str(code or "").strip()
+ rotate_code = bool(requested_code) and not requested_code.startswith("••••") and requested_code != "Protected invite"
+ with _connect() as conn:
+ if rotate_code:
+ normalized_code = _normalize_invite_secret(requested_code)
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET code = ?, code_hint = ?, label = ?, description = ?, profile_id = ?, role = ?,
+ max_uses = ?, enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ _hash_signup_invite_code(normalized_code), _invite_code_hint(normalized_code),
+ label, description, profile_id, role, max_uses, 1 if enabled else 0,
+ expires_at, recipient_email, timestamp, invite_id,
+ ),
+ )
+ else:
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
+ enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ 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 rotate_signup_invite_code(invite_id: int, code: str) -> Optional[Dict[str, Any]]:
+ normalized_code = _normalize_invite_secret(code)
+ if not normalized_code:
+ raise ValueError("Invite code is required")
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET code = ?, code_hint = ?, updated_at = ?
+ WHERE id = ? AND enabled = 1
+ """,
+ (
+ _hash_signup_invite_code(normalized_code),
+ _invite_code_hint(normalized_code),
+ timestamp,
+ invite_id,
+ ),
+ )
+ if cursor.rowcount <= 0:
+ return None
+ invite = get_signup_invite_by_id(invite_id)
+ if invite:
+ invite["code"] = normalized_code
+ invite["code_available"] = True
+ return invite
+
+
+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 reserve_signup_invite_use(invite_id: int) -> bool:
+ """Atomically reserve capacity before any remote account is provisioned."""
+ with _connect() as conn:
+ cursor = conn.execute('''
+ UPDATE signup_invites SET use_count = use_count + 1
+ WHERE id = ? AND enabled = 1
+ AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))
+ AND ((recipient_email IS NOT NULL AND recipient_email != '' AND use_count < 1)
+ OR ((recipient_email IS NULL OR recipient_email = '') AND (max_uses IS NULL OR use_count < max_uses)))
+ ''', (invite_id,))
+ return cursor.rowcount == 1
+
+
+def release_signup_invite_use(invite_id: int) -> None:
+ with _connect() as conn:
+ conn.execute('UPDATE signup_invites SET use_count = MAX(0, use_count - 1) WHERE id = ?', (invite_id,))
+
+
+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, auth_version
+ 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
+ verified, updated_hash = verify_and_update_password(password, row[2])
+ if not verified:
+ continue
+ if updated_hash:
+ with _connect() as conn:
+ conn.execute(
+ "UPDATE users SET password_hash = ? WHERE id = ?",
+ (updated_hash, row[0]),
+ )
+ 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],
+ "auth_version": int(row[17] or 1),
+ }
+ 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, auth_version
+ 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],
+ "auth_version": int(row[18] or 1),
+ }
+ )
+ 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_set=%s", username, bool(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 = ?, auth_version = auth_version + 1
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (password_hash, username),
+ )
+
+
+def increment_user_auth_version(username: str) -> int:
+ with _connect() as conn:
+ conn.execute(
+ "UPDATE users SET auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE",
+ (username,),
+ )
+ row = conn.execute(
+ "SELECT auth_version FROM users WHERE username = ? COLLATE NOCASE",
+ (username,),
+ ).fetchone()
+ return int(row[0] or 1) if row else 0
+
+
+def _rate_limit_key_hash(key: str) -> str:
+ key_material = str(
+ settings.jwt_secret or settings.settings_encryption_key or "magent-rate-limit"
+ ).encode("utf-8")
+ return hmac.new(
+ key_material, str(key or "").encode("utf-8"), sha256
+ ).hexdigest()
+
+
+def get_rate_limit_status(
+ scope: str, key: str, window_seconds: int, maximum: int
+) -> tuple[bool, int]:
+ now = unix_time()
+ cutoff = now - max(1, int(window_seconds))
+ key_hash = _rate_limit_key_hash(key)
+ with _connect() as conn:
+ conn.execute("DELETE FROM auth_rate_limits WHERE occurred_at < ?", (cutoff,))
+ row = conn.execute(
+ """
+ SELECT COUNT(*), MIN(occurred_at)
+ FROM auth_rate_limits
+ WHERE scope = ? AND key_hash = ? AND occurred_at >= ?
+ """,
+ (scope, key_hash, cutoff),
+ ).fetchone()
+ count = int((row or [0])[0] or 0)
+ oldest = float(row[1]) if row and row[1] is not None else now
+ retry_after = max(1, int(window_seconds - (now - oldest)))
+ return count >= max(1, int(maximum)), retry_after
+
+
+def record_rate_limit_event(scope: str, key: str) -> None:
+ with _connect() as conn:
+ conn.execute(
+ "INSERT INTO auth_rate_limits (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
+ (scope, _rate_limit_key_hash(key), unix_time()),
+ )
+
+
+def clear_rate_limit_events(scope: str, key: str) -> None:
+ with _connect() as conn:
+ conn.execute(
+ "DELETE FROM auth_rate_limits WHERE scope = ? AND key_hash = ?",
+ (scope, _rate_limit_key_hash(key)),
+ )
+
+
+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, updated_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],
+ "updated_at": row[10],
+ }
+ )
+ 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 decrypt_setting_value(key, row[0])
+
+
+def set_setting(key: str, value: Optional[str]) -> None:
+ updated_at = datetime.now(timezone.utc).isoformat()
+ stored_value = encrypt_setting_value(key, value)
+ 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, stored_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] = decrypt_setting_value(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 expires_at=%s",
+ username,
+ auth_provider,
+ expires_at,
+ )
+ 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")
+
+
+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 delete_portal_item(item_id: int) -> bool:
+ with _connect() as conn:
+ conn.execute(
+ "UPDATE portal_items SET related_item_id = NULL WHERE related_item_id = ?",
+ (item_id,),
+ )
+ conn.execute("DELETE FROM portal_comments WHERE item_id = ?", (item_id,))
+ conn.execute("DELETE FROM portal_item_activity WHERE item_id = ?", (item_id,))
+ deleted = conn.execute(
+ "DELETE FROM portal_items WHERE id = ?",
+ (item_id,),
+ ).rowcount
+ if deleted:
+ logger.info("portal item deleted id=%s", item_id)
+ return bool(deleted)
+
+
+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 add_portal_item_activity(
+ item_id: int,
+ *,
+ event_type: str,
+ actor_username: str,
+ actor_role: str,
+ message: str,
+ metadata_json: Optional[str] = None,
+) -> Dict[str, Any]:
+ now = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO portal_item_activity (
+ item_id,
+ event_type,
+ actor_username,
+ actor_role,
+ message,
+ metadata_json,
+ created_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ item_id,
+ event_type,
+ actor_username,
+ actor_role,
+ message,
+ metadata_json,
+ now,
+ ),
+ )
+ activity_id = cursor.lastrowid
+ row = conn.execute(
+ """
+ SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
+ FROM portal_item_activity
+ WHERE id = ?
+ """,
+ (activity_id,),
+ ).fetchone()
+ if not row:
+ raise RuntimeError("Portal activity could not be loaded after insert.")
+ return {
+ "id": row[0],
+ "item_id": row[1],
+ "event_type": row[2],
+ "actor_username": row[3],
+ "actor_role": row[4],
+ "message": row[5],
+ "metadata_json": row[6],
+ "created_at": row[7],
+ }
+
+
+def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]:
+ safe_limit = max(1, min(int(limit), 500))
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
+ FROM portal_item_activity
+ WHERE item_id = ?
+ ORDER BY created_at ASC, id ASC
+ LIMIT ?
+ """,
+ (item_id, safe_limit),
+ ).fetchall()
+ return [
+ {
+ "id": row[0],
+ "item_id": row[1],
+ "event_type": row[2],
+ "actor_username": row[3],
+ "actor_role": row[4],
+ "message": row[5],
+ "metadata_json": row[6],
+ "created_at": row[7],
+ }
+ for row in rows
+ ]
+
+
+def get_portal_overview(kind: Optional[str] = None) -> Dict[str, Any]:
+ with _connect() as conn:
+ kind_rows = conn.execute(
+ """
+ SELECT kind, COUNT(*)
+ FROM portal_items
+ WHERE (? IS NULL OR kind = ?)
+ GROUP BY kind
+ """, (kind, kind)
+ ).fetchall()
+ status_rows = conn.execute(
+ """
+ SELECT status, COUNT(*)
+ FROM portal_items
+ WHERE (? IS NULL OR kind = ?)
+ GROUP BY status
+ """, (kind, kind)
+ ).fetchall()
+ request_workflow_rows = conn.execute(
+ """
+ SELECT
+ COALESCE(workflow_request_status, ''),
+ COALESCE(workflow_media_status, ''),
+ COUNT(*)
+ FROM portal_items
+ WHERE kind = 'request' AND (? IS NULL OR kind = ?)
+ GROUP BY workflow_request_status, workflow_media_status
+ """, (kind, kind)
+ ).fetchall()
+ total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items WHERE (? IS NULL OR kind = ?)", (kind, kind)).fetchone()
+ total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments c JOIN portal_items i ON i.id = c.item_id WHERE (? IS NULL OR i.kind = ?)", (kind, kind)).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()
+ cutoff_epoch = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
+ 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
+ reset_tokens = conn.execute(
+ "DELETE FROM password_reset_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)",
+ (cutoff, cutoff),
+ ).rowcount
+ invites = conn.execute(
+ """
+ DELETE FROM signup_invites
+ WHERE updated_at < ?
+ AND (enabled = 0 OR expires_at < ? OR (max_uses IS NOT NULL AND use_count >= max_uses))
+ AND id != COALESCE((SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'self_service_invite_master_id'), -1)
+ """,
+ (cutoff, cutoff),
+ ).rowcount
+ rate_limits = conn.execute(
+ "DELETE FROM auth_rate_limits WHERE occurred_at < ?",
+ (unix_time() - 86400,),
+ ).rowcount
+ email_deliveries = 0
+ for table in ("email_recap_deliveries", "newsletter_deliveries"):
+ if _table_exists(conn, table):
+ email_deliveries += int(
+ conn.execute(f"DELETE FROM {table} WHERE created_at < ?", (cutoff_epoch,)).rowcount or 0
+ )
+ return {
+ "actions": int(actions or 0),
+ "snapshots": int(snapshots or 0),
+ "password_reset_tokens": int(reset_tokens or 0),
+ "invites": int(invites or 0),
+ "rate_limits": int(rate_limits or 0),
+ "email_deliveries": email_deliveries,
+ }
+
+
+def get_request_stage_cache():
+ with _connect() as conn:
+ return {row[0]: {'source_updated': row[1], 'ready': bool(row[2]), 'checked_at': row[3]}
+ for row in conn.execute('SELECT request_id, source_updated, ready, checked_at FROM request_stage_cache')}
+
+
+def save_request_stage_cache(rows):
+ with _connect() as conn:
+ conn.executemany('INSERT OR REPLACE INTO request_stage_cache (request_id, source_updated, ready, checked_at) VALUES (?, ?, ?, ?)', rows)
+ conn.execute('DELETE FROM request_stage_cache WHERE request_id NOT IN (SELECT request_id FROM requests_cache)')
diff --git a/backend/app/feature_access.py b/backend/app/feature_access.py
new file mode 100644
index 0000000..7a377cc
--- /dev/null
+++ b/backend/app/feature_access.py
@@ -0,0 +1,37 @@
+"""Live account permissions. Invite access uses the existing users column."""
+from .db import _connect
+
+FEATURES = ("stats", "requests", "new_requests", "issues", "invites", "ignore_profile_limits")
+
+
+def permissions(user: dict) -> dict[str, bool]:
+ if user.get("role") == "admin":
+ return dict.fromkeys(FEATURES, True)
+ values = dict.fromkeys(FEATURES, True)
+ values["ignore_profile_limits"] = False
+ values["invites"] = bool(user.get("invite_management_enabled", False))
+ with _connect() as conn:
+ rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
+ JOIN users u ON u.id = p.user_id WHERE u.username = ? COLLATE NOCASE""",
+ (user.get("username", ""),)).fetchall()
+ values.update({key: bool(enabled) for key, enabled in rows if key in FEATURES and key != "invites"})
+ return values
+
+
+def update_permissions(changes: dict[str, bool], username: str | None = None) -> int:
+ if not changes or any(key not in FEATURES or type(value) is not bool for key, value in changes.items()):
+ raise ValueError("Choose valid features with true or false values")
+ with _connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ users = conn.execute("SELECT id FROM users WHERE role != 'admin'" +
+ (" AND username = ? COLLATE NOCASE" if username is not None else ""),
+ (username,) if username is not None else ()).fetchall()
+ for (user_id,) in users:
+ for feature, enabled in changes.items():
+ if feature == "invites":
+ conn.execute("UPDATE users SET invite_management_enabled = ? WHERE id = ?", (int(enabled), user_id))
+ else:
+ conn.execute("""INSERT INTO user_feature_permissions(user_id, feature, enabled) VALUES (?, ?, ?)
+ ON CONFLICT(user_id, feature) DO UPDATE SET enabled = excluded.enabled""",
+ (user_id, feature, int(enabled)))
+ return len(users)
diff --git a/backend/app/feature_guards.py b/backend/app/feature_guards.py
new file mode 100644
index 0000000..c1ea94f
--- /dev/null
+++ b/backend/app/feature_guards.py
@@ -0,0 +1,75 @@
+from fastapi import Depends, HTTPException, Request
+from .auth import get_current_user, get_current_user_event_stream
+from .db import get_portal_item
+
+
+def check(user: dict, *features: str) -> None:
+ access = user.get("features") or {}
+ if user.get("role") == "admin":
+ return
+ if not any(access.get(feature, False) for feature in features):
+ raise HTTPException(status_code=403, detail="This feature is disabled for your account")
+
+
+def require_stats(user: dict = Depends(get_current_user)) -> dict:
+ check(user, "stats")
+ return user
+
+
+def require_invites(user: dict = Depends(get_current_user)) -> dict:
+ check(user, "invites")
+ return user
+
+
+def require_request_access(request: Request, user: dict = Depends(get_current_user)) -> None:
+ path = request.url.path.rstrip("/")
+ if path.endswith("/search") and "/actions/" not in path:
+ # The issue picker uses the same media search; creation is checked separately.
+ check(user, "new_requests", "issues")
+ elif path.endswith(("/create", "/request-options")):
+ check(user, "new_requests")
+ elif path.endswith(("/issue-options", "/replacement-options", "/actions/replace", "/actions/search-missing", "/actions/repair-subtitles")):
+ check(user, "issues")
+ else:
+ check(user, "requests")
+
+
+async def require_portal_access(request: Request, user: dict = Depends(get_current_user)) -> None:
+ if user.get("role") == "admin":
+ return
+ path = request.url.path.rstrip("/")
+ access = user.get("features", {})
+ if access.get("requests") and access.get("issues") and access.get("new_requests"):
+ return
+ if "/issues" in path:
+ check(user, "issues")
+ elif path.endswith("/requests") or path.endswith("/pipeline"):
+ check(user, "requests")
+ elif "item_id" in request.path_params:
+ try:
+ item = get_portal_item(int(request.path_params["item_id"]))
+ except (ValueError, TypeError):
+ item = None
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+ check(user, "requests" if item.get("kind") == "request" else "issues")
+ elif path.endswith("/items") and request.method == "POST":
+ payload = await request.json()
+ kind = str(payload.get("kind") or "").strip().lower() if isinstance(payload, dict) else ""
+ check(user, "new_requests" if not kind or kind == "request" else "issues")
+ elif path.endswith(("/items", "/overview")) and request.query_params.get("kind"):
+ kind = request.query_params["kind"].strip().lower()
+ if not kind:
+ check(user, "requests")
+ check(user, "issues")
+ else:
+ check(user, "requests" if kind == "request" else "issues")
+ else:
+ # Unfiltered lists/overview can include both kinds.
+ check(user, "requests")
+ check(user, "issues")
+
+
+def require_request_stream(user: dict = Depends(get_current_user_event_stream)) -> dict:
+ check(user, "requests")
+ return user
diff --git a/backend/app/installation_origin.py b/backend/app/installation_origin.py
new file mode 100644
index 0000000..b942371
--- /dev/null
+++ b/backend/app/installation_origin.py
@@ -0,0 +1,32 @@
+"""Origin validation shared by first-install setup and container startup."""
+
+import os
+from urllib.parse import urlsplit
+
+
+def managed_runtime() -> bool:
+ # Set by the entrypoint, never by an HTTP header or a database setting.
+ return os.environ.get("MAGENT_RUNTIME_MANAGED") == "1"
+
+
+def normalize_application_origin(value: str) -> str:
+ if not isinstance(value, str) or not value or any(
+ c.isspace() or ord(c) < 33 or ord(c) == 127 or c in '<>"\\*?#' for c in value
+ ):
+ raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.")
+ try:
+ parsed = urlsplit(value)
+ if (parsed.scheme not in {"http", "https"} or not parsed.hostname
+ or parsed.username is not None or parsed.password is not None
+ or parsed.path not in {"", "/"} or parsed.netloc.endswith(":")):
+ raise ValueError
+ port = parsed.port
+ if port is not None and not 1 <= port <= 65535:
+ raise ValueError
+ host = parsed.hostname.encode("idna").decode("ascii").lower()
+ if ":" in host:
+ host = f"[{host}]"
+ suffix = f":{port}" if port is not None and port != (443 if parsed.scheme == "https" else 80) else ""
+ return f"{parsed.scheme}://{host}{suffix}"
+ except (ValueError, UnicodeError):
+ raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.") from None
diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py
new file mode 100644
index 0000000..d5a8e72
--- /dev/null
+++ b/backend/app/logging_config.py
@@ -0,0 +1,223 @@
+import contextvars
+import json
+import logging
+import os
+import re
+from datetime import datetime, timezone
+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
+_SENSITIVE_PATH_PATTERNS = (
+ re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
+)
+
+
+class RequestContextFilter(logging.Filter):
+ def filter(self, record: logging.LogRecord) -> bool:
+ record.request_id = REQUEST_ID_CONTEXT.get("-")
+ return True
+
+
+class JsonLogFormatter(logging.Formatter):
+ """Stable JSON output for production log collectors."""
+
+ def format(self, record: logging.LogRecord) -> str:
+ payload: dict[str, Any] = {
+ "timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
+ "level": record.levelname,
+ "logger": record.name,
+ "request_id": getattr(record, "request_id", "-"),
+ "message": record.getMessage(),
+ }
+ if record.exc_info:
+ payload["exception"] = self.formatException(record.exc_info)
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+
+
+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 sanitize_path(path: str) -> str:
+ sanitized = str(path or "")
+ for pattern in _SENSITIVE_PATH_PATTERNS:
+ sanitized = pattern.sub(r"\1[REDACTED]", sanitized)
+ return sanitized
+
+
+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
+ return "[REDACTED]"
+
+
+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",
+ log_format: Optional[str] = "text",
+) -> 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",
+ )
+ try:
+ os.chmod(log_path, 0o600)
+ except OSError:
+ pass
+ handlers.append(file_handler)
+
+ context_filter = RequestContextFilter()
+ if str(log_format or "text").strip().lower() == "json":
+ formatter: logging.Formatter = JsonLogFormatter()
+ else:
+ 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..b151c79
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,378 @@
+import asyncio
+import logging
+import os
+import time
+import uuid
+from typing import Awaitable, Callable
+
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.exception_handlers import request_validation_exception_handler
+from fastapi.responses import JSONResponse
+
+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_local_request_stage_loop,
+ 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 .routers.operations import router as operations_router
+from .routers.insights import router as insights_router
+from .routers.identities import router as identities_router
+from .routers.recaps import router as recaps_router
+from .routers.newsletters import router as newsletters_router
+from .routers.backups import router as backups_router
+from .routers.setup import router as setup_router
+from .services.backups import apply_pending_restore
+from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
+from .services.jellyfin_sync import run_daily_jellyfin_sync
+from .services.issue_resolution import run_issue_confirmation_loop
+from .services.email_recaps import run_email_recap_loop
+from .services.newsletters import run_newsletter_loop
+from .services.operation_progress import (
+ begin_operation,
+ finish_operation,
+ normalize_operation_id,
+ reset_operation,
+)
+from .logging_config import (
+ bind_request_id,
+ configure_logging,
+ reset_request_id,
+ sanitize_headers,
+ sanitize_path,
+)
+from .runtime import get_runtime_settings
+from .metrics import record_api, start_metrics
+from .request_limits import InstallationBodyLimitMiddleware
+from .secret_storage import validate_secret_storage_configuration
+from .services.request_origins import ConfiguredOriginCORSMiddleware, can_claim_initial_origin, is_allowed_request_origin
+
+logger = logging.getLogger(__name__)
+_background_tasks: list[asyncio.Task[None]] = []
+_background_started = False
+
+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(
+ ConfiguredOriginCORSMiddleware,
+ allow_origins=[settings.cors_allow_origin],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+app.add_middleware(InstallationBodyLimitMiddleware)
+
+
+@app.exception_handler(RequestValidationError)
+async def installation_validation_error(request: Request, exc: RequestValidationError):
+ if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
+ # Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
+ # includes rejected raw input. Never echo tokens/passwords/passphrases.
+ return JSONResponse(
+ status_code=422,
+ content={"detail": [
+ {key: error[key] for key in ("type", "loc", "msg") if key in error}
+ for error in exc.errors()
+ ]},
+ headers={"Cache-Control": "no-store"},
+ )
+ return await request_validation_exception_handler(request, exc)
+
+
+@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)
+ operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
+ operation_token = None
+ if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
+ operation_token = begin_operation(
+ operation_id,
+ label=request.headers.get("X-Magent-Operation-Label"),
+ path=sanitize_path(request.url.path),
+ )
+ request.state.request_id = request_id
+ if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
+ origin = str(request.headers.get("origin") or "")
+ initial_origin_claim = (
+ request.method.upper() == "POST" and request.url.path == "/setup/bootstrap"
+ and can_claim_initial_origin()
+ )
+ if origin and not is_allowed_request_origin(origin) and not initial_origin_claim:
+ record_api(request, 403, 0.0)
+ if operation_id and operation_token is not None:
+ finish_operation(operation_id, success=False, status_code=403)
+ reset_operation(operation_token)
+ reset_request_id(token)
+ return JSONResponse(
+ status_code=403,
+ content={"detail": "Cross-origin state change rejected"},
+ headers={"X-Request-ID": request_id},
+ )
+ started_at = time.perf_counter()
+ body_summary = {
+ "content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
+ "declared_bytes": request.headers.get("content-length"),
+ }
+ logger.info(
+ "request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s",
+ request.method,
+ sanitize_path(request.url.path),
+ sorted(set(request.query_params.keys())),
+ 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)
+ record_api(request, 500, time.perf_counter() - started_at)
+ logger.exception(
+ "request failed method=%s path=%s duration_ms=%s",
+ request.method,
+ sanitize_path(request.url.path),
+ duration_ms,
+ )
+ if operation_id and operation_token is not None:
+ finish_operation(operation_id, success=False, status_code=500)
+ reset_operation(operation_token)
+ reset_request_id(token)
+ raise
+
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ record_api(request, response.status_code, time.perf_counter() - started_at)
+ 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=()")
+ response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+ # 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,
+ sanitize_path(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"}
+ }
+ ),
+ )
+ if operation_id and operation_token is not None:
+ finish_operation(
+ operation_id,
+ success=response.status_code < 400,
+ status_code=response.status_code,
+ )
+ reset_operation(operation_token)
+ 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 len(jwt_secret) < 32 or jwt_secret == "change-me":
+ logger.warning(
+ "security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
+ )
+ admin_password = str(settings.admin_password or "")
+ if admin_password == "adminadmin":
+ logger.warning(
+ "security configuration warning: ADMIN_PASSWORD is 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_secret_configuration() -> None:
+ jwt_secret = str(settings.jwt_secret or "").strip()
+ if len(jwt_secret) < 32 or jwt_secret == "change-me":
+ raise RuntimeError(
+ "JWT_SECRET must be a strong, non-default value of at least 32 characters before startup."
+ )
+ validate_secret_storage_configuration()
+
+
+def _enforce_secure_startup_configuration() -> None:
+ _enforce_secret_configuration()
+ admin_password = str(settings.admin_password or "")
+ if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
+ if is_setup_required() and setup_token_configured():
+ return
+ raise RuntimeError(
+ "First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
+ "or a secure ADMIN_PASSWORD, until an admin account exists."
+ )
+
+
+@app.on_event("startup")
+async def startup() -> None:
+ start_metrics()
+ 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,
+ log_format=settings.log_format,
+ )
+ logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
+ _log_security_configuration_warnings()
+ _enforce_secret_configuration()
+ # Restore offline, before any schema migration, database reader or worker.
+ apply_pending_restore()
+ initialize_setup_state()
+ 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,
+ log_format=runtime.log_format,
+ )
+ 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,
+ )
+ app.state.on_setup_complete = _start_background_tasks
+ await _start_background_tasks()
+ logger.info("startup complete")
+
+
+async def _start_background_tasks() -> None:
+ global _background_started
+ if _background_started:
+ return
+ if is_setup_required():
+ logger.info("Background imports and automation paused until setup is complete")
+ return
+ if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
+ logger.info("Background imports and automation disabled by configuration")
+ return
+ _background_started = True
+ _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
+ _launch_background_task("requests-warmup", startup_warmup_requests_cache)
+ _launch_background_task("request-local-stages", run_local_request_stage_loop)
+ _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)
+ _launch_background_task("issue-confirmation", run_issue_confirmation_loop)
+ _launch_background_task("email-recaps", run_email_recap_loop)
+ _launch_background_task("newsletters", run_newsletter_loop)
+
+
+@app.on_event("shutdown")
+async def shutdown() -> None:
+ global _background_started
+ for task in _background_tasks:
+ task.cancel()
+ if _background_tasks:
+ await asyncio.gather(*_background_tasks, return_exceptions=True)
+ _background_tasks.clear()
+ _background_started = False
+
+
+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)
+app.include_router(operations_router)
+app.include_router(insights_router)
+app.include_router(identities_router)
+app.include_router(recaps_router)
+app.include_router(newsletters_router)
+app.include_router(backups_router)
+app.include_router(setup_router)
diff --git a/backend/app/metrics.py b/backend/app/metrics.py
new file mode 100644
index 0000000..b7e1595
--- /dev/null
+++ b/backend/app/metrics.py
@@ -0,0 +1,27 @@
+"""Low-cardinality operational metrics; no URLs, query values or user data."""
+import os
+from prometheus_client import Counter, Histogram, start_http_server
+
+BUCKETS = (.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60)
+API_CALLS = Counter('magent_api_requests_total', 'API responses by route template', ['method', 'route', 'status'])
+API_TIME = Histogram('magent_api_response_seconds', 'Time until response headers (not stream lifetime)', ['method', 'route'], buckets=BUCKETS)
+REMOTE_CALLS = Counter('magent_remote_requests_total', 'Logical service client calls', ['service', 'method', 'status'])
+REMOTE_TIME = Histogram('magent_remote_response_seconds', 'Logical service client call duration', ['service', 'method'], buckets=BUCKETS)
+_server = None
+
+def start_metrics():
+ global _server
+ if _server is None and os.getenv('MAGENT_METRICS_ENABLED', '').lower() == 'true':
+ _server = start_http_server(int(os.getenv('MAGENT_METRICS_PORT', '9108')), addr=os.getenv('MAGENT_METRICS_BIND', '127.0.0.1'))
+
+def record_api(request, status, seconds):
+ route = getattr(request.scope.get('route'), 'path', 'unmatched')
+ method = request.method if request.method in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
+ API_CALLS.labels(method, route, str(status)).inc()
+ API_TIME.labels(method, route).observe(max(0, seconds))
+
+def record_remote(service, method, status, seconds):
+ service = service if service in {'Seerr', 'Jellyfin', 'Sonarr', 'Radarr', 'Bazarr', 'Prowlarr', 'qBittorrent'} else 'Other'
+ method = method.upper() if method.upper() in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
+ REMOTE_CALLS.labels(service, method, str(status)).inc()
+ REMOTE_TIME.labels(service, method).observe(max(0, seconds))
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/request_limits.py b/backend/app/request_limits.py
new file mode 100644
index 0000000..d154c18
--- /dev/null
+++ b/backend/app/request_limits.py
@@ -0,0 +1,50 @@
+"""Bound security-sensitive request bodies before JSON/multipart parsing."""
+
+from starlette.exceptions import HTTPException
+from starlette.responses import JSONResponse
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+
+# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart
+# envelope; count streamed chunks as well as checking the untrusted header.
+RESTORE_BODY_LIMIT = 34 * 1024 * 1024
+BOOTSTRAP_BODY_LIMIT = 16 * 1024
+
+
+class InstallationBodyLimitMiddleware:
+ def __init__(self, app: ASGIApp) -> None:
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http" or scope.get("method") != "POST":
+ await self.app(scope, receive, send)
+ return
+ path = scope.get("path", "").rstrip("/")
+ limit = {
+ "/admin/backups/restore": RESTORE_BODY_LIMIT,
+ "/admin/backups/export": BOOTSTRAP_BODY_LIMIT,
+ "/setup/bootstrap": BOOTSTRAP_BODY_LIMIT,
+ }.get(path)
+ if limit is None:
+ await self.app(scope, receive, send)
+ return
+ headers = dict(scope.get("headers", []))
+ try:
+ length = int(headers.get(b"content-length", b"0"))
+ except ValueError:
+ length = -1
+ if length < 0 or length > limit:
+ await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send)
+ return
+ received = 0
+
+ async def bounded_receive() -> Message:
+ nonlocal received
+ message = await receive()
+ if message["type"] == "http.request":
+ received += len(message.get("body", b""))
+ if received > limit:
+ raise HTTPException(status_code=413, detail="Request body is too large.")
+ return message
+
+ await self.app(scope, bounded_receive, send)
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
new file mode 100644
index 0000000..5119538
--- /dev/null
+++ b/backend/app/routers/admin.py
@@ -0,0 +1,2164 @@
+from ..feature_access import permissions, update_permissions
+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 normalize_banner_color, settings as env_settings
+from ..api_models import COMMON_ERROR_RESPONSES
+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,
+ set_setting,
+ set_user_blocked,
+ delete_user_data_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,
+ increment_user_auth_version,
+ 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,
+ 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,
+ rotate_signup_invite_code,
+ delete_signup_invite,
+ get_signup_invite_by_code,
+ disable_signup_invites_by_creator,
+ delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
+)
+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 (
+ get_cached_jellyfin_users,
+ get_cached_jellyseerr_users,
+ 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)],
+ responses=COMMON_ERROR_RESPONSES,
+)
+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",
+ )
+
+
+def _optional_recipient_email(value: object) -> Optional[str]:
+ if value is None or (isinstance(value, str) and not value.strip()):
+ return None
+ normalized = normalize_delivery_email(value)
+ if normalized:
+ return normalized
+ raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
+
+SENSITIVE_KEYS = {
+ "jellystat_api_key",
+ "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",
+ "bazarr_api_key",
+ "prowlarr_api_key",
+ "qbittorrent_password",
+}
+
+URL_SETTING_KEYS = {
+ "jellystat_base_url",
+ "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",
+ "bazarr_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",
+}
+
+BANNER_COLOR_SETTING_KEYS = {
+ "site_banner_background_color",
+ "site_banner_border_color",
+}
+
+SETTING_KEYS: List[str] = [
+ "jellystat_base_url",
+ "jellystat_api_key",
+ "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",
+ "bazarr_base_url",
+ "bazarr_api_key",
+ "bazarr_default_language",
+ "prowlarr_base_url",
+ "prowlarr_api_key",
+ "qbittorrent_base_url",
+ "qbittorrent_username",
+ "qbittorrent_password",
+ "log_level",
+ "log_format",
+ "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_stage_refresh_minutes",
+ "requests_delta_sync_interval_minutes",
+ "requests_full_sync_time",
+ "requests_cleanup_time",
+ "requests_cleanup_days",
+ "requests_data_source",
+ "issue_confirmation_contact_attempts",
+ "issue_confirmation_interval_value",
+ "issue_confirmation_interval_unit",
+ "site_banner_enabled",
+ "site_banner_message",
+ "site_banner_tone",
+ "site_banner_background_color",
+ "site_banner_border_color",
+ "site_login_message",
+ "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]:
+ from ..installation_origin import managed_runtime, normalize_application_origin
+ if managed_runtime() and "magent_application_url" in payload:
+ try:
+ payload = {**payload, "magent_application_url": normalize_application_origin(payload["magent_application_url"])}
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ 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 == "requests_stage_refresh_minutes":
+ try:
+ interval = int(value_to_store)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="Local stage refresh must be a whole number from 1 to 1440 minutes") from exc
+ if not 1 <= interval <= 1440:
+ raise HTTPException(status_code=400, detail="Local stage refresh must be from 1 to 1440 minutes")
+ value_to_store = str(interval)
+ if key == "issue_confirmation_contact_attempts":
+ try:
+ attempts = int(value_to_store)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
+ if attempts < 0 or attempts > 10:
+ raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
+ value_to_store = str(attempts)
+ if key == "issue_confirmation_interval_value":
+ try:
+ interval_value = int(value_to_store)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
+ if interval_value < 1 or interval_value > 365:
+ raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
+ value_to_store = str(interval_value)
+ if key == "issue_confirmation_interval_unit":
+ value_to_store = value_to_store.lower()
+ if value_to_store not in {"days", "weeks", "months"}:
+ raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
+ if key in BANNER_COLOR_SETTING_KEYS:
+ normalized_color = normalize_banner_color(value_to_store)
+ if not normalized_color:
+ raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must be a six-digit hex colour such as #ffc857")
+ value_to_store = normalized_color
+ 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_format", "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,
+ log_format=runtime.log_format,
+ )
+ 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")
+ 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}
+
+ from ..services.jellyfin_sync import sync_jellyfin_users
+ imported = await sync_jellyfin_users()
+ return {"status": "ok", "matched": len(jellyseerr_users), "skipped": 0, "imported": imported, "total": len(jellyseerr_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}
+
+ from ..services.jellyfin_sync import sync_jellyfin_users
+ imported = await sync_jellyfin_users()
+ return {"status": "ok", "imported": imported, "cleared": 0}
+
+@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, "features": permissions(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, "features": permissions(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, "features": permissions(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":
+ deletion = delete_user_data_by_username(username)
+ deleted = bool(deletion.get("deleted"))
+ result["local"] = {
+ "status": "ok" if deleted else "not_found",
+ "deleted": bool(deleted),
+ "data_cleanup": deletion,
+ }
+
+ 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}/email")
+async def update_user_email(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")
+
+ email = _optional_recipient_email(payload.get("email"))
+ if email:
+ duplicate = next(
+ (
+ candidate
+ for candidate in get_all_users()
+ if str(candidate.get("username") or "").casefold() != username.casefold()
+ and str(candidate.get("email") or "").strip().casefold() == email.casefold()
+ ),
+ None,
+ )
+ if duplicate:
+ raise HTTPException(status_code=409, detail="That email address is already assigned to another user")
+
+ if not set_user_email(username, email):
+ raise HTTPException(status_code=404, detail="User not found")
+ refreshed = get_user_by_username(username)
+ logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email))
+ return {"status": "ok", "user": refreshed, "email": email}
+
+
+@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)
+ increment_user_auth_version(username)
+ 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"))
+ if not invite.get("enabled"):
+ operational_state = "disabled"
+ state_label = "Disabled"
+ attention_reason = "This invite has been switched off."
+ elif invite.get("is_expired"):
+ operational_state = "expired"
+ state_label = "Expired"
+ attention_reason = "The invite has passed its expiry date."
+ elif invite.get("remaining_uses") == 0:
+ operational_state = "exhausted"
+ state_label = "Fully used"
+ attention_reason = "Every permitted sign-up has been used."
+ elif invite.get("profile_id") is not None and (
+ profile is None or profile.get("is_active") is False
+ ):
+ operational_state = "profile_unavailable"
+ state_label = "Profile unavailable"
+ attention_reason = "The assigned profile is missing or disabled."
+ else:
+ operational_state = "ready"
+ state_label = "Ready to use"
+ attention_reason = None
+ results.append(
+ {
+ **invite,
+ "operational_state": operational_state,
+ "state_label": state_label,
+ "attention_reason": attention_reason,
+ "profile": (
+ {
+ "id": profile.get("id"),
+ "name": profile.get("name"),
+ }
+ if profile
+ else None
+ ),
+ }
+ )
+ return {
+ "invites": results,
+ "summary": {
+ "total": len(results),
+ "ready": sum(1 for invite in results if invite["operational_state"] == "ready"),
+ "attention": sum(1 for invite in results if invite["operational_state"] != "ready"),
+ "used_signups": sum(int(invite.get("use_count") or 0) for invite in results),
+ "with_recipient": sum(1 for invite in results if invite.get("recipient_email")),
+ },
+ }
+
+
+@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"))
+
+ if template_key == 'invited':
+ if not invite:
+ raise HTTPException(status_code=400, detail='Choose an invitation before sending it.')
+ if int(invite.get('use_count') or 0) > 0:
+ raise HTTPException(status_code=400, detail='This invitation has already been used. Create a new invitation.')
+ if invite.get('recipient_email') and normalize_delivery_email(invite['recipient_email']) != recipient_email:
+ raise HTTPException(status_code=400, detail='This invitation belongs to a different recipient. Create a new invitation.')
+ invite = update_signup_invite(
+ int(invite['id']), code=invite['code'], label=invite.get('label'),
+ description=invite.get('description'), profile_id=invite.get('profile_id'),
+ role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
+ expires_at=invite.get('expires_at'), recipient_email=recipient_email,
+ )
+ if not invite:
+ raise HTTPException(status_code=404, detail='Invite not found')
+ invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code())
+ if not invite:
+ raise HTTPException(status_code=409, detail='Invite is unavailable')
+
+ 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 invite_id=%s username=%s",
+ template_key,
+ 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 = _optional_recipient_email(payload.get("recipient_email"))
+ send_email = bool(payload.get("send_email"))
+ if send_email and not recipient_email:
+ raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
+ 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 label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
+ invite.get("id"),
+ invite.get("label"),
+ invite.get("profile_id"),
+ invite.get("role"),
+ invite.get("max_uses"),
+ invite.get("enabled"),
+ bool(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")
+ requested_code = _normalize_optional_text(payload.get("code"))
+ if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite":
+ code = _normalize_invite_code(requested_code)
+ else:
+ code = str(existing.get("code") or "")
+ 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 = _optional_recipient_email(payload.get("recipient_email"))
+ send_email = bool(payload.get("send_email"))
+ if send_email and not recipient_email:
+ raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
+ 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:
+ rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
+ if not rotated:
+ raise ValueError("Invite is unavailable")
+ invite = rotated
+ 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 label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
+ invite.get("id"),
+ invite.get("label"),
+ invite.get("profile_id"),
+ invite.get("role"),
+ invite.get("max_uses"),
+ invite.get("enabled"),
+ bool(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.post("/invites/{invite_id}/rotate")
+async def rotate_invite(
+ invite_id: int,
+ current_user: Dict[str, Any] = Depends(require_admin),
+) -> Dict[str, Any]:
+ invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
+ if not invite:
+ raise HTTPException(status_code=409, detail="Invite is unavailable")
+ logger.info(
+ "Admin rotated invite: invite_id=%s actor=%s",
+ invite_id,
+ current_user.get("username"),
+ )
+ return {"status": "ok", "invite": invite}
+
+
+@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}
+
+
+@router.put("/users/features/bulk")
+async def bulk_feature_permissions(payload: Dict[str, Any]) -> dict:
+ try:
+ updated = update_permissions(payload)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"updated": updated, "scope": "non-admin-users"}
+
+
+@router.put("/users/{username}/features")
+async def user_feature_permissions(username: str, payload: Dict[str, Any]) -> dict:
+ 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="Administrators always have all features")
+ try:
+ update_permissions(payload, username)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"features": permissions(get_user_by_username(username))}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..0caf5d6
--- /dev/null
+++ b/backend/app/routers/auth.py
@@ -0,0 +1,1533 @@
+from ..feature_guards import require_invites
+from datetime import datetime, timedelta, timezone
+import logging
+import secrets
+import string
+
+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,
+ get_all_users,
+ 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,
+ rotate_signup_invite_code,
+ delete_signup_invite,
+ reserve_signup_invite_use,
+ release_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,
+ increment_user_auth_version,
+ get_rate_limit_status,
+ record_rate_limit_event,
+ clear_rate_limit_events,
+)
+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 ..api_models import (
+ COMMON_ERROR_RESPONSES,
+ ChangePasswordRequest,
+ ForgotPasswordRequest,
+ PasswordResetRequest,
+ ProfileEmailUpdateRequest,
+ SignupRequest,
+ request_data,
+)
+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"], responses=COMMON_ERROR_RESPONSES)
+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."
+)
+
+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 _optional_recipient_email(value: object) -> str | None:
+ if value is None or not str(value).strip():
+ return None
+ return _require_recipient_email(value)
+
+
+def _optional_account_email(value: object) -> str | None:
+ if value is None or not str(value).strip():
+ return None
+ normalized = normalize_delivery_email(value)
+ if normalized:
+ return normalized
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Enter 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 _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:
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ record_rate_limit_event("login-ip", ip_key)
+ record_rate_limit_event("login-user", user_key)
+ logger.warning("login failure recorded")
+
+
+def _clear_login_failures(request: Request, username: str) -> None:
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ clear_rate_limit_events("login-ip", ip_key)
+ clear_rate_limit_events("login-user", user_key)
+
+
+def _enforce_login_rate_limit(request: Request, username: str) -> None:
+ 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)
+ ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
+ user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
+ exceeded = ip_exceeded or user_exceeded
+ retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
+ if exceeded:
+ logger.warning(
+ "login rate limit exceeded retry_after=%s", 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:
+ ip_key = _auth_client_ip(request)
+ identifier_key = _password_reset_rate_key_identifier(identifier)
+ record_rate_limit_event("reset-ip", ip_key)
+ record_rate_limit_event("reset-identifier", identifier_key)
+ logger.info("password reset rate event recorded")
+
+
+def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
+ 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)
+ ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
+ identifier_exceeded, identifier_retry = get_rate_limit_status(
+ "reset-identifier", identifier_key, window, max_identifier
+ )
+ exceeded = ip_exceeded or identifier_exceeded
+ retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
+ if exceeded:
+ logger.warning(
+ "password reset rate limit exceeded retry_after=%s", 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"),
+ "code_available": bool(invite.get("code_available")),
+ "email_bound": bool(invite.get("recipient_email")),
+ "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"),
+ "code_available": bool(invite.get("code_available")),
+ "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"),
+ "code_available": bool(invite.get("code_available")),
+ "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"], auth_version=int(user.get("auth_version") or 1)
+ )
+ _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", auth_version=int(user.get("auth_version") or 1)
+ )
+ _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")
+ from ..services.jellyfin_identity import user_for_identity
+ identity_owner = user_for_identity(auth_response['User'].get('Id'), runtime.jellyfin_base_url)
+ if identity_owner:
+ preferred_match = identity_owner
+ user = identity_owner
+ canonical_username = identity_owner['username']
+ _assert_user_can_login(user)
+ 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
+ from ..services.jellyfin_identity import link_user
+
+ jellyfin_id = client._extract_user_id(auth_response)
+ if jellyfin_id:
+ link_user(canonical_username, jellyfin_id, runtime.jellyfin_base_url)
+ 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)
+ refreshed_user = get_user_by_username(canonical_username) or user or {}
+ token = create_access_token(
+ canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
+ )
+ _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)
+ id_matches = [row for row in get_all_users() if jellyseerr_user_id is not None and row.get('jellyseerr_user_id') == jellyseerr_user_id]
+ if len(id_matches) > 1:
+ raise HTTPException(409, 'Multiple Magent accounts claim this Seerr identity. Ask an administrator to repair the links.')
+ ci_matches = get_users_by_username_ci(form_data.username)
+ preferred_match = id_matches[0] if id_matches else _pick_preferred_ci_user_match(ci_matches, form_data.username)
+ if preferred_match and preferred_match.get('jellyseerr_user_id') not in (None, jellyseerr_user_id):
+ raise HTTPException(409, 'The account name and authenticated identity disagree. Ask an administrator to repair the links.')
+ 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)
+ refreshed_user = get_user_by_username(canonical_username) or user or {}
+ token = create_access_token(
+ canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
+ )
+ _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, current_user: dict = Depends(get_current_user)
+) -> dict:
+ increment_user_auth_version(str(current_user.get("username") or ""))
+ 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,
+ auth_version=int(current_user.get("auth_version") or 1),
+ )
+ 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: SignupRequest, response: Response) -> dict:
+ payload = request_data(payload)
+ 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", username)
+
+ 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")
+
+ account_email = normalize_delivery_email(invite.get('recipient_email'))
+ if account_email:
+ supplied_email = str(payload.get('email') or '').strip()
+ if supplied_email and normalize_delivery_email(supplied_email) != account_email:
+ raise HTTPException(status_code=400, detail='This invitation is tied to the email address it was sent to.')
+ else:
+ account_email = normalize_delivery_email(payload.get('email'))
+ if not account_email:
+ raise HTTPException(status_code=400, detail='A valid email address is required to create your account.')
+
+ 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()
+
+ if not reserve_signup_invite_use(int(invite['id'])):
+ raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.')
+ account_created = False
+ try:
+ 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=account_email,
+ 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=f"invite:{invite.get('id')}",
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+ account_created = True
+ 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)
+ refreshed_user = get_user_by_username(username) or created_user or {}
+ token = create_access_token(
+ username, role, auth_version=int(refreshed_user.get("auth_version") or 1)
+ )
+ set_last_login(username)
+ logger.info(
+ "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%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("id"),
+ )
+ 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,
+ },
+ )
+ finally:
+ if not account_created:
+ release_signup_invite_use(int(invite['id']))
+
+
+@router.post("/password/forgot")
+async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
+ payload = request_data(payload)
+ 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)
+ logger.info("password reset requested")
+ 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",
+ reset_result.get("username"),
+ reset_result.get("auth_provider"),
+ )
+ else:
+ logger.info(
+ "password reset request completed with no eligible account",
+ )
+ except Exception as exc:
+ logger.warning(
+ "password reset email dispatch failed detail=%s", type(exc).__name__,
+ )
+ 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: PasswordResetRequest) -> dict:
+ payload = request_data(payload)
+ 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.put("/profile/email")
+async def update_profile_email(
+ payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
+) -> dict:
+ payload = request_data(payload)
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+
+ email = _optional_account_email(payload.get("email"))
+ if email:
+ duplicate = next(
+ (
+ candidate
+ for candidate in get_all_users()
+ if str(candidate.get("username") or "").casefold() != username.casefold()
+ and str(candidate.get("email") or "").strip().casefold() == email.casefold()
+ ),
+ None,
+ )
+ if duplicate:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="That email address is already assigned to another account.",
+ )
+
+ if not set_user_email(username, email):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+ logger.info("User updated profile contact email: username=%s email_set=%s", username, bool(email))
+ return {"status": "ok", "email": email}
+
+
+@router.get("/profile/invites", dependencies=[Depends(require_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
+ send_email = bool(payload.get("send_email"))
+ recipient_email = _optional_recipient_email(recipient_email)
+ if send_email and not recipient_email:
+ recipient_email = _require_recipient_email(recipient_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")
+ if (
+ isinstance(requested_code, str)
+ and requested_code.strip()
+ and not requested_code.strip().startswith("••••")
+ and requested_code.strip() != "Protected invite"
+ ):
+ 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
+ send_email = bool(payload.get("send_email"))
+ recipient_email = _optional_recipient_email(recipient_email)
+ if send_email and not recipient_email:
+ recipient_email = _require_recipient_email(recipient_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:
+ rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
+ if not rotated:
+ raise ValueError("Invite is unavailable")
+ invite = rotated
+ 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.post("/profile/invites/{invite_id}/rotate")
+async def rotate_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)
+ invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable")
+ return {"status": "ok", "invite": _serialize_self_invite(invite)}
+
+
+@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: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
+) -> dict:
+ payload = request_data(payload)
+ 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)
+ increment_user_auth_version(username)
+ 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/backups.py b/backend/app/routers/backups.py
new file mode 100644
index 0000000..929a9a6
--- /dev/null
+++ b/backend/app/routers/backups.py
@@ -0,0 +1,85 @@
+"""Administrator-only encrypted backup downloads and staged restores."""
+
+from typing import Literal
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
+from fastapi.responses import Response
+from pydantic import BaseModel, ConfigDict, Field, SecretStr
+from starlette.concurrency import run_in_threadpool
+
+from ..auth import require_admin
+from ..db import get_rate_limit_status, record_rate_limit_event
+from ..services import backups
+
+def _no_store(response: Response) -> None:
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["Pragma"] = "no-cache"
+
+
+router = APIRouter(
+ prefix="/admin/backups", tags=["backups"],
+ dependencies=[Depends(require_admin), Depends(_no_store)],
+)
+
+
+class ExportRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ passphrase: SecretStr = Field(min_length=12, max_length=1024)
+ include_cache: bool = False
+
+
+def _rate_limit(user: dict) -> None:
+ key = str(user["username"])
+ exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
+ if exceeded:
+ raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
+ record_rate_limit_event("backups", key)
+
+
+@router.get("")
+def status() -> dict:
+ return backups.backup_status()
+
+
+@router.post("/export")
+def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
+ _rate_limit(user)
+ try:
+ content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
+ except backups.BackupError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ return Response(content, media_type="application/octet-stream", headers={
+ "Content-Disposition": f'attachment; filename="{filename}"',
+ "Cache-Control": "no-store", "Pragma": "no-cache",
+ })
+
+
+@router.post("/restore", status_code=202)
+async def restore(
+ file: UploadFile = File(...),
+ passphrase: str = Form(..., min_length=12, max_length=1024),
+ confirmation: Literal["RESTORE"] = Form(...),
+ user: dict = Depends(require_admin),
+) -> dict:
+ _rate_limit(user)
+ try:
+ if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
+ raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
+ metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
+ except backups.BackupError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ finally:
+ await file.close()
+ return {
+ "status": "staged", "restart_required": True, "backup": metadata,
+ "message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
+ }
+
+
+@router.delete("/restore")
+def cancel() -> dict:
+ try:
+ backups.cancel_restore()
+ except backups.BackupError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ return {"status": "cancelled"}
diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py
new file mode 100644
index 0000000..b2f5c9f
--- /dev/null
+++ b/backend/app/routers/branding.py
@@ -0,0 +1,152 @@
+import os
+import warnings
+from io import BytesIO
+from typing import Any, Dict
+
+from fastapi import APIRouter, HTTPException, UploadFile
+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()
+_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
+_MAX_IMAGE_PIXELS = 25_000_000
+_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
+_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
+
+
+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]:
+ content_type = str(file.content_type or "").lower()
+ extension = os.path.splitext(str(file.filename or ""))[1].lower()
+ if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS:
+ raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.")
+ content = await file.read(_MAX_UPLOAD_BYTES + 1)
+ if not content:
+ raise HTTPException(status_code=400, detail="Uploaded file is empty.")
+ if len(content) > _MAX_UPLOAD_BYTES:
+ raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).")
+ try:
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
+ candidate = Image.open(BytesIO(content))
+ if candidate.format not in {"PNG", "JPEG", "WEBP"}:
+ raise ValueError("Unsupported image format")
+ if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
+ raise Image.DecompressionBombError("Image pixel limit exceeded")
+ candidate.verify()
+ image = Image.open(BytesIO(content))
+ image.load()
+ except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) 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..638689b
--- /dev/null
+++ b/backend/app/routers/events.py
@@ -0,0 +1,245 @@
+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 ..feature_guards import require_request_stream, check
+from ..feature_access import permissions
+from ..db import get_user_by_username
+from . import requests as requests_router
+
+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(require_request_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
+ next_recent_at = 0.0
+ heartbeat_counter = 0
+
+ while True:
+ if await request.is_disconnected():
+ break
+
+ try:
+ account = get_user_by_username(user.get("username", ""))
+ if not account or account.get("is_blocked") or account.get("is_expired"):
+ break
+ check({**account, "features": permissions(account)}, "requests")
+ except HTTPException:
+ 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 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(require_request_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
+
+ try:
+ account = get_user_by_username(user.get("username", ""))
+ if not account or account.get("is_blocked") or account.get("is_expired"):
+ break
+ check({**account, "features": permissions(account)}, "requests")
+ except HTTPException:
+ 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/identities.py b/backend/app/routers/identities.py
new file mode 100644
index 0000000..017b3d1
--- /dev/null
+++ b/backend/app/routers/identities.py
@@ -0,0 +1,99 @@
+from fastapi import APIRouter, Depends, Response
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from ..auth import require_admin
+from ..services.identity_review import confirm_identities, review_identities, resolve_identity, repair_identity
+from ..services.duplicate_accounts import repair_duplicates
+
+router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
+
+
+class Confirmation(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ revision: str = Field(pattern=r"^[a-f0-9]{64}$")
+ user_ids: list[int] = Field(min_length=1, max_length=3000)
+
+ @field_validator("user_ids")
+ @classmethod
+ def unique_positive_ids(cls, value):
+ if any(user_id <= 0 for user_id in value) or len(set(value)) != len(value):
+ raise ValueError("Choose unique positive user IDs")
+ return value
+
+
+@router.get("")
+async def review(response: Response):
+ response.headers["Cache-Control"] = "no-store"
+ report, _, _ = await review_identities()
+ return report
+
+
+@router.post("/confirm")
+async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
+ response.headers["Cache-Control"] = "no-store"
+ return await confirm_identities(payload.revision, payload.user_ids, admin)
+
+
+class Resolution(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ user_id: int = Field(gt=0, strict=True)
+ jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
+
+
+class ResolutionConfirmation(Resolution):
+ revision: str = Field(pattern=r"^[a-f0-9]{64}$")
+
+
+@router.post("/resolve/check")
+async def check_resolution(payload: Resolution, response: Response):
+ response.headers["Cache-Control"] = "no-store"
+ return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
+
+
+@router.post("/resolve/confirm")
+async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
+ response.headers["Cache-Control"] = "no-store"
+ return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
+
+
+class RepairResolution(Resolution):
+ create_seerr: bool = Field(default=False, strict=True)
+
+
+class RepairConfirmation(RepairResolution):
+ revision: str = Field(pattern=r'^[a-f0-9]{64}$')
+
+
+@router.post('/repair/check')
+async def check_repair(payload: RepairResolution, response: Response):
+ response.headers['Cache-Control'] = 'no-store'
+ return await repair_identity(payload.user_id, payload.jellyfin_user_id, create_seerr=payload.create_seerr)
+
+
+@router.post('/repair/confirm')
+async def confirm_repair(payload: RepairConfirmation, response: Response, admin: dict = Depends(require_admin)):
+ response.headers['Cache-Control'] = 'no-store'
+ return await repair_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin, payload.create_seerr)
+
+
+class DuplicateCheck(BaseModel):
+ model_config = ConfigDict(extra='forbid')
+ user_id: int = Field(gt=0, strict=True)
+ keep_id: int | None = Field(default=None, gt=0, strict=True)
+
+
+class DuplicateConfirmation(DuplicateCheck):
+ keep_id: int = Field(gt=0, strict=True)
+ revision: str = Field(pattern=r'^[a-f0-9]{64}$')
+
+
+@router.post('/duplicates/check')
+async def check_duplicates(payload: DuplicateCheck, response: Response):
+ response.headers['Cache-Control'] = 'no-store'
+ return await repair_duplicates(payload.user_id, payload.keep_id)
+
+
+@router.post('/duplicates/confirm')
+async def confirm_duplicates(payload: DuplicateConfirmation, response: Response, admin: dict = Depends(require_admin)):
+ response.headers['Cache-Control'] = 'no-store'
+ return await repair_duplicates(payload.user_id, payload.keep_id, payload.revision, admin)
diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py
new file mode 100644
index 0000000..c64c66c
--- /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
+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/insights.py b/backend/app/routers/insights.py
new file mode 100644
index 0000000..6c3f616
--- /dev/null
+++ b/backend/app/routers/insights.py
@@ -0,0 +1,78 @@
+from ..feature_guards import require_stats
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from ..auth import get_current_user
+from ..clients.jellystat import HistoryLimitError, JellystatError
+from ..services.insights import get_insights
+from ..services.insights_artwork import get_artwork
+from ..services.monthly_reports import get_monthly_report, report_csv
+from ..runtime import get_runtime_settings
+
+router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
+
+
+class MonthlyReportQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ month: str | None = Field(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$")
+
+
+async def monthly_data(user: dict, month: str | None) -> dict:
+ try:
+ return await get_monthly_report(user, month)
+ except ValueError as exc:
+ raise HTTPException(422, "Choose the current month or one of the previous 23 months.") from exc
+ except HistoryLimitError as exc:
+ raise HTTPException(422, "This report exceeds Jellystat's history limit. No partial report has been generated.") from exc
+ except JellystatError as exc:
+ raise HTTPException(502, "Your monthly report is temporarily unavailable. Please try again shortly.") from exc
+
+
+@router.get("/reports/monthly")
+async def monthly_report(query: Annotated[MonthlyReportQuery, Query()], response: Response,
+ user: dict = Depends(get_current_user)) -> dict:
+ response.headers["Cache-Control"] = "no-store"
+ return await monthly_data(user, query.month)
+
+
+@router.get("/reports/monthly.csv")
+async def monthly_export(query: Annotated[MonthlyReportQuery, Query()], user: dict = Depends(get_current_user)):
+ report = await monthly_data(user, query.month)
+ if report["state"] != "ready":
+ raise HTTPException(409, "Connect Jellystat and link your viewing account before downloading a report.")
+ return Response(report_csv(report), media_type="text/csv; charset=utf-8", headers={
+ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
+ "Content-Disposition": f'attachment; filename="magent-monthly-report-{report["month"]}.csv"'})
+
+
+@router.get("/artwork/{item_id}")
+async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
+ content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
+ return Response(content=content, media_type=media_type,
+ headers={"Cache-Control": "private, max-age=600", "Vary": "Cookie, Authorization", "X-Content-Type-Options": "nosniff"})
+
+
+class InsightsQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ days: int = 30
+
+ @field_validator("days")
+ @classmethod
+ def supported_period(cls, value: int) -> int:
+ if value not in {7, 30, 90, 365}:
+ raise ValueError("Choose 7, 30, 90 or 365 days")
+ return value
+
+
+@router.get("")
+async def dashboard(query: Annotated[InsightsQuery, Query()], response: Response,
+ user: dict = Depends(get_current_user)) -> dict:
+ response.headers["Cache-Control"] = "no-store"
+ try:
+ return await get_insights(user, query.days)
+ except HistoryLimitError as exc:
+ raise HTTPException(status_code=422, detail="There is too much history for this period. Choose a shorter period.") from exc
+ except JellystatError as exc:
+ raise HTTPException(status_code=502, detail="Your viewing stats are temporarily unavailable. Please try again shortly.") from exc
diff --git a/backend/app/routers/newsletters.py b/backend/app/routers/newsletters.py
new file mode 100644
index 0000000..5e7d025
--- /dev/null
+++ b/backend/app/routers/newsletters.py
@@ -0,0 +1,193 @@
+import time
+from datetime import datetime, timezone
+from typing import Literal
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response
+from pydantic import Field, field_validator
+
+from ..services.public_urls import magent_public_url
+from ..auth import get_current_user, require_admin
+from ..runtime import get_runtime_settings
+from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
+from .recaps import StrictPayload, Preference, RecapSettings, TokenAction, no_cache
+
+router = APIRouter(tags=['newsletters'], dependencies=[Depends(no_cache)])
+
+
+class Settings(StrictPayload):
+ enabled: bool
+ weekday: int = Field(ge=0, le=6)
+ hour: int = Field(ge=0, le=23)
+ limit_titles: int = Field(ge=1, le=24)
+ public_url: str = Field(default="", max_length=500)
+ intro: str = Field(default='', max_length=2000)
+ revision: int = Field(ge=1)
+ _url = field_validator('public_url')(RecapSettings.origin_only.__func__)
+
+
+class NewDraft(StrictPayload):
+ days: Literal[7, 14, 30] = 7
+
+
+class Selection(StrictPayload):
+ id: str = Field(pattern=r'^[a-f0-9]{32}$')
+ selected: bool
+ featured: bool
+
+
+class Version(StrictPayload):
+ revision: int = Field(ge=1)
+
+
+class EditionUpdate(Version):
+ subject: str = Field(min_length=1, max_length=150)
+ intro: str = Field(default='', max_length=2000)
+ titles: list[Selection] = Field(max_length=60)
+
+ @field_validator('subject')
+ @classmethod
+ def subject_line(cls, value):
+ value = value.strip()
+ if not value or any(ord(char) < 32 or ord(char) == 127 for char in value):
+ raise ValueError('Use a single, non-empty subject line.')
+ return value
+
+
+class Test(Version):
+ request_id: UUID
+
+
+class Publish(Version):
+ send_at: datetime | None = None
+
+
+def fail(exc):
+ if isinstance(exc, service.NewsletterError):
+ raise HTTPException(exc.status, exc.detail) from exc
+ if isinstance(exc, store.Conflict):
+ raise HTTPException(429 if 'five minutes' in str(exc) else 409, str(exc)) from exc
+ raise HTTPException(502, str(exc) if isinstance(exc, catalog.CatalogError) else 'Jellyfin took too long to prepare this edition. Please try again.') from exc
+
+
+@router.get('/profile/newsletters')
+def preference(user: dict = Depends(get_current_user)):
+ try:
+ return service.preferences(user)
+ except service.NewsletterError as exc:
+ fail(exc)
+
+
+@router.put('/profile/newsletters')
+async def set_preference(payload: Preference, user: dict = Depends(get_current_user)):
+ try:
+ if payload.enabled:
+ return await service.subscribe(user)
+ store.disable(service.account_for(user)['id'])
+ return service.preferences(user)
+ except service.NewsletterError as exc:
+ fail(exc)
+
+
+@router.post('/newsletter-subscription/check')
+def check_token(payload: TokenAction):
+ try:
+ return service.token_action(payload.token, payload.action)
+ except service.NewsletterError as exc:
+ fail(exc)
+
+
+@router.post('/newsletter-subscription/confirm')
+def confirm_token(payload: TokenAction):
+ try:
+ return service.token_action(payload.token, payload.action, apply=True)
+ except service.NewsletterError as exc:
+ fail(exc)
+
+
+@router.get('/admin/newsletters')
+def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = Depends(require_admin)):
+ ready, detail = service.delivery_ready()
+ return {'settings': store.public_settings(), 'ready': ready, 'detail': detail,
+ 'playback_url': service.playback_url(get_runtime_settings()), **store.overview(offset)}
+
+
+@router.put('/admin/newsletters')
+def settings(payload: Settings, user: dict = Depends(require_admin)):
+ try:
+ public_url = magent_public_url(payload.public_url or store.settings()['public_url'])
+ ready, detail = service.delivery_ready(public_url)
+ if payload.enabled and not ready:
+ raise service.NewsletterError(detail)
+ return store.save_settings({**payload.model_dump(), "public_url": public_url}, datetime.now(timezone.utc))
+ except (service.NewsletterError, store.Conflict) as exc:
+ fail(exc)
+
+
+@router.post('/admin/newsletters/drafts', status_code=201)
+async def create_draft(payload: NewDraft, user: dict = Depends(require_admin)):
+ try:
+ return await service.create_draft(user, payload.days)
+ except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
+ fail(exc)
+
+
+@router.get('/admin/newsletters/editions/{identity}')
+def edition(identity: UUID, user: dict = Depends(require_admin)):
+ try:
+ return service.require_edition(identity.hex)
+ except service.NewsletterError as exc:
+ fail(exc)
+
+
+@router.put('/admin/newsletters/editions/{identity}')
+def update_edition(identity: UUID, payload: EditionUpdate, user: dict = Depends(require_admin)):
+ try:
+ return store.update_edition(identity.hex, payload.revision, payload.subject, payload.intro,
+ [entry.model_dump() for entry in payload.titles], time.time())
+ except store.Conflict as exc:
+ fail(exc)
+
+
+@router.post('/admin/newsletters/editions/{identity}/preview')
+async def preview(identity: UUID, payload: Version, user: dict = Depends(require_admin)):
+ try:
+ return await service.preview(identity.hex, payload.revision)
+ except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
+ fail(exc)
+
+
+@router.post('/admin/newsletters/editions/{identity}/test', status_code=202)
+def send_test(identity: UUID, payload: Test, user: dict = Depends(require_admin)):
+ try:
+ return service.queue_test(user, identity.hex, payload.revision, str(payload.request_id))
+ except (service.NewsletterError, store.Conflict) as exc:
+ fail(exc)
+
+
+@router.post('/admin/newsletters/editions/{identity}/publish', status_code=202)
+def publish(identity: UUID, payload: Publish, user: dict = Depends(require_admin)):
+ try:
+ return service.publish(identity.hex, payload.revision, payload.send_at)
+ except (service.NewsletterError, store.Conflict) as exc:
+ fail(exc)
+
+
+@router.post('/admin/newsletters/editions/{identity}/cancel')
+def cancel(identity: UUID, user: dict = Depends(require_admin)):
+ try:
+ service.require_edition(identity.hex)
+ return store.cancel(identity.hex, time.time())
+ except service.NewsletterError as exc:
+ fail(exc)
+
+
+@router.get('/admin/newsletters/artwork/{identity}')
+async def artwork(identity: UUID, user: dict = Depends(require_admin)):
+ runtime = get_runtime_settings()
+ if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
+ raise HTTPException(404, 'Artwork unavailable')
+ content = await catalog.poster(runtime, identity.hex)
+ if not content:
+ raise HTTPException(404, 'Artwork unavailable')
+ return Response(content=content, media_type='image/jpeg', headers={'Cache-Control': 'private, max-age=600'})
diff --git a/backend/app/routers/operations.py b/backend/app/routers/operations.py
new file mode 100644
index 0000000..48264fb
--- /dev/null
+++ b/backend/app/routers/operations.py
@@ -0,0 +1,19 @@
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..auth import get_current_user
+from ..services.operation_progress import get_operation
+
+
+router = APIRouter(
+ prefix="/operations",
+ tags=["operations"],
+ dependencies=[Depends(get_current_user)],
+)
+
+
+@router.get("/{operation_id}")
+async def operation_status(operation_id: str) -> dict:
+ operation = get_operation(operation_id)
+ if not operation:
+ raise HTTPException(status_code=404, detail="Operation not found")
+ return operation
diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py
new file mode 100644
index 0000000..6a48702
--- /dev/null
+++ b/backend/app/routers/portal.py
@@ -0,0 +1,1559 @@
+from __future__ import annotations
+from ..feature_guards import require_portal_access
+
+import logging
+import re
+import time
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional, Tuple
+
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from ..auth import get_current_user
+from ..api_models import COMMON_ERROR_RESPONSES
+from ..clients.jellyfin import JellyfinClient
+from ..db import (
+ add_portal_item_activity,
+ add_portal_comment,
+ count_portal_items,
+ create_portal_item,
+ delete_portal_item,
+ get_portal_item,
+ get_portal_overview,
+ list_portal_comments as _list_portal_comments,
+ get_all_users,
+ list_portal_item_activity,
+ list_portal_items,
+ update_portal_item,
+)
+from ..services.issue_resolution import (
+ begin_issue_confirmation,
+ issue_resolution_state,
+ respond_to_issue_confirmation,
+)
+from ..services.notifications import send_portal_notification
+from ..runtime import get_runtime_settings
+
+router = APIRouter(
+ prefix="/portal",
+ tags=["portal"],
+ dependencies=[Depends(get_current_user), Depends(require_portal_access)],
+ responses=COMMON_ERROR_RESPONSES,
+)
+logger = logging.getLogger(__name__)
+
+PORTAL_KINDS = {"request", "issue", "feature"}
+PORTAL_STATUSES = {
+ # Existing generic statuses
+ "new",
+ "triaging",
+ "planned",
+ "in_progress",
+ "blocked",
+ "done",
+ "declined",
+ "closed",
+ "awaiting_confirmation",
+ # 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",
+ "transcode",
+ "service_unavailable",
+ "broken_media",
+ "wrong_content",
+ "audio",
+ "subtitle",
+ "quality",
+ "metadata",
+ "missing_content",
+ "other",
+}
+
+_MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None}
+_MEDIA_STATUS_CACHE_SECONDS = 15.0
+
+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"
+
+
+ISSUE_WORKFLOW_STAGES = (
+ ("reported", "Reported"),
+ ("review", "Under review"),
+ ("planned", "Fix planned"),
+ ("repair", "Fix underway"),
+ ("confirmation", "Confirm fix"),
+ ("resolved", "Resolved"),
+)
+
+ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = {
+ "new": (
+ 0,
+ "Issue received",
+ "Your report has been logged and is waiting for the support team to review it.",
+ "active",
+ ),
+ "triaging": (
+ 1,
+ "Being investigated",
+ "The support team is checking the report and identifying the right fix.",
+ "active",
+ ),
+ "planned": (
+ 2,
+ "Fix ready to begin",
+ "The problem has been reviewed and the next action has been selected.",
+ "active",
+ ),
+ "in_progress": (
+ 3,
+ "Fix in progress",
+ "Work is underway on the affected content or service.",
+ "active",
+ ),
+ "blocked": (
+ 3,
+ "Fix needs attention",
+ "Work has paused because the support team needs another service, resource, or decision before continuing.",
+ "attention",
+ ),
+ "awaiting_confirmation": (
+ 4,
+ "Waiting for confirmation",
+ "A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.",
+ "active",
+ ),
+ "done": (
+ 5,
+ "Issue resolved",
+ "The reported problem has been fixed and the issue is complete.",
+ "complete",
+ ),
+ "closed": (
+ 5,
+ "Issue resolved",
+ "The reported problem has been fixed and the issue is closed.",
+ "complete",
+ ),
+}
+
+
+def _issue_workflow_payload(status: Any) -> Dict[str, Any]:
+ normalized_status = str(status or "new").strip().lower()
+ stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get(
+ normalized_status,
+ ISSUE_STATUS_TO_STAGE["new"],
+ )
+ steps = []
+ for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES):
+ step_state = (
+ "complete"
+ if index < stage_index or (index == stage_index and state == "complete")
+ else "active"
+ if index == stage_index
+ else "waiting"
+ )
+ if index == stage_index and state == "attention":
+ step_state = "attention"
+ steps.append({"key": key, "label": label, "state": step_state})
+ return {
+ "current_step": stage_index + 1,
+ "total_steps": len(ISSUE_WORKFLOW_STAGES),
+ "stage": ISSUE_WORKFLOW_STAGES[stage_index][0],
+ "stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1],
+ "headline": headline,
+ "message": message,
+ "state": state,
+ "steps": steps,
+ }
+
+
+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 _public_media_status_payload(
+ *,
+ status: str,
+ headline: str,
+ message: str,
+ latency_ms: Optional[int] = None,
+ version: Optional[str] = None,
+ restart_pending: Optional[bool] = None,
+ active_streams: Optional[int] = None,
+ transcoding_streams: Optional[int] = None,
+ session_check_available: bool = False,
+) -> Dict[str, Any]:
+ return {
+ "checked_at": datetime.now(timezone.utc).isoformat(),
+ "status": status,
+ "headline": headline,
+ "message": message,
+ "latency_ms": latency_ms,
+ "server": {
+ "version": version,
+ "restart_pending": restart_pending,
+ },
+ "activity": {
+ "active_streams": active_streams,
+ "transcoding_streams": transcoding_streams,
+ "available": session_check_available,
+ },
+ }
+
+
+def _public_text(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ identities = {str(u.get(key) or '').strip() for u in get_all_users() for key in ('username', 'email')}
+ identities.discard('')
+ if identities:
+ pattern = r'(? Dict[str, Any]:
+ return {
+ 'id': comment.get('id'), 'item_id': comment.get('item_id'),
+ 'author_username': 'Support team' if comment.get('author_role') == 'admin' else 'Reporter',
+ 'author_role': comment.get('author_role'), 'created_at': comment.get('created_at'),
+ 'message': _public_text(comment.get('message')), 'is_internal': False,
+ }
+
+
+def list_portal_comments(*args, **kwargs):
+ comments = _list_portal_comments(*args, **kwargs)
+ return comments if kwargs.get('include_internal') else [_public_comment(c) for c in comments if not c.get('is_internal')]
+
+
+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_delete": is_admin and str(item.get("kind") or "").lower() == "issue",
+ "can_raise_issue": str(item.get("kind") or "") == "request",
+ "can_confirm_resolution": (
+ str(item.get("kind") or "").lower() == "issue"
+ and str(item.get("status") or "").lower() == "awaiting_confirmation"
+ and (is_admin or is_owner)
+ ),
+ }
+ 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":
+ resolution = issue_resolution_state(item)
+ 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")),
+ "workflow": _issue_workflow_payload(item.get("status")),
+ "confirmation": {
+ "status": resolution.get("status"),
+ "attempts_sent": int(resolution.get("attemptsSent") or 0),
+ "maximum_attempts": int(resolution.get("maximumAttempts") or 0),
+ "last_contact_at": resolution.get("lastContactAt"),
+ "next_contact_at": resolution.get("nextContactAt"),
+ "interval_value": resolution.get("intervalValue"),
+ "interval_unit": resolution.get("intervalUnit"),
+ "last_delivery_succeeded": resolution.get("lastDeliverySucceeded"),
+ },
+ }
+ if not is_admin:
+ serialized = {key: value for key, value in serialized.items() if key in {
+ 'id', 'kind', 'title', 'description', 'media_type', 'year', 'source_request_id',
+ 'related_item_id', 'status', 'workflow_request_status', 'workflow_media_status',
+ 'issue_type', 'issue_resolved_at', 'priority', 'created_at', 'updated_at',
+ 'last_activity_at', 'permissions', 'workflow', 'issue',
+ }}
+ serialized['created_by_username'] = user.get('username') if is_owner else 'Another member'
+ serialized['title'] = _public_text(serialized.get('title'))
+ serialized['description'] = _public_text(serialized.get('description'))
+ return serialized
+
+
+def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -> list[Dict[str, Any]]:
+ activity = [
+ entry
+ for entry in list_portal_item_activity(int(item["id"]), limit=300)
+ if include_internal or entry.get("event_type") != "internal_note_added"
+ ]
+ if not any(entry.get("event_type") == "item_created" for entry in activity):
+ activity.insert(
+ 0,
+ {
+ "id": f"created-{item['id']}",
+ "item_id": item["id"],
+ "event_type": "item_created",
+ "actor_username": item.get("created_by_username") or "unknown",
+ "actor_role": "user",
+ "message": (
+ "Issue raised and added to the support queue."
+ if str(item.get("kind") or "").lower() == "issue"
+ else "Portal item created."
+ ),
+ "metadata_json": None,
+ "created_at": item.get("created_at"),
+ },
+ )
+ if include_internal:
+ return activity
+ public_activity: list[Dict[str, Any]] = []
+ for entry in activity:
+ public_entry = {key: value for key, value in entry.items() if key != "metadata_json"}
+ actor_role = str(entry.get("actor_role") or "user").lower()
+ public_entry["actor_username"] = (
+ "Magent"
+ if actor_role == "system"
+ else "Support team"
+ if actor_role == "admin"
+ else "Reporter"
+ )
+ public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user"
+ public_entry['message'] = _public_text(public_entry.get('message'))
+ public_activity.append(public_entry)
+ return public_activity
+
+
+def _record_activity(
+ item_id: int,
+ *,
+ event_type: str,
+ message: str,
+ user: Dict[str, Any],
+) -> None:
+ add_portal_item_activity(
+ item_id,
+ event_type=event_type,
+ actor_username=str(user.get("username") or "unknown"),
+ actor_role=str(user.get("role") or "user"),
+ message=message,
+ )
+
+
+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(kind: Optional[str] = None, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ kind = _normalize_choice(kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True)
+ mine = count_portal_items(kind=kind, mine_username=str(current_user.get("username") or ""))
+ return {
+ "overview": get_portal_overview(kind) if kind else get_portal_overview(),
+ "my_items": mine,
+ }
+
+
+@router.get("/issues/media-status")
+async def portal_media_status() -> Dict[str, Any]:
+ """Return a short, privacy-safe Jellyfin health check for guided issue reporting."""
+ now = time.monotonic()
+ cached_payload = _MEDIA_STATUS_CACHE.get("payload")
+ if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0):
+ return cached_payload
+
+ runtime = get_runtime_settings()
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not jellyfin.configured():
+ payload = _public_media_status_payload(
+ status="not_configured",
+ headline="Media server status is unavailable",
+ message="Magent cannot run a playback check right now. Your report can still be submitted.",
+ )
+ _MEDIA_STATUS_CACHE.update(
+ expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
+ payload=payload,
+ )
+ return payload
+
+ started_at = time.perf_counter()
+ try:
+ system_info = await jellyfin.get_system_info()
+ except (httpx.HTTPError, RuntimeError, ValueError):
+ latency_ms = round((time.perf_counter() - started_at) * 1000)
+ payload = _public_media_status_payload(
+ status="down",
+ headline="The media server is not responding",
+ message=(
+ "This looks broader than one title. The report will include the failed server check "
+ "so an administrator can investigate the service first."
+ ),
+ latency_ms=latency_ms,
+ )
+ _MEDIA_STATUS_CACHE.update(
+ expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
+ payload=payload,
+ )
+ return payload
+ except Exception:
+ logger.exception("guided issue Jellyfin system check failed")
+ latency_ms = round((time.perf_counter() - started_at) * 1000)
+ payload = _public_media_status_payload(
+ status="down",
+ headline="The media server check failed",
+ message="Your report can still be submitted and will include this failed service check.",
+ latency_ms=latency_ms,
+ )
+ _MEDIA_STATUS_CACHE.update(
+ expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
+ payload=payload,
+ )
+ return payload
+
+ latency_ms = round((time.perf_counter() - started_at) * 1000)
+ info = system_info if isinstance(system_info, dict) else {}
+ version_value = info.get("Version")
+ version = str(version_value).strip() if version_value is not None else None
+ restart_pending = bool(info.get("HasPendingRestart"))
+
+ session_check_available = False
+ active_streams: Optional[int] = None
+ transcoding_streams: Optional[int] = None
+ try:
+ sessions = await jellyfin.get_sessions()
+ if isinstance(sessions, list):
+ session_check_available = True
+ active_streams = sum(
+ 1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem")
+ )
+ transcoding_streams = sum(
+ 1
+ for session in sessions
+ if isinstance(session, dict)
+ and session.get("NowPlayingItem")
+ and session.get("TranscodingInfo")
+ )
+ except Exception:
+ logger.warning("guided issue Jellyfin session check unavailable", exc_info=True)
+
+ if restart_pending:
+ status = "degraded"
+ headline = "Media server is online but needs attention"
+ message = "Jellyfin is responding, but it reports that a restart is pending."
+ elif session_check_available and active_streams:
+ status = "up"
+ headline = "Media server is online and actively streaming"
+ message = (
+ "Other playback is currently working, so this is more likely specific to the title, "
+ "audio track, subtitle, client, or transcode path."
+ )
+ else:
+ status = "up"
+ headline = "Media server is online"
+ message = "Jellyfin responded normally. Continue with the report if playback is still failing."
+
+ payload = _public_media_status_payload(
+ status=status,
+ headline=headline,
+ message=message,
+ latency_ms=latency_ms,
+ version=version,
+ restart_pending=restart_pending,
+ active_streams=active_streams,
+ transcoding_streams=transcoding_streams,
+ session_check_available=session_check_available,
+ )
+ _MEDIA_STATUS_CACHE.update(
+ expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
+ payload=payload,
+ )
+ return payload
+
+
+@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,
+ )
+ _record_activity(
+ int(created["id"]),
+ event_type="item_created",
+ message=(
+ "Issue raised and added to the support queue."
+ if created.get("kind") == "issue"
+ else f"{str(created.get('kind') or 'Portal item').capitalize()} created."
+ ),
+ user=current_user,
+ )
+ 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,
+ "activity": _activity_payload(created, include_internal=_is_admin(current_user)),
+ }
+
+
+@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,
+ )
+ _record_activity(
+ int(created["id"]),
+ event_type="item_created",
+ message=f"Issue raised and linked to collection request #{item_id}.",
+ user=current_user,
+ )
+ 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,
+ "activity": _activity_payload(created, include_internal=_is_admin(current_user)),
+ "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,
+ "activity": _activity_payload(item, include_internal=_is_admin(current_user)),
+ }
+
+
+@router.delete("/items/{item_id}")
+async def portal_delete_item(
+ item_id: int,
+ 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 item not found")
+ if str(item.get("kind") or "").lower() != "issue":
+ raise HTTPException(status_code=400, detail="Only issues can be deleted here")
+ if not delete_portal_item(item_id):
+ raise HTTPException(status_code=404, detail="Issue not found")
+ logger.info(
+ "portal issue deleted id=%s title=%s actor=%s",
+ item_id,
+ item.get("title"),
+ current_user.get("username"),
+ )
+ return {"status": "deleted", "item_id": item_id}
+
+
+@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)
+ item_kind = str(item.get("kind") or "").lower()
+ 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 = item_kind
+ 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 == "closed":
+ updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
+ elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}:
+ 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,
+ "activity": _activity_payload(item, include_internal=is_admin),
+ }
+
+ updated = update_portal_item(item_id, **updates)
+ if not updated:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+
+ requested_issue_status = str(updates.get("status") or "").lower()
+ should_start_confirmation = item_kind == "issue" and (
+ (requested_issue_status == "done" and str(item.get("status") or "").lower() != "done")
+ or (
+ requested_issue_status == "awaiting_confirmation"
+ and str(item.get("status") or "").lower() != "awaiting_confirmation"
+ )
+ )
+ if should_start_confirmation:
+ try:
+ updated = await begin_issue_confirmation(
+ item_id,
+ actor_username=str(current_user.get("username") or "unknown"),
+ actor_role=str(current_user.get("role") or "admin"),
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
+ if changed_fields:
+ if item_kind == "issue" and not should_start_confirmation:
+ old_status = str(item.get("status") or "unknown").replace("_", " ")
+ new_status = str(updated.get("status") or "unknown").replace("_", " ")
+ activity_message = (
+ f"Status changed from {old_status} to {new_status}."
+ if item.get("status") != updated.get("status")
+ else f"Issue details updated: {', '.join(sorted(changed_fields))}."
+ )
+ _record_activity(
+ item_id,
+ event_type="status_changed" if item.get("status") != updated.get("status") else "issue_updated",
+ message=activity_message,
+ user=current_user,
+ )
+ 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,
+ "activity": _activity_payload(updated, include_internal=is_admin),
+ }
+
+
+@router.post("/issues/{item_id}/resolution-response")
+async def portal_issue_resolution_response(
+ 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 or str(item.get("kind") or "").lower() != "issue":
+ raise HTTPException(status_code=404, detail="Issue not found")
+ if not (_is_admin(current_user) or _is_owner(current_user, item)):
+ raise HTTPException(status_code=403, detail="Only the reporter or an admin can confirm this resolution")
+ if not isinstance(payload.get("resolved"), bool):
+ raise HTTPException(status_code=400, detail="resolved must be true or false")
+ try:
+ updated = respond_to_issue_confirmation(
+ item_id,
+ resolved=payload["resolved"],
+ actor_username=str(current_user.get("username") or "unknown"),
+ actor_role=str(current_user.get("role") or "user"),
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ await _notify(
+ event_type="portal_issue_resolution_confirmed" if payload["resolved"] else "portal_issue_resolution_rejected",
+ item=updated,
+ user=current_user,
+ note="resolved=true" if payload["resolved"] else "resolved=false",
+ )
+ return {
+ "item": _serialize_item(updated, current_user),
+ "comments": list_portal_comments(item_id, include_internal=_is_admin(current_user)),
+ "activity": _activity_payload(updated, include_internal=_is_admin(current_user)),
+ }
+
+
+@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,
+ )
+ if str(item.get("kind") or "").lower() == "issue":
+ _record_activity(
+ item_id,
+ event_type="internal_note_added" if is_internal else "comment_added",
+ message=(
+ f"Internal troubleshooting note: {message[:240]}"
+ if is_internal
+ else f"Support update: {message[:240]}"
+ ),
+ user=current_user,
+ )
+ 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 if is_admin else _public_comment(comment)}
diff --git a/backend/app/routers/recaps.py b/backend/app/routers/recaps.py
new file mode 100644
index 0000000..0fea8bf
--- /dev/null
+++ b/backend/app/routers/recaps.py
@@ -0,0 +1,144 @@
+from datetime import datetime, timezone
+from typing import Literal
+from urllib.parse import urlsplit
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from ..services.public_urls import magent_public_url
+from ..auth import require_admin
+from ..feature_guards import require_stats
+from ..services import email_recaps as recaps, recap_store as store
+
+
+def no_cache(response: Response):
+ response.headers["Cache-Control"] = "no-store"
+
+
+router = APIRouter(tags=["email-recaps"], dependencies=[Depends(no_cache)])
+
+
+class StrictPayload(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class Preference(StrictPayload):
+ enabled: bool
+ automatic_monthly: bool | None = Field(default=None, strict=True)
+
+
+class RecapSettings(StrictPayload):
+ enabled: bool
+ day: int = Field(ge=1, le=28)
+ hour: int = Field(ge=0, le=23)
+ public_url: str = Field(default="", max_length=500)
+
+ @field_validator("public_url")
+ @classmethod
+ def origin_only(cls, value: str) -> str:
+ value = value.strip().rstrip('/')
+ if not value:
+ return value
+ try:
+ url = urlsplit(value)
+ port = url.port
+ except ValueError as exc:
+ raise ValueError("Enter the public Magent address, such as https://magent.example.com.") from exc
+ if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
+ or url.path or url.query or url.fragment or any(char.isspace() or ord(char) < 33 for char in value)
+ or any(char in value for char in '<>"\\') or (port is not None and port < 1)):
+ raise ValueError("Enter a http(s) Magent address without a path, credentials or query.")
+ return value
+
+
+class TestEmail(StrictPayload):
+ month: str | None = Field(default=None, pattern=r"^[0-9]{4}-[0-9]{2}$")
+ request_id: UUID
+
+
+class TokenAction(StrictPayload):
+ token: str = Field(min_length=40, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
+ action: Literal["confirm", "unsubscribe"]
+
+
+def error(exc: recaps.RecapError):
+ raise HTTPException(exc.status, exc.detail) from exc
+
+
+@router.get("/profile/email-recaps")
+def preferences(user: dict = Depends(require_stats)) -> dict:
+ try:
+ return recaps.preferences(user)
+ except recaps.RecapError as exc:
+ error(exc)
+
+
+@router.put("/profile/email-recaps")
+async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
+ try:
+ if payload.enabled:
+ return await recaps.subscribe(user, payload.automatic_monthly)
+ store.disable(recaps.current_account(user)["id"])
+ return recaps.preferences(user)
+ except recaps.RecapError as exc:
+ error(exc)
+
+
+@router.post("/email-recaps/check")
+def check_token(payload: TokenAction) -> dict:
+ try:
+ return recaps.token_action(payload.token, payload.action)
+ except recaps.RecapError as exc:
+ error(exc)
+
+
+@router.post("/email-recaps/confirm")
+def apply_token(payload: TokenAction) -> dict:
+ try:
+ return recaps.token_action(payload.token, payload.action, apply=True)
+ except recaps.RecapError as exc:
+ error(exc)
+
+
+@router.get("/admin/email-recaps")
+def overview(offset: int = Query(default=0, ge=0), user: dict = Depends(require_admin)) -> dict:
+ ready, detail = recaps.delivery_ready()
+ months = recaps.month_periods(None, datetime.now(timezone.utc))["available_months"][1:]
+ return {"settings": store.settings(), "ready": ready, "detail": detail, "months": months,
+ "worker_enabled": recaps.worker_enabled(), **store.history(offset=offset)}
+
+
+@router.put("/admin/email-recaps")
+def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dict:
+ if payload.enabled:
+ # Validate against the proposed URL without writing any partial settings.
+ ready, detail = recaps.smtp_email_config_ready()
+ runtime = recaps.get_runtime_settings()
+ if not magent_public_url(payload.public_url or store.settings()["public_url"]) or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
+ raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
+ return store.save_settings({**payload.model_dump(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, datetime.now(timezone.utc))
+
+
+@router.get("/admin/email-recaps/preview")
+async def preview(month: str | None = Query(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$"), user: dict = Depends(require_admin)) -> dict:
+ try:
+ return await recaps.preview(user, month)
+ except recaps.RecapError as exc:
+ error(exc)
+
+
+@router.post("/admin/email-recaps/test", status_code=202)
+def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
+ try:
+ return recaps.queue_test(user, payload.month, str(payload.request_id))
+ except recaps.RecapError as exc:
+ error(exc)
+
+
+@router.post('/profile/email-recaps/send', status_code=202)
+def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
+ try:
+ return recaps.queue_personal(user, payload.month, str(payload.request_id))
+ except recaps.RecapError as exc:
+ error(exc)
diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py
new file mode 100644
index 0000000..8cfb934
--- /dev/null
+++ b/backend/app/routers/requests.py
@@ -0,0 +1,4010 @@
+from ..services import manual_releases
+from ..services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome, series_search_outcome
+from ..feature_guards import require_request_access
+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.bazarr import BazarrClient
+from ..ai.triage import triage_snapshot
+from ..auth import get_current_user
+from ..api_models import COMMON_ERROR_RESPONSES
+from ..runtime import get_runtime_settings
+from .images import cache_tmdb_image, is_tmdb_cached
+from ..db import (
+ get_request_stage_cache,
+ save_request_stage_cache,
+ add_portal_item_activity,
+ get_portal_item,
+ save_action,
+ get_recent_actions,
+ get_recent_snapshots,
+ 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_portal_item,
+ update_artwork_cache_stats,
+ cleanup_history,
+ is_seerr_media_failure_suppressed,
+ record_seerr_media_failure,
+ clear_seerr_media_failure,
+ get_request_download_evidence,
+ start_request_repair,
+ get_request_repairs,
+ active_repair_request_ids,
+)
+from ..services.media_repair import current_cycle_torrents
+from ..services.download_labels import label_episode_downloads
+from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path
+from ..models import Snapshot, TriageResult, RequestType
+from ..services.snapshot import (
+ _summarize_qbit,
+ _torrent_progress,
+ build_snapshot,
+ jellyfin_item_matches_request,
+)
+
+router = APIRouter(
+ prefix="/requests",
+ tags=["requests"],
+ dependencies=[Depends(get_current_user), Depends(require_request_access)],
+ responses=COMMON_ERROR_RESPONSES,
+)
+
+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],
+ raise_errors: bool = False,
+) -> 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:
+ if raise_errors:
+ raise
+ 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_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
+ can_add_seasons = _user_can_use_search_auto(user)
+ if not can_add_seasons:
+ snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
+ pipeline = snapshot.presentation.get("pipeline")
+ if isinstance(pipeline, list):
+ for stage in pipeline:
+ if isinstance(stage, dict) and stage.get("id") == "library":
+ stage["canAddSeasons"] = can_add_seasons
+ if user.get("role") != "admin":
+ # The standard request view is intentionally collaborative, but service payloads can
+ # contain requester identities, internal URLs, download hashes and diagnostic errors.
+ snapshot.timeline = []
+ snapshot.raw = {}
+ return snapshot
+
+
+def _require_advanced_request_access(user: Dict[str, Any]) -> None:
+ if user.get("role") != "admin":
+ raise HTTPException(
+ status_code=403,
+ detail="Advanced request details are available to administrators only",
+ )
+
+
+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"
+ message = ""
+ if response is not None:
+ try:
+ payload = response.json()
+ if isinstance(payload, dict):
+ message = str(payload.get("message") or payload.get("error") or "").strip()
+ elif isinstance(payload, list):
+ validation_messages = [
+ str(item.get("errorMessage") or item.get("message") or "").strip()
+ for item in payload
+ if isinstance(item, dict)
+ ]
+ message = "; ".join(item for item in validation_messages if item)
+ except ValueError:
+ message = response.text.strip()
+ if message:
+ compact_message = " ".join(message.split())[:500]
+ return f"{service} could not complete the request ({status}): {compact_message}"
+ return f"{service} could not complete the request ({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 _merge_request_media_details(
+ request_payload: Dict[str, Any], details: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Fill display metadata omitted by Seerr's request mutation/detail payloads."""
+ merged = dict(request_payload)
+ media = request_payload.get("media")
+ media = dict(media) if isinstance(media, dict) else {}
+ media_type = _normalize_media_type(
+ media.get("mediaType") or request_payload.get("mediaType") or request_payload.get("type")
+ )
+
+ title = details.get("title") or details.get("name")
+ if title and not (media.get("title") or media.get("name")):
+ if media_type == "tv":
+ media["name"] = title
+ else:
+ media["title"] = title
+
+ date_value = details.get("releaseDate") or details.get("firstAirDate")
+ if not media.get("year") and isinstance(date_value, str) and date_value[:4].isdigit():
+ media["year"] = int(date_value[:4])
+
+ for camel_key, snake_key in (
+ ("posterPath", "poster_path"),
+ ("backdropPath", "backdrop_path"),
+ ):
+ if not (media.get(camel_key) or media.get(snake_key)):
+ value = details.get(camel_key) or details.get(snake_key)
+ if value:
+ media[camel_key] = value
+
+ if media_type and not media.get("mediaType"):
+ media["mediaType"] = media_type
+ merged["media"] = media
+ return merged
+
+
+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 _normalize_request_profiles(value: Any) -> list[Dict[str, Any]]:
+ if not isinstance(value, list):
+ return []
+ profiles: list[Dict[str, Any]] = []
+ for item in value:
+ if not isinstance(item, dict):
+ continue
+ profile_id = _quality_profile_id(item.get("id"))
+ name = str(item.get("name") or "").strip()
+ if profile_id is None or not name:
+ continue
+ profiles.append({"id": profile_id, "name": name})
+ return profiles
+
+
+def _normalize_request_roots(value: Any) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ roots: list[str] = []
+ for item in value:
+ if not isinstance(item, dict):
+ continue
+ path = str(item.get("path") or "").strip()
+ if path:
+ roots.append(path)
+ return roots
+
+
+def _normalize_seerr_servers(value: Any) -> list[Dict[str, Any]]:
+ if isinstance(value, list):
+ return [item for item in value if isinstance(item, dict)]
+ if isinstance(value, dict):
+ results = value.get("results")
+ if isinstance(results, list):
+ return [item for item in results if isinstance(item, dict)]
+ return []
+
+
+async def _resolve_request_destination(
+ runtime: Any,
+ seerr: JellyseerrClient,
+ media_type: str,
+) -> Dict[str, Any]:
+ if media_type == "tv":
+ collector_name = "Sonarr"
+ collector = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ configured_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
+ configured_root = str(runtime.sonarr_root_folder or "").strip()
+ else:
+ collector_name = "Radarr"
+ collector = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ configured_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
+ configured_root = str(runtime.radarr_root_folder or "").strip()
+
+ if not collector.configured():
+ raise HTTPException(status_code=400, detail=f"{collector_name} is not configured")
+
+ try:
+ server_settings, profile_payload, root_payload = await asyncio.gather(
+ seerr.get_service_settings(media_type),
+ collector.get_quality_profiles(),
+ collector.get_root_folders(),
+ )
+ except httpx.HTTPStatusError as exc:
+ service = "Seerr" if "/settings/" in str(exc.request.url) else collector_name
+ raise HTTPException(status_code=502, detail=_format_upstream_error(service, exc)) from exc
+
+ servers = [item for item in _normalize_seerr_servers(server_settings) if not item.get("is4k")]
+ if not servers:
+ raise HTTPException(
+ status_code=409,
+ detail=f"Seerr has no standard {collector_name} destination configured.",
+ )
+ server = next((item for item in servers if item.get("isDefault")), servers[0])
+
+ profiles = _normalize_request_profiles(profile_payload)
+ if not profiles:
+ raise HTTPException(status_code=409, detail=f"{collector_name} has no quality profiles available.")
+ profile_ids = {int(item["id"]) for item in profiles}
+
+ # Magent's administrator default is authoritative for every new request.
+ # An unset default inherits Seerr's profile; a stale default must be repaired.
+ default_profile_id = configured_profile_id
+ if default_profile_id is None:
+ default_profile_id = _quality_profile_id(server.get("activeProfileId"))
+ if default_profile_id not in profile_ids:
+ raise HTTPException(
+ status_code=409,
+ detail=f"The default quality profile is not available in {collector_name}. Ask an administrator to select a valid default in Admin settings.",
+ )
+ selected_profile_id = default_profile_id
+
+ roots = _normalize_request_roots(root_payload)
+ root_folder = str(server.get("activeDirectory") or "").strip()
+ if root_folder not in roots:
+ root_folder = configured_root if configured_root in roots else ""
+ if not root_folder:
+ raise HTTPException(
+ status_code=409,
+ detail=f"Seerr's {collector_name} library location does not match an active {collector_name} root folder.",
+ )
+
+ server_id = _quality_profile_id(server.get("id"))
+ if server_id is None:
+ raise HTTPException(status_code=409, detail=f"Seerr's {collector_name} destination is invalid.")
+
+ return {
+ "collector": collector_name,
+ "server_id": server_id,
+ "server_name": str(server.get("name") or collector_name),
+ "profile_id": int(selected_profile_id),
+ "default_profile_id": int(default_profile_id),
+ "profiles": profiles,
+ "root_folder": root_folder,
+ }
+
+
+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):
+ parsed_cached = _parse_request_payload(cached)
+ if parsed_cached.get("title"):
+ return cached
+ details = await _get_media_details(
+ client, parsed_cached.get("media_type"), parsed_cached.get("tmdb_id")
+ )
+ if isinstance(details, dict):
+ cached = _merge_request_media_details(cached, details)
+ _cache_set(cache_key, cached)
+ 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):
+ parsed_fetched = _parse_request_payload(fetched)
+ if not parsed_fetched.get("title"):
+ details = await _get_media_details(
+ client, parsed_fetched.get("media_type"), parsed_fetched.get("tmdb_id")
+ )
+ if isinstance(details, dict):
+ fetched = _merge_request_media_details(fetched, details)
+ _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 []
+ repairing = active_repair_request_ids()
+ 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 str(item.get("request_id")) in repairing:
+ item = {**item, "status": 5, "repairing": True}
+ 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, Any],
+ *,
+ require_owner: bool = False,
+) -> Optional[Dict[str, Any]]:
+ if user.get("role") == "admin":
+ return None
+ if not user.get("username"):
+ raise HTTPException(status_code=403, detail="Request not accessible for this user")
+ if not require_owner:
+ return None
+ request_data = await client.get_request(str(request_id))
+ if not isinstance(request_data, dict):
+ raise HTTPException(status_code=404, detail="Request not found")
+ requester_id = _extract_requested_by_id(request_data)
+ current_seerr_id = user.get("jellyseerr_user_id")
+ if isinstance(current_seerr_id, int) and requester_id == current_seerr_id:
+ return request_data
+ if _request_matches_user(request_data, str(user.get("username") or "")):
+ return request_data
+ email = str(user.get("email") or "").strip()
+ if email and _request_matches_user(request_data, email):
+ return request_data
+ raise HTTPException(
+ status_code=403,
+ detail="Only the original requester or an administrator can change this request",
+ )
+
+
+async def _ensure_request_mutation_access(
+ runtime: Any, request_id: int, user: Dict[str, Any]
+) -> Optional[Dict[str, Any]]:
+ """Fail closed when a non-admin request owner cannot be verified."""
+ if user.get("role") == "admin":
+ return None
+ client = JellyseerrClient(
+ getattr(runtime, "jellyseerr_base_url", None),
+ getattr(runtime, "jellyseerr_api_key", None),
+ )
+ if not client.configured():
+ raise HTTPException(
+ status_code=403,
+ detail="Request ownership cannot be verified while Seerr is unavailable",
+ )
+ return await _ensure_request_access(
+ client, request_id, user, require_owner=True
+ )
+
+
+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 _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 _filter_arr_release_results(results: Any, include_rejected: bool = False) -> List[Dict[str, Any]]:
+ if not isinstance(results, list):
+ return []
+ keep: List[Dict[str, Any]] = []
+ seen: set[tuple[Any, Any]] = set()
+ for item in results:
+ if not isinstance(item, dict):
+ continue
+ key = (item.get("indexerId"), item.get("guid"))
+ if not key[0] or not key[1] or key in seen:
+ continue
+ accepted, override, reasons = manual_releases.decision(item)
+ if not accepted and not include_rejected:
+ continue
+ seen.add(key)
+ quality_payload = item.get("quality")
+ quality_name = None
+ if isinstance(quality_payload, dict):
+ quality_value = quality_payload.get("quality")
+ if isinstance(quality_value, dict):
+ quality_name = str(quality_value.get("name") or "").strip() or None
+ elif isinstance(quality_payload.get("name"), str):
+ quality_name = quality_payload["name"].strip() or None
+ 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"),
+ "magnetUrl": item.get("magnetUrl"),
+ "protocol": item.get("protocol"),
+ "approved": accepted,
+ "rejected": item.get("rejected"),
+ "temporarilyRejected": item.get("temporarilyRejected"),
+ "downloadAllowed": item.get("downloadAllowed"),
+ "fullSeason": item.get("fullSeason"),
+ "seasonNumber": item.get("seasonNumber"),
+ "quality": quality_name,
+ "requiresOverride": override,
+ "selectable": accepted or override,
+ "rejections": reasons,
+ "episodeNumbers": item.get("mappedEpisodeNumbers") or item.get("episodeNumbers"),
+ "customFormatScore": item.get("customFormatScore"),
+ }
+ )
+ keep.sort(key=lambda item: (not bool(item.get("approved")), not item["requiresOverride"]))
+ releases = keep[:200]
+ for index, release in enumerate(releases):
+ release["bestPick"] = index == 0 and release.get("approved") is True
+ return releases
+
+
+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
+
+
+def _replacement_file_name(file_data: Dict[str, Any]) -> str:
+ raw = file_data.get("relativePath") or file_data.get("path") or file_data.get("sceneName")
+ if isinstance(raw, str) and raw.strip():
+ return raw.strip().replace("\\", "/").rsplit("/", 1)[-1]
+ return "Managed media file"
+
+
+def _replacement_quality_name(file_data: Dict[str, Any]) -> Optional[str]:
+ quality = file_data.get("quality")
+ if not isinstance(quality, dict):
+ return None
+ nested = quality.get("quality")
+ if isinstance(nested, dict):
+ value = nested.get("name")
+ return str(value).strip() if value is not None and str(value).strip() else None
+ value = quality.get("name")
+ return str(value).strip() if value is not None and str(value).strip() else None
+
+
+def _jellyfin_media_signature(item: Any) -> Dict[str, Any]:
+ if not isinstance(item, dict):
+ return {}
+ return {
+ key: item.get(key)
+ for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources")
+ if item.get(key) is not None
+ }
+
+
+def _replacement_file_payload(
+ file_data: Dict[str, Any],
+ *,
+ episode_numbers: Optional[List[str]] = None,
+) -> Optional[Dict[str, Any]]:
+ file_id = file_data.get("id")
+ if not isinstance(file_id, int) or file_id <= 0:
+ return None
+ size = file_data.get("size")
+ return {
+ "id": file_id,
+ "name": _replacement_file_name(file_data),
+ "quality": _replacement_quality_name(file_data),
+ "size": int(size) if isinstance(size, (int, float)) and size >= 0 else None,
+ "season_number": file_data.get("seasonNumber") if isinstance(file_data.get("seasonNumber"), int) else None,
+ "episodes": episode_numbers or [],
+ }
+
+
+def _linked_issue_for_replacement(
+ issue_id: Any,
+ *,
+ request_id: str,
+ user: Dict[str, str],
+) -> Optional[Dict[str, Any]]:
+ if issue_id is None:
+ return None
+ if not isinstance(issue_id, int) or issue_id <= 0:
+ raise HTTPException(status_code=400, detail="A valid linked issue is required")
+ issue = get_portal_item(issue_id)
+ if not issue or str(issue.get("kind") or "").lower() != "issue":
+ raise HTTPException(status_code=404, detail="Linked issue not found")
+ if str(issue.get("external_ref") or "") != f"/requests/{request_id}":
+ raise HTTPException(status_code=409, detail="The issue is not linked to this request")
+ is_admin = str(user.get("role") or "").lower() == "admin"
+ is_owner = str(issue.get("created_by_username") or "").lower() == str(user.get("username") or "").lower()
+ if not (is_admin or is_owner):
+ raise HTTPException(status_code=403, detail="You cannot update this linked issue")
+ return issue
+
+
+def _record_replacement_activity(
+ issue: Optional[Dict[str, Any]],
+ *,
+ user: Dict[str, str],
+ event_type: str,
+ message: str,
+ metadata: Optional[Dict[str, Any]] = None,
+) -> None:
+ tracking = (metadata or {}).get("repairTracking")
+ if event_type in {"replacement_started", "missing_search_started"} and isinstance(tracking, dict):
+ start_request_repair(tracking)
+ if not issue:
+ return
+ current_status = str(issue.get("status") or "new").strip().lower()
+ next_status: Optional[str] = None
+ if event_type.endswith("_started"):
+ next_status = "in_progress"
+ elif event_type.endswith("_failed"):
+ next_status = "blocked"
+ if next_status and current_status not in {"done", "closed"}:
+ update_portal_item(
+ int(issue["id"]),
+ status=next_status,
+ issue_resolved_at=None,
+ )
+ add_portal_item_activity(
+ int(issue["id"]),
+ event_type=event_type,
+ actor_username=str(user.get("username") or "unknown"),
+ actor_role=str(user.get("role") or "user"),
+ message=message,
+ metadata_json=(
+ json.dumps(metadata, separators=(",", ":"), sort_keys=True)
+ if metadata
+ else None
+ ),
+ )
+
+
+def _released_episode(episode: Dict[str, Any]) -> bool:
+ if episode.get("hasFile") is True:
+ return True
+ raw_date = episode.get("airDateUtc") or episode.get("airDate")
+ if not isinstance(raw_date, str) or not raw_date.strip():
+ return False
+ try:
+ parsed = datetime.fromisoformat(raw_date.strip().replace("Z", "+00:00"))
+ except ValueError:
+ return False
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc) <= datetime.now(timezone.utc)
+
+
+def _issue_episode_payloads(
+ episodes: Any,
+) -> List[Dict[str, Any]]:
+ results: List[Dict[str, Any]] = []
+ if not isinstance(episodes, list):
+ return results
+ for episode in episodes:
+ if not isinstance(episode, dict):
+ continue
+ episode_id = episode.get("id")
+ season_number = episode.get("seasonNumber")
+ episode_number = episode.get("episodeNumber")
+ if not all(isinstance(value, int) for value in (episode_id, season_number, episode_number)):
+ continue
+ released = _released_episode(episode)
+ has_file = episode.get("hasFile") is True or (
+ isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0
+ )
+ monitored = episode.get("monitored") is not False
+ results.append(
+ {
+ "id": episode_id,
+ "season_number": season_number,
+ "episode_number": episode_number,
+ "code": f"S{season_number:02d}E{episode_number:02d}",
+ "title": str(episode.get("title") or f"Episode {episode_number}").strip(),
+ "released": released,
+ "monitored": monitored,
+ "has_file": has_file,
+ "missing": released and not has_file,
+ "best_fit": released and not has_file,
+ "file_id": episode.get("episodeFileId") if has_file else None,
+ }
+ )
+ results.sort(key=lambda item: (item["season_number"], item["episode_number"]))
+ return results
+
+
+def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ grouped: Dict[int, List[Dict[str, Any]]] = {}
+ for episode in episodes:
+ grouped.setdefault(int(episode["season_number"]), []).append(episode)
+ return [
+ {
+ "season_number": season_number,
+ "label": "Specials" if season_number == 0 else f"Season {season_number}",
+ "episode_count": len(items),
+ "available_count": sum(1 for item in items if item["has_file"]),
+ "missing_count": sum(1 for item in items if item["missing"]),
+ "best_fit": any(item["best_fit"] for item in items),
+ }
+ for season_number, items in sorted(grouped.items())
+ if any(item["released"] for item in items)
+ ]
+
+
+async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
+ try:
+ return await resolve_root_folder_path(client, root_folder, service_name)
+ except RootFolderNotFoundError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/{request_id}/issue-options")
+async def issue_target_options(
+ request_id: str,
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+ runtime = get_runtime_settings()
+ snapshot = await build_snapshot(request_id)
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict):
+ return {
+ "request_id": request_id,
+ "request_type": snapshot.request_type.value,
+ "title": snapshot.title,
+ "collector_id": None,
+ "movie": None,
+ "seasons": [],
+ "episodes": [],
+ "can_act": False,
+ "message": "This title is not currently linked to Sonarr or Radarr.",
+ }
+
+ collector_id = arr_item.get("id")
+ if not isinstance(collector_id, int):
+ raise HTTPException(status_code=502, detail="Sonarr/Radarr returned an invalid media record")
+ if snapshot.request_type == RequestType.movie:
+ movie_file = arr_item.get("movieFile") if isinstance(arr_item.get("movieFile"), dict) else None
+ return {
+ "request_id": request_id,
+ "request_type": "movie",
+ "title": snapshot.title,
+ "collector_id": collector_id,
+ "movie": {
+ "selected_label": snapshot.title,
+ "has_file": bool(movie_file),
+ "missing": not bool(movie_file),
+ "best_fit": not bool(movie_file),
+ "file_id": movie_file.get("id") if movie_file else None,
+ },
+ "seasons": [],
+ "episodes": [],
+ "can_act": _user_can_use_search_auto(user),
+ "message": "Choose the movie to continue.",
+ }
+
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not sonarr.configured():
+ raise HTTPException(status_code=400, detail="Sonarr is not configured")
+ try:
+ episodes = await sonarr.get_episodes(collector_id)
+ except Exception as exc:
+ logger.warning("Sonarr issue options failed request_id=%s error=%s", request_id, exc)
+ raise HTTPException(
+ status_code=502,
+ detail="Magent could not read the seasons and episodes from Sonarr.",
+ ) from exc
+ episode_options = _issue_episode_payloads(episodes)
+ return {
+ "request_id": request_id,
+ "request_type": "tv",
+ "title": snapshot.title,
+ "collector_id": collector_id,
+ "movie": None,
+ "seasons": _issue_season_payloads(episode_options),
+ "episodes": episode_options,
+ "can_act": _user_can_use_search_auto(user),
+ "message": "Choose a season, then select every affected episode.",
+ }
+
+
+@router.get("/{request_id}/replacement-options")
+async def replacement_options(
+ request_id: str,
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+ runtime = get_runtime_settings()
+ seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if seerr.configured():
+ await _ensure_request_access(seerr, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict):
+ return {
+ "request_id": request_id,
+ "request_type": snapshot.request_type.value,
+ "title": snapshot.title,
+ "files": [],
+ "message": "This title is not currently linked to a Sonarr/Radarr library item.",
+ }
+
+ files: List[Dict[str, Any]] = []
+ if snapshot.request_type == RequestType.movie:
+ movie_file = arr_item.get("movieFile")
+ if isinstance(movie_file, dict):
+ option = _replacement_file_payload(movie_file)
+ if option:
+ files.append(option)
+ elif snapshot.request_type == RequestType.tv:
+ series_id = arr_item.get("id")
+ if not isinstance(series_id, int):
+ raise HTTPException(status_code=502, detail="Sonarr returned an invalid series record")
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not sonarr.configured():
+ raise HTTPException(status_code=400, detail="Sonarr is not configured")
+ try:
+ episode_files, episodes = await asyncio.gather(
+ sonarr.get_episode_files(series_id),
+ sonarr.get_episodes(series_id),
+ )
+ except Exception as exc:
+ logger.warning("Sonarr replacement options failed request_id=%s error=%s", request_id, exc)
+ raise HTTPException(
+ status_code=502,
+ detail="Magent could not read the managed episode files from Sonarr.",
+ ) from exc
+ episode_labels: Dict[int, List[str]] = {}
+ if isinstance(episodes, list):
+ for episode in episodes:
+ if not isinstance(episode, dict):
+ continue
+ file_id = episode.get("episodeFileId")
+ season_number = episode.get("seasonNumber")
+ episode_number = episode.get("episodeNumber")
+ if not all(isinstance(value, int) for value in (file_id, season_number, episode_number)):
+ continue
+ label = f"S{season_number:02d}E{episode_number:02d}"
+ episode_labels.setdefault(file_id, []).append(label)
+ if isinstance(episode_files, list):
+ for file_data in episode_files:
+ if not isinstance(file_data, dict):
+ continue
+ option = _replacement_file_payload(
+ file_data,
+ episode_numbers=episode_labels.get(file_data.get("id"), []),
+ )
+ if option:
+ files.append(option)
+ files.sort(
+ key=lambda item: (
+ item.get("season_number") if isinstance(item.get("season_number"), int) else 9999,
+ ",".join(item.get("episodes") or []),
+ str(item.get("name") or ""),
+ )
+ )
+
+ return {
+ "request_id": request_id,
+ "request_type": snapshot.request_type.value,
+ "title": snapshot.title,
+ "files": files,
+ "can_replace": _user_can_use_search_auto(user),
+ "message": (
+ "Choose the exact managed file to remove and replace."
+ if files
+ else "Sonarr/Radarr does not currently report a managed file for this title."
+ ),
+ }
+
+
+@router.post("/{request_id}/actions/replace")
+async def action_replace_media(
+ request_id: str,
+ payload: Dict[str, Any],
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(status_code=403, detail="Media replacement is disabled for this user")
+ if payload.get("confirmed") is not True:
+ raise HTTPException(status_code=400, detail="Replacement confirmation is required")
+ raw_file_ids = payload.get("file_ids")
+ if raw_file_ids is None:
+ raw_file_ids = [payload.get("file_id")]
+ if not isinstance(raw_file_ids, list):
+ raise HTTPException(status_code=400, detail="Managed files must be supplied as a list")
+ file_ids = list(dict.fromkeys(
+ value
+ for value in raw_file_ids
+ if isinstance(value, int) and not isinstance(value, bool) and value > 0
+ ))
+ if not file_ids or len(file_ids) != len(raw_file_ids) or len(file_ids) > 100:
+ raise HTTPException(status_code=400, detail="Choose between 1 and 100 valid managed files")
+ linked_issue = _linked_issue_for_replacement(
+ payload.get("issue_id"),
+ request_id=request_id,
+ user=user,
+ )
+
+ runtime = get_runtime_settings()
+ await _ensure_request_mutation_access(runtime, 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")
+
+ collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
+ target_names: List[str] = []
+ collector_id: Optional[int] = None
+ target_episodes: List[Dict[str, int]] = []
+ jellyfin_baseline: List[Dict[str, Any]] = []
+ repair_tracking: Dict[str, Any] = {}
+
+ def record_cycle() -> None:
+ repair_tracking.update({
+ "requestId": request_id,
+ "actionId": "replace_media",
+ "mediaType": snapshot.request_type.value,
+ "collectorId": collector_id,
+ "originalFileIds": file_ids,
+ "previousDownloadIds": list(dict.fromkeys(
+ list(snapshot.raw.get("qbittorrent", {}).get("downloadIds") or [])
+ + [str(t.get("hash")) for t in snapshot.raw.get("qbittorrent", {}).get("torrents", []) if t.get("hash")]
+ )),
+ "episodes": target_episodes,
+ "jellyfinBaseline": jellyfin_baseline,
+ "jellyfinFoundAtStart": bool(snapshot.raw.get("jellyfin", {}).get("catalogFound",
+ snapshot.raw.get("jellyfin", {}).get("found"))),
+ "startedAt": datetime.now(timezone.utc).isoformat(),
+ })
+ start_request_repair(repair_tracking)
+
+ try:
+ if snapshot.request_type == RequestType.movie:
+ movie_id = arr_item.get("id")
+ movie_file = arr_item.get("movieFile")
+ if not isinstance(movie_id, int) or not isinstance(movie_file, dict):
+ raise HTTPException(status_code=409, detail="Radarr does not report a replaceable movie file")
+ if len(file_ids) != 1 or movie_file.get("id") != file_ids[0]:
+ raise HTTPException(status_code=409, detail="The selected movie file is no longer current")
+ collector_id = movie_id
+ jellyfin_baseline = [
+ _jellyfin_media_signature(snapshot.raw.get("jellyfin", {}).get("item"))
+ ]
+ jellyfin_baseline = [item for item in jellyfin_baseline if item]
+ target_names = [_replacement_file_name(movie_file)]
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if not radarr.configured():
+ raise HTTPException(status_code=400, detail="Radarr is not configured")
+ await radarr.monitor_movie(movie_id, True)
+ await asyncio.to_thread(record_cycle)
+ await radarr.delete_movie_file(file_ids[0])
+ await radarr.search(movie_id)
+ elif snapshot.request_type == RequestType.tv:
+ series_id = arr_item.get("id")
+ if not isinstance(series_id, int):
+ raise HTTPException(status_code=502, detail="Sonarr returned an invalid series record")
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not sonarr.configured():
+ raise HTTPException(status_code=400, detail="Sonarr is not configured")
+ episode_files, episodes = await asyncio.gather(
+ sonarr.get_episode_files(series_id),
+ sonarr.get_episodes(series_id),
+ )
+ selected_files = [
+ item
+ for item in episode_files
+ if isinstance(item, dict) and item.get("id") in file_ids
+ ] if isinstance(episode_files, list) else []
+ if len(selected_files) != len(file_ids):
+ raise HTTPException(status_code=409, detail="One or more selected episode files are no longer current")
+ target_episodes = [
+ {
+ "id": int(episode["id"]),
+ "seasonNumber": int(episode["seasonNumber"]),
+ "episodeNumber": int(episode["episodeNumber"]),
+ }
+ for episode in episodes
+ if isinstance(episode, dict)
+ and episode.get("episodeFileId") in file_ids
+ and isinstance(episode.get("id"), int)
+ and isinstance(episode.get("seasonNumber"), int)
+ and isinstance(episode.get("episodeNumber"), int)
+ ] if isinstance(episodes, list) else []
+ episode_ids = [episode["id"] for episode in target_episodes]
+ if not episode_ids:
+ raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file")
+ collector_id = series_id
+ jellyfin_series = snapshot.raw.get("jellyfin", {}).get("item")
+ jellyfin_series_id = jellyfin_series.get("Id") if isinstance(jellyfin_series, dict) else None
+ if jellyfin_series_id:
+ try:
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ jellyfin_episodes = await jellyfin.get_series_episodes(str(jellyfin_series_id))
+ target_pairs = {
+ (episode["seasonNumber"], episode["episodeNumber"])
+ for episode in target_episodes
+ }
+ jellyfin_baseline = [
+ {
+ "seasonNumber": int(item["ParentIndexNumber"]),
+ "episodeNumber": int(item["IndexNumber"]),
+ **_jellyfin_media_signature(item),
+ }
+ for item in jellyfin_episodes
+ if isinstance(item.get("ParentIndexNumber"), int)
+ and isinstance(item.get("IndexNumber"), int)
+ and (item["ParentIndexNumber"], item["IndexNumber"]) in target_pairs
+ ]
+ except Exception:
+ logger.warning("Could not capture Jellyfin episode baseline request_id=%s", request_id)
+ target_names = [_replacement_file_name(file_data) for file_data in selected_files]
+ await sonarr.monitor_episodes(episode_ids, True)
+ await asyncio.to_thread(record_cycle)
+ for selected_file_id in file_ids:
+ await sonarr.delete_episode_file(selected_file_id)
+ await sonarr.search_episodes(episode_ids)
+ else:
+ raise HTTPException(status_code=400, detail="Unknown request type")
+ except HTTPException as exc:
+ detail = f"The media replacement could not be started: {exc.detail}"
+ await asyncio.to_thread(
+ save_action, request_id, "replace_media", "Replace media file", "failed", detail
+ )
+ _record_replacement_activity(
+ linked_issue,
+ user=user,
+ event_type="replacement_failed",
+ message=detail,
+ )
+ raise
+ except Exception as exc:
+ logger.exception("%s media replacement failed request_id=%s file_ids=%s", collector, request_id, file_ids)
+ detail = (
+ f"{collector} could not complete the replacement. Check the request action history "
+ "before trying again."
+ )
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "replace_media",
+ "Replace media file",
+ "failed",
+ detail,
+ )
+ _record_replacement_activity(
+ linked_issue,
+ user=user,
+ event_type="replacement_failed",
+ message=detail,
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+
+ file_label = "the selected managed file" if len(target_names) == 1 else f"{len(target_names)} selected managed files"
+ message = f"{collector} removed {file_label} and started a replacement search."
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "replace_media",
+ "Replace media file",
+ "ok",
+ message,
+ )
+ _record_replacement_activity(
+ linked_issue,
+ user=user,
+ event_type="replacement_started",
+ message=message,
+ metadata={
+ "repairTracking": repair_tracking
+ },
+ )
+ return {
+ "status": "ok",
+ "message": message,
+ "collector": collector,
+ "request_id": request_id,
+ "file_ids": file_ids,
+ }
+
+
+def _positive_id_list(
+ value: Any,
+ *,
+ field: str,
+ maximum: int = 200,
+ minimum: int = 1,
+) -> List[int]:
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise HTTPException(status_code=400, detail=f"{field} must be a list")
+ normalized = list(
+ dict.fromkeys(
+ item
+ for item in value
+ if isinstance(item, int) and not isinstance(item, bool) and item >= minimum
+ )
+ )
+ if len(normalized) != len(value) or len(normalized) > maximum:
+ raise HTTPException(status_code=400, detail=f"Choose up to {maximum} valid {field.replace('_', ' ')}")
+ return normalized
+
+
+@router.post("/{request_id}/actions/search-missing")
+async def action_search_missing_media(
+ request_id: str,
+ payload: Dict[str, Any],
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(status_code=403, detail="Collection searches are disabled for this user")
+ linked_issue = _linked_issue_for_replacement(
+ payload.get("issue_id"), request_id=request_id, user=user
+ )
+ episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids")
+ season_numbers = _positive_id_list(
+ payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
+ )
+ runtime = get_runtime_settings()
+ await _ensure_request_mutation_access(runtime, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
+ raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
+ collector_id = int(arr_item["id"])
+ target_episodes: List[Dict[str, int]] = []
+ try:
+ if snapshot.request_type == RequestType.movie:
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if not radarr.configured():
+ raise HTTPException(status_code=400, detail="Radarr is not configured")
+ await radarr.monitor_movie(collector_id, True)
+ await radarr.search(collector_id)
+ message = "Radarr started searching for the missing movie."
+ searched_ids: List[int] = []
+ else:
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not sonarr.configured():
+ raise HTTPException(status_code=400, detail="Sonarr is not configured")
+ episodes = await sonarr.get_episodes(collector_id)
+ if not isinstance(episodes, list):
+ raise HTTPException(status_code=502, detail="Sonarr did not return an episode list")
+ episode_map = {
+ int(item["id"]): item
+ for item in episodes
+ if isinstance(item, dict) and isinstance(item.get("id"), int)
+ }
+ if episode_ids:
+ if any(item_id not in episode_map for item_id in episode_ids):
+ raise HTTPException(status_code=409, detail="One or more selected episodes are no longer in Sonarr")
+ searched_ids = episode_ids
+ else:
+ searched_ids = [
+ item_id
+ for item_id, episode in episode_map.items()
+ if _released_episode(episode)
+ and not (
+ episode.get("hasFile") is True
+ or (isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0)
+ )
+ and (
+ (season_numbers and episode.get("seasonNumber") in season_numbers)
+ or (not season_numbers and episode.get("monitored") is not False)
+ )
+ ]
+ if searched_ids:
+ target_episodes = [
+ {
+ "id": int(episode_map[episode_id]["id"]),
+ "seasonNumber": int(episode_map[episode_id]["seasonNumber"]),
+ "episodeNumber": int(episode_map[episode_id]["episodeNumber"]),
+ }
+ for episode_id in searched_ids
+ if isinstance(episode_map.get(episode_id), dict)
+ and isinstance(episode_map[episode_id].get("seasonNumber"), int)
+ and isinstance(episode_map[episode_id].get("episodeNumber"), int)
+ ]
+ await sonarr.monitor_episodes(searched_ids, True)
+ await sonarr.search_episodes(searched_ids)
+ message = f"Sonarr started searching for {len(searched_ids)} selected missing episode(s)."
+ else:
+ await sonarr.search(collector_id)
+ message = "Sonarr refreshed the series and started a full missing-episode search."
+ except HTTPException as exc:
+ detail = f"The missing-content search could not start: {exc.detail}"
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "search_missing",
+ "Search for missing content",
+ "failed",
+ detail,
+ )
+ _record_replacement_activity(
+ linked_issue,
+ user=user,
+ event_type="missing_search_failed",
+ message=detail,
+ )
+ raise
+ except Exception as exc:
+ logger.exception("missing content search failed request_id=%s", request_id)
+ detail = "Sonarr/Radarr could not start the missing-content search."
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "search_missing",
+ "Search for missing content",
+ "failed",
+ detail,
+ )
+ _record_replacement_activity(
+ linked_issue, user=user, event_type="missing_search_failed", message=detail
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ await asyncio.to_thread(
+ save_action, request_id, "search_missing", "Search for missing content", "ok", message
+ )
+ _record_replacement_activity(
+ linked_issue,
+ user=user,
+ event_type="missing_search_started",
+ message=message,
+ metadata={
+ "repairTracking": {
+ "requestId": request_id,
+ "actionId": "search_missing",
+ "mediaType": snapshot.request_type.value,
+ "collectorId": collector_id,
+ "originalFileIds": [],
+ "episodes": target_episodes,
+ "jellyfinBaseline": [],
+ "jellyfinFoundAtStart": bool(snapshot.raw.get("jellyfin", {}).get("found")),
+ "startedAt": datetime.now(timezone.utc).isoformat(),
+ }
+ },
+ )
+ return {"status": "ok", "message": message, "episode_ids": searched_ids}
+
+
+@router.post("/{request_id}/actions/add-seasons")
+async def action_add_seasons(
+ request_id: str,
+ payload: Dict[str, Any],
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(status_code=403, detail="Adding seasons is disabled for this user")
+ season_numbers = _positive_id_list(
+ payload.get("season_numbers"), field="season_numbers", maximum=100
+ )
+ if not season_numbers:
+ raise HTTPException(status_code=400, detail="Choose at least one season")
+
+ runtime = get_runtime_settings()
+ await _ensure_request_mutation_access(runtime, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ if snapshot.request_type != RequestType.tv:
+ raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
+ raise HTTPException(status_code=404, detail="Series not found in Sonarr")
+
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not sonarr.configured():
+ raise HTTPException(status_code=400, detail="Sonarr is not configured")
+ series_id = int(arr_item["id"])
+ label = "Add seasons"
+ try:
+ series = await sonarr.get_series(series_id)
+ if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
+ raise HTTPException(status_code=502, detail="Sonarr did not return the series seasons")
+ known_seasons = {
+ season.get("seasonNumber")
+ for season in series["seasons"]
+ if isinstance(season, dict)
+ and isinstance(season.get("seasonNumber"), int)
+ and season.get("seasonNumber") > 0
+ }
+ if any(season_number not in known_seasons for season_number in season_numbers):
+ raise HTTPException(status_code=409, detail="One or more selected seasons are no longer available in Sonarr")
+
+ updated_seasons = [
+ {**season, "monitored": True}
+ if isinstance(season, dict) and season.get("seasonNumber") in season_numbers
+ else season
+ for season in series["seasons"]
+ ]
+ if series.get("monitored") is not True or updated_seasons != series["seasons"]:
+ await sonarr.update_series({**series, "monitored": True, "seasons": updated_seasons})
+
+ episodes = await sonarr.get_episodes(series_id)
+ if not isinstance(episodes, list):
+ raise HTTPException(status_code=502, detail="Sonarr did not return an episode list")
+ selected_episodes = [
+ episode for episode in episodes
+ if isinstance(episode, dict)
+ and episode.get("seasonNumber") in season_numbers
+ and isinstance(episode.get("id"), int)
+ ]
+ episode_ids = [int(episode["id"]) for episode in selected_episodes]
+ if episode_ids:
+ await sonarr.monitor_episodes(episode_ids, True)
+ search_ids = [
+ int(episode["id"])
+ for episode in selected_episodes
+ if _released_episode(episode)
+ and episode.get("hasFile") is not True
+ and not (
+ isinstance(episode.get("episodeFileId"), int)
+ and episode.get("episodeFileId") > 0
+ )
+ ]
+ if search_ids:
+ await sonarr.search_episodes(search_ids)
+
+ verified_series = await sonarr.get_series(series_id)
+ verified_seasons = verified_series.get("seasons") if isinstance(verified_series, dict) else []
+ verified_season_map = {
+ season.get("seasonNumber"): season.get("monitored")
+ for season in verified_seasons
+ if isinstance(season, dict) and isinstance(season.get("seasonNumber"), int)
+ }
+ if (
+ not isinstance(verified_series, dict)
+ or verified_series.get("monitored") is not True
+ or any(verified_season_map.get(number) is not True for number in season_numbers)
+ ):
+ raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected season")
+ if episode_ids:
+ verified_episodes = await sonarr.get_episodes(series_id)
+ if not isinstance(verified_episodes, list) or any(
+ isinstance(episode, dict)
+ and episode.get("id") in episode_ids
+ and episode.get("monitored") is not True
+ for episode in verified_episodes
+ ):
+ raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected episode")
+ except HTTPException as exc:
+ detail = f"The seasons could not be added: {exc.detail}"
+ await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
+ raise
+ except Exception as exc:
+ logger.exception("add seasons failed request_id=%s", request_id)
+ detail = "Sonarr could not add the selected seasons."
+ await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
+ raise HTTPException(status_code=502, detail=detail) from exc
+
+ season_label = ", ".join(str(number) for number in season_numbers)
+ message = f"Season{'s' if len(season_numbers) != 1 else ''} {season_label} added to Sonarr."
+ if search_ids:
+ message += f" Searching for {len(search_ids)} released missing episode{'s' if len(search_ids) != 1 else ''}."
+ elif episode_ids:
+ message += " All known episodes are already collected or have not aired yet."
+ else:
+ message += " New episodes will be monitored when Sonarr discovers them."
+ await asyncio.to_thread(save_action, request_id, "add_seasons", label, "ok", message)
+ fresh_snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user)
+ return {
+ "status": "ok",
+ "message": message,
+ "season_numbers": season_numbers,
+ "searched_episode_count": len(search_ids),
+ "snapshot": fresh_snapshot,
+ }
+
+
+@router.post("/{request_id}/actions/repair-subtitles")
+async def action_repair_subtitles(
+ request_id: str,
+ payload: Dict[str, Any],
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(status_code=403, detail="Subtitle repairs are disabled for this user")
+ linked_issue = _linked_issue_for_replacement(
+ payload.get("issue_id"), request_id=request_id, user=user
+ )
+ episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
+ forced = payload.get("forced") is True
+ runtime = get_runtime_settings()
+ await _ensure_request_mutation_access(runtime, int(request_id), user)
+ bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
+ if not bazarr.configured() or not runtime.bazarr_api_key:
+ raise HTTPException(status_code=400, detail="Bazarr is not configured")
+ language = str(runtime.bazarr_default_language or "en").strip().lower() or "en"
+ snapshot = await build_snapshot(request_id)
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
+ raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
+ collector_id = int(arr_item["id"])
+ try:
+ if snapshot.request_type == RequestType.movie:
+ await bazarr.search_movie_subtitles(
+ collector_id, language=language, forced=forced
+ )
+ repaired_count = 1
+ message = f"Bazarr started a fresh {language.upper()} subtitle search for the movie."
+ else:
+ if not episode_ids:
+ raise HTTPException(status_code=400, detail="Choose at least one episode")
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ episodes = await sonarr.get_episodes(collector_id)
+ valid_ids = {
+ int(item["id"])
+ for item in episodes
+ if isinstance(item, dict) and isinstance(item.get("id"), int)
+ } if isinstance(episodes, list) else set()
+ if any(episode_id not in valid_ids for episode_id in episode_ids):
+ raise HTTPException(status_code=409, detail="One or more selected episodes are no longer in Sonarr")
+ for episode_id in episode_ids:
+ await bazarr.search_episode_subtitles(
+ collector_id,
+ episode_id,
+ language=language,
+ forced=forced,
+ )
+ repaired_count = len(episode_ids)
+ message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)."
+ except HTTPException as exc:
+ detail = f"The subtitle repair could not start: {exc.detail}"
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "repair_subtitles",
+ "Repair subtitles",
+ "failed",
+ detail,
+ )
+ _record_replacement_activity(
+ linked_issue,
+ user=user,
+ event_type="subtitle_repair_failed",
+ message=detail,
+ )
+ raise
+ except Exception as exc:
+ logger.exception("Bazarr subtitle repair failed request_id=%s", request_id)
+ detail = "Bazarr could not start the subtitle repair."
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "repair_subtitles",
+ "Repair subtitles",
+ "failed",
+ detail,
+ )
+ _record_replacement_activity(
+ linked_issue, user=user, event_type="subtitle_repair_failed", message=detail
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ await asyncio.to_thread(
+ save_action, request_id, "repair_subtitles", "Repair subtitles", "ok", message
+ )
+ _record_replacement_activity(
+ linked_issue, user=user, event_type="subtitle_repair_started", message=message
+ )
+ return {"status": "ok", "message": message, "count": repaired_count}
+
+
+@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_for_user(snapshot, user)
+
+
+async def _restore_request_monitoring(snapshot: Snapshot, request: dict) -> bool:
+ # Pending or declined requests must not gain collection access via Recheck.
+ if request.get('status') != 2:
+ return False
+ item = (snapshot.raw.get('arr') or {}).get('item')
+ if not isinstance(item, dict) or not isinstance(item.get('id'), int):
+ return False
+ runtime = get_runtime_settings()
+ if snapshot.request_type == RequestType.movie:
+ client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ fresh = await client.get_movie(item['id'])
+ if not isinstance(fresh, dict):
+ raise ValueError('Radarr did not return the requested movie')
+ if fresh.get('monitored') is True:
+ return False
+ await client.update_movie({**fresh, 'monitored': True})
+ verified = await client.get_movie(item['id'])
+ if not isinstance(verified, dict) or verified.get('monitored') is not True:
+ raise ValueError('Radarr did not enable monitoring')
+ return True
+ client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ fresh = await client.get_series(item['id'])
+ if not isinstance(fresh, dict):
+ raise ValueError('Sonarr did not return the requested series')
+ requested = {season['seasonNumber'] for season in (request.get('seasons') or [])
+ if isinstance(season, dict) and isinstance(season.get('seasonNumber'), int)}
+ seasons = [{**season, 'monitored': True} if season.get('seasonNumber') in requested else season
+ for season in (fresh.get('seasons') or [])]
+ changed = fresh.get('monitored') is not True or seasons != fresh.get('seasons', [])
+ if changed:
+ await client.update_series({**fresh, 'monitored': True, 'seasons': seasons})
+ episodes = await client.get_episodes(item['id']) if requested else []
+ ids = [episode['id'] for episode in episodes if episode.get('seasonNumber') in requested
+ and episode.get('monitored') is not True and isinstance(episode.get('id'), int)]
+ if ids:
+ await client.monitor_episodes(ids, True)
+ verified_episodes = await client.get_episodes(item['id'])
+ if any(e.get('id') in ids and e.get('monitored') is not True for e in verified_episodes):
+ raise ValueError('Sonarr did not enable episode monitoring')
+ if changed:
+ verified = await client.get_series(item['id'])
+ if not isinstance(verified, dict) or verified.get('monitored') is not True or any(
+ season.get('seasonNumber') in requested and season.get('monitored') is not True
+ for season in verified.get('seasons', [])):
+ raise ValueError('Sonarr did not enable monitoring')
+ return changed or bool(ids)
+
+
+@router.post("/{request_id}/actions/recheck")
+async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+
+ runtime = get_runtime_settings()
+ seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not seerr.configured():
+ raise HTTPException(status_code=400, detail="Seerr is not configured")
+ fresh_request = await _ensure_request_access(
+ seerr, int(request_id), user, require_owner=True
+ )
+
+ if fresh_request is None:
+ try:
+ fresh_request = await seerr.get_request(request_id)
+ except httpx.HTTPStatusError as exc:
+ detail = _format_upstream_error("Seerr", exc)
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "recheck_pipeline",
+ "Recheck request status",
+ "failed",
+ detail,
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ except Exception as exc:
+ logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
+ detail = "Magent could not reach Seerr to recheck this request."
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "recheck_pipeline",
+ "Recheck request status",
+ "failed",
+ detail,
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+
+ if not isinstance(fresh_request, dict):
+ raise HTTPException(status_code=404, detail="Request not found in Seerr")
+
+ parsed = _parse_request_payload(fresh_request)
+ if not parsed.get("title"):
+ details = await _get_media_details(
+ seerr, parsed.get("media_type"), parsed.get("tmdb_id")
+ )
+ if isinstance(details, dict):
+ fresh_request = _merge_request_media_details(fresh_request, details)
+ parsed = _parse_request_payload(fresh_request)
+ if parsed.get("request_id") != int(request_id):
+ raise HTTPException(status_code=502, detail="Seerr returned an unexpected request record")
+
+ cache_record = _build_request_cache_record(parsed, fresh_request)
+ await asyncio.to_thread(upsert_request_cache, **cache_record)
+ _cache_set(f"request:{request_id}", fresh_request)
+ _refresh_recent_cache_from_db()
+
+ snapshot = await build_snapshot(request_id)
+ try:
+ restored = await _restore_request_monitoring(snapshot, fresh_request)
+ except Exception as exc:
+ logger.warning('Recheck monitoring failed request_id=%s: %s', request_id, exc)
+ raise HTTPException(502, 'Could not restore monitoring in Sonarr/Radarr. Please try Recheck again.') from exc
+ if restored:
+ snapshot = await build_snapshot(request_id)
+ snapshot = _filter_snapshot_for_user(snapshot, user)
+ status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated")
+ message = ("Monitoring restored. " if restored else "") + f"Recheck complete. {status_label}."
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "recheck_pipeline",
+ "Recheck request status",
+ "ok",
+ message,
+ )
+ return {"status": "ok", "message": message, "snapshot": snapshot}
+
+
+@router.get("/{request_id}/download-progress")
+async def get_download_progress(
+ request_id: str, user: Dict[str, str] = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Return a lightweight qBittorrent update for an open request page."""
+ if not request_id.isdigit():
+ raise HTTPException(status_code=400, detail="Invalid request id")
+
+ runtime = get_runtime_settings()
+ seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if seerr.configured():
+ await _ensure_request_access(seerr, int(request_id), user)
+
+ repairs = await asyncio.to_thread(get_request_repairs, request_id, active_only=False)
+ cycle = repairs[-1]["startedAt"] if repairs else None
+ evidence = await asyncio.to_thread(get_request_download_evidence, request_id, 20)
+ historical_torrents = evidence.get("torrents") if isinstance(evidence, dict) else []
+ hashes: List[str] = []
+ if isinstance(historical_torrents, list):
+ hashes = list(
+ dict.fromkeys(
+ str(torrent.get("hash") or "").strip()
+ for torrent in historical_torrents
+ if isinstance(torrent, dict) and torrent.get("hash")
+ )
+ )
+
+ qbittorrent = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+ if not qbittorrent.configured():
+ raise HTTPException(status_code=503, detail="qBittorrent is not configured")
+
+ try:
+ # Discover new jobs from the collector, not only yesterday's hashes or
+ # legacy Magent tags. Sonarr-owned downloads do not have those tags.
+ queue = None
+ request = await asyncio.to_thread(get_request_cache_payload, int(request_id))
+ if not isinstance(request, dict) and seerr.configured():
+ request = await seerr.get_request(request_id)
+ media = (request or {}).get("media") or {}
+ if (request or {}).get("type") == "tv" and media.get("tvdbId"):
+ collector = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ items = await collector.get_series_by_tvdb_id(int(media["tvdbId"]))
+ item = items[0] if isinstance(items, list) and items else None
+ if item and item.get("id"):
+ queue = await collector.get_queue(int(item["id"]))
+ queue = {**queue, "records": [r for r in _queue_records(queue) if r.get("seriesId") == item["id"]]}
+ hashes.extend(_download_ids(_queue_records(queue)))
+ hashes = list(dict.fromkeys(h.strip().lower() for h in hashes if h.strip()))
+ if hashes:
+ result = await qbittorrent.get_torrents_by_hashes("|".join(hashes))
+ else:
+ result = await qbittorrent.get_torrents_by_tag(f"magent-{request_id}")
+ except Exception as exc:
+ logger.warning("Live qBittorrent progress failed request_id=%s: %s", request_id, exc)
+ raise HTTPException(status_code=502, detail="Live download progress is temporarily unavailable") from exc
+
+ torrents = label_episode_downloads(current_cycle_torrents(result, cycle), queue)
+ for torrent in torrents:
+ if isinstance(torrent, dict):
+ torrent["progressPercent"] = _torrent_progress(torrent)
+
+ if torrents:
+ summary = _summarize_qbit(torrents)
+ state = str(summary.get("state") or "idle")
+ message = str(summary.get("message") or "Download found in qBittorrent.")
+ elif evidence.get("observed"):
+ state = "missing"
+ message = "The previous download is no longer visible in qBittorrent."
+ else:
+ state = "not_started"
+ message = "No download attempt has been observed."
+
+ return {
+ "request_id": request_id,
+ "state": state,
+ "summary": message,
+ "torrents": torrents,
+ "updated_at": datetime.now(timezone.utc).isoformat(),
+ "repairCycle": cycle,
+ "visible": bool(torrents) or bool(evidence.get("observed")),
+ }
+
+
+@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)
+ # Browsing is always local. Synchronization is owned by background workers.
+ allow_remote = False
+ 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()
+ # Jellyfin can promote working requests to ready. Filter the displayed
+ # stage before pagination, including working candidates in the ready view.
+ candidate_codes = [4, 5] if status_codes == [4] else status_codes
+ take = max(1, min(int(take), 200))
+ skip = max(0, int(skip))
+ rows = _get_recent_from_cache(
+ requested_by, requested_by_id,
+ len(_recent_cache.get("items") or []), 0, since_iso,
+ status_codes=candidate_codes,
+ )
+ matched = 0
+ cache_mode = (runtime.artwork_cache_mode or "remote").lower()
+ allow_title_hydrate = False
+ allow_artwork_hydrate = False
+ stage_cache = await asyncio.to_thread(get_request_stage_cache)
+ 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"):
+ 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 row.get("repairing"):
+ status = 5
+ status_label = "Repair in progress"
+ elif status_label in {"Working on it", "Ready to watch", "Partially ready"}:
+ saved = stage_cache.get(row.get("request_id")) or {}
+ is_available = bool(saved.get("ready"))
+ status_label = _status_label_with_jellyfin(status, is_available)
+ if status_label == STATUS_LABELS[4]:
+ status = 4
+ if status_codes and status not in status_codes:
+ continue
+ matched += 1
+ if matched <= skip:
+ continue
+ 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),
+ },
+ }
+ )
+ if len(results) >= take:
+ break
+
+ return {"results": results}
+
+
+@router.get("/search")
+async def search_requests(
+ query: str,
+ page: int = 1,
+ media_type: Optional[str] = None,
+ 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
+
+ requested_media_type = _normalize_media_type(media_type) if media_type is not None else None
+ if media_type is not None and requested_media_type is None:
+ raise HTTPException(status_code=400, detail="media_type must be 'movie' or 'tv'")
+
+ 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")
+ if requested_media_type is not None and media_type != requested_media_type:
+ continue
+ 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,
+ "overview": item.get("overview"),
+ "posterPath": item.get("posterPath") or item.get("poster_path"),
+ "backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
+ }
+ )
+
+ return {"results": results}
+
+
+@router.get("/request-options")
+async def request_options(
+ media_type: str,
+ tmdb_id: int,
+ user: Dict[str, str] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ del user
+ normalized_media_type = _normalize_media_type(media_type)
+ if normalized_media_type is None:
+ raise HTTPException(status_code=400, detail="media_type must be 'movie' or 'tv'")
+ if tmdb_id <= 0:
+ raise HTTPException(status_code=400, detail="tmdb_id must be a positive integer")
+
+ 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:
+ details, destination = await asyncio.gather(
+ client.get_movie(tmdb_id) if normalized_media_type == "movie" else client.get_tv(tmdb_id),
+ _resolve_request_destination(runtime, client, normalized_media_type),
+ )
+ except HTTPException:
+ raise
+ 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="Seerr returned invalid media details")
+
+ title = str(details.get("title") or details.get("name") or "Untitled")
+ date_value = details.get("releaseDate") or details.get("firstAirDate")
+ year = int(date_value[:4]) if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit() else None
+ seasons: list[Dict[str, Any]] = []
+ if normalized_media_type == "tv":
+ for season in details.get("seasons", []):
+ if not isinstance(season, dict):
+ continue
+ season_number = _quality_profile_id(season.get("seasonNumber"))
+ if season_number is None or season_number <= 0:
+ continue
+ seasons.append(
+ {
+ "seasonNumber": season_number,
+ "name": str(season.get("name") or f"Season {season_number}"),
+ "episodeCount": _quality_profile_id(season.get("episodeCount")) or 0,
+ "airDate": season.get("airDate"),
+ }
+ )
+
+ media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
+ requests_list = media_info.get("requests")
+ existing_request_id = None
+ if isinstance(requests_list, list) and requests_list and isinstance(requests_list[0], dict):
+ existing_request_id = _quality_profile_id(requests_list[0].get("id"))
+
+ return {
+ "media": {
+ "title": title,
+ "year": year,
+ "type": normalized_media_type,
+ "tmdbId": tmdb_id,
+ "overview": details.get("overview"),
+ "posterPath": details.get("posterPath") or details.get("poster_path"),
+ "backdropPath": details.get("backdropPath") or details.get("backdrop_path"),
+ "seasons": seasons,
+ "existingRequestId": existing_request_id,
+ "originalLanguage": language_info(details),
+ },
+ "destination": {
+ "collector": destination["collector"],
+ "serverName": destination["server_name"],
+ "defaultProfileId": destination["default_profile_id"],
+ "profiles": destination["profiles"],
+ },
+ }
+
+
+@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")
+
+ language = language_info(details)
+ accept_original = payload.get("acceptOriginalLanguage", False)
+ if not isinstance(accept_original, bool):
+ raise HTTPException(400, "The language choice must be true or false.")
+ if accept_original and not language:
+ raise HTTPException(409, "The original language could not be verified. Reload this title.")
+
+ 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):
+ if accept_original:
+ raise HTTPException(409, 'This title is already requested. Open its request and choose Use original audio & search to update the existing movie.')
+ 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),
+ }
+
+ if media_type == "tv" and seasons:
+ valid_seasons = {
+ _quality_profile_id(item.get("seasonNumber"))
+ for item in details.get("seasons", [])
+ if isinstance(item, dict)
+ }
+ invalid_seasons = [season for season in seasons if season not in valid_seasons]
+ if invalid_seasons:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Season selection is not available for this series: {invalid_seasons}",
+ )
+
+ destination = await _resolve_request_destination(runtime, client, media_type)
+ if accept_original and media_type == "movie":
+ destination["profile_id"] = await original_profile(
+ RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), destination["profile_id"])
+ # Seerr does not update an already-existing Radarr movie's profile on request creation.
+ await apply_original_to_movie(RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), tmdb_id)
+
+ 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,
+ server_id=destination["server_id"],
+ profile_id=destination["profile_id"],
+ root_folder=destination["root_folder"],
+ )
+ 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")
+
+ created = _merge_request_media_details(created, details)
+ 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')}."
+ + (f" Original-language audio accepted ({language['code']})." if accept_original else ""),
+ )
+
+ 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_for_user(await build_snapshot(request_id), user)
+ return triage_snapshot(snapshot)
+
+
+async def _request_language_context(request_id, user, *, require_owner: bool = False):
+ runtime = get_runtime_settings()
+ seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ request = await _ensure_request_access(
+ seerr, int(request_id), user, require_owner=require_owner
+ )
+ if request is None:
+ request = await seerr.get_request(request_id)
+ if not isinstance(request, dict) or request.get('type') != 'movie':
+ return runtime, None, None
+ tmdb_id = (request.get('media') or {}).get('tmdbId')
+ if not isinstance(tmdb_id, int):
+ raise HTTPException(502, 'Seerr did not return the movie identity.')
+ details = await seerr.get_movie(tmdb_id)
+ return runtime, tmdb_id, language_info(details or {})
+
+
+@router.get("/{request_id}/language")
+async def request_language(request_id: str, user: dict = Depends(get_current_user)):
+ runtime, tmdb_id, language = await _request_language_context(request_id, user)
+ if not language:
+ return {'language': None}
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ movies = await radarr.get_movie_by_tmdb_id(tmdb_id)
+ movie = next((m for m in (movies or []) if m.get('tmdbId') == tmdb_id), None)
+ profiles = await radarr.get_quality_profiles() if movie else []
+ profile = next((p for p in profiles if p['id'] == movie['qualityProfileId']), {}) if movie else {}
+ return {'language': language, 'originalEnabled': is_original_profile(profile),
+ 'canChange': bool(movie) and _user_can_use_search_auto(user),
+ 'profileLanguage': (profile.get('language') or {}).get('name')}
+
+
+@router.post("/{request_id}/actions/language")
+async def accept_request_language(request_id: str, payload: dict, user: dict = Depends(get_current_user)):
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(403, 'Search and download changes are disabled for this account.')
+ if payload.get('acceptOriginalLanguage') is not True:
+ raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
+ runtime, tmdb_id, language = await _request_language_context(
+ request_id, user, require_owner=True
+ )
+ if not language:
+ raise HTTPException(409, 'This request has no verified non-English original language.')
+ if payload.get('languageCode') != language['code']:
+ raise HTTPException(409, 'The language metadata changed. Reload the request and review it again.')
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ profile_id = await apply_original_to_movie(radarr, tmdb_id)
+ if profile_id is None:
+ raise HTTPException(409, 'The movie is not in Radarr yet. Recheck the pipeline first.')
+ await asyncio.to_thread(save_action, request_id, 'original_language', 'Accept original-language audio',
+ 'ok', f"Original-language audio accepted ({language['code']}); Radarr profile {profile_id} verified.")
+ result = await action_search_auto(request_id, user)
+ result['message'] = 'Original-language audio enabled. ' + result['message']
+ return result
+
+
+@router.post("/{request_id}/actions/search")
+async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user), offset: int = 0) -> dict:
+ if offset < 0:
+ raise HTTPException(400, 'Search offset must be zero or greater.')
+ total_missing = 0
+ next_offset = None
+ runtime = get_runtime_settings()
+ await _ensure_request_mutation_access(runtime, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ arr_item = snapshot.raw.get("arr", {}).get("item")
+ if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
+ raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
+
+ results: List[Dict[str, Any]] = []
+ collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
+ try:
+ if snapshot.request_type == RequestType.tv:
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if not sonarr.configured():
+ raise HTTPException(status_code=400, detail="Sonarr not configured")
+ episodes = await sonarr.get_episodes(int(arr_item["id"]))
+ missing_by_season = _missing_episode_ids_by_season(episodes)
+ season_numbers = sorted(missing_by_season)
+ if not season_numbers:
+ message = "Sonarr has no missing monitored episodes to search for."
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "search_releases",
+ "Search and choose a download",
+ "ok",
+ message,
+ )
+ return {"status": "ok", "message": message, "collector": collector, "releases": []}
+ missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]]
+ total_missing = len(missing_ids)
+ batch = missing_ids[offset:offset + 3]
+ next_offset = offset + len(batch) if offset + len(batch) < total_missing else None
+ semaphore = asyncio.Semaphore(3)
+ async def search_episode(identity):
+ async with semaphore:
+ found = await sonarr.search_episode_releases(identity)
+ if not isinstance(found, list):
+ raise HTTPException(502, 'Sonarr did not return valid episode search results. Try again.')
+ return found
+ searches = await asyncio.gather(*(search_episode(identity) for identity in batch))
+ # Interleave per-episode rankings so a prolific episode cannot hide the others.
+ for position in range(max((len(items) for items in searches), default=0)):
+ for items in searches:
+ if position < len(items):
+ results.append(items[position])
+ elif snapshot.request_type == RequestType.movie:
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ if not radarr.configured():
+ raise HTTPException(status_code=400, detail="Radarr not configured")
+ movie_results = await radarr.search_releases(int(arr_item["id"]))
+ if isinstance(movie_results, list):
+ results = movie_results
+ else:
+ raise HTTPException(status_code=400, detail="Unknown request type")
+ except HTTPException:
+ raise
+ except httpx.HTTPStatusError as exc:
+ _log_arr_http_error(collector, "interactive release search", exc)
+ detail = _format_upstream_error(collector, exc)
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "search_releases",
+ "Search and choose a download",
+ "failed",
+ detail,
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+ except Exception as exc:
+ logger.exception("%s interactive release search failed request_id=%s", collector, request_id)
+ detail = f"{collector} could not complete the release search: {exc}"
+ await asyncio.to_thread(
+ save_action,
+ request_id,
+ "search_releases",
+ "Search and choose a download",
+ "failed",
+ detail,
+ )
+ raise HTTPException(status_code=502, detail=detail) from exc
+
+ releases = _filter_arr_release_results(results, include_rejected=True)
+ approved = sum(not r['requiresOverride'] and r['selectable'] for r in releases)
+ source = runtime.sonarr_base_url if collector == 'Sonarr' else runtime.radarr_base_url
+ override_allowed = manual_releases.can_override(user)
+ for release in releases:
+ if release['selectable'] and (not release['requiresOverride'] or override_allowed):
+ release['selectionToken'] = manual_releases.issue_selection(release, request_id, user, source, arr_item['id'])
+ for key in ('downloadUrl', 'magnetUrl'):
+ release.pop(key, None)
+ rejection_reasons = sorted({str(reason) for result in results for reason in (result.get('rejections') or [])})[:8]
+ message = (f'{len(releases)} releases shown; {approved} meet the assigned profile. Review the reasons on other releases.'
+ if releases else 'No releases were returned for the missing content. Try again later or check the indexers.')
+ if len(results) > len(releases):
+ message += ' Duplicate results are combined; up to 200 ranked releases are shown.'
+ if total_missing:
+ message += f' Searched {min(3, max(0, total_missing - offset))} of {total_missing} missing monitored episodes.'
+ await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message)
+ return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message,
+ 'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons,
+ 'canIgnoreProfileLimits': override_allowed, 'nextOffset': next_offset,
+ 'totalMissingEpisodes': total_missing, 'releases': releases}
+
+
+@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()
+ await _ensure_request_mutation_access(runtime, 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")
+ 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."
+ 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}
+ )
+ outcome = await series_search_outcome(client, int(arr_item["id"]), [item['response'] for item in responses])
+ message = outcome['message']
+ await asyncio.to_thread(
+ save_action, request_id, "search_auto", "Search and auto-download", "ok", message
+ )
+ return {"status": outcome["status"], "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")
+ response = await client.search(int(arr_item["id"]))
+ outcome = await movie_search_outcome(client, int(arr_item["id"]), response)
+ message = outcome['message']
+ await asyncio.to_thread(
+ save_action, request_id, "search_auto", "Search and auto-download", "ok", message
+ )
+ return {"status": outcome["status"], "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()
+ await _ensure_request_mutation_access(runtime, 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()
+ await _ensure_request_mutation_access(runtime, 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
+ except ValueError as exc:
+ detail = f"Sonarr could not add this series: {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")
+ title = snapshot.title
+ if title in {None, "", "Unknown"}:
+ title = (
+ media.get("title")
+ or media.get("name")
+ or jelly.get("title")
+ or jelly.get("name")
+ )
+ try:
+ response = await client.add_movie(
+ int(tmdb_id), runtime.radarr_quality_profile_id, root_folder, title=title
+ )
+ 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
+ except ValueError as exc:
+ detail = f"Radarr could not add this movie: {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:
+ _require_advanced_request_access(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)
+ 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:
+ _require_advanced_request_access(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)
+ 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()
+ await _ensure_request_mutation_access(runtime, int(request_id), user)
+ snapshot = await build_snapshot(request_id)
+ guid = payload.get("guid")
+ indexer_id = payload.get("indexerId")
+ release_title = payload.get("title")
+ if not guid or not indexer_id:
+ raise HTTPException(status_code=400, detail="Missing guid or indexerId")
+ try:
+ arr_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(
+ "Collector grab requested: request_id=%s guid=%s indexer_id=%s has_title=%s",
+ request_id,
+ guid,
+ indexer_id,
+ bool(release_title),
+ )
+
+ if snapshot.request_type.value == "tv":
+ arr_client: Any = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ service_label = "Sonarr"
+ elif snapshot.request_type.value == "movie":
+ arr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ service_label = "Radarr"
+ else:
+ raise HTTPException(status_code=400, detail="Unknown request type")
+
+ if not arr_client.configured():
+ raise HTTPException(status_code=400, detail=f"{service_label} not configured")
+
+ arr_item = snapshot.raw.get('arr', {}).get('item') or {}
+ source = runtime.sonarr_base_url if service_label == 'Sonarr' else runtime.radarr_base_url
+ receipt = manual_releases.verify_selection(payload, request_id, user, source, arr_item.get('id'))
+ release_title = receipt.get('title')
+ arr_error: Optional[str] = None
+ try:
+ await arr_client.grab_release(str(guid), arr_indexer_id)
+ action_message = (
+ f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
+ + (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
+ )
+ 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},
+ }
+ except httpx.HTTPStatusError as exc:
+ _log_arr_http_error(service_label, "release grab", exc)
+ status_code = exc.response.status_code if exc.response is not None else None
+ arr_error = _format_upstream_error(service_label, exc)
+ if status_code == 404:
+ raise HTTPException(409, 'The collector no longer has this release cached. Search again before downloading.') from exc
+ except Exception as exc:
+ logger.exception("%s release grab failed request_id=%s", service_label, request_id)
+ arr_error = str(exc)
+
+ failure_message = (
+ f"The selected release could not be started through {service_label}. "
+ + (arr_error or f"{service_label} did not accept the release.")
+ )
+ await asyncio.to_thread(
+ save_action, request_id, "grab", "Download selected release", "failed", failure_message
+ )
+ raise HTTPException(status_code=502, detail=failure_message)
+
+
+async def refresh_local_request_stages():
+ runtime = get_runtime_settings()
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not jellyfin.configured():
+ return
+ rows = await asyncio.to_thread(get_cached_requests_since, (datetime.now(timezone.utc) - timedelta(days=RECENT_CACHE_MAX_DAYS)).isoformat())
+ saved = await asyncio.to_thread(get_request_stage_cache)
+ interval = max(1, min(1440, int(runtime.requests_stage_refresh_minutes))) * 60
+ now = time.time()
+ due = [row for row in rows if row.get('status') == 5 and
+ (now - (saved.get(row['request_id']) or {}).get('checked_at', 0) >= interval or
+ (saved.get(row['request_id']) or {}).get('source_updated') != row.get('updated_at'))]
+ semaphore = asyncio.Semaphore(4)
+ async def check(row):
+ async with semaphore:
+ payload = await asyncio.to_thread(get_request_cache_payload, row['request_id'])
+ try:
+ ready = await _request_is_available_in_jellyfin(jellyfin, row.get('title'), row.get('year'), row.get('media_type'), payload, {}, raise_errors=True)
+ return (row['request_id'], row.get('updated_at'), int(ready), time.time())
+ except Exception:
+ logger.warning('Local request stage refresh failed request_id=%s', row['request_id'])
+ return None
+ checked = await asyncio.gather(*(check(row) for row in due))
+ await asyncio.to_thread(save_request_stage_cache, [row for row in checked if row is not None])
+
+
+async def run_local_request_stage_loop():
+ while True:
+ try:
+ await refresh_local_request_stages()
+ except Exception:
+ logger.exception('Local request stage refresh failed')
+ await asyncio.sleep(30)
diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py
new file mode 100644
index 0000000..497b483
--- /dev/null
+++ b/backend/app/routers/setup.py
@@ -0,0 +1,92 @@
+"""Initial install bootstrap and authenticated setup wizard endpoints."""
+
+from inspect import isawaitable
+
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from pydantic import Field, SecretStr
+
+from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
+from ..auth import _extract_client_ip, require_admin
+from ..services import setup as setup_service
+from ..installation_origin import normalize_application_origin
+from ..services.request_origins import can_claim_initial_origin
+
+
+router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
+
+
+class BootstrapRequest(StrictRequest):
+ setup_token: SecretStr = Field(min_length=1, max_length=1024)
+ username: str = Field(min_length=1, max_length=100)
+ password: SecretStr = Field(min_length=1, max_length=1024)
+ application_url: str | None = Field(default=None, max_length=2048)
+
+
+class SetupProgress(StrictRequest):
+ step: setup_service.SetupStep
+
+
+@router.get("/status")
+def public_status(response: Response) -> dict:
+ response.headers["Cache-Control"] = "no-store"
+ return setup_service.get_public_setup_status()
+
+
+@router.post("/bootstrap", status_code=201)
+def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
+ status = setup_service.get_public_setup_status()
+ if not status["needs_admin"]:
+ raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
+ retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
+ if retry_after is not None:
+ raise HTTPException(
+ status_code=429,
+ detail="Too many setup attempts. Try again later.",
+ headers={"Retry-After": str(retry_after)},
+ )
+ try:
+ application_url = payload.application_url
+ if application_url is not None:
+ application_url = normalize_application_origin(application_url)
+ origin = request.headers.get("origin", "")
+ if not origin or application_url != normalize_application_origin(origin):
+ raise HTTPException(status_code=403, detail="The site address must match the address open in your browser.")
+ elif can_claim_initial_origin():
+ raise HTTPException(status_code=400, detail="Confirm the application URL to create the administrator.")
+ setup_service.bootstrap_administrator(
+ payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value(),
+ application_url=application_url,
+ )
+ except setup_service.InvalidSetupTokenError as exc:
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
+ except setup_service.SetupUnavailableError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"status": "created", "username": payload.username.strip()}
+
+
+@router.get("/state", dependencies=[Depends(require_admin)])
+def get_state() -> dict:
+ return setup_service.get_setup_state()
+
+
+@router.put("/state", dependencies=[Depends(require_admin)])
+def update_state(payload: SetupProgress) -> dict:
+ return setup_service.update_setup_step(payload.step)
+
+
+@router.post("/complete", dependencies=[Depends(require_admin)])
+async def finish_setup(request: Request) -> dict:
+ try:
+ state = setup_service.complete_setup()
+ except setup_service.SetupUnavailableError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ # Startup owns worker lifecycle. Its callback must be idempotent so retries
+ # after a network interruption cannot start duplicate import/automation jobs.
+ callback = getattr(request.app.state, "on_setup_complete", None)
+ if callback is not None:
+ result = callback()
+ if isawaitable(result):
+ await result
+ return state
diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py
new file mode 100644
index 0000000..c3d1bcd
--- /dev/null
+++ b/backend/app/routers/site.py
@@ -0,0 +1,62 @@
+from typing import Any, Dict
+from urllib.parse import urlsplit
+
+from fastapi import APIRouter, Depends
+
+from ..auth import get_current_user
+from ..build_info import BUILD_NUMBER, CHANGELOG
+from ..config import normalize_banner_color
+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()
+ login_message = (runtime.site_login_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,
+ "backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
+ "borderColor": normalize_banner_color(runtime.site_banner_border_color),
+ },
+ "login": {
+ "message": login_message,
+ "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()
+ playback_url = (runtime.jellyfin_public_url or "").strip()
+ try:
+ parsed = urlsplit(playback_url)
+ valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
+ except ValueError:
+ valid = False
+ info["mediaServerUrl"] = playback_url if valid else None
+ 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..d85463c
--- /dev/null
+++ b/backend/app/routers/status.py
@@ -0,0 +1,188 @@
+from typing import Any, Dict
+import httpx
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..auth import require_admin
+from ..runtime import get_runtime_settings
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.sonarr import SonarrClient
+from ..clients.radarr import RadarrClient
+from ..clients.bazarr import BazarrClient
+from ..clients.prowlarr import ProwlarrClient
+from ..clients.qbittorrent import QBittorrentClient
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellystat import JellystatClient
+
+router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
+
+
+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)
+ bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_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,
+ )
+ )
+ services.append(
+ await _check(
+ "Bazarr",
+ bazarr.configured() and bool(runtime.bazarr_api_key),
+ bazarr.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,
+ )
+ )
+
+ jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
+ # Optional analytics must not degrade the media pipeline when not configured.
+ if jellystat.configured():
+ services.append(await _check("Jellystat", True, jellystat.test_connection))
+
+ 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)
+ bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_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()
+ if service_key == "jellystat":
+ jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
+ return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
+ 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),
+ "bazarr": (
+ "Bazarr",
+ bazarr.configured() and bool(runtime.bazarr_api_key),
+ bazarr.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..10eddcc
--- /dev/null
+++ b/backend/app/runtime.py
@@ -0,0 +1,70 @@
+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_stage_refresh_minutes",
+ "requests_delta_sync_interval_minutes",
+ "requests_cleanup_days",
+ "issue_confirmation_contact_attempts",
+ "issue_confirmation_interval_value",
+ "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/schema_migrations.py b/backend/app/schema_migrations.py
new file mode 100644
index 0000000..287019f
--- /dev/null
+++ b/backend/app/schema_migrations.py
@@ -0,0 +1,116 @@
+"""Transactional, versioned SQLite schema migrations for Magent."""
+
+from __future__ import annotations
+
+import sqlite3
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Callable
+
+
+MigrationStep = Callable[[sqlite3.Connection], None]
+
+
+@dataclass(frozen=True)
+class Migration:
+ version: int
+ name: str
+ apply: MigrationStep
+
+
+def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
+ return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
+
+
+def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
+ column = definition.split(maxsplit=1)[0].strip('"')
+ if column not in _column_names(conn, table):
+ conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
+
+
+def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
+ for definition in (
+ "email TEXT",
+ "last_login_at TEXT",
+ "is_blocked INTEGER NOT NULL DEFAULT 0",
+ "auth_provider TEXT NOT NULL DEFAULT 'local'",
+ "jellyfin_password_hash TEXT",
+ "last_jellyfin_auth_at TEXT",
+ "jellyseerr_user_id INTEGER",
+ "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",
+ "auth_version INTEGER NOT NULL DEFAULT 1",
+ ):
+ _add_column(conn, "users", definition)
+
+ for definition in ("recipient_email TEXT", "code_hint TEXT"):
+ _add_column(conn, "signup_invites", definition)
+
+ for definition in (
+ "related_item_id INTEGER",
+ "workflow_request_status TEXT",
+ "workflow_media_status TEXT",
+ "issue_type TEXT",
+ "issue_resolved_at TEXT",
+ "metadata_json TEXT",
+ ):
+ _add_column(conn, "portal_items", definition)
+
+ _add_column(conn, "requests_cache", "requested_by_id INTEGER")
+
+ statements = (
+ "CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
+ "(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
+ "CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
+ "(related_item_id, updated_at DESC, id DESC)",
+ "CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
+ "CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
+ "CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
+ "CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
+ "CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
+ "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)",
+ )
+ for statement in statements:
+ conn.execute(statement)
+
+
+MIGRATIONS = (
+ Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
+)
+
+
+def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ applied_at TEXT NOT NULL
+ )
+ """
+ )
+ applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
+ completed: list[int] = []
+ for migration in MIGRATIONS:
+ if migration.version in applied:
+ continue
+ savepoint = f"magent_migration_{migration.version}"
+ conn.execute(f"SAVEPOINT {savepoint}")
+ try:
+ migration.apply(conn)
+ conn.execute(
+ "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
+ (migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
+ )
+ conn.execute(f"RELEASE SAVEPOINT {savepoint}")
+ except Exception:
+ conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
+ conn.execute(f"RELEASE SAVEPOINT {savepoint}")
+ raise
+ completed.append(migration.version)
+ return completed
diff --git a/backend/app/secret_storage.py b/backend/app/secret_storage.py
new file mode 100644
index 0000000..f7cf856
--- /dev/null
+++ b/backend/app/secret_storage.py
@@ -0,0 +1,74 @@
+import base64
+import hashlib
+from typing import Optional
+
+from cryptography.fernet import Fernet, InvalidToken
+
+from .config import settings
+
+
+ENCRYPTED_PREFIX = "enc:v1:"
+SENSITIVE_SETTING_KEYS = frozenset(
+ {
+ "jellystat_api_key", "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", "bazarr_api_key",
+ "prowlarr_api_key", "qbittorrent_password", "discord_webhook_url",
+ }
+)
+
+
+def _fernet_key() -> bytes:
+ configured = str(settings.settings_encryption_key or "").strip()
+ if configured:
+ try:
+ decoded = base64.urlsafe_b64decode(configured.encode("ascii"))
+ except Exception as exc:
+ raise RuntimeError("SETTINGS_ENCRYPTION_KEY must be a valid Fernet key") from exc
+ if len(decoded) != 32:
+ raise RuntimeError("SETTINGS_ENCRYPTION_KEY must decode to exactly 32 bytes")
+ return configured.encode("ascii")
+ jwt_secret = str(settings.jwt_secret or "").strip()
+ if len(jwt_secret) < 32 or jwt_secret == "change-me":
+ raise RuntimeError(
+ "SETTINGS_ENCRYPTION_KEY is required when JWT_SECRET is not a strong migration key"
+ )
+ derived = hashlib.sha256(("magent-settings-v1:" + jwt_secret).encode("utf-8")).digest()
+ return base64.urlsafe_b64encode(derived)
+
+
+def is_sensitive_setting(key: str) -> bool:
+ return str(key or "").strip().lower() in SENSITIVE_SETTING_KEYS
+
+
+def validate_secret_storage_configuration() -> None:
+ """Validate the configured or JWT-derived Fernet key without touching stored data."""
+ Fernet(_fernet_key())
+
+
+def encrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
+ if value is None or not is_sensitive_setting(key):
+ return value
+ text = str(value)
+ if text.startswith(ENCRYPTED_PREFIX):
+ return text
+ token = Fernet(_fernet_key()).encrypt(text.encode("utf-8")).decode("ascii")
+ return ENCRYPTED_PREFIX + token
+
+
+def decrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
+ if value is None or not is_sensitive_setting(key):
+ return value
+ text = str(value)
+ if not text.startswith(ENCRYPTED_PREFIX):
+ return text
+ try:
+ return Fernet(_fernet_key()).decrypt(
+ text[len(ENCRYPTED_PREFIX) :].encode("ascii")
+ ).decode("utf-8")
+ except InvalidToken as exc:
+ raise RuntimeError(
+ f"Stored secret '{key}' cannot be decrypted with the configured key"
+ ) from exc
diff --git a/backend/app/security.py b/backend/app/security.py
new file mode 100644
index 0000000..e3bc378
--- /dev/null
+++ b/backend/app/security.py
@@ -0,0 +1,116 @@
+from datetime import datetime, timedelta, timezone
+import uuid
+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=["argon2", "pbkdf2_sha256"],
+ deprecated=["pbkdf2_sha256"],
+ argon2__memory_cost=65536,
+ argon2__time_cost=3,
+ argon2__parallelism=4,
+)
+_ALGORITHM = "HS256"
+MIN_PASSWORD_LENGTH = 12
+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:
+ try:
+ return _pwd_context.verify(plain_password, hashed_password)
+ except (TypeError, ValueError):
+ return False
+
+
+def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, Optional[str]]:
+ try:
+ return _pwd_context.verify_and_update(plain_password, hashed_password)
+ except (TypeError, ValueError):
+ return False, None
+
+
+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",
+ auth_version: int = 1,
+) -> str:
+ issued_at = datetime.now(timezone.utc)
+ payload: Dict[str, Any] = {
+ "sub": subject,
+ "role": role,
+ "typ": token_type,
+ "exp": expires_at,
+ "iat": issued_at,
+ "jti": uuid.uuid4().hex,
+ "iss": settings.jwt_issuer,
+ "aud": settings.jwt_audience,
+ "ver": max(1, int(auth_version or 1)),
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
+
+def create_access_token(
+ subject: str,
+ role: str,
+ expires_minutes: Optional[int] = None,
+ *,
+ auth_version: int = 1,
+) -> 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", auth_version=auth_version)
+
+
+def create_stream_token(
+ subject: str,
+ role: str,
+ expires_seconds: int = 120,
+ *,
+ auth_version: int = 1,
+) -> 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", auth_version=auth_version)
+
+
+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],
+ audience=settings.jwt_audience,
+ issuer=settings.jwt_issuer,
+ options={"require": ["exp", "iat", "jti", "iss", "aud", "sub", "typ", "ver"]},
+ )
+
+
+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/arr.py b/backend/app/services/arr.py
new file mode 100644
index 0000000..1edff84
--- /dev/null
+++ b/backend/app/services/arr.py
@@ -0,0 +1,21 @@
+"""Shared Sonarr/Radarr configuration helpers."""
+
+from typing import Any
+
+
+class RootFolderNotFoundError(ValueError):
+ pass
+
+
+async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
+ configured = str(root_folder or "").strip()
+ if not configured.isdigit():
+ return configured
+ folders = await client.get_root_folders()
+ if isinstance(folders, list):
+ for folder in folders:
+ if isinstance(folder, dict) and folder.get("id") == int(configured):
+ path = str(folder.get("path") or "").strip()
+ if path:
+ return path
+ raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
diff --git a/backend/app/services/backups.py b/backend/app/services/backups.py
new file mode 100644
index 0000000..930803d
--- /dev/null
+++ b/backend/app/services/backups.py
@@ -0,0 +1,647 @@
+"""Encrypted, portable backups and restart-only SQLite restores.
+
+Restore is deliberately a two-step operation: the authenticated request validates
+and stages it, then a single backend process applies it before opening the DB.
+A durable journal and a private rollback copy protect interrupted installations.
+"""
+
+from __future__ import annotations
+
+from contextlib import closing, contextmanager
+from datetime import datetime, timezone
+import hashlib
+import io
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import secrets
+import shutil
+import sqlite3
+import stat
+import tempfile
+import threading
+import time
+from typing import Any, BinaryIO, Iterator
+import uuid
+import zipfile
+import zlib
+
+from cryptography.exceptions import InvalidTag
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
+from pydantic import TypeAdapter
+
+from ..config import Settings, settings
+from ..db import _db_path
+from ..installation_origin import managed_runtime, normalize_application_origin
+from ..schema_migrations import MIGRATIONS
+from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
+
+FORMAT_VERSION = 1
+MAX_UPLOAD_BYTES = 32 * 1024 * 1024
+MAX_EXPANDED_BYTES = 128 * 1024 * 1024
+MAX_ENTRIES = 20_000
+MAGIC = b"MAGENT-BACKUP\x00\x01"
+_LOCK = threading.Lock()
+_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
+_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
+# Host identity, process controls and local file locations belong to the target.
+_LOCAL_FIELDS = {
+ "sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
+ "admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
+ "auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
+ "auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
+ "log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
+ "magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
+ "magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
+ "magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
+ "site_build_number", "site_changelog", "magent_allow_private_notification_targets",
+}
+
+
+class BackupError(ValueError):
+ """A safe-to-display backup validation or state error."""
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _assets_root() -> Path:
+ # Matches the image and branding routers, independently of SQLITE_PATH.
+ return Path.cwd() / "data"
+
+
+def _control_root() -> Path:
+ return Path(_db_path()).absolute().parent / "backups"
+
+
+def _private_dir(path: Path) -> None:
+ if path.is_symlink():
+ raise BackupError("Backup directories must not be symbolic links")
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
+ path.chmod(0o700)
+
+
+def _write_private(path: Path, content: bytes) -> None:
+ with path.open("xb") as handle:
+ path.chmod(0o600)
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+
+
+def _write_json(path: Path, data: dict) -> None:
+ temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
+ try:
+ _write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
+ os.replace(temporary, path)
+ _sync_directory(path.parent)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def _sync_directory(path: Path) -> None:
+ if os.name != "nt":
+ descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
+
+
+def _sync_tree(path: Path) -> None:
+ for parent, _directories, files in os.walk(path, topdown=False):
+ for filename in files:
+ with (Path(parent) / filename).open("r+b") as handle:
+ os.fsync(handle.fileno())
+ _sync_directory(Path(parent))
+
+
+@contextmanager
+def _exclusive_operation() -> Iterator[None]:
+ if not _LOCK.acquire(blocking=False):
+ raise BackupError("Another backup or restore operation is in progress")
+ handle = None
+ locked = False
+ try:
+ root = _control_root()
+ _private_dir(root)
+ handle = (root / "operation.lock").open("a+b")
+ os.chmod(handle.name, 0o600)
+ # OS locks are released even if a process crashes; support the dev host too.
+ if os.name == "nt":
+ import msvcrt
+ handle.seek(0)
+ if not handle.read(1):
+ handle.write(b"0")
+ handle.flush()
+ handle.seek(0)
+ try:
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ except OSError as exc:
+ raise BackupError("Another backup or restore operation is in progress") from exc
+ else:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError as exc:
+ raise BackupError("Another backup or restore operation is in progress") from exc
+ locked = True
+ yield
+ finally:
+ if handle is not None:
+ if locked:
+ if os.name == "nt":
+ import msvcrt
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ import fcntl
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+ handle.close()
+ _LOCK.release()
+
+
+def validate_passphrase(passphrase: str) -> None:
+ if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
+ raise BackupError("Use a backup passphrase between 12 and 1024 characters")
+
+
+def _key(passphrase: str, salt: bytes) -> bytes:
+ validate_passphrase(passphrase)
+ return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
+
+
+def _encrypt(content: bytes, passphrase: str) -> bytes:
+ salt, nonce = os.urandom(16), os.urandom(12)
+ header = MAGIC + salt + nonce
+ return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
+
+
+def _decrypt(content: bytes, passphrase: str) -> bytes:
+ header_size = len(MAGIC) + 28
+ if len(content) > MAX_UPLOAD_BYTES:
+ raise BackupError("Backup exceeds the 32 MiB upload limit")
+ if len(content) < header_size + 16 or not content.startswith(MAGIC):
+ raise BackupError("This is not a supported encrypted Magent backup")
+ salt = content[len(MAGIC):len(MAGIC) + 16]
+ nonce = content[len(MAGIC) + 16:header_size]
+ try:
+ return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
+ except InvalidTag as exc:
+ raise BackupError("Incorrect passphrase or damaged backup") from exc
+
+
+def _database_copy(source: Path, destination: Path) -> None:
+ if not source.is_file() or source.is_symlink():
+ raise BackupError("The configured database is unavailable or is a symbolic link")
+ deadline = time.monotonic() + 60
+
+ def progress(_status: int, _remaining: int, _total: int) -> None:
+ if time.monotonic() > deadline:
+ raise BackupError("Database is too busy to back up; try again shortly")
+
+ with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
+ with closing(sqlite3.connect(destination)) as dst:
+ destination.chmod(0o600)
+ src.backup(dst, pages=256, progress=progress, sleep=0.05)
+ dst.execute("PRAGMA journal_mode=DELETE")
+
+
+def _portable_database(path: Path) -> None:
+ """Materialize env-backed settings and remove source-specific encryption."""
+ with closing(sqlite3.connect(path)) as conn, conn:
+ conn.execute("PRAGMA secure_delete=ON")
+ # init_db recreates application-owned triggers after restoration; never
+ # distribute executable schema objects in a data backup.
+ for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
+ quoted = str(trigger).replace('"', '""')
+ conn.execute(f'DROP TRIGGER "{quoted}"')
+ overrides = dict(conn.execute("SELECT key, value FROM settings"))
+ for key, default in settings.model_dump().items():
+ if key in _LOCAL_FIELDS:
+ continue
+ value = overrides.get(key)
+ value = default if value is None else decrypt_setting_value(key, value)
+ 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, "" if value is None else str(value), _now()),
+ )
+ for key in _LOCAL_FIELDS:
+ conn.execute("DELETE FROM settings WHERE key=?", (key,))
+ # Future secret keys may not yet be exposed through Settings.
+ for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
+ if key in SENSITIVE_SETTING_KEYS:
+ conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
+ conn.commit()
+ conn.execute("VACUUM")
+
+
+def _asset_allowed(name: str, include_cache: bool) -> bool:
+ parts = PurePosixPath(name).parts
+ if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
+ return True
+ return bool(
+ include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
+ and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
+ and parts[4] not in {".", ".."}
+ )
+
+
+def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
+ root = _assets_root()
+ for directory in ("branding", "artwork") if include_cache else ("branding",):
+ base = root / directory
+ if not base.exists():
+ continue
+ if base.is_symlink() or root.is_symlink():
+ raise BackupError("Asset directories must not be symbolic links")
+ for parent, directories, files in os.walk(base, followlinks=False):
+ if any((Path(parent) / name).is_symlink() for name in directories + files):
+ raise BackupError("Symbolic links are not supported in backup assets")
+ for filename in files:
+ path = Path(parent) / filename
+ archive_name = "files/" + path.relative_to(root).as_posix()
+ if _asset_allowed(archive_name, include_cache):
+ yield path, archive_name
+
+
+def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
+ validate_passphrase(passphrase)
+ with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
+ directory = Path(temporary)
+ directory.chmod(0o700)
+ database = directory / "database.sqlite3"
+ _database_copy(Path(_db_path()).absolute(), database)
+ _portable_database(database)
+ files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
+ if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
+ raise BackupError("Backup is too large; retry without the artwork cache")
+ archive_path = directory / "payload.zip"
+ manifest = {
+ "format_version": FORMAT_VERSION, "created_at": _now(),
+ "build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
+ "files": {},
+ }
+ with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
+ archive_path.chmod(0o600)
+ total = 0
+ for path, name in files:
+ digest = hashlib.sha256()
+ size = 0
+ with path.open("rb") as source, archive.open(name, "w") as destination:
+ while chunk := source.read(1024 * 1024):
+ total += len(chunk)
+ size += len(chunk)
+ if total > MAX_EXPANDED_BYTES:
+ raise BackupError("Backup is too large; retry without the artwork cache")
+ digest.update(chunk)
+ destination.write(chunk)
+ manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
+ archive.writestr("manifest.json", json.dumps(manifest))
+ if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
+ raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
+ encrypted = _encrypt(archive_path.read_bytes(), passphrase)
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ return encrypted, f"magent-backup-{stamp}.magent-backup"
+
+
+def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
+ try:
+ with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
+ conn.execute("PRAGMA trusted_schema=OFF")
+ deadline = time.monotonic() + 30
+ conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
+ if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
+ raise BackupError("Backup database failed its integrity check")
+ schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
+ if len(schema) > 500 or any(
+ kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
+ for kind, _name, sql in schema
+ ):
+ raise BackupError("Backup contains an unsupported database schema")
+ if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
+ raise BackupError("Backup database contains broken references")
+ required = {
+ "settings": {"key", "value", "updated_at"},
+ "users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
+ "signup_invites": {"id", "code", "enabled"},
+ "requests_cache": {"request_id", "payload_json"},
+ "schema_migrations": {"version", "name", "applied_at"},
+ "password_reset_tokens": {"id", "token_hash"},
+ }
+ for table, fields in required.items():
+ columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
+ if not fields <= columns:
+ raise BackupError("Backup does not contain a compatible Magent database")
+ optional = {
+ "installation_setup": {"id", "completed", "step", "completed_at"},
+ "installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
+ }
+ table_names = {name for kind, name, _sql in schema if kind == "table"}
+ for table, fields in optional.items():
+ if table in table_names:
+ columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
+ if not fields <= columns:
+ raise BackupError("Backup setup state has an incompatible schema")
+ # An admin can stage a restore only after target initialization. Its
+ # schema is a trusted reference for *all* runtime columns, including
+ # versioned migrations that init_db will not rerun on a restored DB.
+ target = Path(_db_path()).absolute()
+ if target.is_file() and target != path:
+ with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
+ tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
+ for table in tables:
+ if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
+ continue
+ quoted = str(table).replace('"', '""')
+ expected = {
+ row[1]: (row[2].upper(), bool(row[3]), row[5])
+ for row in reference.execute(f'PRAGMA table_info("{quoted}")')
+ }
+ actual = {
+ row[1]: (row[2].upper(), bool(row[3]), row[5])
+ for row in conn.execute(f'PRAGMA table_info("{quoted}")')
+ }
+ if expected != actual:
+ raise BackupError("Backup is missing database columns required by this installation")
+ versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
+ if versions != {migration.version for migration in MIGRATIONS}:
+ raise BackupError("Backup schema is incompatible; restore using the same Magent version")
+ if not conn.execute(
+ "SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
+ ).fetchone():
+ raise BackupError("Backup must contain an active administrator account")
+ values = dict(conn.execute("SELECT key,value FROM settings"))
+ if _LOCAL_FIELDS.intersection(values):
+ raise BackupError("Backup contains host-specific configuration")
+ # Pydantic checks the types of portable settings without reading env values.
+ for key, value in values.items():
+ if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
+ value = decrypt_setting_value(key, value)
+ if key in Settings.model_fields and value not in {None, ""}:
+ field = Settings.model_fields[key]
+ TypeAdapter(field.rebuild_annotation()).validate_python(value)
+ except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
+ if isinstance(exc, BackupError):
+ raise
+ raise BackupError("Backup database or configuration is invalid") from exc
+
+
+def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
+ try:
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
+ entries = archive.infolist()
+ if not entries or len(entries) > MAX_ENTRIES:
+ raise BackupError("Backup contains too many files")
+ names = [entry.filename for entry in entries]
+ if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
+ raise BackupError("Backup manifest is missing or contains duplicate files")
+ if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
+ raise BackupError("Expanded backup exceeds the 128 MiB limit")
+ for entry in entries:
+ parts = PurePosixPath(entry.filename).parts
+ mode = entry.external_attr >> 16
+ if (
+ entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
+ or str(PurePosixPath(entry.filename)) != entry.filename
+ or ":" in entry.filename or any(part in {".", ".."} for part in parts)
+ or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
+ or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
+ ):
+ raise BackupError("Backup contains an unsafe archive entry")
+ if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
+ raise BackupError("Backup manifest is too large")
+ manifest = json.loads(archive.read("manifest.json"))
+ if (
+ not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
+ or not isinstance(manifest.get("include_cache"), bool)
+ or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
+ or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
+ or not isinstance(manifest.get("files"), dict)
+ or set(manifest["files"]) != set(names) - {"manifest.json"}
+ ):
+ raise BackupError("Backup manifest is invalid or unsupported")
+ extracted_bytes = 0
+ for entry in entries:
+ name = entry.filename
+ if name == "manifest.json":
+ continue
+ if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
+ raise BackupError("Backup contains an unsupported file")
+ expected = manifest["files"][name]
+ if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
+ raise BackupError("Backup file does not match its manifest")
+ target = directory.joinpath(*PurePosixPath(name).parts)
+ _private_dir(target.parent)
+ digest = hashlib.sha256()
+ with archive.open(entry) as source, target.open("xb") as destination:
+ target.chmod(0o600)
+ while chunk := source.read(1024 * 1024):
+ extracted_bytes += len(chunk)
+ if extracted_bytes > MAX_EXPANDED_BYTES:
+ raise BackupError("Expanded backup exceeds the 128 MiB limit")
+ digest.update(chunk)
+ destination.write(chunk)
+ destination.flush()
+ os.fsync(destination.fileno())
+ if digest.hexdigest() != expected.get("sha256"):
+ raise BackupError("Backup file failed its checksum")
+ _validate_database(directory / "database.sqlite3")
+ return manifest
+ except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
+ if isinstance(exc, BackupError):
+ raise
+ raise BackupError("Backup archive is invalid or damaged") from exc
+
+
+def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
+ validate_passphrase(passphrase)
+ with _exclusive_operation():
+ root = _control_root()
+ pending = root / "pending"
+ if pending.exists():
+ raise BackupError("A restore is already staged; cancel it before uploading another")
+ payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
+ destination_origin = None
+ if managed_runtime():
+ from .public_urls import magent_public_url
+ try:
+ destination_origin = normalize_application_origin(magent_public_url())
+ except ValueError:
+ raise BackupError("Configure a valid destination application address before restoring a backup") from None
+ with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
+ stage = Path(temporary)
+ stage.chmod(0o700)
+ manifest = _extract_archive(payload, stage)
+ with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
+ conn.execute("PRAGMA secure_delete=ON")
+ for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
+ if key in SENSITIVE_SETTING_KEYS:
+ if value and str(value).startswith("enc:v1:"):
+ raise BackupError("Backup settings are not portable")
+ conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
+ if destination_origin is not None:
+ # The backup's hostname must not replace this installation's
+ # trusted browser origin or change its cookie policy.
+ conn.execute(
+ "INSERT INTO settings(key,value,updated_at) VALUES ('magent_application_url',?,?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
+ (destination_origin, _now()),
+ )
+ # Do not revive reset links or existing browser sessions. Invites remain intact.
+ conn.execute("DELETE FROM password_reset_tokens")
+ conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
+ if not manifest["include_cache"]:
+ conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
+ conn.commit()
+ # Remove plaintext secret remnants from replaced/free SQLite pages.
+ conn.execute("VACUUM")
+ metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
+ metadata["staged_at"] = _now()
+ _write_json(stage / "metadata.json", metadata)
+ # Stage survives reboot; it contains only secrets encrypted for this installation.
+ os.replace(stage, pending)
+ _sync_directory(root)
+ return metadata
+
+
+def backup_status() -> dict[str, Any]:
+ root = _control_root()
+ pending_path = root / "pending" / "metadata.json"
+ last_path = root / "last-restore.json"
+ return {
+ "format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
+ "max_expanded_bytes": MAX_EXPANDED_BYTES,
+ "include_cache_default": False,
+ "pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
+ "last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
+ }
+
+
+def cancel_restore() -> None:
+ with _exclusive_operation():
+ pending = _control_root() / "pending"
+ if pending.is_symlink():
+ raise BackupError("Invalid staged restore directory")
+ if pending.exists():
+ shutil.rmtree(pending)
+
+
+def _replace_file(source: Path, target: Path) -> None:
+ _private_dir(target.parent)
+ temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
+ try:
+ shutil.copyfile(source, temporary)
+ temporary.chmod(0o600)
+ with temporary.open("r+b") as handle:
+ os.fsync(handle.fileno())
+ os.replace(temporary, target)
+ _sync_directory(target.parent)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def _replace_assets(source: Path, target: Path) -> None:
+ if target.is_symlink():
+ raise BackupError("Asset directories must not be symbolic links")
+ if target.exists():
+ shutil.rmtree(target)
+ if source.exists():
+ shutil.copytree(source, target, copy_function=shutil.copyfile)
+ for parent, _directories, files in os.walk(target):
+ Path(parent).chmod(0o700)
+ for filename in files:
+ (Path(parent) / filename).chmod(0o600)
+ _sync_tree(target)
+ if target.parent.exists():
+ _sync_directory(target.parent)
+
+
+def _recover(journal: dict, root: Path) -> None:
+ rollback_name = journal.get("rollback_directory", "")
+ if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
+ raise BackupError("Restore recovery journal is invalid")
+ rollback = root / rollback_name
+ database = Path(_db_path()).absolute()
+ if journal["had_database"]:
+ _replace_file(rollback / "database.sqlite3", database)
+ else:
+ database.unlink(missing_ok=True)
+ for suffix in ("-wal", "-shm", "-journal"):
+ Path(str(database) + suffix).unlink(missing_ok=True)
+ for name in journal["asset_roots"]:
+ if name not in {"branding", "artwork"}:
+ raise BackupError("Restore recovery journal is invalid")
+ _replace_assets(rollback / "files" / name, _assets_root() / name)
+ _write_json(root / "last-restore.json", {
+ "status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
+ "message": "An interrupted or failed restore was rolled back automatically.",
+ })
+ _write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
+ pending = root / "pending"
+ if pending.exists():
+ shutil.rmtree(pending)
+ (root / "restore-journal.json").unlink()
+ _sync_directory(root)
+
+
+def apply_pending_restore() -> bool:
+ """Call once before init_db, with no other backend processes using the DB."""
+ with _exclusive_operation():
+ root = _control_root()
+ journal_path = root / "restore-journal.json"
+ if journal_path.exists():
+ journal = json.loads(journal_path.read_text())
+ if journal.get("phase") in {"complete", "rolled_back"}:
+ if (root / "pending").exists():
+ shutil.rmtree(root / "pending")
+ journal_path.unlink()
+ _sync_directory(root)
+ return journal["phase"] == "complete"
+ _recover(journal, root)
+ return False
+ pending = root / "pending"
+ if not pending.exists():
+ return False
+ if pending.is_symlink():
+ raise BackupError("Invalid staged restore directory")
+ metadata = json.loads((pending / "metadata.json").read_text())
+ _validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
+ database = Path(_db_path()).absolute()
+ rollback = root / ("rollback-" + uuid.uuid4().hex)
+ _private_dir(rollback)
+ # Ensure all disk-space/permission failures in backup happen before replacement.
+ if database.exists():
+ _database_copy(database, rollback / "database.sqlite3")
+ names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
+ # Reject links anywhere before copying or deleting the controlled asset trees.
+ list(_asset_files(metadata["include_cache"]))
+ for name in names:
+ source = _assets_root() / name
+ if source.exists():
+ shutil.copytree(source, rollback / "files" / name)
+ _sync_tree(rollback)
+ journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
+ _write_json(journal_path, journal)
+ try:
+ for suffix in ("-wal", "-shm", "-journal"):
+ Path(str(database) + suffix).unlink(missing_ok=True)
+ _replace_file(pending / "database.sqlite3", database)
+ for name in names:
+ _replace_assets(pending / "files" / name, _assets_root() / name)
+ _write_json(root / "last-restore.json", {
+ "status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
+ "backup_created_at": metadata["created_at"],
+ })
+ _write_json(journal_path, {**journal, "phase": "complete"})
+ except Exception:
+ _recover(journal, root)
+ raise
+ shutil.rmtree(pending)
+ journal_path.unlink()
+ _sync_directory(root)
+ return True
diff --git a/backend/app/services/collector_search.py b/backend/app/services/collector_search.py
new file mode 100644
index 0000000..87fbe79
--- /dev/null
+++ b/backend/app/services/collector_search.py
@@ -0,0 +1,61 @@
+"""Read title-specific search activity without starting a search or changing monitoring."""
+
+from typing import Any
+
+from ..clients.base import ApiClient
+from ..models import RequestType
+
+
+def _ids(values: Any) -> set[int]:
+ if not isinstance(values, list):
+ return set()
+ return {value for value in values if type(value) is int and value > 0}
+
+
+def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
+ """Only a matching queued/started search is evidence of current activity.
+
+ Completed commands, RSS syncs and library-wide jobs do not establish that this
+ title is being searched. Episode searches are matched using Sonarr episode IDs.
+ """
+ if not isinstance(commands, list):
+ return "unavailable"
+ episode_ids = _ids([
+ episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
+ if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
+ ])
+ queued = False
+ for command in commands:
+ if not isinstance(command, dict):
+ continue
+ body = command.get("body")
+ if not isinstance(body, dict):
+ continue
+ name = str(command.get("name") or body.get("name") or "").lower()
+ if request_type == RequestType.movie:
+ matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
+ else:
+ matches = (
+ name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
+ ) or (
+ name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
+ )
+ if not matches or command.get("ended"):
+ continue
+ status = str(command.get("status", "")).lower()
+ if status in {"started", "1"}:
+ return "searching"
+ if status in {"queued", "0"}:
+ queued = True
+ return "queued" if queued else "idle"
+
+
+async def read_search_status(
+ client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
+) -> str:
+ try:
+ commands = await client.get("/api/v3/command", timeout_seconds=3.0)
+ except Exception:
+ # Search telemetry must not turn a healthy library record into an error.
+ return "unavailable"
+ return search_status(commands, request_type, item_id, episodes)
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/download_labels.py b/backend/app/services/download_labels.py
new file mode 100644
index 0000000..491dd42
--- /dev/null
+++ b/backend/app/services/download_labels.py
@@ -0,0 +1,25 @@
+from typing import Any
+
+
+def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]:
+ """Join by collector download ID, never by fuzzy title matching.
+
+ A pack shares one transfer percentage; do not pretend its episodes have
+ individually measured progress.
+ """
+ records = queue.get("records", []) if isinstance(queue, dict) else queue
+ labels: dict[str, set[str]] = {}
+ for row in records if isinstance(records, list) else []:
+ episode = row.get("episode") or {}
+ season, number = episode.get("seasonNumber"), episode.get("episodeNumber")
+ if isinstance(season, int) and isinstance(number, int):
+ key = str(row.get("downloadId") or "").lower()
+ labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}")
+ for torrent in torrents:
+ episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set()))
+ torrent["episodeLabels"] = episodes
+ torrent["episodeLabel"] = (
+ " · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "")
+ if episodes else None
+ )
+ return torrents
diff --git a/backend/app/services/duplicate_accounts.py b/backend/app/services/duplicate_accounts.py
new file mode 100644
index 0000000..aed62fb
--- /dev/null
+++ b/backend/app/services/duplicate_accounts.py
@@ -0,0 +1,197 @@
+"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
+import asyncio
+import json
+from contextlib import closing
+from datetime import datetime, timezone
+
+from fastapi import HTTPException
+from .. import db
+from ..feature_access import FEATURES
+from . import identity_review as review
+from .jellyfin_identity import source_key
+
+NAME_REFERENCES = {
+ 'signup_invites': ('created_by',),
+ 'portal_items': ('created_by_username', 'assignee_username'),
+ 'portal_comments': ('author_username',),
+ 'portal_item_activity': ('actor_username',),
+ 'platform_issues': ('reporter_username',),
+ 'platform_issue_events': ('author_username',),
+ 'requests_cache': ('requested_by', 'requested_by_norm'),
+}
+
+
+def account_state(conn, ids):
+ conn.row_factory = db.sqlite3.Row
+ placeholders = ','.join('?' for _ in ids)
+ return {table: [dict(row) for row in conn.execute(
+ f'SELECT * FROM {table} WHERE {column} IN ({placeholders}) ORDER BY {column}', ids)]
+ for table, column in [('users', 'id'), ('user_feature_permissions', 'user_id'),
+ ('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
+
+
+def identity_group(report, target):
+ identity = target['candidate_jellyfin_id']
+ return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
+
+
+def build_preview(report, local, runtime, state, user_id, keep_id=None):
+ target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
+ if not target:
+ raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
+ group = identity_group(report, target)
+ ids = {row['user']['id'] for row in group}
+ if len(ids) < 2:
+ raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
+ jf_id = target['candidate_jellyfin_id']
+ source = source_key(runtime.jellyfin_base_url)
+ owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
+ recommended = min(ids, key=lambda identity: (identity not in owned, identity))
+ keep_id = keep_id or recommended
+ if keep_id not in ids:
+ raise HTTPException(400, 'Choose an account from this duplicate group to keep.')
+ problems = []
+ if any(report['services'].get(service) != 'available' for service in ('jellyfin', 'seerr', 'jellystat')):
+ problems.append('Restore all three media-service connections before consolidating accounts.')
+ if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
+ problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
+ seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
+ for row in group:
+ if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
+ problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
+ if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
+ problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
+ if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
+ problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
+ if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
+ problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
+ for link in local['links']:
+ if link['local_user_id'] in ids:
+ if link['source'] != source or review.normalized_id(link['jellyfin_user_id']) != jf_id:
+ problems.append('A duplicate has a different saved Jellyfin identity or server.')
+ elif link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id:
+ problems.append('Another account or orphaned reservation owns this Jellyfin identity.')
+ for item in local['confirmations']:
+ if item['local_user_id'] in ids:
+ if (item['jellyfin_server_id'] != report['server_id'] or item['jellyfin_user_id'] != jf_id
+ or item['jellyfin_source'] != source or item['seerr_source'] != source_key(runtime.jellyseerr_base_url)
+ or item['seerr_user_id'] != seerr_id):
+ problems.append('A saved confirmation points to a different identity or server.')
+ elif item['jellyfin_server_id'] == report['server_id'] and item['jellyfin_user_id'] == jf_id:
+ problems.append('Another confirmation owns this identity.')
+ if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
+ (seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
+ problems.append('An account outside this identity group also claims the identity.')
+ accounts = [account for account in state['users'] if account['id'] in ids]
+ kept = next(account for account in accounts if account['id'] == keep_id)
+ overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
+ features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
+ overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
+ expiries = [account['expires_at'] for account in accounts if account['expires_at']]
+ try:
+ expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
+ except (ValueError, TypeError, AttributeError):
+ expiry = kept['expires_at']
+ problems.append('An expiry date is invalid. Correct it before repairing duplicates.')
+ proposed = {'id': keep_id, 'username': target['jellyfin']['name'] if target['jellyfin'] else kept['username'],
+ 'email': kept['email'], 'profile_id': kept['profile_id'], 'expires_at': expiry,
+ 'is_blocked': any(account['is_blocked'] for account in accounts),
+ 'auto_search_enabled': all(account['auto_search_enabled'] for account in accounts),
+ 'features': features, 'jellyfin_user_id': jf_id, 'seerr_user_id': seerr_id}
+ public = [{key: account.get(key) for key in ('id', 'username', 'email', 'profile_id', 'last_login_at', 'created_at')}
+ for account in accounts]
+ return {'accounts': public, 'keep_id': keep_id, 'recommended_id': recommended, 'proposed': proposed,
+ 'issues': sorted(set(problems)), 'can_confirm': not problems,
+ 'revision': review.digest([report['revision'], state, keep_id, proposed])}
+
+
+async def prepare(user_id, keep_id=None):
+ report, local, runtime = await review.review_identities()
+ target = next((row for row in local['users'] if row['id'] == user_id), None)
+ if not target:
+ raise HTTPException(404, 'Account not found.')
+ report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
+ ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
+ with closing(db._connect()) as conn:
+ conn.execute('BEGIN')
+ if review.digest(review.snapshot(conn)) != review.digest(local):
+ raise HTTPException(409, 'Accounts changed during the check. Preview again.')
+ state = account_state(conn, ids)
+ return build_preview(report, local, runtime, state, user_id, keep_id), report, local, runtime, state
+
+
+def consolidate(preview, report, local, runtime, state, admin):
+ if not preview['can_confirm']:
+ raise HTTPException(409, 'This duplicate group cannot be consolidated. Review the listed conflicts.')
+ ids = sorted(account['id'] for account in state['users'])
+ keep = preview['keep_id']
+ removed = [identity for identity in ids if identity != keep]
+ values = preview['proposed']
+ now = datetime.now(timezone.utc).isoformat()
+ with closing(db._connect()) as conn, conn:
+ conn.execute('BEGIN IMMEDIATE')
+ if (review.digest(review.snapshot(conn)) != review.digest(local)
+ or review.digest(account_state(conn, ids)) != review.digest(state)
+ or review.config_digest(review.get_runtime_settings()) != review.config_digest(runtime)):
+ raise HTTPException(409, 'Accounts, permissions or subscriptions changed. Preview again before saving.')
+ for table in ('email_recap_deliveries', 'newsletter_deliveries'):
+ if conn.execute(f"SELECT 1 FROM {table} WHERE user_id IN ({','.join('?' for _ in ids)}) AND state='sending'", ids).fetchone():
+ raise HTTPException(409, 'An account email is currently being sent. Wait for delivery to finish, then preview again.')
+ archive = {**state, 'links': [entry for entry in local['links'] if entry['local_user_id'] in ids],
+ 'confirmations': [entry for entry in local['confirmations'] if entry['local_user_id'] in ids],
+ 'proposed': values}
+ conn.execute('INSERT INTO user_duplicate_repairs(kept_user_id,archive_json,repaired_by,repaired_at) VALUES(?,?,?,?)',
+ (keep, json.dumps(archive, sort_keys=True), admin['username'], now))
+ names = {account['username'] for account in state['users']}
+ tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
+ for table, columns in NAME_REFERENCES.items():
+ if table not in tables:
+ continue
+ for column in columns:
+ old_values = {review.name_key(name) for name in names} if column == 'requested_by_norm' else names
+ for name in old_values:
+ new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
+ conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
+ activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
+ for entry in activity:
+ conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
+ for entry in activity:
+ conn.execute('''INSERT INTO user_activity(username,ip,user_agent,first_seen_at,last_seen_at,hit_count)
+ VALUES(?,?,?,?,?,?) ON CONFLICT(username,ip,user_agent) DO UPDATE SET
+ first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at),
+ hit_count=hit_count+excluded.hit_count''', (values['username'], entry['ip'], entry['user_agent'], entry['first_seen_at'], entry['last_seen_at'], entry['hit_count']))
+ for name in names:
+ conn.execute('DELETE FROM password_reset_tokens WHERE username=? COLLATE NOCASE', (name,))
+ for identity in removed:
+ # Duplicate subscriptions are not inherited. Preserve delivery history and cancel outstanding work.
+ for table in ('email_recap_deliveries', 'newsletter_deliveries'):
+ conn.execute(f"UPDATE {table} SET state='cancelled',detail='Duplicate account consolidated.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (identity,))
+ conn.execute(f'UPDATE {table} SET user_id=? WHERE user_id=?', (keep, identity))
+ conn.execute('DELETE FROM jellyfin_user_links WHERE local_user_id=?', (identity,))
+ conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
+ conn.execute('DELETE FROM users WHERE id=?', (identity,))
+ last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
+ conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
+ invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
+ (values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
+ values['features']['invites'], values['expires_at'], last_login, keep))
+ for feature, enabled in values['features'].items():
+ if feature != 'invites':
+ conn.execute('''INSERT INTO user_feature_permissions VALUES(?,?,?)
+ ON CONFLICT(user_id,feature) DO UPDATE SET enabled=excluded.enabled''', (keep, feature, int(enabled)))
+ conn.execute('''INSERT INTO jellyfin_user_links VALUES(?,?,?) ON CONFLICT(source,local_user_id)
+ DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id''', (source_key(runtime.jellyfin_base_url), keep, values['jellyfin_user_id']))
+ conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (keep,))
+ conn.execute('''INSERT INTO user_identity_confirmations VALUES(?,?,?,?,?,?,?,?)''',
+ (keep, report['server_id'], values['jellyfin_user_id'], source_key(runtime.jellyfin_base_url),
+ source_key(runtime.jellyseerr_base_url), values['seerr_user_id'], now, admin['username']))
+ return {'kept_user_id': keep, 'consolidated': len(removed), 'repaired_at': now}
+
+
+async def repair_duplicates(user_id, keep_id=None, revision=None, admin=None):
+ preview, report, local, runtime, state = await prepare(user_id, keep_id)
+ if revision is None:
+ return preview
+ if revision != preview['revision']:
+ raise HTTPException(409, 'The duplicate-account preview changed. Preview again before saving.')
+ return await asyncio.to_thread(consolidate, preview, report, local, runtime, state, admin)
diff --git a/backend/app/services/email_queue.py b/backend/app/services/email_queue.py
new file mode 100644
index 0000000..dc27eb3
--- /dev/null
+++ b/backend/app/services/email_queue.py
@@ -0,0 +1,33 @@
+"""Shared claim and completion rules for the two durable email queues."""
+
+import uuid
+
+
+def queue_table(table: str) -> str:
+ if table not in {"email_recap_deliveries", "newsletter_deliveries"}:
+ raise ValueError("Unknown email queue")
+ return table
+
+
+def claim(conn, table: str, now: float) -> dict | None:
+ table = queue_table(table)
+ conn.execute(f"""UPDATE {table} SET state='unknown', detail='Delivery interrupted after sending began; check the mail server.', updated_at=?
+ WHERE state='sending' AND lease_until""", (now, now))
+ conn.execute(f"""UPDATE {table} SET state=CASE WHEN attempts>=3 THEN 'failed' ELSE 'retry' END,
+ next_attempt_at=?, updated_at=?, detail='Email preparation interrupted.'
+ WHERE state='preparing' AND lease_until""", (now, now, now))
+ row = conn.execute(f"""SELECT * FROM {table} WHERE state IN ('queued', 'retry') AND next_attempt_at<=?
+ ORDER BY created_at, id LIMIT 1""", (now,)).fetchone()
+ if not row:
+ return None
+ claim_id = uuid.uuid4().hex
+ conn.execute(f"""UPDATE {table} SET state='preparing', claim=?, lease_until=?,
+ attempts=attempts+1, updated_at=? WHERE id=?""", (claim_id, now + 1800, now, row["id"]))
+ return dict(conn.execute(f"SELECT * FROM {table} WHERE id=?", (row["id"],)).fetchone())
+
+
+def finish(conn, table: str, delivery: dict, state: str, detail: str, now: float, delay: int = 0):
+ table = queue_table(table)
+ conn.execute(f"""UPDATE {table} SET state=?, detail=?, updated_at=?, next_attempt_at=?, lease_until=NULL
+ WHERE id=? AND claim=? AND state IN ('preparing', 'sending')""",
+ (state, detail, now, now + delay, delivery["id"], delivery["claim"]))
diff --git a/backend/app/services/email_recaps.py b/backend/app/services/email_recaps.py
new file mode 100644
index 0000000..50b1699
--- /dev/null
+++ b/backend/app/services/email_recaps.py
@@ -0,0 +1,297 @@
+"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
+
+import asyncio
+import logging
+import os
+import time
+import uuid
+from datetime import datetime, timezone
+from urllib.parse import urlencode
+
+from .. import db
+from ..clients.jellystat import HistoryLimitError, JellystatError
+from ..runtime import get_runtime_settings
+from . import recap_email as mail, recap_store as store
+from .invite_email import smtp_email_config_ready
+from .jellyfin_identity import linked_user_id, source_key
+from .monthly_reports import get_monthly_report, month_periods
+
+logger = logging.getLogger(__name__)
+
+
+class RecapError(Exception):
+ def __init__(self, detail: str, status: int = 409):
+ self.detail, self.status = detail, status
+ super().__init__(detail)
+
+
+def worker_enabled() -> bool:
+ return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
+
+
+def delivery_ready() -> tuple[bool, str]:
+ config = store.settings()
+ if not config["public_url"]:
+ return False, "Set the application URL in Hosting & proxy for email links."
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ return False, detail
+ runtime = get_runtime_settings()
+ if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
+ return False, "Connect Jellystat to generate viewing recaps."
+ if not worker_enabled():
+ return False, "Background automation is paused on this server."
+ return True, "Email delivery is configured."
+
+
+def current_account(user: dict) -> dict:
+ account = db.get_user_by_username(user.get("username", ""))
+ if not account or account.get("is_blocked") or account.get("is_expired"):
+ raise RecapError("This account cannot receive viewing recaps.", 403)
+ return account
+
+
+def binding_matches(sub: dict, account: dict) -> bool:
+ runtime = get_runtime_settings()
+ return bool(account and not account.get("is_blocked") and not account.get("is_expired")
+ and mail.valid_email(account.get("email"))
+ and account["email"].strip().casefold() == sub["email"].strip().casefold()
+ and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
+ and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
+
+
+def active_subscription(account: dict) -> dict | None:
+ sub = store.subscription(account["id"])
+ if sub and sub["state"] != "off" and not binding_matches(sub, account):
+ store.disable(account["id"])
+ sub = store.subscription(account["id"])
+ return sub
+
+
+def preferences(user: dict) -> dict:
+ account = current_account(user)
+ sub = active_subscription(account)
+ config = store.settings()
+ ready, detail = delivery_ready()
+ runtime = get_runtime_settings()
+ linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
+ email = mail.valid_email(account.get("email"))
+ state = sub["state"] if sub else "off"
+ if state == "pending" and sub["confirmation_expires"] <= time.time():
+ state = "expired"
+ return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
+ "detail": detail if not ready else "Save a valid email address in your profile." if not email else
+ "Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
+ "automatic_monthly": bool(sub["automatic_monthly"]) if sub else False,
+ "can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]),
+ "schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
+ "day": config["day"], "hour": config["hour"], "timezone": "UTC",
+ "resend_after": (sub["requested_at"] + 300) if sub else None}
+
+
+async def subscribe(user: dict, automatic_monthly: bool | None = None) -> dict:
+ account = current_account(user)
+ preference = preferences(user)
+ automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
+ if preference["state"] == "enabled":
+ store.set_automatic(account['id'], automatic)
+ return preferences(user)
+ if not preference["can_subscribe"]:
+ raise RecapError(preference["detail"])
+ config = store.settings()
+ runtime = get_runtime_settings()
+ try:
+ token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
+ linked_user_id(account["username"], runtime.jellyfin_base_url), time.time(), automatic)
+ except ValueError as exc:
+ raise RecapError(str(exc), 429) from exc
+ url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
+ rendered = mail.render_confirmation(account["username"], url)
+ try:
+ await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
+ mail.message_id(uuid.uuid4().hex, config["public_url"]))
+ except mail.DeliveryError as exc:
+ raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
+ return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to enable personal report emails."}
+
+
+def token_action(token: str, action: str, *, apply: bool = False) -> dict:
+ sub = store.token_subscription(token, action)
+ if not sub:
+ raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
+ if action == "unsubscribe":
+ if apply:
+ store.disable(sub["user_id"])
+ return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
+ account = db.get_user_by_id(sub["user_id"])
+ if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
+ or not binding_matches(sub, account)):
+ raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
+ if apply and not store.confirm(sub, time.time()):
+ raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
+ return {"action": action, "state": "enabled" if apply else "ready"}
+
+
+def completed_month(month: str | None) -> str:
+ try:
+ period = month_periods(month, datetime.now(timezone.utc))
+ except ValueError as exc:
+ raise RecapError(str(exc), 422) from exc
+ if period["is_partial"]:
+ raise RecapError("Choose a completed month for an email recap.", 422)
+ return period["month"]
+
+
+async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
+ """Embed only signed artwork from this account's report; missing art is optional."""
+ import base64
+ import re
+ from .insights_artwork import get_artwork
+ runtime = get_runtime_settings()
+ images = []
+ report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
+
+ async def picture(index, row):
+ match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
+ if not match:
+ return
+ try:
+ data, mime = await get_artwork(account, runtime, *match.groups())
+ cid = f"recap-title-{index}@magent"
+ row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
+ images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
+ except Exception:
+ pass # An unavailable poster must never prevent a personal report.
+
+ await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
+ rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
+ if not preview:
+ rendered["inline_images"] = images
+ return rendered
+
+
+async def preview(user: dict, month: str | None) -> dict:
+ account = current_account(user)
+ selected = completed_month(month)
+ config = store.settings()
+ if not config["public_url"]:
+ raise RecapError("Set the application URL in Hosting & proxy before previewing an email.")
+ try:
+ report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
+ except HistoryLimitError as exc:
+ raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
+ except (JellystatError, TimeoutError) as exc:
+ raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
+ if report["state"] != "ready":
+ raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
+ return {"month": selected, "email": account.get("email"), **await illustrated_recap(
+ report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
+
+
+def queue_test(user: dict, month: str | None, request_id: str) -> dict:
+ account = current_account(user)
+ ready, detail = delivery_ready()
+ if not ready:
+ raise RecapError(detail)
+ sub = active_subscription(account)
+ if not sub or sub["state"] != "enabled":
+ raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
+ selected = completed_month(month)
+ try:
+ delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
+ except ValueError as exc:
+ raise RecapError(str(exc), 429) from exc
+ return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
+
+
+def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
+ account = db.get_user_by_id(delivery["user_id"])
+ from ..feature_access import permissions
+ if not account or not permissions(account)["stats"]:
+ raise mail.DeliveryCancelled()
+ sub = active_subscription(account) if account else None
+ config = store.settings()
+ ready, _ = delivery_ready()
+ if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
+ or sub["email"] != delivery["email"] or not binding_matches(sub, account)
+ or config["public_url"] != delivery["public_url"]
+ or (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
+ raise mail.DeliveryCancelled()
+ return account, sub
+
+
+async def process_delivery(delivery: dict) -> None:
+ state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
+ try:
+ account, sub = eligible_delivery(delivery)
+ report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
+ if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
+ raise mail.DeliveryError("failed", "A complete personal report is not available.")
+ unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
+ rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
+
+ def before_data():
+ eligible_delivery(delivery)
+ if not store.begin_sending(delivery, time.time()):
+ raise mail.DeliveryCancelled()
+
+ await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
+ mail.message_id(delivery["id"], delivery["public_url"]), before_data)
+ state, detail = "sent", "Accepted by the mail server."
+ except mail.DeliveryCancelled:
+ state, detail = "cancelled", "Consent, account details or email configuration changed."
+ except HistoryLimitError:
+ state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
+ except (JellystatError, TimeoutError):
+ state, detail = "retry", "Viewing history is temporarily unavailable."
+ except mail.DeliveryError as exc:
+ state, detail = exc.state, exc.detail
+ except Exception as exc:
+ # Do not expose provider errors or private report content in history/logs.
+ logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
+ row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
+ if row and row["state"] == "sending":
+ state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
+ if state == "retry":
+ if delivery["attempts"] >= 3:
+ state, detail = "failed", detail + " Stopped after three attempts."
+ else:
+ delay = 300 if delivery["attempts"] == 1 else 1800
+ store.finish(delivery, state, detail, time.time(), delay)
+
+
+async def run_once() -> None:
+ store.enqueue_due(datetime.now(timezone.utc))
+ for _ in range(10):
+ delivery = store.claim_delivery(time.time())
+ if not delivery:
+ break
+ await process_delivery(delivery)
+
+
+async def run_email_recap_loop() -> None:
+ while True:
+ try:
+ await run_once()
+ except Exception as exc:
+ logger.error("email recap worker failed type=%s", type(exc).__name__)
+ await asyncio.sleep(30)
+
+
+def queue_personal(user: dict, month: str | None, request_id: str) -> dict:
+ account = current_account(user)
+ ready, detail = delivery_ready()
+ if not ready:
+ raise RecapError(detail)
+ sub = active_subscription(account)
+ if not sub or sub['state'] != 'enabled':
+ raise RecapError('Confirm your profile email in email preferences before emailing a report.')
+ try:
+ selected = month_periods(month, datetime.now(timezone.utc))['month']
+ except ValueError as exc:
+ raise RecapError(str(exc), 422) from exc
+ try:
+ delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand')
+ except ValueError as exc:
+ raise RecapError(str(exc), 429) from exc
+ return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}
diff --git a/backend/app/services/identity_review.py b/backend/app/services/identity_review.py
new file mode 100644
index 0000000..839bc38
--- /dev/null
+++ b/backend/app/services/identity_review.py
@@ -0,0 +1,369 @@
+"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
+
+import asyncio
+import copy
+import hashlib
+import json
+import re
+import sqlite3
+
+import httpx
+from collections import defaultdict
+from contextlib import closing
+from datetime import datetime, timezone
+
+from fastapi import HTTPException
+
+from .. import db
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.jellystat import JellystatClient
+from ..runtime import get_runtime_settings
+from .jellyfin_identity import source_key
+
+MAX_USERS = 3000
+CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
+ "jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
+
+
+def normalized_id(value):
+ value = str(value or "").lower().replace("-", "")
+ return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
+
+
+def name_key(value):
+ return str(value or "").strip().casefold()
+
+
+def digest(value):
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
+
+
+def config_digest(runtime):
+ return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
+
+
+def snapshot(conn):
+ conn.row_factory = sqlite3.Row
+ return {
+ "users": [dict(row) for row in conn.execute(
+ "SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
+ "links": [dict(row) for row in conn.execute(
+ "SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
+ "confirmations": [dict(row) for row in conn.execute(
+ "SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
+ # Detect settings changes between checking services and committing the reviewed links.
+ "config_revision": digest([tuple(row) for row in conn.execute(
+ "SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
+ }
+
+
+def read_snapshot():
+ with closing(db._connect()) as conn:
+ return snapshot(conn)
+
+
+async def jellyfin_directory(runtime):
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ return {"state": "not_configured", "users": []}
+ try:
+ users, server = await asyncio.gather(client.get_users(), client.get_system_info())
+ server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
+ if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
+ raise ValueError()
+ clean = []
+ seen = set()
+ for user in users:
+ user_id = normalized_id(user.get("Id"))
+ if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
+ raise ValueError()
+ seen.add(user_id)
+ clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
+ return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
+ except Exception:
+ return {"state": "unavailable", "users": []}
+
+
+async def seerr_directory(runtime):
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.base_url or not client.api_key:
+ return {"state": "not_configured", "users": []}
+ try:
+ users = []
+ seen = set()
+ expected_total = None
+ async with asyncio.timeout(20):
+ for skip in range(0, MAX_USERS, 100):
+ page = await client.get_users(take=100, skip=skip)
+ total = page["pageInfo"]["results"]
+ batch = page["results"]
+ if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
+ raise ValueError()
+ if expected_total is not None and total != expected_total:
+ raise ValueError()
+ expected_total = total
+ for user in batch:
+ user_id = user.get("id")
+ if type(user_id) is not int or user_id <= 0 or user_id in seen:
+ raise ValueError()
+ seen.add(user_id)
+ users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
+ "jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
+ if len(users) == total:
+ return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
+ if len(batch) != 100 or len(users) > total:
+ raise ValueError()
+ except Exception:
+ pass
+ return {"state": "unavailable", "users": []}
+
+
+def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False):
+ original = local
+ selections = selections or {}
+ if repair:
+ local = copy.deepcopy(local)
+ for user in local['users']:
+ if user['id'] in selections:
+ user['jellyseerr_user_id'] = None
+ local['links'] = [link for link in local['links'] if not (
+ link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))]
+ local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections]
+ if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
+ raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
+ jf_by_id = {row["id"]: row for row in jellyfin["users"]}
+ jf_by_name = defaultdict(list)
+ for row in jellyfin["users"]:
+ jf_by_name[name_key(row["name"])].append(row["id"])
+ seerr_by_id = {row["id"]: row for row in seerr["users"]}
+ seerr_by_jf = defaultdict(list)
+ for row in seerr["users"]:
+ if row["jellyfin_id"]:
+ seerr_by_jf[row["jellyfin_id"]].append(row)
+ current_source = source_key(runtime.jellyfin_base_url)
+ seerr_source = source_key(runtime.jellyseerr_base_url)
+ links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
+ confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
+ local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
+ for user in local["users"]:
+ local_by_name[name_key(user["username"])].append(user["id"])
+ if user["jellyseerr_user_id"] is not None:
+ local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
+ rows = []
+ for user in local["users"]:
+ issues = []
+ saved = confirmed.get(user["id"])
+ linked = links.get(user["id"])
+ stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
+ by_name = jf_by_name.get(name_key(user["username"]), [])
+ basis = "none"
+ candidate = None
+ if saved:
+ candidate = saved["jellyfin_user_id"]
+ basis = "confirmed_id"
+ if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
+ issues.append("The confirmed server or Seerr connection has changed.")
+ elif linked:
+ candidate, basis = linked, "stored_jellyfin_id"
+ elif stored_seerr and stored_seerr["jellyfin_id"]:
+ candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
+ elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
+ candidate, basis = by_name[0], "suggested_username"
+ if repair and user['id'] in selections and any(
+ item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id')
+ for item in original['confirmations']):
+ issues.append('The Jellyfin server changed. A server migration requires separate review.')
+ if user["id"] in selections:
+ chosen = selections[user["id"]]
+ if saved and chosen != saved["jellyfin_user_id"]:
+ issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
+ candidate, basis = chosen, "admin_selected"
+ if len(local_by_name[name_key(user["username"])]) > 1:
+ issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
+ if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
+ issues.append("Multiple Magent rows share the stored Seerr ID.")
+ if len(by_name) > 1:
+ issues.append("This name matches multiple distinct Jellyfin IDs.")
+ if candidate and by_name and candidate not in by_name:
+ issues.append("The stored ID and current Jellyfin username point to different accounts.")
+ if linked and candidate and linked != candidate:
+ issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
+ jf = jf_by_id.get(candidate)
+ if candidate and not jf and jellyfin["state"] == "available":
+ issues.append("The linked Jellyfin ID is absent from the current server.")
+ expected_seerr = seerr_by_jf.get(candidate, [])
+ if len(expected_seerr) > 1:
+ issues.append("Multiple Seerr users reference the same Jellyfin ID.")
+ if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
+ len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
+ ):
+ issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
+ if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
+ issues.append("The stored Seerr ID has changed since confirmation.")
+ js = jellystat.get(candidate, {"state": "not_checked"})
+ rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
+ "stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
+ "basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
+ "can_confirm": False, "state": "unlinked"})
+ candidates = defaultdict(list)
+ for row in rows:
+ if row["candidate_jellyfin_id"]:
+ candidates[row["candidate_jellyfin_id"]].append(row)
+ for row in rows:
+ candidate = row["candidate_jellyfin_id"]
+ if len(candidates.get(candidate, [])) > 1:
+ row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
+ # Also protect IDs already reserved by a link/confirmation whose local user was deleted.
+ if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
+ and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
+ item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
+ and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
+ row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
+ if row["issues"]:
+ row["state"] = "conflict"
+ elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
+ row["state"] = "unavailable"
+ elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
+ row["state"] = "unlinked"
+ elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
+ row["state"] = "confirmed"
+ else:
+ row["state"] = "ready"
+ row["can_confirm"] = True
+ upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
+ "detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
+ if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
+ upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
+ "detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
+ services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
+ "jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
+ "not_checked" if not jellystat else
+ "unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
+ report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
+ "jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"],
+ "counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
+ "jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
+ **{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
+ report["revision"] = digest([report, digest(original), config_digest(runtime), repair])
+ report["checked_at"] = datetime.now(timezone.utc).isoformat()
+ return report
+
+
+async def review_identities(selections=None, repair=False):
+ runtime = await asyncio.to_thread(get_runtime_settings)
+ local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
+ if len(local["users"]) > MAX_USERS:
+ raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
+ ids = {row["id"] for row in jf["users"]}
+ ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
+ ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
+ ids.discard(None)
+ if len(ids) > MAX_USERS:
+ raise HTTPException(422, "There are too many upstream IDs for one identity check.")
+ stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
+ js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
+ return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime
+
+
+def save_confirmations(report, local, runtime, user_ids, admin, repair=False):
+ rows = {row["user"]["id"]: row for row in report["rows"]}
+ if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
+ raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
+ now = datetime.now(timezone.utc).isoformat()
+ try:
+ with closing(db._connect()) as conn, conn:
+ conn.execute("BEGIN IMMEDIATE")
+ if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
+ raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
+ for user_id in user_ids:
+ row = rows[user_id]
+ jf_id = row["jellyfin"]["id"]
+ seerr_id = row["seerr"][0]["id"]
+ conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
+ ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
+ (source_key(runtime.jellyfin_base_url), user_id, jf_id))
+ conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
+ conn.execute("""INSERT INTO user_identity_confirmations
+ (local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
+ VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
+ jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
+ (user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
+ seerr_id, now, admin["username"]))
+ if repair:
+ before_user = next(user for user in local['users'] if user['id'] == user_id)
+ before = {'seerr_user_id': before_user['jellyseerr_user_id'],
+ 'links': [link for link in local['links'] if link['local_user_id'] == user_id],
+ 'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)}
+ conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?,
+ jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""",
+ (report['server_id'], jf_id, source_key(runtime.jellyfin_base_url),
+ source_key(runtime.jellyseerr_base_url), seerr_id, user_id))
+ conn.execute("""INSERT INTO user_identity_repairs
+ (local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""",
+ (user_id, json.dumps(before, sort_keys=True), json.dumps({
+ 'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id,
+ 'seerr_user_id': seerr_id}, sort_keys=True), now, admin['username']))
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
+ return {"confirmed": len(user_ids), "confirmed_at": now}
+
+
+async def confirm_identities(revision, user_ids, admin):
+ report, local, runtime = await review_identities()
+ if report["revision"] != revision:
+ raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
+ return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
+
+
+async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
+ report, local, runtime = await review_identities({user_id: jellyfin_user_id})
+ if revision is not None:
+ if report["revision"] != revision:
+ raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
+ return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
+ return {"revision": report["revision"], "server_id": report["server_id"],
+ "row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
+
+
+async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False):
+ report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
+ row = next(row for row in report['rows'] if row['user']['id'] == user_id)
+ importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin']
+ and not row['seerr'] and row['jellystat']['state'] == 'matched'
+ and report['services']['seerr'] == 'available')
+ if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name'])
+ for account in report['seerr_users']):
+ importing = False
+ row['issues'].append('A Seerr account already has this name. Review its existing link before importing.')
+ report['revision'] = digest([report['revision'], create_seerr])
+ if revision is not None:
+ if report['revision'] != revision:
+ raise HTTPException(409, 'The repair preview changed. Check the selected account again.')
+ if importing:
+ if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
+ raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.')
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ try:
+ await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]})
+ except (httpx.HTTPError, ValueError) as exc:
+ raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc
+ # Upstream and SQLite cannot share a transaction. Reconcile using live IDs;
+ # never delete an imported account if the local save is blocked or interrupted.
+ refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
+ if config_digest(fresh_runtime) != config_digest(runtime):
+ raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.')
+ try:
+ return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True)
+ except HTTPException as exc:
+ raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc
+ return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True)
+ before = next(user for user in local['users'] if user['id'] == user_id)
+ linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id
+ and link['source'] == source_key(runtime.jellyfin_base_url)), None)
+ row['can_confirm'] = row['can_confirm'] or importing
+ return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row,
+ 'action': 'import_seerr' if importing else 'repair_magent',
+ 'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']},
+ 'seerr_users': report['seerr_users'],
+ 'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')}
diff --git a/backend/app/services/insights.py b/backend/app/services/insights.py
new file mode 100644
index 0000000..2e2e69d
--- /dev/null
+++ b/backend/app/services/insights.py
@@ -0,0 +1,243 @@
+import asyncio
+import hashlib
+import json
+import math
+import sqlite3
+import time
+from collections import defaultdict
+from contextlib import closing
+from datetime import datetime, timedelta, timezone
+
+from .. import db
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellystat import JellystatClient, JellystatError
+from ..runtime import get_runtime_settings
+from .jellyfin_identity import link_user, linked_user_id
+from .insights_artwork import item_id as artwork_item_id, with_artwork
+
+_cache: dict[tuple, tuple[float, dict]] = {}
+CACHE_SECONDS = 60
+
+HARDWARE = {"amf": "AMD AMF", "qsv": "Intel Quick Sync", "nvenc": "NVIDIA NVENC",
+ "v4l2m2m": "V4L2", "vaapi": "VAAPI", "videotoolbox": "Apple VideoToolbox", "rkmpp": "Rockchip MPP"}
+HARDWARE_ENUM = {0: "none", 1: "amf", 2: "qsv", 3: "nvenc", 4: "v4l2m2m", 5: "vaapi", 6: "videotoolbox", 7: "rkmpp"}
+
+
+def add_transcoding(row, duration, media_type, totals, hardware, audio_codecs):
+ # Jellystat can retain stale transcoding metadata after a switch to DirectPlay.
+ method = row.get("PlayMethod")
+ if method not in {"Transcode", "DirectStream"}:
+ return
+ info = row.get("TranscodingInfo")
+ if isinstance(info, str):
+ try:
+ info = json.loads(info)
+ except ValueError:
+ info = None
+ info = info if isinstance(info, dict) else {}
+ video_present = media_type in {"movie", "episode"} or bool(info.get("VideoCodec"))
+ if method == "Transcode" and video_present:
+ if info.get("IsVideoDirect") is False:
+ totals["video_minutes"] += duration
+ value = info.get("HardwareAccelerationType")
+ value = HARDWARE_ENUM.get(value) if type(value) is int else str(value or "").strip().lower()
+ if value in HARDWARE:
+ totals["hardware_video_minutes"] += duration
+ hardware[HARDWARE[value]] += duration
+ elif value == "none":
+ totals["software_video_minutes"] += duration
+ else:
+ totals["unknown_hardware_minutes"] += duration
+ elif info.get("IsVideoDirect") is not True:
+ totals["unknown_video_minutes"] += duration
+ if info.get("IsAudioDirect") is False:
+ totals["audio_minutes"] += duration
+ codec = str(info.get("AudioCodec") or "Unknown").upper()[:30]
+ audio_codecs[codec] += duration
+ elif info.get("IsAudioDirect") is not True:
+ totals["unknown_audio_minutes"] += duration
+
+
+def _date(value) -> datetime:
+ try:
+ result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
+ except (ValueError, TypeError) as exc:
+ raise JellystatError("Jellystat returned an invalid history date") from exc
+
+
+def _duration(value) -> float:
+ try:
+ result = float(value or 0)
+ if not math.isfinite(result) or result < 0:
+ raise ValueError()
+ return result
+ except (ValueError, TypeError, OverflowError) as exc:
+ raise JellystatError("Jellystat returned an invalid playback duration") from exc
+
+
+async def resolve_identity(user: dict, runtime) -> str | None:
+ identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
+ if identity:
+ return identity
+ if user.get("auth_provider") != "jellyfin":
+ return None
+ # Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
+ # Local accounts and email-prefix matches cannot claim a Jellyfin identity.
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ return None
+ try:
+ users = await client.get_users()
+ except Exception as exc:
+ raise JellystatError("Could not resolve the linked Jellyfin account") from exc
+ matches = [entry for entry in users if isinstance(entry, dict)
+ and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
+ if len(matches) != 1 or not matches[0].get("Id"):
+ return None
+ await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
+ return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
+
+
+def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
+ operator = "<" if end_exclusive else "<="
+ clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
+ params = [start.isoformat(), end.isoformat()]
+ if user.get("jellyseerr_user_id") is not None:
+ clause += " AND requested_by_id = ?"
+ params.append(user["jellyseerr_user_id"])
+ else:
+ clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
+ params.append(user["username"].strip().lower())
+ with closing(db._connect()) as conn, conn:
+ conn.row_factory = sqlite3.Row
+ counts = conn.execute(f"""SELECT COUNT(*) AS total,
+ COALESCE(SUM(media_type = 'movie'), 0) AS movies,
+ COALESCE(SUM(media_type = 'tv'), 0) AS tv,
+ COALESCE(SUM(status = 1), 0) AS pending,
+ COALESCE(SUM(status = 2), 0) AS approved,
+ COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
+ recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
+ WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
+ return {**dict(counts), "recent": [dict(row) for row in recent]}
+
+
+def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
+ library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
+ daily_seconds = defaultdict(float)
+ weekdays = [0.0] * 7
+ media_minutes = defaultdict(float)
+ longest_play = 0.0
+ clients = defaultdict(float)
+ methods = defaultdict(float)
+ transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
+ "unknown_hardware_minutes", "unknown_video_minutes", "unknown_audio_minutes"), 0.0)
+ hardware, audio_codecs = defaultdict(float), defaultdict(float)
+ titles = {}
+ movie_ids, episode_ids, seen = set(), set(), set()
+ recent = []
+ seconds = 0.0
+ for row in history:
+ row_id = str(row.get("Id") or "")
+ if not row_id:
+ raise JellystatError("Jellystat returned history without an activity ID")
+ if row_id in seen:
+ continue
+ seen.add(row_id)
+ date = _date(row.get("ActivityDateInserted"))
+ # Defend against older upstream versions ignoring the range filter.
+ if date < start or (date >= end if end_exclusive else date > end):
+ continue
+ duration = _duration(row.get("PlaybackDuration"))
+ if duration <= 0:
+ continue
+ item_id = str(row.get("NowPlayingItemId") or row_id)
+ episode_id = row.get("EpisodeId")
+ library_type = library_types.get(str(row.get("ParentId")), "")
+ media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
+ add_transcoding(row, duration / 60, media_type, transcoding, hardware, audio_codecs)
+ if media_type == "episode":
+ episode_ids.add(str(episode_id))
+ elif media_type == "movie":
+ movie_ids.add(item_id)
+ weekdays[date.weekday()] += duration / 60
+ media_minutes[media_type] += duration / 60
+ longest_play = max(longest_play, duration / 60)
+ seconds += duration
+ daily_seconds[date.date().isoformat()] += duration
+ client = str(row.get("Client") or "Unknown player")[:200]
+ clients[client] += duration
+ method = str(row.get("PlayMethod") or "Unknown")
+ method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
+ methods[method] += duration
+ name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
+ series = str(row.get("SeriesName") or "")[:500]
+ title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
+ title["minutes"] += duration / 60
+ title["plays"] += 1
+ recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
+ "episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
+ "minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
+ "method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
+ last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
+ count = (last_date - start.date()).days + 1
+ daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
+ "minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
+ active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
+ longest = run = 0
+ for day in daily:
+ run = run + 1 if day["date"] in active_days else 0
+ longest = max(longest, run)
+ current = 0
+ cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
+ while cursor.isoformat() in active_days:
+ current += 1
+ cursor -= timedelta(days=1)
+ top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
+ for row in top:
+ row["minutes"] = round(row["minutes"], 1)
+ return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
+ "episodes": len(episode_ids), "active_days": len(active_days),
+ "current_streak": current, "longest_streak": longest},
+ "patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0,
+ "longest_play_minutes": round(longest_play, 1),
+ "weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0,
+ "weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))],
+ "media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]},
+ "daily": daily, "top_titles": top,
+ "clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
+ "methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
+ "transcoding": {**{name: round(value, 1) for name, value in transcoding.items()},
+ "hardware": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(hardware.items(), key=lambda pair: -pair[1])],
+ "audio_codecs": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(audio_codecs.items(), key=lambda pair: -pair[1])],
+ "gpu_busy_minutes": None},
+ "recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
+
+
+async def get_insights(user: dict, days: int) -> dict:
+ runtime = await asyncio.to_thread(get_runtime_settings)
+ end = datetime.now(timezone.utc)
+ start = end - timedelta(days=days)
+ requests = await asyncio.to_thread(request_summary, user, start, end)
+ base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
+ "is_admin": user.get("role") == "admin", "summary": None}
+ client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
+ if not client.configured():
+ return {**base, "state": "not_configured"}
+ identity = await resolve_identity(user, runtime)
+ if not identity:
+ return {**base, "state": "unlinked"}
+ key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
+ runtime.jellyfin_base_url, identity, days)
+ cached = _cache.get(key)
+ if cached and cached[0] > time.monotonic():
+ return {**base, **with_artwork(cached[1], user, runtime)}
+ history, libraries = await client.get_user_history(identity, start, end)
+ data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
+ "period_start": start.isoformat(), "period_end": end.isoformat()}
+ for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
+ _cache.pop(expired, None)
+ if len(_cache) >= 128:
+ _cache.pop(next(iter(_cache)))
+ _cache[key] = (time.monotonic() + CACHE_SECONDS, data)
+ return {**base, **with_artwork(data, user, runtime)}
diff --git a/backend/app/services/insights_artwork.py b/backend/app/services/insights_artwork.py
new file mode 100644
index 0000000..f7a4082
--- /dev/null
+++ b/backend/app/services/insights_artwork.py
@@ -0,0 +1,101 @@
+"""Private Jellyfin thumbnails for items returned in a user's own viewing history."""
+
+import asyncio
+import hashlib
+import hmac
+import re
+import time
+from collections import OrderedDict
+
+import httpx
+from fastapi import HTTPException
+
+from ..config import settings
+
+TOKEN_SECONDS = 3600
+MAX_IMAGE_BYTES = 1024 * 1024
+MAX_CACHE_BYTES = 16 * 1024 * 1024
+_cache = OrderedDict()
+_downloads = asyncio.Semaphore(6)
+
+
+def item_id(value):
+ value = str(value or "").replace("-", "").lower()
+ return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
+
+
+def source(runtime):
+ return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest()
+
+
+def signature(user, runtime, media_id, expires):
+ message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}"
+ return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
+
+
+def with_artwork(data, user, runtime):
+ expires = int(time.time()) + TOKEN_SECONDS
+ result = {**data}
+ for field in ("recent", "top_titles"):
+ rows = []
+ for play in data.get(field, []):
+ row = {**play}
+ media_id = row.pop("artwork_item_id", None)
+ row["artwork_url"] = None
+ if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
+ token = f"{expires}.{signature(user, runtime, media_id, expires)}"
+ row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
+ rows.append(row)
+ result[field] = rows
+ return result
+
+
+def verify_artwork_token(user, runtime, media_id, token):
+ if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id):
+ raise HTTPException(404, "Artwork unavailable")
+ if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token):
+ raise HTTPException(403, "Artwork link is invalid or expired")
+ try:
+ expires_text, supplied = token.split(".", 1)
+ expires = int(expires_text)
+ except (ValueError, TypeError):
+ raise HTTPException(403, "Artwork link is invalid or expired") from None
+ now = int(time.time())
+ if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)):
+ raise HTTPException(403, "Artwork link is invalid or expired")
+
+
+async def get_artwork(user, runtime, media_id, token):
+ verify_artwork_token(user, runtime, media_id, token)
+ if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
+ raise HTTPException(404, "Artwork unavailable")
+ key = (source(runtime), media_id)
+ async with _downloads:
+ cached = _cache.get(key)
+ if cached and cached[0] > time.monotonic():
+ _cache.move_to_end(key)
+ return cached[1], cached[2]
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary",
+ headers={"X-Emby-Token": runtime.jellyfin_api_key},
+ params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response:
+ response.raise_for_status()
+ content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+ if content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ raise ValueError()
+ content = bytearray()
+ async for chunk in response.aiter_bytes():
+ content.extend(chunk)
+ if len(content) > MAX_IMAGE_BYTES:
+ raise ValueError()
+ if not content:
+ raise ValueError()
+ except (httpx.HTTPError, ValueError) as exc:
+ raise HTTPException(404, "Artwork unavailable") from exc
+ for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
+ _cache.pop(expired, None)
+ while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES):
+ _cache.popitem(last=False)
+ _cache[key] = (time.monotonic() + 600, bytes(content), content_type)
+ return bytes(content), content_type
diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py
new file mode 100644
index 0000000..66c569e
--- /dev/null
+++ b/backend/app/services/invite_email.py
@@ -0,0 +1,1391 @@
+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 host=%s port=%s tls=%s ssl=%s auth=%s",
+ host,
+ port,
+ use_tls,
+ use_ssl,
+ bool(username and password),
+ )
+ 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 host=%s mode=ssl", host,
+ )
+ 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", host)
+ receipt = _send_via_smtp_session(
+ smtp,
+ from_address=from_address,
+ recipient_email=recipient_email,
+ message=message,
+ )
+ logger.info(
+ "smtp send accepted host=%s mode=plain", host,
+ )
+ 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", template_key)
+ 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")
+ 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")
+ 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 provider=%s",
+ username,
+ 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/issue_resolution.py b/backend/app/services/issue_resolution.py
new file mode 100644
index 0000000..7c9a9a7
--- /dev/null
+++ b/backend/app/services/issue_resolution.py
@@ -0,0 +1,477 @@
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+from html import escape
+import json
+import logging
+from typing import Any, Dict, Optional
+
+from ..config import settings as env_settings
+from ..db import (
+ add_portal_item_activity,
+ get_portal_item,
+ get_user_by_username,
+ list_portal_item_activity,
+ list_portal_items,
+ update_portal_item,
+)
+from ..runtime import get_runtime_settings
+from .invite_email import resolve_user_delivery_email, send_generic_email
+from .snapshot import build_snapshot
+from .media_repair import evaluate_media_repair
+
+
+logger = logging.getLogger(__name__)
+_SYSTEM_USER = "Magent"
+_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _parse_datetime(value: Any) -> Optional[datetime]:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
+
+
+def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
+ raw = item.get("metadata_json")
+ if not isinstance(raw, str) or not raw.strip():
+ return {}
+ try:
+ parsed = json.loads(raw)
+ except (TypeError, ValueError):
+ return {}
+ return parsed if isinstance(parsed, dict) else {}
+
+
+def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
+ state = _metadata(item).get("resolutionConfirmation")
+ return dict(state) if isinstance(state, dict) else {}
+
+
+def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
+ metadata = _metadata(item)
+ metadata["resolutionConfirmation"] = state
+ return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
+
+
+def _interval_delta(value: int, unit: str) -> timedelta:
+ safe_value = max(1, min(int(value), 365))
+ normalized_unit = str(unit or "days").strip().lower()
+ if normalized_unit == "weeks":
+ return timedelta(weeks=safe_value)
+ if normalized_unit == "months":
+ return timedelta(days=30 * safe_value)
+ return timedelta(days=safe_value)
+
+
+def _workflow_settings() -> tuple[int, int, str]:
+ runtime = get_runtime_settings()
+ attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
+ interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
+ interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
+ if interval_unit not in {"days", "weeks", "months"}:
+ interval_unit = "days"
+ return attempts, interval_value, interval_unit
+
+
+def _app_url() -> str:
+ runtime = get_runtime_settings()
+ for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
+ candidate = str(value or "").strip()
+ if candidate:
+ return candidate.rstrip("/")
+ return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
+
+
+def _issue_url(item_id: int) -> str:
+ return f"{_app_url()}/portal/issues?item={item_id}"
+
+
+def _activity(
+ item_id: int,
+ event_type: str,
+ message: str,
+ *,
+ actor_username: str = _SYSTEM_USER,
+ actor_role: str = "system",
+ metadata: Optional[Dict[str, Any]] = None,
+) -> None:
+ add_portal_item_activity(
+ item_id,
+ event_type=event_type,
+ actor_username=actor_username,
+ actor_role=actor_role,
+ message=message,
+ metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
+ )
+
+
+def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
+ raw = entry.get("metadata_json")
+ if not isinstance(raw, str) or not raw.strip():
+ return {}
+ try:
+ parsed = json.loads(raw)
+ except (TypeError, ValueError):
+ return {}
+ return parsed if isinstance(parsed, dict) else {}
+
+
+def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
+ activity = list_portal_item_activity(item_id, limit=500)
+ for entry in reversed(activity):
+ # A rejected repair must not be proposed again simply because the same
+ # replacement file is still present. Wait for a NEW repair attempt.
+ if str(entry.get("event_type") or "") == "resolution_rejected":
+ return {}, activity
+ if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
+ continue
+ tracking = _activity_metadata(entry).get("repairTracking")
+ if isinstance(tracking, dict):
+ return dict(tracking), activity
+ return {}, activity
+
+
+async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
+ snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
+ raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
+ jellyfin = dict(raw.get("jellyfin") or {})
+ jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
+ return await evaluate_media_repair(
+ tracking, (raw.get("arr") or {}).get("item"), jellyfin,
+ episodes=(raw.get("arr") or {}).get("episodes"),
+ )
+
+
+def _close_issue(
+ item: Dict[str, Any],
+ *,
+ reason: str,
+ confirmed: bool,
+ actor_username: str = _SYSTEM_USER,
+ actor_role: str = "system",
+) -> Dict[str, Any]:
+ now = _now().isoformat()
+ state = issue_resolution_state(item)
+ state.update(
+ {
+ "status": "confirmed" if confirmed else "auto_closed",
+ "confirmedAt": now if confirmed else state.get("confirmedAt"),
+ "closedAt": now,
+ "nextContactAt": None,
+ "closedReason": reason,
+ }
+ )
+ updated = update_portal_item(
+ int(item["id"]),
+ status="closed",
+ issue_resolved_at=now,
+ metadata_json=_metadata_with_resolution(item, state),
+ )
+ if not updated:
+ raise RuntimeError("Issue could not be closed")
+ _activity(
+ int(item["id"]),
+ "resolution_confirmed" if confirmed else "issue_auto_closed",
+ reason,
+ actor_username=actor_username,
+ actor_role=actor_role,
+ )
+ return updated
+
+
+async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
+ maximum, interval_value, interval_unit = _workflow_settings()
+ state = issue_resolution_state(item)
+ attempts = max(0, int(state.get("attemptsSent") or 0))
+ if maximum <= 0:
+ return _close_issue(
+ item,
+ reason="Issue closed automatically because reporter confirmation emails are disabled.",
+ confirmed=False,
+ )
+ if attempts >= maximum:
+ return _close_issue(
+ item,
+ reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
+ confirmed=False,
+ )
+
+ attempt_number = attempts + 1
+ reporter = get_user_by_username(str(item.get("created_by_username") or ""))
+ recipient = resolve_user_delivery_email(reporter)
+ issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
+ sent = False
+ delivery_error: Optional[str] = None
+ if recipient:
+ subject = f"Ready to try again? Magent issue #{item['id']}"
+ body_text = (
+ "Your repair looks ready to test.\n\n"
+ f"{item.get('title') or 'Your reported issue'}\n\n"
+ "Please try the affected content in Jellyfin. Is it fixed?\n\n"
+ f"YES — it works: {issue_url}#yes\n"
+ f"NO — still broken: {issue_url}#no\n\n"
+ "Confirm your answer in Magent. You may need to sign in first.\n"
+ "Yes closes the report. No keeps it open for another look.\n\n"
+ f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
+ )
+ body_html = (
+ ''
+ '
'
+ 'MAGENT
'
+ 'Ready to try again? '
+ 'Your repair looks ready to test. Give the affected content a try, then let us know:
'
+ f'{escape(str(item.get("title") or "Your reported issue"))}
'
+ 'Is it fixed? '
+ f'YES — it works '
+ f'NO — still broken '
+ 'Confirm your answer in Magent. You may need to sign in first. Yes closes the report. No keeps it open for another look.
'
+ f'Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])} If we do not hear back after the reminder period, this report will close automatically.
'
+ '
'
+ )
+ try:
+ await send_generic_email(
+ recipient_email=recipient,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ sent = True
+ except Exception as exc:
+ delivery_error = str(exc)
+ logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
+ else:
+ delivery_error = "No email address is stored for the reporter."
+
+ now = _now()
+ state.update(
+ {
+ "status": "awaiting_confirmation",
+ "attemptsSent": attempt_number,
+ "maximumAttempts": maximum,
+ "lastContactAt": now.isoformat(),
+ "nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
+ "intervalValue": interval_value,
+ "intervalUnit": interval_unit,
+ "lastDeliverySucceeded": sent,
+ "lastDeliveryError": delivery_error,
+ }
+ )
+ updated = update_portal_item(
+ int(item["id"]),
+ metadata_json=_metadata_with_resolution(item, state),
+ )
+ if not updated:
+ raise RuntimeError("Issue confirmation schedule could not be saved")
+ if sent:
+ message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
+ else:
+ message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
+ _activity(
+ int(item["id"]),
+ "confirmation_email_sent" if sent else "confirmation_email_failed",
+ message,
+ metadata={
+ "attempt": attempt_number,
+ "maximum": maximum,
+ "nextContactAt": state["nextContactAt"],
+ "deliveryError": delivery_error,
+ },
+ )
+ return updated
+
+
+async def begin_issue_confirmation(
+ item_id: int,
+ *,
+ actor_username: str,
+ actor_role: str,
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item or str(item.get("kind") or "").lower() != "issue":
+ raise ValueError("Issue not found")
+ now = _now().isoformat()
+ maximum, interval_value, interval_unit = _workflow_settings()
+ state = {
+ "status": "awaiting_confirmation",
+ "startedAt": now,
+ "attemptsSent": 0,
+ "maximumAttempts": maximum,
+ "lastContactAt": None,
+ "nextContactAt": now,
+ "intervalValue": interval_value,
+ "intervalUnit": interval_unit,
+ "confirmedAt": None,
+ "closedAt": None,
+ }
+ updated = update_portal_item(
+ item_id,
+ status="awaiting_confirmation",
+ issue_resolved_at=None,
+ metadata_json=_metadata_with_resolution(item, state),
+ )
+ if not updated:
+ raise RuntimeError("Issue confirmation workflow could not be started")
+ _activity(
+ item_id,
+ "resolution_proposed",
+ "The issue was marked fixed and sent to the reporter for confirmation.",
+ actor_username=actor_username,
+ actor_role=actor_role,
+ metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
+ )
+ return await _contact_reporter(updated)
+
+
+def respond_to_issue_confirmation(
+ item_id: int,
+ *,
+ resolved: bool,
+ actor_username: str,
+ actor_role: str,
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item or str(item.get("kind") or "").lower() != "issue":
+ raise ValueError("Issue not found")
+ if str(item.get("status") or "").lower() != "awaiting_confirmation":
+ raise ValueError("This issue is not waiting for resolution confirmation")
+ if resolved:
+ return _close_issue(
+ item,
+ reason="The reporter confirmed that the issue is fixed.",
+ confirmed=True,
+ actor_username=actor_username,
+ actor_role=actor_role,
+ )
+
+ now = _now().isoformat()
+ state = issue_resolution_state(item)
+ state.update(
+ {
+ "status": "reported_still_broken",
+ "reporterResponseAt": now,
+ "nextContactAt": None,
+ "closedAt": None,
+ }
+ )
+ updated = update_portal_item(
+ item_id,
+ status="in_progress",
+ issue_resolved_at=None,
+ metadata_json=_metadata_with_resolution(item, state),
+ )
+ if not updated:
+ raise RuntimeError("Issue could not be reopened")
+ _activity(
+ item_id,
+ "resolution_rejected",
+ "The reporter said the issue is still happening. The issue was returned to In progress.",
+ actor_username=actor_username,
+ actor_role=actor_role,
+ )
+ return updated
+
+
+async def process_active_media_repairs() -> Dict[str, int]:
+ items = list_portal_items(kind="issue", status="in_progress", limit=500)
+ result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
+ for item in items:
+ tracking, activity = _repair_tracking(int(item["id"]))
+ if not tracking:
+ continue
+ result["checked"] += 1
+ try:
+ evidence = await _media_repair_evidence(tracking)
+ if evidence.get("complete"):
+ _activity(
+ int(item["id"]),
+ "repair_verified",
+ str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
+ metadata={
+ "requestId": tracking.get("requestId"),
+ "actionId": tracking.get("actionId"),
+ },
+ )
+ await begin_issue_confirmation(
+ int(item["id"]),
+ actor_username=_SYSTEM_USER,
+ actor_role="system",
+ )
+ result["completed"] += 1
+ continue
+
+ result["waiting"] += 1
+ if evidence.get("phase") == "indexing" and not any(
+ str(entry.get("event_type") or "") == "repair_imported"
+ for entry in activity
+ ):
+ _activity(
+ int(item["id"]),
+ "repair_imported",
+ str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
+ metadata={
+ "requestId": tracking.get("requestId"),
+ "actionId": tracking.get("actionId"),
+ },
+ )
+ except Exception:
+ result["failed"] += 1
+ logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
+ return result
+
+
+async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
+ current = (now or _now()).astimezone(timezone.utc)
+ items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
+ result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
+ maximum, _, _ = _workflow_settings()
+ for item in items:
+ state = issue_resolution_state(item)
+ due_at = _parse_datetime(state.get("nextContactAt"))
+ if due_at and due_at > current:
+ continue
+ try:
+ attempts = max(0, int(state.get("attemptsSent") or 0))
+ if maximum <= 0 or attempts >= maximum:
+ _close_issue(
+ item,
+ reason=(
+ "Issue closed automatically because reporter confirmation emails are disabled."
+ if maximum <= 0
+ else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
+ ),
+ confirmed=False,
+ )
+ result["closed"] += 1
+ else:
+ await _contact_reporter(item)
+ result["contacted"] += 1
+ except Exception:
+ result["failed"] += 1
+ logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
+ return result
+
+
+async def run_issue_confirmation_loop() -> None:
+ while True:
+ try:
+ repair_result = await process_active_media_repairs()
+ if repair_result["completed"] or repair_result["failed"]:
+ logger.info("automatic media repair sweep complete result=%s", repair_result)
+ result = await process_due_issue_confirmations()
+ if result["contacted"] or result["closed"] or result["failed"]:
+ logger.info("issue confirmation sweep complete result=%s", result)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("issue confirmation sweep failed")
+ await asyncio.sleep(60)
diff --git a/backend/app/services/jellyfin_identity.py b/backend/app/services/jellyfin_identity.py
new file mode 100644
index 0000000..463d541
--- /dev/null
+++ b/backend/app/services/jellyfin_identity.py
@@ -0,0 +1,51 @@
+"""Stable Jellyfin identities for private, user-scoped integrations."""
+
+import hashlib
+from contextlib import closing
+
+from .. import db
+
+
+def source_key(base_url: str | None) -> str:
+ return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
+
+
+def linked_user_id(username: str, base_url: str | None) -> str | None:
+ user = db.get_user_by_username(username)
+ if not user or not base_url:
+ return None
+ with closing(db._connect()) as conn, conn:
+ row = conn.execute(
+ "SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
+ (source_key(base_url), user["id"]),
+ ).fetchone()
+ return row[0] if row else None
+
+
+def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
+ """Use only verified login or canonical Jellyfin user sync, never playback names."""
+ user = db.get_user_by_username(username)
+ if not user or not jellyfin_user_id or not base_url:
+ return
+ with closing(db._connect()) as conn, conn:
+ if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
+ # Reviewed identities are updated only through the admin confirmation workflow.
+ return
+ # A renamed or re-created account must not silently take over an existing identity.
+ conn.execute(
+ "INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
+ (source_key(base_url), user["id"], str(jellyfin_user_id)),
+ )
+
+
+def user_for_identity(jellyfin_user_id: str, base_url: str | None):
+ """Resolve a verified upstream login to its existing local account."""
+ if not jellyfin_user_id or not base_url:
+ return None
+ with closing(db._connect()) as conn:
+ rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
+ (source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
+ if len(rows) > 1:
+ from fastapi import HTTPException
+ raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
+ return db.get_user_by_id(rows[0][0]) if rows else None
diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py
new file mode 100644
index 0000000..de55a68
--- /dev/null
+++ b/backend/app/services/jellyfin_sync.py
@@ -0,0 +1,115 @@
+import logging
+from collections import Counter
+from contextlib import closing
+from .. import db
+from .jellyfin_identity import source_key
+from .identity_review import normalized_id, name_key
+
+from fastapi import HTTPException
+
+from ..clients.jellyfin import JellyfinClient
+from ..db import (
+ create_user_if_missing,
+ get_user_by_username,
+ set_user_auth_provider,
+ set_user_jellyseerr_id,
+)
+from ..runtime import get_runtime_settings
+from .jellyfin_identity import link_user
+from .user_cache import (
+ extract_jellyseerr_user_email,
+ get_cached_jellyseerr_users,
+ 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()
+ imported = 0
+ name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
+ with closing(db._connect()) as conn:
+ links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
+ 'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
+ if not name or not jf_id or name_counts[name_key(name)] != 1:
+ continue
+ matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
+ if len(matches) > 1:
+ continue
+ matched = matches[0] if matches else None
+ matched_id = matched.get('id') if matched else None
+ owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
+ if len(owners) > 1:
+ continue
+ existing = db.get_user_by_id(owners[0]) if owners else None
+ if not existing and matched_id is not None:
+ candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
+ if len(candidates) > 1:
+ continue
+ existing = candidates[0] if candidates else None
+ if not existing:
+ existing = get_user_by_username(name)
+ if existing:
+ existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
+ if existing_links and any(value != jf_id for value in existing_links):
+ continue
+ if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
+ continue
+ canonical = existing['username']
+ # Never overwrite a stored Seerr identity on name evidence.
+ if existing.get('jellyseerr_user_id') not in (None, matched_id):
+ continue
+ set_user_auth_provider(canonical, 'jellyfin')
+ else:
+ canonical = name
+ if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
+ jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
+ imported += 1
+ if matched_id is not None:
+ set_user_jellyseerr_id(canonical, matched_id)
+ link_user(canonical, jf_id, runtime.jellyfin_base_url)
+ 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/manual_releases.py b/backend/app/services/manual_releases.py
new file mode 100644
index 0000000..1029384
--- /dev/null
+++ b/backend/app/services/manual_releases.py
@@ -0,0 +1,55 @@
+"""Manual collector decisions and short-lived, request-bound selection receipts."""
+from datetime import datetime, timedelta, timezone
+import hashlib
+import jwt
+from fastapi import HTTPException
+from ..config import settings
+
+
+def can_override(user):
+ return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
+
+
+def decision(item):
+ reasons = [str(r) for r in (item.get('rejections') or [])]
+ accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
+ and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
+ # Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
+ profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
+ 'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
+ 'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
+ 'larger than', 'smaller than', 'size limit', 'release profile',
+ )) for reason in reasons)
+ override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
+ return accepted, override, reasons
+
+
+def source_id(url):
+ return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
+
+
+def issue_selection(release, request_id, user, source, item_id):
+ return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
+ 'source': source_id(source), 'item': item_id, 'guid': release['guid'],
+ 'indexer': release['indexerId'], 'title': release.get('title'),
+ 'override': release['requiresOverride'], 'rejections': release['rejections'],
+ 'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
+ settings.jwt_secret, algorithm='HS256')
+
+
+def verify_selection(payload, request_id, user, source, item_id):
+ try:
+ receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
+ algorithms=['HS256'], audience='manual-release')
+ except jwt.InvalidTokenError as exc:
+ raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
+ if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
+ or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
+ or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
+ raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
+ if receipt.get('override'):
+ if not can_override(user):
+ raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
+ if payload.get('ignoreProfileLimits') is not True:
+ raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
+ return receipt
diff --git a/backend/app/services/media_repair.py b/backend/app/services/media_repair.py
new file mode 100644
index 0000000..a6bf252
--- /dev/null
+++ b/backend/app/services/media_repair.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any, Dict
+
+from ..clients.jellyfin import JellyfinClient
+from ..clients.sonarr import SonarrClient
+from ..runtime import get_runtime_settings
+
+
+def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
+ """Old seeding jobs are not proof of a replacement download.
+
+ A same-hash retry is valid when it is downloading again or was added anew.
+ Without a completion/add timestamp, a completed legacy job cannot prove that.
+ """
+ rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
+ if not cycle:
+ return rows
+ cutoff = datetime.fromisoformat(cycle).timestamp()
+ def belongs(item: Dict[str, Any]) -> bool:
+ try:
+ progress = float(item.get("progress", 0))
+ completed = float(item.get("completion_on") or 0)
+ added = float(item.get("added_on") or 0)
+ except (TypeError, ValueError):
+ return False
+ return progress < 1 or max(completed, added) >= cutoff
+ return [item for item in rows if belongs(item)]
+
+
+def _positive_ints(value: Any) -> list[int]:
+ if not isinstance(value, list):
+ return []
+ return [
+ int(item)
+ for item in value
+ if isinstance(item, int) and not isinstance(item, bool) and item > 0
+ ]
+
+
+def _media_signature(item: Any) -> Dict[str, str]:
+ if not isinstance(item, dict):
+ return {}
+ result: Dict[str, str] = {}
+ for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
+ value = item.get(key)
+ if isinstance(value, (dict, list)):
+ result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
+ elif value is not None and str(value).strip():
+ result[key] = str(value).strip()
+ return result
+
+
+def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
+ previous = _media_signature(baseline)
+ if not previous:
+ return True
+ return any(current.get(key) and current.get(key) != value for key, value in previous.items())
+
+
+async def evaluate_media_repair(
+ tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
+ *, episodes: Any = None,
+) -> Dict[str, Any]:
+ request_id = str(tracking.get("requestId") or "").strip()
+ action_id = str(tracking.get("actionId") or "").strip()
+ media_type = str(tracking.get("mediaType") or "").strip().lower()
+ collector_id = tracking.get("collectorId")
+ if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
+ return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
+
+ jellyfin_item = jellyfin.get("item")
+ original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
+ baselines = tracking.get("jellyfinBaseline")
+ baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
+ found_at_start = tracking.get("jellyfinFoundAtStart") is True
+
+ if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
+ return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
+
+ if media_type == "movie":
+ movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
+ current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
+ imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
+ if not imported:
+ return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
+ if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
+ return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
+ current_signature = _media_signature(jellyfin_item)
+ if action_id == "replace_media" and found_at_start:
+ if not baselines or not _signature_changed(current_signature, baselines[0]):
+ return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
+ return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
+
+ target_rows = tracking.get("episodes")
+ targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
+ target_ids = {
+ int(item["id"])
+ for item in targets
+ if isinstance(item.get("id"), int) and int(item["id"]) > 0
+ }
+ target_pairs = {
+ (int(item["seasonNumber"]), int(item["episodeNumber"]))
+ for item in targets
+ if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
+ }
+ if not target_ids or not target_pairs:
+ return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
+
+ runtime = get_runtime_settings()
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ if episodes is None:
+ episodes = await sonarr.get_episodes(collector_id)
+ episode_map = {
+ int(item["id"]): item
+ for item in episodes
+ if isinstance(item, dict) and isinstance(item.get("id"), int)
+ } if isinstance(episodes, list) else {}
+ imported = all(
+ episode_id in episode_map
+ and episode_map[episode_id].get("hasFile") is not False
+ and (
+ episode_map[episode_id].get("hasFile") is True
+ or (
+ isinstance(episode_map[episode_id].get("episodeFileId"), int)
+ and episode_map[episode_id]["episodeFileId"] > 0
+ )
+ )
+ and episode_map[episode_id].get("episodeFileId") not in original_file_ids
+ for episode_id in target_ids
+ )
+ if not imported:
+ return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
+
+ jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
+ jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
+ return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
+ jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
+ current_by_pair = {
+ (int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
+ for item in jellyfin_episodes
+ if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
+ }
+ if not all(pair in current_by_pair for pair in target_pairs):
+ return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
+ if action_id == "replace_media" and found_at_start:
+ baseline_by_pair = {
+ (int(item["seasonNumber"]), int(item["episodeNumber"])): item
+ for item in baselines
+ if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
+ }
+ if any(pair not in baseline_by_pair for pair in target_pairs):
+ return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
+ if not all(
+ _signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
+ for pair in target_pairs
+ ):
+ return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
+ return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
diff --git a/backend/app/services/monthly_reports.py b/backend/app/services/monthly_reports.py
new file mode 100644
index 0000000..7b85b5e
--- /dev/null
+++ b/backend/app/services/monthly_reports.py
@@ -0,0 +1,149 @@
+"""Personal calendar-month reports built from retained Jellystat history."""
+
+import asyncio
+import csv
+import hashlib
+import io
+import re
+import time
+from datetime import datetime, timezone
+
+from ..clients.jellystat import JellystatClient
+from ..runtime import get_runtime_settings
+from .insights import request_summary, resolve_identity, summarize
+from .insights_artwork import with_artwork
+
+_cache: dict[tuple, tuple[float, dict]] = {}
+CACHE_SECONDS = 60
+MONTH_COUNT = 24
+
+
+def shift_month(value: datetime, offset: int) -> datetime:
+ year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
+ return datetime(year, month + 1, 1, tzinfo=timezone.utc)
+
+
+def month_periods(month: str | None, now: datetime) -> dict:
+ now = now.astimezone(timezone.utc)
+ this_month = shift_month(now, 0)
+ available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
+ selected = month if month is not None else available[1]
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
+ raise ValueError("Choose the current month or one of the previous 23 months.")
+ start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
+ calendar_end = shift_month(start, 1)
+ end = min(calendar_end, now)
+ previous_start = shift_month(start, -1)
+ partial = end < calendar_end
+ previous_end = min(previous_start + (end - start), start) if partial else start
+ return {"month": selected, "available_months": available, "timezone": "UTC",
+ "period_start": start.isoformat(), "period_end": end.isoformat(),
+ "is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
+ "comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
+ "comparison_capped": partial and previous_start + (end - start) > start}
+
+
+def change(current: float, previous: float) -> dict:
+ difference = round(current - previous, 1)
+ percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
+ return {"current": current, "previous": previous, "difference": difference, "percent": percent}
+
+
+async def get_monthly_report(user: dict, month: str | None = None) -> dict:
+ now = datetime.now(timezone.utc)
+ periods = month_periods(month, now)
+ runtime = await asyncio.to_thread(get_runtime_settings)
+ base = {**periods, "source": "Jellystat", "is_admin": user.get("role") == "admin", "summary": None}
+ client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
+ if not client.configured():
+ return {**base, "state": "not_configured"}
+ identity = await resolve_identity(user, runtime)
+ if not identity:
+ return {**base, "state": "unlinked"}
+ # Cache playback only. Request ownership and request statuses are read afresh.
+ key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
+ runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
+ cached = _cache.get(key)
+ if cached and cached[0] > time.monotonic():
+ data = cached[1]
+ else:
+ history, libraries = await client.get_user_history(identity,
+ datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
+ current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
+ datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
+ previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
+ datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
+ data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
+ for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
+ _cache.pop(expired, None)
+ if len(_cache) >= 128:
+ _cache.pop(next(iter(_cache)))
+ _cache[key] = (time.monotonic() + CACHE_SECONDS, data)
+ requests, previous_requests = await asyncio.gather(
+ asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
+ datetime.fromisoformat(data["period_end"]), end_exclusive=True),
+ asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
+ datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
+ changes = {name: change(data["summary"][name], data["previous_summary"][name])
+ for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
+ changes["requests"] = change(requests["total"], previous_requests["total"])
+ return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
+ "previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
+ "changes": changes}
+
+
+def report_csv(report: dict) -> str:
+ """Export normalized data only; protect text cells from spreadsheet formulas."""
+ output = io.StringIO(newline="")
+ writer = csv.writer(output)
+
+ def row(*cells):
+ safe = []
+ for cell in cells:
+ if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
+ cell = "'" + cell
+ safe.append(cell)
+ writer.writerow(safe)
+
+ row("Magent monthly viewing report", report["month"])
+ row("Timezone", "UTC")
+ row("Period start (inclusive)", report["period_start"])
+ row("Period end (exclusive)", report["period_end"])
+ row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
+ row("Comparison start (inclusive)", report["comparison_start"])
+ row("Comparison end (exclusive)", report["comparison_end"])
+ row("Generated at", report["updated_at"])
+ row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
+ row()
+ row("Metric", "This period", "Previous period", "Difference", "Change (%)")
+ labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
+ "plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
+ for name, label in labels.items():
+ value = report["changes"][name]
+ row(label, value["current"], value["previous"], value["difference"], value["percent"])
+ row()
+ row("Date (UTC)", "Minutes watched")
+ for day in report["daily"]:
+ row(day["date"], day["minutes"])
+ row()
+ row("Most watched title", "Media type", "Minutes", "Plays")
+ for title in report["top_titles"]:
+ row(title["title"], title["type"], title["minutes"], title["plays"])
+ for field, label in (("clients", "Player"), ("methods", "Streaming method")):
+ row()
+ row(label, "Playback minutes")
+ for entry in report[field]:
+ row(entry["name"], entry["minutes"])
+ row()
+ row("Transcoding", "Playback minutes")
+ for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
+ ("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
+ ("unknown_hardware_minutes", "Video hardware not recorded"),
+ ("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
+ row(label, report["transcoding"][field])
+ row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
+ row()
+ row("Requests", "Count")
+ for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
+ row(label, report["requests"][field])
+ return "\ufeff" + output.getvalue()
diff --git a/backend/app/services/newsletter_catalog.py b/backend/app/services/newsletter_catalog.py
new file mode 100644
index 0000000..ff1671e
--- /dev/null
+++ b/backend/app/services/newsletter_catalog.py
@@ -0,0 +1,202 @@
+"""Bounded Jellyfin arrival snapshots, recipient access checks and email-safe posters."""
+
+import asyncio
+import hashlib
+import io
+import time
+from collections import OrderedDict
+from datetime import datetime, timezone
+
+import httpx
+from PIL import Image
+
+from .insights_artwork import item_id
+from .jellyfin_identity import source_key
+
+MAX_ITEMS = 5000
+PAGE_SIZE = 200
+MAX_TITLES = 60
+_posters = OrderedDict()
+_poster_lock = asyncio.Semaphore(4)
+
+
+class CatalogError(Exception):
+ pass
+
+
+def date(value) -> datetime | None:
+ try:
+ result = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
+ return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
+ except (ValueError, TypeError):
+ return None
+
+
+async def get_json(client, runtime, path, params=None):
+ try:
+ response = await client.get(runtime.jellyfin_base_url.rstrip('/') + path,
+ headers={'X-Emby-Token': runtime.jellyfin_api_key}, params=params)
+ response.raise_for_status()
+ return response.json()
+ except (httpx.HTTPError, ValueError) as exc:
+ raise CatalogError('Jellyfin is temporarily unavailable. Please try again.') from exc
+
+
+def group_arrivals(items: list[dict], start: datetime, end: datetime) -> list[dict]:
+ groups = {}
+ seen = set()
+ for row in items:
+ identity = item_id(row.get('Id'))
+ added = date(row.get('DateCreated'))
+ if (not identity or identity in seen or not added or not start <= added < end
+ or row.get('LocationType') == 'Virtual' or row.get('IsPlaceHolder')):
+ continue
+ kind = row.get('Type')
+ if kind not in {'Movie', 'Episode'}:
+ continue
+ parent = item_id(row.get('SeriesId')) if kind == 'Episode' else identity
+ if not parent:
+ continue
+ seen.add(identity)
+ title = str((row.get('SeriesName') if kind == 'Episode' else row.get('Name')) or '').strip()
+ if not title:
+ continue
+ entry = groups.setdefault(parent, {'id': parent, 'type': 'series' if kind == 'Episode' else 'movie',
+ 'title': title[:250], 'year': row.get('ProductionYear') if kind == 'Movie' else None,
+ 'overview': str(row.get('Overview') or '')[:500] if kind == 'Movie' else '',
+ 'added_at': added.isoformat(), 'has_artwork': False, 'items': [], 'selected': False, 'featured': False})
+ entry['added_at'] = max(entry['added_at'], added.isoformat())
+ entry['has_artwork'] |= bool(row.get('SeriesPrimaryImageTag') if kind == 'Episode' else (row.get('ImageTags') or {}).get('Primary'))
+ entry['items'].append({'id': identity, 'season': row.get('ParentIndexNumber') if kind == 'Episode' else None,
+ 'number': row.get('IndexNumber') if kind == 'Episode' else None})
+ return sorted(groups.values(), key=lambda row: (row['added_at'], row['id']), reverse=True)
+
+
+async def collect(runtime, start: datetime, end: datetime, limit: int = 12) -> dict:
+ if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
+ raise CatalogError('Connect Jellyfin before collecting new arrivals.')
+ rows, seen = [], set()
+ exhausted = False
+ async with httpx.AsyncClient(timeout=20) as client:
+ info = await get_json(client, runtime, '/System/Info')
+ server_id = item_id(info.get('Id')) if isinstance(info, dict) else None
+ if not server_id:
+ raise CatalogError('Jellyfin did not return its server identity.')
+ for offset in range(0, MAX_ITEMS, PAGE_SIZE):
+ payload = await get_json(client, runtime, '/Items', {'Recursive': 'true', 'IncludeItemTypes': 'Movie,Episode',
+ 'SortBy': 'DateCreated,SortName', 'SortOrder': 'Descending', 'Fields': 'DateCreated,Overview',
+ 'EnableUserData': 'false', 'IsMissing': 'false', 'IsPlaceHolder': 'false', 'Limit': PAGE_SIZE, 'StartIndex': offset})
+ if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
+ raise CatalogError('Jellyfin returned an incomplete arrival list.')
+ page = payload['Items']
+ total = payload.get('TotalRecordCount')
+ if not isinstance(total, int) or total < offset + len(page):
+ raise CatalogError('Jellyfin returned an incomplete arrival count.')
+ for row in page:
+ if not isinstance(row, dict) or not item_id(row.get('Id')) or not date(row.get('DateCreated')):
+ raise CatalogError('Jellyfin returned an arrival without a valid identity or added date.')
+ identity = item_id(row['Id'])
+ if identity in seen:
+ raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
+ seen.add(identity)
+ if rows and date(row['DateCreated']) > date(rows[-1]['DateCreated']):
+ raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
+ rows.append(row)
+ if (not page or len(page) < PAGE_SIZE) and offset + len(page) < total:
+ raise CatalogError('Jellyfin returned an incomplete arrival page.')
+ if not page or any(date(row['DateCreated']) < start for row in page) or offset + len(page) >= total:
+ exhausted = True
+ break
+ if not exhausted:
+ raise CatalogError('More than 5,000 recent items were found. Choose a shorter arrival period; no partial edition was created.')
+ titles = group_arrivals(rows, start, end)
+ total = len(titles)
+ titles = titles[:MAX_TITLES]
+ for index, title in enumerate(titles):
+ title['selected'] = index < limit
+ return {'source': source_key(runtime.jellyfin_base_url), 'server_id': server_id,
+ 'period_start': start.isoformat(), 'period_end': end.isoformat(), 'total_titles': total, 'titles': titles}
+
+
+async def for_recipient(runtime, content: dict, jellyfin_id: str) -> dict:
+ """Scope every ID lookup to a view Jellyfin permits this user to browse.
+
+ Jellyfin 10.11's AddUserToQuery skips its default library filter when ItemIds
+ is present. UserId alone is insufficient; ParentId supplies the allowed scope.
+ """
+ if not item_id(jellyfin_id):
+ raise CatalogError('The recipient does not have a valid Jellyfin identity.')
+ selected = [entry for entry in content['titles'] if entry['selected']]
+ ids = sorted({identity for entry in selected for identity in [entry['id'], *(item['id'] for item in entry['items'])]})
+ allowed = set()
+ async with httpx.AsyncClient(timeout=20) as client:
+ info = await get_json(client, runtime, '/System/Info')
+ if not isinstance(info, dict) or source_key(runtime.jellyfin_base_url) != content['source'] or item_id(info.get('Id')) != content['server_id']:
+ raise CatalogError('The Jellyfin server changed. Create a new edition for the current library.')
+ user = await get_json(client, runtime, '/Users/' + jellyfin_id)
+ if not isinstance(user, dict) or item_id(user.get('Id')) != item_id(jellyfin_id) or not isinstance(user.get('Policy'), dict):
+ raise CatalogError('Could not verify the recipient’s Jellyfin account.')
+ if user['Policy'].get('IsDisabled') or user['Policy'].get('EnableMediaPlayback') is False:
+ return {**content, 'titles': [], 'recipient_disabled': True}
+ views = await get_json(client, runtime, '/UserViews', {'UserId': jellyfin_id, 'IncludeHidden': 'true', 'IncludeExternalContent': 'false'})
+ if not isinstance(views, dict) or not isinstance(views.get('Items'), list) or len(views['Items']) > 32:
+ raise CatalogError('Could not check the recipient’s library access.')
+ for view in views['Items']:
+ parent = item_id(view.get('Id')) if isinstance(view, dict) else None
+ if not parent:
+ raise CatalogError('Jellyfin returned a library without a valid identity.')
+ for offset in range(0, len(ids), 100):
+ chunk = ids[offset:offset + 100]
+ payload = await get_json(client, runtime, '/Items', {'UserId': jellyfin_id, 'ParentId': parent, 'Ids': ','.join(chunk),
+ 'Recursive': 'true', 'Limit': len(chunk), 'EnableUserData': 'false', 'EnableImages': 'false',
+ 'IsMissing': 'false', 'IsPlaceHolder': 'false'})
+ if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
+ raise CatalogError('Could not check the recipient’s library access.')
+ allowed.update(item_id(item.get('Id')) for item in payload['Items'] if isinstance(item, dict))
+ titles = []
+ for entry in selected:
+ accessible = [item for item in entry['items'] if item['id'] in allowed]
+ if entry['id'] in allowed and accessible:
+ titles.append({**entry, 'items': accessible})
+ return {**content, 'titles': titles}
+
+
+async def poster(runtime, identity: str) -> bytes | None:
+ if not item_id(identity):
+ return None
+ key = (source_key(runtime.jellyfin_base_url), hashlib.sha256(runtime.jellyfin_api_key.encode()).hexdigest(), identity)
+ async with _poster_lock:
+ cached = _posters.get(key)
+ if cached and cached[0] > time.monotonic():
+ _posters.move_to_end(key)
+ return cached[1]
+ result = None
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ async with client.stream('GET', runtime.jellyfin_base_url.rstrip('/') + f'/Items/{identity}/Images/Primary',
+ headers={'X-Emby-Token': runtime.jellyfin_api_key}, params={'maxWidth': 160, 'maxHeight': 240, 'quality': 82, 'format': 'Jpg'}) as response:
+ response.raise_for_status()
+ data = bytearray()
+ async for chunk in response.aiter_bytes():
+ data.extend(chunk)
+ if len(data) > 512 * 1024:
+ raise ValueError('Poster too large')
+ with Image.open(io.BytesIO(data)) as image:
+ if image.width * image.height > 4_000_000:
+ raise ValueError('Poster dimensions too large')
+ image.thumbnail((160, 240))
+ target = io.BytesIO()
+ image.convert('RGB').save(target, format='JPEG', quality=82)
+ result = target.getvalue()
+ except (httpx.HTTPError, ValueError, OSError, Image.DecompressionBombError):
+ pass
+ _posters[key] = (time.monotonic() + (1800 if result else 60), result)
+ while len(_posters) > 128:
+ _posters.popitem(last=False)
+ return result
+
+
+async def posters(runtime, content: dict) -> dict:
+ titles = [entry for entry in content['titles'] if entry['selected'] and entry['has_artwork']]
+ results = await asyncio.gather(*(poster(runtime, entry['id']) for entry in titles))
+ return {entry['id']: data for entry, data in zip(titles, results) if data}
diff --git a/backend/app/services/newsletter_email.py b/backend/app/services/newsletter_email.py
new file mode 100644
index 0000000..1f0c8bf
--- /dev/null
+++ b/backend/app/services/newsletter_email.py
@@ -0,0 +1,74 @@
+import base64
+import html
+from urllib.parse import urlencode
+
+from .recap_email import document
+
+
+def description(entry):
+ if entry['type'] == 'movie':
+ return f"Movie · {entry['year']}" if entry.get('year') else 'Movie'
+ seasons = sorted({item['season'] for item in entry['items'] if isinstance(item.get('season'), int)})
+ count = len(entry['items'])
+ labels = ', '.join('Specials' if value == 0 else str(value) for value in seasons[:8])
+ suffix = f" · {'Season' if len(seasons) == 1 else 'Seasons'} {labels}" if labels else ''
+ return f"{count} new {'episode' if count == 1 else 'episodes'}{suffix}"
+
+
+def render_confirmation(username, url):
+ intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from your media library."
+ return {'subject': 'Confirm your Magent newsletter subscription',
+ 'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
+ 'body_html': document(title='Your next watch starts here.', intro=intro,
+ content='A weekly look at new movies and TV updates, with posters and links to watch.
',
+ action='Confirm newsletter subscription', url=url, kicker='NEW IN YOUR LIBRARY',
+ footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
+
+
+def render(content, images, public_url, playback_url, unsubscribe_url, *, preview=False, test=False):
+ esc = html.escape
+ titles = [entry for entry in content['titles'] if entry['selected']]
+ body, lines, attachments = [], [], []
+ intro = str(content.get('intro') or '').strip()
+ if intro:
+ body.append(f'{esc(intro).replace(chr(10), " ")}
')
+ lines += [intro, '']
+ sections = [('Featured picks', [entry for entry in titles if entry['featured']]),
+ ('New movies', [entry for entry in titles if not entry['featured'] and entry['type'] == 'movie']),
+ ('Fresh episodes', [entry for entry in titles if not entry['featured'] and entry['type'] == 'series'])]
+ for heading, entries in sections:
+ if not entries:
+ continue
+ body.append(f'{heading} ')
+ lines += [heading, '']
+ for entry in entries:
+ watch = playback_url + '/web/index.html#!/details?' + urlencode({'id': entry['id'], 'serverId': content['server_id']})
+ image_data = images.get(entry['id'])
+ cid = f"newsletter-{entry['id']}@magent"
+ if image_data:
+ source = 'data:image/jpeg;base64,' + base64.b64encode(image_data).decode() if preview else 'cid:' + cid
+ poster = f' '
+ if not preview:
+ attachments.append({'cid': cid, 'data': image_data})
+ else:
+ poster = f'{"TV" if entry["type"] == "series" else "MOVIE"}
'
+ details = description(entry)
+ overview = str(entry.get('overview') or '')[:180]
+ copy = f'{esc(overview)}
' if overview and entry['featured'] else ''
+ body.append(f'''''')
+ lines += [entry['title'], details, watch, '']
+ if not titles:
+ body.append('Your next discovery is waiting in your media library.
')
+ period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
+ footer = f'You subscribed to the Magent newsletter. Arrivals recorded by Jellyfin · {esc(period)}Unsubscribe from newsletters · Email preferences '
+ subject = ('[Test] ' if test else '') + content['subject']
+ return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Jellyfin: {playback_url}', '',
+ f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
+ f'Email preferences: {public_url}/profile#newsletters']),
+ 'body_html': document(title='What’s new in your library',
+ intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
+ content=''.join(body), action='Explore Jellyfin', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
+ 'inline_images': attachments}
diff --git a/backend/app/services/newsletter_store.py b/backend/app/services/newsletter_store.py
new file mode 100644
index 0000000..fe70e6c
--- /dev/null
+++ b/backend/app/services/newsletter_store.py
@@ -0,0 +1,351 @@
+"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
+
+import hashlib
+import json
+import secrets
+import uuid
+from contextlib import closing
+from datetime import datetime, timedelta, timezone
+
+from .. import db
+from . import email_queue
+from .recap_store import read_one, transaction
+from .public_urls import magent_public_url
+
+
+class Conflict(ValueError):
+ pass
+
+
+def init_schema(conn):
+ for sql in (
+ """CREATE TABLE IF NOT EXISTS newsletter_settings (
+ id INTEGER PRIMARY KEY CHECK(id=1), enabled INTEGER NOT NULL DEFAULT 0,
+ weekday INTEGER NOT NULL DEFAULT 4, hour INTEGER NOT NULL DEFAULT 9, limit_titles INTEGER NOT NULL DEFAULT 12,
+ public_url TEXT NOT NULL DEFAULT '', intro TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
+ next_send_at REAL, generation_claim TEXT, generation_until REAL, generation_attempts INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT NOT NULL DEFAULT '')""",
+ "INSERT OR IGNORE INTO newsletter_settings (id, public_url) SELECT 1, public_url FROM email_recap_settings WHERE id=1",
+ """CREATE TABLE IF NOT EXISTS newsletter_subscriptions (
+ user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
+ identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
+ confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
+ confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
+ """CREATE TABLE IF NOT EXISTS newsletter_editions (
+ id TEXT PRIMARY KEY, subject TEXT NOT NULL, intro TEXT NOT NULL, content_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL DEFAULT 'draft', origin TEXT NOT NULL DEFAULT 'manual',
+ weekly_key TEXT UNIQUE, send_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL, created_by TEXT NOT NULL)""",
+ """CREATE TABLE IF NOT EXISTS newsletter_versions (
+ edition_id TEXT NOT NULL, revision INTEGER NOT NULL, content_json TEXT NOT NULL,
+ PRIMARY KEY (edition_id, revision))""",
+ """CREATE TABLE IF NOT EXISTS newsletter_deliveries (
+ id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
+ edition_id TEXT NOT NULL, edition_revision INTEGER NOT NULL, kind TEXT NOT NULL,
+ email TEXT NOT NULL, subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
+ created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
+ claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
+ "CREATE INDEX IF NOT EXISTS idx_newsletter_queue ON newsletter_deliveries (state, next_attempt_at)",
+ """CREATE TRIGGER IF NOT EXISTS newsletter_account_changed AFTER UPDATE OF email, is_blocked ON users
+ WHEN LOWER(TRIM(COALESCE(NEW.email,''))) != LOWER(TRIM(COALESCE(OLD.email,''))) OR NEW.is_blocked=1
+ BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=NEW.id; END""",
+ """CREATE TRIGGER IF NOT EXISTS newsletter_account_deleted AFTER DELETE ON users
+ BEGIN DELETE FROM newsletter_subscriptions WHERE user_id=OLD.id;
+ UPDATE newsletter_deliveries SET state='cancelled', detail='Account removed.'
+ WHERE user_id=OLD.id AND state IN ('queued','retry','preparing'); END""",
+ """CREATE TRIGGER IF NOT EXISTS newsletter_identity_changed AFTER UPDATE ON jellyfin_user_links
+ WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source OR NEW.local_user_id != OLD.local_user_id
+ BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
+ """CREATE TRIGGER IF NOT EXISTS newsletter_identity_deleted AFTER DELETE ON jellyfin_user_links
+ BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
+ ):
+ conn.execute(sql)
+
+
+def settings() -> dict:
+ result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
+ result['public_url'] = magent_public_url(result['public_url'])
+ result['enabled'] = bool(result['enabled'])
+ return result
+
+
+def public_settings() -> dict:
+ return {key: value for key, value in settings().items() if key in
+ {'enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision', 'next_send_at', 'last_error'}}
+
+
+def next_due(now: datetime, weekday: int, hour: int) -> datetime:
+ now = now.astimezone(timezone.utc)
+ due = now.replace(hour=hour, minute=0, second=0, microsecond=0) + timedelta(days=(weekday - now.weekday()) % 7)
+ return due if due > now else due + timedelta(days=7)
+
+
+def save_settings(values: dict, now: datetime):
+ values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
+ with transaction() as conn:
+ old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
+ if old['revision'] != values['revision']:
+ raise Conflict('The newsletter settings changed. Refresh before saving.')
+ due = next_due(now, values['weekday'], values['hour']).timestamp() if values['enabled'] else None
+ conn.execute("""UPDATE newsletter_settings SET enabled=?, weekday=?, hour=?, limit_titles=?, public_url=?, intro=?,
+ revision=revision+1, next_send_at=?, generation_claim=NULL, generation_until=NULL, generation_attempts=0, last_error='' WHERE id=1""",
+ (values['enabled'], values['weekday'], values['hour'], values['limit_titles'], values['public_url'], values['intro'], due))
+ if not values['enabled'] or any(old[key] != values[key] for key in ('weekday', 'hour', 'public_url')):
+ conn.execute("UPDATE newsletter_editions SET state='cancelled', updated_at=? WHERE origin='weekly' AND state IN ('scheduled','queued')", (now.timestamp(),))
+ conn.execute("""UPDATE newsletter_deliveries SET state='cancelled', detail='Weekly schedule paused or changed.'
+ WHERE state IN ('queued','retry','preparing') AND kind='edition'
+ AND edition_id IN (SELECT id FROM newsletter_editions WHERE state='cancelled')""")
+ return public_settings()
+
+
+def subscription(user_id):
+ return read_one('SELECT * FROM newsletter_subscriptions WHERE user_id=?', (user_id,))
+
+
+def disable(user_id):
+ with transaction() as conn:
+ conn.execute("UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
+ conn.execute("UPDATE newsletter_deliveries SET state='cancelled', detail='Newsletter subscription turned off.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (user_id,))
+
+
+def request_confirmation(user, source, identity, now):
+ token = secrets.token_urlsafe(32)
+ with transaction() as conn:
+ old = conn.execute('SELECT requested_at FROM newsletter_subscriptions WHERE user_id=?', (user['id'],)).fetchone()
+ if old and old[0] > now - 300:
+ raise Conflict('Please wait five minutes before requesting another confirmation.')
+ conn.execute("""INSERT INTO newsletter_subscriptions (user_id,state,email,identity_source,identity_id,version,
+ confirmation_hash,confirmation_expires,requested_at,unsubscribe_token) VALUES (?,'pending',?,?,?,?,?,?,?,?)
+ ON CONFLICT(user_id) DO UPDATE SET state='pending',email=excluded.email,identity_source=excluded.identity_source,
+ identity_id=excluded.identity_id,version=excluded.version,confirmation_hash=excluded.confirmation_hash,
+ confirmation_expires=excluded.confirmation_expires,requested_at=excluded.requested_at,confirmed_at=NULL,
+ unsubscribe_token=excluded.unsubscribe_token""",
+ (user['id'], user['email'].strip(), source, identity, uuid.uuid4().hex,
+ hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
+ return token
+
+
+def token_subscription(token, action):
+ if action == 'confirm':
+ return read_one('SELECT * FROM newsletter_subscriptions WHERE confirmation_hash=?', (hashlib.sha256(token.encode()).hexdigest(),))
+ return read_one('SELECT * FROM newsletter_subscriptions WHERE unsubscribe_token=?', (token,))
+
+
+def confirm(sub, now):
+ with transaction() as conn:
+ result = conn.execute("""UPDATE newsletter_subscriptions SET state='enabled',confirmed_at=?,confirmation_hash=NULL
+ WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
+ AND EXISTS (SELECT 1 FROM users u JOIN jellyfin_user_links j ON j.local_user_id=u.id
+ WHERE u.id=newsletter_subscriptions.user_id AND u.is_blocked=0
+ AND LOWER(TRIM(u.email))=LOWER(TRIM(newsletter_subscriptions.email))
+ AND j.source=identity_source AND j.jellyfin_user_id=identity_id)""", (now, sub['user_id'], sub['version'], now))
+ return result.rowcount == 1
+
+
+def unpack(row):
+ if row is None:
+ return None
+ result = dict(row)
+ result['content'] = json.loads(result.pop('content_json'))
+ return result
+
+
+def edition(identity):
+ return unpack(read_one('SELECT * FROM newsletter_editions WHERE id=?', (identity,)))
+
+
+def create_edition(content, subject, intro, creator, now):
+ identity = uuid.uuid4().hex
+ with transaction() as conn:
+ conn.execute('''INSERT INTO newsletter_editions (id,subject,intro,content_json,created_at,updated_at,created_by)
+ VALUES (?,?,?,?,?,?,?)''', (identity, subject, intro, json.dumps(content), now, now, creator))
+ return edition(identity)
+
+
+def editable(conn, identity, revision):
+ row = conn.execute('SELECT * FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
+ if not row or row['revision'] != revision:
+ raise Conflict('This edition changed. Reload it before continuing.')
+ if row['state'] != 'draft':
+ raise Conflict('This edition is already scheduled or finished. Create a new draft to make changes.')
+ return unpack(row)
+
+
+def update_edition(identity, revision, subject, intro, selections, now):
+ with transaction() as conn:
+ old = editable(conn, identity, revision)
+ titles = old['content']['titles']
+ selected = {entry['id']: entry for entry in selections}
+ if len(selected) != len(selections) or set(selected) != {entry['id'] for entry in titles}:
+ raise Conflict('The title selection does not match this draft. Reload the edition.')
+ if sum(bool(entry['selected']) for entry in selections) > 24 or sum(bool(entry['featured']) for entry in selections) > 3:
+ raise Conflict('Choose up to 24 titles and three featured picks.')
+ if any(entry['featured'] and not entry['selected'] for entry in selections):
+ raise Conflict('Featured picks must be included in the edition.')
+ for entry in titles:
+ entry.update(selected=selected[entry['id']]['selected'], featured=selected[entry['id']]['featured'])
+ conn.execute('UPDATE newsletter_editions SET subject=?,intro=?,content_json=?,revision=revision+1,updated_at=? WHERE id=?',
+ (subject, intro, json.dumps(old['content']), now, identity))
+ return edition(identity)
+
+
+def snapshot(conn, row):
+ data = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
+ # Store only included titles; retries of a test retain the exact saved version.
+ data['titles'] = [entry for entry in data['titles'] if entry['selected']]
+ conn.execute('INSERT OR IGNORE INTO newsletter_versions (edition_id,revision,content_json) VALUES (?,?,?)',
+ (row['id'], row['revision'], json.dumps(data)))
+
+
+def version(delivery):
+ row = read_one('SELECT content_json FROM newsletter_versions WHERE edition_id=? AND revision=?', (delivery['edition_id'], delivery['edition_revision']))
+ return json.loads(row['content_json']) if row else None
+
+
+def publish(identity, revision, send_at, now):
+ with transaction() as conn:
+ previous = conn.execute('SELECT revision,state FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
+ if previous and previous['revision'] == revision and previous['state'] in {'scheduled', 'queued', 'complete'}:
+ return edition(identity)
+ row = editable(conn, identity, revision)
+ if not any(entry['selected'] for entry in row['content']['titles']) and not row['intro'].strip():
+ raise Conflict('Add an announcement or select a title before sending.')
+ snapshot(conn, row)
+ conn.execute("UPDATE newsletter_editions SET state='scheduled',send_at=?,updated_at=? WHERE id=?", (send_at, now, identity))
+ return edition(identity)
+
+
+def cancel(identity, now):
+ with transaction() as conn:
+ conn.execute("UPDATE newsletter_editions SET state='cancelled',updated_at=? WHERE id=? AND state IN ('draft','scheduled','queued')", (now, identity))
+ conn.execute("UPDATE newsletter_deliveries SET state='cancelled',detail='Edition cancelled.',updated_at=? WHERE edition_id=? AND state IN ('queued','retry','preparing')", (now, identity))
+ return edition(identity)
+
+
+def _enqueue(conn, sub, row, kind, key, public_url, now):
+ identity = uuid.uuid4().hex
+ conn.execute('''INSERT OR IGNORE INTO newsletter_deliveries (id,dedupe_key,user_id,edition_id,edition_revision,kind,email,
+ subscription_version,public_url,created_at,updated_at,next_attempt_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
+ (identity, key, sub['user_id'], row['id'], row['revision'], kind, sub['email'], sub['version'], public_url, now, now, now))
+ return conn.execute('SELECT id FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()[0]
+
+
+def enqueue_test(sub, identity, revision, request_id, public_url, now):
+ key = f"test:{sub['user_id']}:{request_id}"
+ with transaction() as conn:
+ previous = conn.execute('SELECT id,edition_id,edition_revision FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()
+ if previous:
+ if previous['edition_id'] != identity or previous['edition_revision'] != revision:
+ raise Conflict('This test request was already used for another saved version.')
+ return previous['id']
+ row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE id=? AND revision=?', (identity, revision)).fetchone())
+ if not row or row['state'] == 'cancelled':
+ raise Conflict('This edition changed or was cancelled. Reload it first.')
+ if conn.execute("SELECT 1 FROM newsletter_deliveries WHERE user_id=? AND kind='test' AND created_at>?", (sub['user_id'], now-300)).fetchone():
+ raise Conflict('Please wait five minutes between newsletter test emails.')
+ snapshot(conn, row)
+ return _enqueue(conn, sub, row, 'test', key, public_url, now)
+
+
+def enqueue_due(now):
+ with transaction() as conn:
+ config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
+ config['public_url'] = magent_public_url(config['public_url'])
+ rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
+ for raw in rows:
+ row = unpack(raw)
+ subs = conn.execute("SELECT * FROM newsletter_subscriptions WHERE state='enabled' AND confirmed_at<=?", (row['send_at'],)).fetchall()
+ for sub in subs:
+ _enqueue(conn, sub, row, 'edition', f"edition:{row['id']}:{sub['user_id']}", config['public_url'], now)
+ conn.execute("UPDATE newsletter_editions SET state=?,updated_at=? WHERE id=?", ('queued' if subs else 'complete', now, row['id']))
+
+
+def claim_delivery(now):
+ with transaction() as conn:
+ return email_queue.claim(conn, 'newsletter_deliveries', now)
+
+
+def begin_sending(delivery, now):
+ with transaction() as conn:
+ result = conn.execute("""UPDATE newsletter_deliveries SET state='sending',updated_at=?,lease_until=?
+ WHERE id=? AND claim=? AND state='preparing'
+ AND EXISTS (SELECT 1 FROM newsletter_subscriptions s JOIN users u ON u.id=s.user_id
+ JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
+ WHERE s.user_id=newsletter_deliveries.user_id AND s.state='enabled'
+ AND s.version=newsletter_deliveries.subscription_version AND u.is_blocked=0
+ AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
+ AND EXISTS (SELECT 1 FROM newsletter_settings WHERE id=1 AND public_url=newsletter_deliveries.public_url)
+ AND EXISTS (SELECT 1 FROM newsletter_editions e WHERE e.id=newsletter_deliveries.edition_id AND e.state!='cancelled')""",
+ (now, now+1800, delivery['id'], delivery['claim']))
+ return result.rowcount == 1
+
+
+def finish(delivery, state, detail, now, delay=0):
+ with transaction() as conn:
+ email_queue.finish(conn, 'newsletter_deliveries', delivery, state, detail, now, delay)
+
+
+def finish_editions(now):
+ with transaction() as conn:
+ conn.execute("""UPDATE newsletter_editions SET state='complete',updated_at=? WHERE state='queued'
+ AND NOT EXISTS (SELECT 1 FROM newsletter_deliveries d WHERE d.edition_id=newsletter_editions.id
+ AND d.kind='edition' AND d.state IN ('queued','preparing','sending','retry'))""", (now,))
+
+
+def claim_weekly(now: datetime):
+ with transaction() as conn:
+ config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
+ stamp = now.timestamp()
+ if not config['enabled'] or not config['next_send_at'] or config['next_send_at'] > stamp or (config['generation_until'] or 0) > stamp:
+ return None
+ claim = uuid.uuid4().hex
+ conn.execute('UPDATE newsletter_settings SET generation_claim=?,generation_until=?,generation_attempts=generation_attempts+1 WHERE id=1', (claim, stamp+600))
+ due = next_due(now, config['weekday'], config['hour']) - timedelta(days=7)
+ return {**config, 'generation_claim': claim, 'due': due, 'generation_attempts': config['generation_attempts']+1}
+
+
+def complete_weekly(config, content, now: datetime, failure=''):
+ with transaction() as conn:
+ current = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
+ if not current['enabled'] or current['revision'] != config['revision'] or current['generation_claim'] != config['generation_claim']:
+ return
+ if failure:
+ retry = config['generation_attempts'] < 3
+ conn.execute('''UPDATE newsletter_settings SET generation_claim=NULL,generation_until=?,last_error=?,next_send_at=?,
+ generation_attempts=? WHERE id=1''', (now.timestamp()+300 if retry else None, failure,
+ current['next_send_at'] if retry else next_due(now, config['weekday'], config['hour']).timestamp(),
+ config['generation_attempts'] if retry else 0))
+ return
+ identity = uuid.uuid4().hex
+ due = config['due']
+ empty = not content['titles']
+ conn.execute('''INSERT OR IGNORE INTO newsletter_editions
+ (id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
+ VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
+ (identity, f"What’s new in your library · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
+ 'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
+ row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
+ if not empty:
+ snapshot(conn, row)
+ conn.execute('''UPDATE newsletter_settings SET next_send_at=?,generation_claim=NULL,generation_until=NULL,
+ generation_attempts=0,last_error=? WHERE id=1''',
+ (next_due(now, config['weekday'], config['hour']).timestamp(), 'No new arrivals for the weekly edition; no email was queued.' if empty else ''))
+
+
+def overview(offset=0):
+ with closing(db._connect()) as conn:
+ import sqlite3
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute('SELECT * FROM newsletter_editions ORDER BY created_at DESC,id LIMIT 30').fetchall()
+ editions = []
+ for raw in rows:
+ row = unpack(raw)
+ content = row.pop('content')
+ row.update(period_start=content['period_start'], period_end=content['period_end'], titles=sum(entry['selected'] for entry in content['titles']))
+ editions.append(row)
+ deliveries = conn.execute('''SELECT d.id,d.edition_id,e.subject,d.kind,d.email,d.state,d.attempts,d.updated_at,d.next_attempt_at,
+ d.detail,u.username FROM newsletter_deliveries d LEFT JOIN users u ON u.id=d.user_id
+ LEFT JOIN newsletter_editions e ON e.id=d.edition_id ORDER BY d.created_at DESC,d.id LIMIT 50 OFFSET ?''', (offset,)).fetchall()
+ subscribers = conn.execute("SELECT COUNT(*) FROM newsletter_subscriptions WHERE state='enabled'").fetchone()[0]
+ total = conn.execute('SELECT COUNT(*) FROM newsletter_deliveries').fetchone()[0]
+ return {'editions': editions, 'deliveries': [dict(row) for row in deliveries], 'subscribers': subscribers, 'total': total}
diff --git a/backend/app/services/newsletters.py b/backend/app/services/newsletters.py
new file mode 100644
index 0000000..ccd0cb1
--- /dev/null
+++ b/backend/app/services/newsletters.py
@@ -0,0 +1,271 @@
+"""Weekly new-arrival newsletters, manual editions and separate opt-in delivery."""
+
+import asyncio
+import logging
+import time
+import uuid
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urlencode, urlsplit
+
+from .. import db
+from ..runtime import get_runtime_settings
+from . import email_recaps, newsletter_catalog as catalog, newsletter_email as template, newsletter_store as store
+from . import recap_email as mail, recap_store
+from .invite_email import smtp_email_config_ready
+from .jellyfin_identity import linked_user_id, source_key
+
+logger = logging.getLogger(__name__)
+NewsletterError = email_recaps.RecapError
+
+
+def playback_url(runtime) -> str:
+ value = str(runtime.jellyfin_public_url or '').strip().rstrip('/')
+ try:
+ parsed = urlsplit(value)
+ if parsed.scheme in {'https', 'http'} and parsed.hostname and not (parsed.username or parsed.password or parsed.query or parsed.fragment) and not any(c.isspace() or c in '<>"\\' for c in value):
+ return value
+ except ValueError:
+ pass
+ return ''
+
+
+def delivery_ready(public_url=None):
+ config = store.settings()
+ if not (public_url if public_url is not None else config['public_url']):
+ return False, 'Set the application URL in Hosting & proxy for newsletter email links.'
+ runtime = get_runtime_settings()
+ if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
+ return False, 'Connect Jellyfin to collect new arrivals.'
+ if not playback_url(runtime):
+ return False, 'Set the public Jellyfin address in Jellyfin settings for Watch links.'
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ return ready, detail
+ if not email_recaps.worker_enabled():
+ return False, 'Background automation is paused on this server.'
+ return True, 'Newsletter delivery is configured.'
+
+
+def account_for(user):
+ account = db.get_user_by_username(user.get('username', ''))
+ if not account or account.get('is_blocked') or account.get('is_expired'):
+ raise NewsletterError('This account cannot receive newsletters.', 403)
+ return account
+
+
+def active_subscription(account):
+ sub = store.subscription(account['id'])
+ if sub and sub['state'] != 'off' and not email_recaps.binding_matches(sub, account):
+ store.disable(account['id'])
+ sub = store.subscription(account['id'])
+ return sub
+
+
+def preferences(user):
+ account = account_for(user)
+ sub = active_subscription(account)
+ runtime = get_runtime_settings()
+ ready, detail = delivery_ready()
+ linked = bool(linked_user_id(account['username'], runtime.jellyfin_base_url))
+ email = mail.valid_email(account.get('email'))
+ config = store.settings()
+ state = sub['state'] if sub else 'off'
+ if state == 'pending' and sub['confirmation_expires'] <= time.time():
+ state = 'expired'
+ return {'state': state, 'email': account.get('email'), 'can_subscribe': ready and linked and bool(email),
+ 'detail': detail if not ready else 'Save a valid profile email address.' if not email else
+ 'Link your Jellyfin account so newsletter titles match your library access.' if not linked else 'New arrivals and featured picks, in your inbox.',
+ 'schedule_enabled': config['enabled'], 'next_send_at': config['next_send_at'], 'weekday': config['weekday'], 'hour': config['hour'],
+ 'resend_after': sub['requested_at'] + 300 if sub else None}
+
+
+async def subscribe(user):
+ account = account_for(user)
+ preference = preferences(user)
+ if preference['state'] == 'enabled':
+ return preference
+ if not preference['can_subscribe']:
+ raise NewsletterError(preference['detail'])
+ runtime = get_runtime_settings()
+ try:
+ token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
+ linked_user_id(account['username'], runtime.jellyfin_base_url), time.time())
+ except store.Conflict as exc:
+ raise NewsletterError(str(exc), 429) from exc
+ # The click supplies separate newsletter consent. Reuse a still-valid confirmed address if available.
+ recap = recap_store.subscription(account['id'])
+ if recap and recap['state'] == 'enabled' and email_recaps.binding_matches(recap, account):
+ if store.confirm(store.subscription(account['id']), time.time()):
+ return {**preferences(user), 'message': 'Newsletter subscription is on, using your confirmed profile email.'}
+ config = store.settings()
+ url = config['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'confirm', 'token': token})
+ try:
+ await asyncio.to_thread(mail.send_email, account['email'].strip(), template.render_confirmation(account['username'], url),
+ mail.message_id(uuid.uuid4().hex, config['public_url']))
+ except mail.DeliveryError as exc:
+ raise NewsletterError('Could not confirm delivery of the verification email. Check your inbox; another can be requested in five minutes.', 502) from exc
+ return {**preferences(user), 'message': 'Check your inbox and confirm within 24 hours to turn on newsletters.'}
+
+
+def token_action(token, action, apply=False):
+ sub = store.token_subscription(token, action)
+ if not sub:
+ raise NewsletterError('This newsletter link is invalid or has already been used. Open Profile to manage your subscription.', 410)
+ if action == 'unsubscribe':
+ if apply:
+ store.disable(sub['user_id'])
+ return {'action': action, 'state': 'off' if apply or sub['state'] == 'off' else 'ready'}
+ account = db.get_user_by_id(sub['user_id'])
+ if sub['state'] != 'pending' or sub['confirmation_expires'] <= time.time() or not email_recaps.binding_matches(sub, account):
+ raise NewsletterError('This confirmation expired or your account changed. Request a new newsletter link in Profile.', 410)
+ if apply and not store.confirm(sub, time.time()):
+ raise NewsletterError('This confirmation is no longer available. Request a new newsletter link in Profile.', 410)
+ return {'action': action, 'state': 'enabled' if apply else 'ready'}
+
+
+async def collect(start, end, limit):
+ runtime = get_runtime_settings()
+ result = await asyncio.wait_for(catalog.collect(runtime, start, end, limit), timeout=180)
+ return {**result, 'playback_url': playback_url(runtime)}
+
+
+async def create_draft(user, days):
+ end = datetime.now(timezone.utc)
+ config = store.settings()
+ content = await collect(end - timedelta(days=days), end, config['limit_titles'])
+ return store.create_edition(content, f"What’s new in your library · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
+
+
+def require_edition(identity, revision=None):
+ row = store.edition(identity)
+ if not row:
+ raise NewsletterError('Newsletter edition not found.', 404)
+ if revision is not None and row['revision'] != revision:
+ raise NewsletterError('This edition changed. Reload it before continuing.')
+ return row
+
+
+async def preview(identity, revision):
+ row = require_edition(identity, revision)
+ runtime = get_runtime_settings()
+ config = store.settings()
+ if not config['public_url'] or not playback_url(runtime):
+ raise NewsletterError('Check the application URL in Hosting & proxy and the public playback URL in Jellyfin settings before previewing.')
+ if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
+ raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
+ content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
+ images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
+ rendered = template.render(content, images, config['public_url'], content['playback_url'], config['public_url'] + '/profile#newsletters', preview=True)
+ rendered.pop('inline_images')
+ return {'id': row['id'], 'revision': row['revision'], **rendered}
+
+
+def queue_test(user, identity, revision, request_id):
+ ready, detail = delivery_ready()
+ if not ready:
+ raise NewsletterError(detail)
+ account = account_for(user)
+ sub = active_subscription(account)
+ if not sub or sub['state'] != 'enabled':
+ raise NewsletterError('Subscribe to newsletters and confirm your email in Profile before sending yourself a test.')
+ delivery_id = store.enqueue_test(sub, identity, revision, request_id, store.settings()['public_url'], time.time())
+ return {'id': delivery_id, 'message': 'Test queued for your confirmed newsletter email. Delivery history will show the result.'}
+
+
+def publish(identity, revision, send_at):
+ ready, detail = delivery_ready()
+ if not ready:
+ raise NewsletterError(detail)
+ row = require_edition(identity, revision)
+ runtime = get_runtime_settings()
+ if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
+ raise NewsletterError('The Jellyfin connection changed. Create a fresh draft before sending.')
+ now = datetime.now(timezone.utc)
+ when = now if send_at is None else send_at
+ if when.tzinfo is None:
+ raise NewsletterError('Choose a send time with an explicit timezone.', 422)
+ when = when.astimezone(timezone.utc)
+ if send_at is not None and not now + timedelta(seconds=30) <= when <= now + timedelta(days=90):
+ raise NewsletterError('Schedule the edition at least 30 seconds ahead and within the next 90 days.', 422)
+ return store.publish(identity, revision, when.timestamp(), now.timestamp())
+
+
+def eligible(delivery):
+ account = db.get_user_by_id(delivery['user_id'])
+ sub = active_subscription(account) if account else None
+ ready, _ = delivery_ready()
+ if not ready or not sub or sub['state'] != 'enabled' or sub['version'] != delivery['subscription_version'] or sub['email'] != delivery['email'] or not email_recaps.binding_matches(sub, account) or store.settings()['public_url'] != delivery['public_url']:
+ raise mail.DeliveryCancelled()
+ row = store.edition(delivery['edition_id'])
+ if not row or row['state'] == 'cancelled':
+ raise mail.DeliveryCancelled()
+ return account, sub
+
+
+async def process_delivery(delivery):
+ state, detail, delay = 'failed', 'Could not prepare this newsletter.', 0
+ try:
+ _, sub = eligible(delivery)
+ content = store.version(delivery)
+ runtime = get_runtime_settings()
+ if not content or content['playback_url'] != playback_url(runtime) or content['source'] != source_key(runtime.jellyfin_base_url):
+ raise mail.DeliveryCancelled()
+ content = await asyncio.wait_for(catalog.for_recipient(runtime, content, sub['identity_id']), timeout=120)
+ if content.get('recipient_disabled') or (not content['titles'] and not content['intro'].strip()):
+ state, detail = 'skipped', 'No selected titles are available to this account.'
+ else:
+ images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
+ unsubscribe = delivery['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
+ rendered = template.render(content, images, delivery['public_url'], content['playback_url'], unsubscribe, test=delivery['kind'] == 'test')
+
+ def before_data():
+ eligible(delivery)
+ if not store.begin_sending(delivery, time.time()):
+ raise mail.DeliveryCancelled()
+
+ await asyncio.to_thread(mail.send_email, delivery['email'], rendered, mail.message_id(delivery['id'], delivery['public_url']), before_data)
+ state, detail = 'sent', 'Accepted by the mail server.'
+ except mail.DeliveryCancelled:
+ state, detail = 'cancelled', 'Subscription, account, edition or email settings changed.'
+ except (catalog.CatalogError, TimeoutError):
+ state, detail = 'retry', 'Jellyfin content or library access could not be checked.'
+ except mail.DeliveryError as exc:
+ state, detail = exc.state, exc.detail
+ except Exception as exc:
+ logger.error('newsletter delivery error id=%s type=%s', delivery['id'], type(exc).__name__)
+ current = store.read_one('SELECT state FROM newsletter_deliveries WHERE id=?', (delivery['id'],))
+ if current and current['state'] == 'sending':
+ state, detail = 'unknown', 'Delivery outcome is unknown; check the mail server.'
+ if state == 'retry':
+ if delivery['attempts'] >= 3:
+ state, detail = 'failed', detail + ' Stopped after three attempts.'
+ else:
+ delay = 300 if delivery['attempts'] == 1 else 1800
+ store.finish(delivery, state, detail, time.time(), delay)
+
+
+async def run_once():
+ if delivery_ready()[0]:
+ config = store.claim_weekly(datetime.now(timezone.utc))
+ if config:
+ try:
+ content = await collect(config['due'] - timedelta(days=7), config['due'], config['limit_titles'])
+ store.complete_weekly(config, content, datetime.now(timezone.utc))
+ except (catalog.CatalogError, TimeoutError):
+ store.complete_weekly(config, None, datetime.now(timezone.utc), 'Could not collect a complete weekly edition from Jellyfin. No newsletter was queued.')
+ store.enqueue_due(time.time())
+ for _ in range(10):
+ delivery = store.claim_delivery(time.time())
+ if not delivery:
+ break
+ await process_delivery(delivery)
+ store.finish_editions(time.time())
+
+
+async def run_newsletter_loop():
+ while True:
+ try:
+ await run_once()
+ except Exception as exc:
+ logger.error('newsletter worker failed type=%s', type(exc).__name__)
+ await asyncio.sleep(30)
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/operation_progress.py b/backend/app/services/operation_progress.py
new file mode 100644
index 0000000..8984e94
--- /dev/null
+++ b/backend/app/services/operation_progress.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+
+from contextvars import ContextVar, Token
+from copy import deepcopy
+from datetime import datetime, timezone
+import re
+import threading
+import time
+import uuid
+from typing import Any, Dict, Optional
+
+
+_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
+_OPERATION_TTL_SECONDS = 15 * 60
+_MAX_OPERATIONS = 500
+_MAX_EVENTS = 60
+_current_operation_id: ContextVar[Optional[str]] = ContextVar(
+ "magent_operation_id", default=None
+)
+_operations: Dict[str, Dict[str, Any]] = {}
+_lock = threading.Lock()
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def normalize_operation_id(value: Optional[str]) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ normalized = value.strip()
+ return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
+
+
+def _prune_locked(now_monotonic: float) -> None:
+ expired = [
+ operation_id
+ for operation_id, operation in _operations.items()
+ if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
+ ]
+ for operation_id in expired:
+ _operations.pop(operation_id, None)
+ if len(_operations) <= _MAX_OPERATIONS:
+ return
+ oldest = sorted(
+ _operations,
+ key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
+ )
+ for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
+ _operations.pop(operation_id, None)
+
+
+def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
+ now_monotonic = time.monotonic()
+ now_iso = _now_iso()
+ normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
+ with _lock:
+ _prune_locked(now_monotonic)
+ _operations[operation_id] = {
+ "id": operation_id,
+ "label": normalized_label,
+ "path": path,
+ "status": "running",
+ "started_at": now_iso,
+ "updated_at": now_iso,
+ "updated_monotonic": now_monotonic,
+ "duration_ms": None,
+ "events": [
+ {
+ "id": uuid.uuid4().hex,
+ "service": "Magent",
+ "state": "complete",
+ "message": "Your action has been received. Magent is starting the checks.",
+ "started_at": now_iso,
+ "finished_at": now_iso,
+ "duration_ms": 0,
+ "status_code": None,
+ }
+ ],
+ }
+ return _current_operation_id.set(operation_id)
+
+
+def reset_operation(token: Token) -> None:
+ _current_operation_id.reset(token)
+
+
+def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
+ operation_id = _current_operation_id.get()
+ if not operation_id:
+ return None
+ event_id = uuid.uuid4().hex
+ now_iso = _now_iso()
+ now_monotonic = time.monotonic()
+ with _lock:
+ operation = _operations.get(operation_id)
+ if not operation:
+ return None
+ operation["events"].append(
+ {
+ "id": event_id,
+ "service": service,
+ "state": "active",
+ "message": message or f"Contacting {service}…",
+ "started_at": now_iso,
+ "finished_at": None,
+ "duration_ms": None,
+ "status_code": None,
+ "started_monotonic": now_monotonic,
+ }
+ )
+ operation["events"] = operation["events"][-_MAX_EVENTS:]
+ operation["updated_at"] = now_iso
+ operation["updated_monotonic"] = now_monotonic
+ return event_id
+
+
+def finish_remote_call(
+ event_id: Optional[str],
+ *,
+ success: bool,
+ status_code: Optional[int] = None,
+ message: Optional[str] = None,
+) -> None:
+ operation_id = _current_operation_id.get()
+ if not operation_id or not event_id:
+ return
+ now_iso = _now_iso()
+ now_monotonic = time.monotonic()
+ with _lock:
+ operation = _operations.get(operation_id)
+ if not operation:
+ return
+ event = next(
+ (candidate for candidate in operation["events"] if candidate.get("id") == event_id),
+ None,
+ )
+ if not event:
+ return
+ started_monotonic = float(event.pop("started_monotonic", now_monotonic))
+ event["state"] = "complete" if success else "error"
+ event["finished_at"] = now_iso
+ event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
+ event["status_code"] = status_code
+ event["message"] = message or (
+ f"{event['service']} responded successfully."
+ if success
+ else f"{event['service']} returned an error."
+ )
+ operation["updated_at"] = now_iso
+ operation["updated_monotonic"] = now_monotonic
+
+
+def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
+ now_iso = _now_iso()
+ now_monotonic = time.monotonic()
+ with _lock:
+ operation = _operations.get(operation_id)
+ if not operation:
+ return
+ for event in operation["events"]:
+ if event.get("state") == "active":
+ started_monotonic = float(event.pop("started_monotonic", now_monotonic))
+ event["state"] = "error"
+ event["finished_at"] = now_iso
+ event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
+ event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
+ started = datetime.fromisoformat(str(operation["started_at"]))
+ duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
+ operation["status"] = "complete" if success else "error"
+ operation["status_code"] = status_code
+ operation["duration_ms"] = round(duration_ms, 1)
+ operation["updated_at"] = now_iso
+ operation["updated_monotonic"] = now_monotonic
+ operation["events"].append(
+ {
+ "id": uuid.uuid4().hex,
+ "service": "Magent",
+ "state": "complete" if success else "error",
+ "message": (
+ "This action has finished. Check the request status for what happens next."
+ if success
+ else "This action could not be completed. Open the activity details to see which step needs attention."
+ ),
+ "started_at": now_iso,
+ "finished_at": now_iso,
+ "duration_ms": 0,
+ "status_code": status_code,
+ }
+ )
+ operation["events"] = operation["events"][-_MAX_EVENTS:]
+
+
+def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
+ normalized = normalize_operation_id(operation_id)
+ if not normalized:
+ return None
+ with _lock:
+ operation = _operations.get(normalized)
+ if not operation:
+ return None
+ result = deepcopy(operation)
+ result.pop("updated_monotonic", None)
+ for event in result.get("events", []):
+ event.pop("started_monotonic", None)
+ return result
diff --git a/backend/app/services/password_reset.py b/backend/app/services/password_reset.py
new file mode 100644
index 0000000..50ecd65
--- /dev/null
+++ b/backend/app/services/password_reset.py
@@ -0,0 +1,335 @@
+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,
+ increment_user_auth_version,
+ 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")
+ 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)
+ increment_user_auth_version(username)
+ 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/public_urls.py b/backend/app/services/public_urls.py
new file mode 100644
index 0000000..a40dcca
--- /dev/null
+++ b/backend/app/services/public_urls.py
@@ -0,0 +1,32 @@
+"""Configured public email links, independent of request Host/forwarded headers."""
+from urllib.parse import urlsplit
+from ..runtime import get_runtime_settings
+from ..installation_origin import managed_runtime
+
+
+def valid_public_url(value):
+ value = str(value or '').strip().rstrip('/')
+ try:
+ parsed = urlsplit(value)
+ if (parsed.scheme in {'http', 'https'} and parsed.hostname
+ and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
+ and (parsed.port is None or parsed.port > 0)
+ and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)):
+ return value
+ except ValueError:
+ pass
+ return ''
+
+
+def magent_public_url(legacy_url=''):
+ runtime = get_runtime_settings()
+ proxy = getattr(runtime, 'magent_proxy_base_url', None)
+ application = getattr(runtime, 'magent_application_url', None)
+ if managed_runtime():
+ return valid_public_url(application)
+ if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
+ return valid_public_url(proxy)
+ if str(application or '').strip():
+ return valid_public_url(application)
+ # Preserve pre-existing installations until Hosting & proxy has been configured.
+ return valid_public_url(legacy_url)
diff --git a/backend/app/services/recap_email.py b/backend/app/services/recap_email.py
new file mode 100644
index 0000000..5ad6138
--- /dev/null
+++ b/backend/app/services/recap_email.py
@@ -0,0 +1,198 @@
+"""Personal recap email rendering and SMTP delivery with explicit acceptance tracking."""
+
+import html
+import re
+import smtplib
+import ssl
+from contextlib import suppress
+from datetime import datetime
+from email.message import EmailMessage
+from email.policy import SMTP as SMTP_POLICY
+from email.utils import formataddr, formatdate
+from urllib.parse import urlsplit
+
+from ..runtime import get_runtime_settings
+
+
+class DeliveryError(Exception):
+ def __init__(self, state: str, detail: str):
+ self.state, self.detail = state, detail
+ super().__init__(detail)
+
+
+class DeliveryCancelled(Exception):
+ pass
+
+
+def valid_email(value: str | None) -> str | None:
+ value = str(value or "").strip()
+ if (len(value) <= 254 and re.fullmatch(r"[^@\s<>;,\"\\]+@[^@\s<>;,\"\\]+\.[^@\s<>;,\"\\]+", value)
+ and all(32 < ord(char) < 127 for char in value)):
+ return value
+ return None
+
+
+def month_label(value: str) -> str:
+ return datetime.strptime(value, "%Y-%m").strftime("%B %Y")
+
+
+def number(value: float) -> str:
+ return f"{value:,.0f}"
+
+
+def document(*, title: str, intro: str, content: str, action: str, url: str, footer: str, kicker: str = 'YOUR MONTH IN VIEWING') -> str:
+ esc = html.escape
+ return f'''{esc(title)}
+
+
+
+MAGENT / {esc(kicker)}
+{esc(title)} {esc(intro)}
+{content}
+{esc(action)} ↗
+
+
'''
+
+
+def render_confirmation(username: str, url: str) -> dict:
+ title = "Your month, delivered."
+ intro = f"Hi {username}, confirm this email address to receive personal viewing reports from Magent. You choose whether to request them yourself or also receive automatic monthly emails."
+ text = f"{intro}\n\nConfirm email recaps: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm."
+ body = document(title=title, intro=intro,
+ content='Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.
',
+ action="Confirm email recaps", url=url,
+ footer="This link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm.")
+ return {"subject": "Confirm your Magent email recaps", "body_text": text, "body_html": body}
+
+
+def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False, requested: bool = False) -> dict:
+ esc = html.escape
+ month = month_label(report["month"])
+ previous = month_label(report["comparison_month"])
+ if report.get('is_partial'):
+ month += ' so far'
+ previous += ' (same elapsed period, capped at month end)' if report.get('comparison_capped') else ' (same elapsed period)'
+ summary = report["summary"]
+ metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
+ ("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
+ cells, lines = [], []
+ for label, key, value in metrics:
+ change = report["changes"][key]
+ difference = change["difference"]
+ comparison = ("No change" if difference == 0 else f"{'+' if difference > 0 else '−'}{number(abs(difference))}")
+ if change["percent"] is not None and difference:
+ comparison += f" ({'+' if difference > 0 else '−'}{abs(change['percent']):g}%)"
+ comparison += f" from {previous}"
+ lines.append(f"{label}: {number(value)}. {comparison}.")
+ cells.append(f'{label} {number(value)} {esc(comparison)} ')
+ content = '' + ''.join(cells[:2]) + ' ' + ''.join(cells[2:]) + '
'
+ habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
+ content += f'{esc(habit)}
'
+ patterns = report.get("patterns", {})
+ if patterns:
+ detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%."
+ lines.append(detail)
+ content += f'{esc(detail)}
'
+ for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])):
+ peak = max(1, *(row["minutes"] for row in rows))
+ content += f'{heading} '
+ for row in rows:
+ width = round(row["minutes"] / peak * 100)
+ content += f'{esc(row["name"])} {number(row["minutes"])} min '
+ lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
+ content += '
'
+ top = report.get("top_titles", [])[:3]
+ if top:
+ content += 'Your most watched '
+ for item in top:
+ artwork = item.get("email_artwork", "")
+ if artwork.startswith(("cid:", "data:image/")):
+ content += f' '
+ content += f'{esc(item["title"])}{number(item["minutes"])} minutes · {number(item["plays"])} plays
'
+ else:
+ content += 'No viewing was recorded this month. Your requests are still included.
'
+ report_url = f"{public_url}/insights/reports?month={report['month']}"
+ intro = f"Hi {username}, here’s your {month} in viewing. A little look back at the stories you spent time with."
+ footer = f'You enabled personal report emails from Magent. Based on retained Jellystat history. Calendar months use UTC; request statuses are current.Unsubscribe from recaps · Email preferences '
+ if requested:
+ intro = 'You requested this report. ' + intro
+ if test:
+ intro = "This is your test recap. " + intro
+ body = document(title=month, intro=intro, content=content, action="Explore your full report", url=report_url, footer=footer)
+ text = '\n'.join([intro, '', *lines, '', habit, '', 'Most watched:',
+ *(f"{item['title']}: {number(item['minutes'])} minutes" for item in top), '',
+ f"Your full report: {report_url}", '', 'Based on retained Jellystat history. Calendar months use UTC; request statuses are current.',
+ f"Unsubscribe from recaps: {unsubscribe_url}", f"Email preferences: {public_url}/profile#monthly-recaps"])
+ return {"subject": f"{'[Test] ' if test else ''}Your {month} in viewing · Magent", "body_text": text, "body_html": body}
+
+
+def send_email(recipient: str, rendered: dict, message_id: str, before_data=lambda: None) -> None:
+ """Return only after SMTP accepts DATA. Never retry an ambiguous DATA disconnect.
+
+ A stable Message-ID aids diagnosis; it is not an SMTP deduplication guarantee.
+ See RFC 5321 §4.5.3.2.6 and Python's smtplib exception definitions.
+ """
+ runtime = get_runtime_settings()
+ sender = valid_email(runtime.magent_notify_email_from_address)
+ if not sender or not valid_email(recipient):
+ raise DeliveryError("failed", "A valid sender and recipient email are required.")
+ message = EmailMessage(policy=SMTP_POLICY)
+ message["From"] = formataddr((str(runtime.magent_notify_email_from_name or "Magent").replace('\r', '').replace('\n', ''), sender))
+ message["To"], message["Subject"] = recipient, rendered["subject"]
+ message["Date"], message["Message-ID"] = formatdate(localtime=False), message_id
+ message["Auto-Submitted"], message["X-Auto-Response-Suppress"] = "auto-generated", "All"
+ message.set_content(rendered["body_text"])
+ message.add_alternative(rendered["body_html"], subtype="html")
+ html_part = message.get_payload()[-1]
+ for attachment in rendered.get('inline_images', []):
+ html_part.add_related(
+ attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
+ filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
+ payload = message.as_bytes()
+ smtp, stage = None, "connect"
+ try:
+ kwargs = {"timeout": 30, "local_hostname": sender.split('@', 1)[1]}
+ if runtime.magent_notify_email_use_ssl:
+ smtp = smtplib.SMTP_SSL(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port,
+ context=ssl.create_default_context(), **kwargs)
+ else:
+ smtp = smtplib.SMTP(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port, **kwargs)
+ smtp.ehlo_or_helo_if_needed()
+ if runtime.magent_notify_email_use_tls and not runtime.magent_notify_email_use_ssl:
+ smtp.starttls(context=ssl.create_default_context())
+ smtp.ehlo()
+ if runtime.magent_notify_email_smtp_username:
+ smtp.login(runtime.magent_notify_email_smtp_username, runtime.magent_notify_email_smtp_password)
+ code, reply = smtp.mail(sender)
+ if code != 250:
+ raise smtplib.SMTPResponseException(code, reply)
+ code, reply = smtp.rcpt(recipient)
+ if code not in (250, 251):
+ raise smtplib.SMTPResponseException(code, reply)
+ before_data()
+ stage = "data"
+ code, reply = smtp.data(payload)
+ if code != 250:
+ raise smtplib.SMTPDataError(code, reply)
+ stage = "accepted"
+ except smtplib.SMTPResponseException as exc:
+ state = "retry" if 400 <= exc.smtp_code < 500 else "failed"
+ raise DeliveryError(state, f"Mail server returned SMTP {exc.smtp_code}.") from exc
+ except (ssl.SSLError, smtplib.SMTPNotSupportedError, UnicodeError, ValueError) as exc:
+ raise DeliveryError("failed", "Check the SMTP security and sender settings.") from exc
+ except (OSError, smtplib.SMTPException) as exc:
+ state = "unknown" if stage == "data" else "retry"
+ detail = "Mail server acceptance is unknown; check its logs before taking further action." if state == "unknown" else "Could not reach or finish connecting to the mail server."
+ raise DeliveryError(state, detail) from exc
+ finally:
+ if smtp:
+ # A failed QUIT after a 250 DATA response must not turn an accepted email into a retry.
+ with suppress(Exception):
+ smtp.quit()
+ with suppress(Exception):
+ smtp.close()
+
+
+def message_id(delivery_id: str, public_url: str) -> str:
+ host = urlsplit(public_url).hostname or "magent.local"
+ return f""
diff --git a/backend/app/services/recap_store.py b/backend/app/services/recap_store.py
new file mode 100644
index 0000000..9adb0f8
--- /dev/null
+++ b/backend/app/services/recap_store.py
@@ -0,0 +1,247 @@
+"""Durable consent, schedule and delivery records for personal email recaps."""
+
+import hashlib
+import secrets
+import sqlite3
+import uuid
+from contextlib import closing, contextmanager
+from datetime import datetime
+
+from .. import db
+from .monthly_reports import shift_month
+from . import email_queue
+from .public_urls import magent_public_url
+
+
+def init_schema(conn: sqlite3.Connection) -> None:
+ for statement in (
+ """CREATE TABLE IF NOT EXISTS email_recap_settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0,
+ day INTEGER NOT NULL DEFAULT 2, hour INTEGER NOT NULL DEFAULT 9,
+ public_url TEXT NOT NULL DEFAULT '', next_send_at REAL)""",
+ "INSERT OR IGNORE INTO email_recap_settings (id) VALUES (1)",
+ """CREATE TABLE IF NOT EXISTS email_recap_subscriptions (
+ user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
+ identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
+ confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
+ confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
+ """CREATE TABLE IF NOT EXISTS email_recap_deliveries (
+ id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
+ month TEXT NOT NULL, kind TEXT NOT NULL, email TEXT NOT NULL,
+ subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
+ created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
+ claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
+ "CREATE INDEX IF NOT EXISTS idx_email_recap_queue ON email_recap_deliveries (state, next_attempt_at)",
+ """CREATE TRIGGER IF NOT EXISTS email_recap_account_changed AFTER UPDATE OF email, is_blocked ON users
+ WHEN LOWER(TRIM(COALESCE(NEW.email, ''))) != LOWER(TRIM(COALESCE(OLD.email, '')))
+ OR NEW.is_blocked = 1
+ BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
+ confirmed_at = NULL WHERE user_id = NEW.id; END""",
+ """CREATE TRIGGER IF NOT EXISTS email_recap_account_deleted AFTER DELETE ON users
+ BEGIN DELETE FROM email_recap_subscriptions WHERE user_id = OLD.id;
+ UPDATE email_recap_deliveries SET state = 'cancelled', detail = 'Account removed.'
+ WHERE user_id = OLD.id AND state IN ('queued', 'retry', 'preparing'); END""",
+ """CREATE TRIGGER IF NOT EXISTS email_recap_identity_changed AFTER UPDATE ON jellyfin_user_links
+ WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source
+ OR NEW.local_user_id != OLD.local_user_id
+ BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
+ confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
+ """CREATE TRIGGER IF NOT EXISTS email_recap_identity_deleted AFTER DELETE ON jellyfin_user_links
+ BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
+ confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
+ ):
+ conn.execute(statement)
+ columns = {row[1] for row in conn.execute('PRAGMA table_info(email_recap_subscriptions)')}
+ if 'automatic_monthly' not in columns:
+ conn.execute('ALTER TABLE email_recap_subscriptions ADD COLUMN automatic_monthly INTEGER NOT NULL DEFAULT 1')
+
+
+@contextmanager
+def transaction():
+ with closing(db._connect()) as conn, conn:
+ conn.row_factory = sqlite3.Row
+ conn.execute("BEGIN IMMEDIATE")
+ yield conn
+
+
+def read_one(sql: str, args=()) -> dict | None:
+ with closing(db._connect()) as conn:
+ conn.row_factory = sqlite3.Row
+ row = conn.execute(sql, args).fetchone()
+ return dict(row) if row else None
+
+
+def settings() -> dict:
+ row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
+ row["public_url"] = magent_public_url(row["public_url"])
+ return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
+
+
+def next_due(now: datetime, day: int, hour: int) -> datetime:
+ due = shift_month(now, 0).replace(day=day, hour=hour)
+ return due if due > now else shift_month(now, 1).replace(day=day, hour=hour)
+
+
+def save_settings(values: dict, now: datetime) -> dict:
+ values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
+ with transaction() as conn:
+ old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
+ changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
+ due = old["next_send_at"]
+ if not values["enabled"]:
+ due = None
+ elif not old["enabled"] or changed:
+ due = next_due(now, values["day"], values["hour"]).timestamp()
+ conn.execute("UPDATE email_recap_settings SET enabled=?, day=?, hour=?, public_url=?, next_send_at=? WHERE id=1",
+ (values["enabled"], values["day"], values["hour"], values["public_url"], due))
+ if not values["enabled"] or changed:
+ conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Schedule paused or changed.', updated_at=?
+ WHERE kind='scheduled' AND state IN ('queued', 'retry', 'preparing')""", (now.timestamp(),))
+ return settings()
+
+
+def subscription(user_id: int) -> dict | None:
+ return read_one("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user_id,))
+
+
+def disable(user_id: int) -> None:
+ with transaction() as conn:
+ conn.execute("UPDATE email_recap_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
+ conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Email recaps turned off.'
+ WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
+
+
+def request_confirmation(user: dict, source: str, identity: str, now: float, automatic_monthly: bool = True) -> str:
+ token = secrets.token_urlsafe(32)
+ with transaction() as conn:
+ old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
+ if old and old["requested_at"] > now - 300:
+ raise ValueError("Please wait five minutes before requesting another confirmation email.")
+ conn.execute("""INSERT INTO email_recap_subscriptions
+ (user_id, state, email, identity_source, identity_id, version, confirmation_hash,
+ confirmation_expires, requested_at, confirmed_at, unsubscribe_token)
+ VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, NULL, ?)
+ ON CONFLICT(user_id) DO UPDATE SET state='pending', email=excluded.email,
+ identity_source=excluded.identity_source, identity_id=excluded.identity_id, version=excluded.version,
+ confirmation_hash=excluded.confirmation_hash, confirmation_expires=excluded.confirmation_expires,
+ requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
+ (user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
+ hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
+ conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (automatic_monthly, user['id']))
+ return token
+
+
+def token_subscription(token: str, action: str) -> dict | None:
+ if action == "confirm":
+ return read_one("SELECT * FROM email_recap_subscriptions WHERE confirmation_hash=?",
+ (hashlib.sha256(token.encode()).hexdigest(),))
+ return read_one("SELECT * FROM email_recap_subscriptions WHERE unsubscribe_token=?", (token,))
+
+
+def confirm(sub: dict, now: float) -> bool:
+ with transaction() as conn:
+ # Recheck address and blocked state in the same transaction as the consent write.
+ result = conn.execute("""UPDATE email_recap_subscriptions SET state='enabled', confirmed_at=?, confirmation_hash=NULL
+ WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
+ AND EXISTS (SELECT 1 FROM users WHERE users.id=user_id AND is_blocked=0
+ AND LOWER(TRIM(users.email))=LOWER(TRIM(email_recap_subscriptions.email)))""",
+ (now, sub["user_id"], sub["version"], now))
+ return result.rowcount == 1
+
+
+def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str, now: float) -> str:
+ delivery_id = uuid.uuid4().hex
+ conn.execute("""INSERT OR IGNORE INTO email_recap_deliveries
+ (id, dedupe_key, user_id, month, kind, email, subscription_version, public_url,
+ created_at, updated_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (delivery_id, key, sub["user_id"], month, kind, sub["email"], sub["version"], public_url, now, now, now))
+ return conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()[0]
+
+
+def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
+ key = f"{kind}:{sub['user_id']}:{request_id}"
+ with transaction() as conn:
+ existing = conn.execute("SELECT id,month,subscription_version FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
+ if existing:
+ if existing['month'] != month or existing['subscription_version'] != sub['version']:
+ raise ValueError('This send request was already used. Refresh before requesting another report.')
+ return existing[0]
+ recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind IN ('test','on_demand') AND created_at>?",
+ (sub["user_id"], now - 300)).fetchone()
+ if recent:
+ raise ValueError("Please wait five minutes between report emails.")
+ return _enqueue(conn, sub, month, kind, key, public_url, now)
+
+
+def enqueue_due(now: datetime) -> int:
+ with transaction() as conn:
+ config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
+ config["public_url"] = magent_public_url(config["public_url"])
+ if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
+ return 0
+ # After long downtime, send only the latest due recap; never backfill a pile of old emails.
+ due = shift_month(now, 0).replace(day=config["day"], hour=config["hour"])
+ if due > now:
+ due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
+ month = shift_month(due, -1).strftime("%Y-%m")
+ subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND automatic_monthly=1 AND confirmed_at<=?", (due.timestamp(),)).fetchall()
+ before = conn.total_changes
+ for sub in subs:
+ _enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
+ count = conn.total_changes - before
+ conn.execute("UPDATE email_recap_settings SET next_send_at=? WHERE id=1",
+ (next_due(now, config["day"], config["hour"]).timestamp(),))
+ return count
+
+
+def claim_delivery(now: float) -> dict | None:
+ with transaction() as conn:
+ return email_queue.claim(conn, "email_recap_deliveries", now)
+
+
+def begin_sending(delivery: dict, now: float) -> bool:
+ with transaction() as conn:
+ # Consent may have changed while the report or SMTP connection was being prepared.
+ result = conn.execute("""UPDATE email_recap_deliveries SET state='sending', updated_at=?, lease_until=?
+ WHERE id=? AND claim=? AND state='preparing'
+ AND EXISTS (SELECT 1 FROM email_recap_subscriptions s JOIN users u ON u.id=s.user_id
+ JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
+ WHERE s.user_id=email_recap_deliveries.user_id AND s.state='enabled'
+ AND s.version=email_recap_deliveries.subscription_version AND u.is_blocked=0
+ AND (email_recap_deliveries.kind!='scheduled' OR s.automatic_monthly=1)
+ AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
+ AND EXISTS (SELECT 1 FROM email_recap_settings c WHERE c.id=1 AND c.public_url=email_recap_deliveries.public_url
+ AND (email_recap_deliveries.kind IN ('test','on_demand') OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
+ return result.rowcount == 1
+
+
+def finish(delivery: dict, state: str, detail: str, now: float, delay: int = 0) -> None:
+ with transaction() as conn:
+ email_queue.finish(conn, "email_recap_deliveries", delivery, state, detail, now, delay)
+
+
+def history(limit: int = 50, offset: int = 0) -> dict:
+ with closing(db._connect()) as conn:
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute("""SELECT d.id, d.month, d.kind, d.email, d.state, d.attempts, d.created_at, d.updated_at,
+ d.next_attempt_at, d.detail, u.username FROM email_recap_deliveries d LEFT JOIN users u ON u.id=d.user_id
+ ORDER BY d.created_at DESC, d.id LIMIT ? OFFSET ?""", (limit, offset)).fetchall()
+ total = conn.execute("SELECT COUNT(*) FROM email_recap_deliveries").fetchone()[0]
+ subscribers = conn.execute("SELECT COUNT(*) FROM email_recap_subscriptions WHERE state='enabled'").fetchone()[0]
+ return {"deliveries": [dict(row) for row in rows], "total": total, "subscribers": subscribers}
+
+
+def set_automatic(user_id: int, enabled: bool):
+ with transaction() as conn:
+ conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (enabled, user_id))
+ if not enabled:
+ conn.execute("""UPDATE email_recap_deliveries SET state='cancelled',detail='Automatic monthly emails turned off.'
+ WHERE user_id=? AND kind='scheduled' AND state IN ('queued','retry','preparing')""", (user_id,))
+
+
+def personal_history(user_id: int) -> list[dict]:
+ with closing(db._connect()) as conn:
+ conn.row_factory = sqlite3.Row
+ return [dict(row) for row in conn.execute("""SELECT id,month,kind,state,created_at,detail
+ FROM email_recap_deliveries WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 5""", (user_id,))]
diff --git a/backend/app/services/request_language.py b/backend/app/services/request_language.py
new file mode 100644
index 0000000..534a83c
--- /dev/null
+++ b/backend/app/services/request_language.py
@@ -0,0 +1,129 @@
+"""Explicit original-language requests without changing shared quality defaults."""
+import asyncio
+import copy
+import hashlib
+import json
+import re
+
+import httpx
+
+from fastapi import HTTPException
+
+_profile_lock = asyncio.Lock()
+_prefix = "Magent Original "
+
+
+def language_info(details):
+ code = str(details.get("originalLanguage") or details.get("original_language") or "").lower()
+ if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}:
+ return None
+ return {"code": code}
+
+
+def profile_body(profile):
+ return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}}
+
+
+def profile_name(body):
+ return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
+
+
+def is_original_profile(profile):
+ return ((profile.get("language") or {}).get("id") == -2
+ and profile.get("name") == profile_name(profile_body(profile)))
+
+
+async def original_profile(client, default_id):
+ # Reuse immutable copies; never edit a profile already used by other titles.
+ async with _profile_lock:
+ try:
+ profiles = await client.get_quality_profiles()
+ except httpx.HTTPError as exc:
+ raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc
+ if not isinstance(profiles, list):
+ raise HTTPException(502, "Radarr returned invalid quality profiles.")
+ default = next((p for p in profiles if p.get("id") == default_id), None)
+ if not default:
+ raise HTTPException(409, "The default quality profile changed. Reload the request.")
+ body = profile_body(default)
+ body["language"] = {"id": -2, "name": "Original"}
+ name = profile_name(body)
+ match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None)
+ if match:
+ return match["id"]
+ try:
+ result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name})
+ except httpx.HTTPError as exc:
+ raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc
+ if not isinstance(result, dict) or not isinstance(result.get("id"), int):
+ raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
+ return result["id"]
+
+
+async def apply_original_to_movie(client, tmdb_id):
+ movies = await client.get_movie_by_tmdb_id(tmdb_id)
+ if not isinstance(movies, list):
+ raise HTTPException(502, "Radarr did not return the movie list.")
+ matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
+ if not matches:
+ return None
+ if len(matches) != 1:
+ raise HTTPException(409, "Radarr returned multiple movies for this identity.")
+ movie = matches[0]
+ profile_id = await original_profile(client, movie['qualityProfileId'])
+ if movie['qualityProfileId'] != profile_id:
+ movie['qualityProfileId'] = profile_id
+ await client.update_movie(movie)
+ verified = await client.get_movie(movie['id'])
+ if not verified or verified.get('qualityProfileId') != profile_id:
+ raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
+ return profile_id
+
+
+async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
+ command_id = command.get('id') if isinstance(command, dict) else None
+ if not isinstance(command_id, int):
+ return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
+ for attempt in range(attempts):
+ state = await client.get(f'/api/v3/command/{command_id}')
+ status = str((state or {}).get('status', '')).lower()
+ queue = await client.get_queue(movie_id)
+ records = queue.get('records', []) if isinstance(queue, dict) else queue or []
+ matching = [item for item in records if item.get('movieId') == movie_id]
+ if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
+ return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
+ if matching:
+ return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
+ if status in {'failed', 'aborted', 'cancelled'}:
+ return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
+ if status == 'completed':
+ movie = await client.get_movie(movie_id)
+ if (movie or {}).get('hasFile'):
+ return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
+ # Command completion precedes download-client queue refresh. Keep polling.
+ pass
+ if attempt + 1 < attempts:
+ await asyncio.sleep(delay)
+ return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
+
+
+async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
+ ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
+ if not ids:
+ return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
+ for attempt in range(attempts):
+ states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
+ queue = await client.get_queue(series_id)
+ records = queue.get('records', []) if isinstance(queue, dict) else queue or []
+ matching = [item for item in records if item.get('seriesId') == series_id]
+ if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
+ return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
+ if matching:
+ return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
+ statuses = {str((state or {}).get('status', '')).lower() for state in states}
+ if statuses & {'failed', 'aborted', 'cancelled'}:
+ return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
+ # Even completed commands can precede Sonarr's download queue refresh.
+ if attempt + 1 < attempts:
+ await asyncio.sleep(delay)
+ return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
diff --git a/backend/app/services/request_origins.py b/backend/app/services/request_origins.py
new file mode 100644
index 0000000..c34ce78
--- /dev/null
+++ b/backend/app/services/request_origins.py
@@ -0,0 +1,63 @@
+"""State-changing requests may originate only from explicitly configured sites.
+
+The public Hosting & proxy URL can be stored in the database, while the CORS
+environment setting still has its localhost default on an upgraded install.
+Never infer a trusted origin from request Host or forwarded headers.
+"""
+
+from urllib.parse import urlsplit
+from starlette.middleware.cors import CORSMiddleware
+
+from ..config import settings
+from ..installation_origin import managed_runtime
+from .public_urls import magent_public_url, valid_public_url
+
+
+def _origin(value: str, *, configured_url: bool = False) -> tuple[str, str, int] | None:
+ value = str(value or "")
+ if any(character.isspace() or ord(character) < 33 or ord(character) == 127 for character in value):
+ return None
+ if "?" in value or "#" in value:
+ return None
+ validated = valid_public_url(value)
+ if not validated:
+ return None
+ parsed = urlsplit(value)
+ if parsed.username is not None or parsed.password is not None:
+ return None
+ if not configured_url and parsed.path:
+ return None
+ return (
+ parsed.scheme.lower(),
+ parsed.hostname.lower(),
+ parsed.port or (443 if parsed.scheme == "https" else 80),
+ )
+
+
+def is_allowed_request_origin(origin: str) -> bool:
+ candidate = _origin(origin)
+ if candidate is None:
+ return False
+ if managed_runtime():
+ # The operator confirms this address using the first-install token.
+ # No localhost fallback remains trusted after a managed installation.
+ return candidate == _origin(magent_public_url(), configured_url=True)
+ if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
+ return True
+ return candidate == _origin(magent_public_url(), configured_url=True)
+
+
+def can_claim_initial_origin() -> bool:
+ if not managed_runtime() or magent_public_url():
+ return False
+ from .setup import get_public_setup_status
+ return get_public_setup_status()["needs_admin"]
+
+
+class ConfiguredOriginCORSMiddleware(CORSMiddleware):
+ """Keep CORS response/preflight policy aligned with managed origin checks."""
+
+ def is_allowed_origin(self, origin: str) -> bool:
+ if managed_runtime():
+ return is_allowed_request_origin(origin)
+ return super().is_allowed_origin(origin)
diff --git a/backend/app/services/setup.py b/backend/app/services/setup.py
new file mode 100644
index 0000000..732c9f8
--- /dev/null
+++ b/backend/app/services/setup.py
@@ -0,0 +1,206 @@
+"""Persistent, operator-authorized first-install setup.
+
+Initialize the marker before the main schema: an existing users table identifies
+an upgraded installation, while a new database must finish the setup wizard.
+The marker and first administrator are protected by SQLite write transactions.
+"""
+
+from datetime import datetime, timezone
+import hmac
+from math import ceil
+from time import time
+from typing import Literal
+
+from .. import db
+from ..config import settings
+from ..security import hash_password, validate_password_policy
+from ..installation_origin import normalize_application_origin
+
+
+SetupStep = Literal["administrator", "apps", "preferences", "review"]
+SETUP_STEPS = ("administrator", "apps", "preferences", "review")
+BOOTSTRAP_WINDOW_SECONDS = 15 * 60
+BOOTSTRAP_IP_ATTEMPTS = 5
+BOOTSTRAP_GLOBAL_ATTEMPTS = 30
+
+
+class SetupUnavailableError(ValueError):
+ """Setup has finished, or another administrator already exists."""
+
+
+class InvalidSetupTokenError(ValueError):
+ """The operator's setup token was absent or did not match."""
+
+
+def initialize_setup_state() -> None:
+ """Run once before init_db; subsequent calls preserve progress."""
+ with db._connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ existing_install = conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
+ ).fetchone() is not None
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS installation_setup (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
+ step TEXT NOT NULL,
+ completed_at TEXT
+ )"""
+ )
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS installation_setup_attempts (
+ scope TEXT NOT NULL,
+ key_hash TEXT NOT NULL,
+ occurred_at REAL NOT NULL
+ )"""
+ )
+ conn.execute(
+ """INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
+ VALUES (1, ?, ?, ?)""",
+ (
+ int(existing_install),
+ "review" if existing_install else "administrator",
+ datetime.now(timezone.utc).isoformat() if existing_install else None,
+ ),
+ )
+
+
+def get_setup_state() -> dict:
+ with db._connect() as conn:
+ # Old databases and isolated callers without startup initialization are
+ # already installed. A missing marker must never open public bootstrap.
+ table = conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
+ ).fetchone()
+ row = conn.execute(
+ "SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
+ ).fetchone() if table else None
+ if row is None:
+ return {"completed": True, "step": "review", "completed_at": None}
+ return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
+
+
+def is_setup_required() -> bool:
+ return not get_setup_state()["completed"]
+
+
+def get_public_setup_status() -> dict:
+ required = is_setup_required()
+ return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
+
+
+def setup_token_configured() -> bool:
+ """Reject missing values and obvious examples, without claiming to measure entropy."""
+ token = str(getattr(settings, "setup_token", "") or "").strip()
+ placeholder = token.casefold().replace("_", "-")
+ return (
+ len(token) >= 32
+ and len(set(token)) > 1
+ and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
+ )
+
+
+def consume_bootstrap_attempt(client_ip: str) -> int | None:
+ """Atomically reserve one attempt; return Retry-After when limited.
+
+ The IP is keyed using the existing HMAC helper, never stored in clear text.
+ A shared cap limits distributed attempts and expensive password hashing.
+ """
+ now = time()
+ cutoff = now - BOOTSTRAP_WINDOW_SECONDS
+ limits = (
+ ("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
+ ("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
+ )
+ with db._connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ conn.execute(
+ "DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
+ (cutoff,),
+ )
+ retry_after = 0
+ for scope, key, maximum in limits:
+ count, oldest = conn.execute(
+ """SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
+ WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
+ (scope, key, cutoff),
+ ).fetchone()
+ if count >= maximum:
+ retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
+ if retry_after:
+ return retry_after
+ conn.executemany(
+ "INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
+ [(scope, key, now) for scope, key, _ in limits],
+ )
+ return None
+
+
+def bootstrap_administrator(setup_token: str, username: str, password: str, *, application_url: str | None = None) -> None:
+ """Claim fresh setup exactly once using the deployment's setup token."""
+ expected = str(getattr(settings, "setup_token", "") or "")
+ if not setup_token_configured() or not hmac.compare_digest(
+ setup_token.encode("utf-8"), expected.encode("utf-8")
+ ):
+ raise InvalidSetupTokenError("Invalid setup token.")
+ username = username.strip()
+ if not username or len(username) > 100 or any(
+ character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
+ ):
+ raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
+ if len(password) > 1024:
+ raise ValueError("Password must contain no more than 1024 characters.")
+ password = validate_password_policy(password)
+ if application_url is not None:
+ application_url = normalize_application_origin(application_url)
+ if not is_setup_required() or db.has_admin_user():
+ raise SetupUnavailableError("Initial administrator setup is no longer available.")
+
+ password_hash = hash_password(password)
+ with db._connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
+ admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
+ if setup is None or setup[0] or admin:
+ raise SetupUnavailableError("Initial administrator setup is no longer available.")
+ if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
+ raise SetupUnavailableError("That username already exists.")
+ conn.execute(
+ """INSERT INTO users (username, password_hash, role, auth_provider, created_at)
+ VALUES (?, ?, 'admin', 'local', ?)""",
+ (username, password_hash, datetime.now(timezone.utc).isoformat()),
+ )
+ conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
+ if application_url is not None:
+ conn.execute(
+ """INSERT INTO settings (key, value, updated_at) VALUES ('magent_application_url', ?, ?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
+ (application_url, datetime.now(timezone.utc).isoformat()),
+ )
+
+
+def update_setup_step(step: SetupStep) -> dict:
+ if step not in SETUP_STEPS:
+ raise ValueError("Invalid setup step.")
+ if not is_setup_required():
+ return get_setup_state()
+ with db._connect() as conn:
+ conn.execute(
+ "UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
+ )
+ return get_setup_state()
+
+
+def complete_setup() -> dict:
+ if not is_setup_required():
+ return get_setup_state()
+ with db._connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
+ raise SetupUnavailableError("Create an administrator before completing setup.")
+ conn.execute(
+ """UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
+ WHERE id = 1 AND completed = 0""",
+ (datetime.now(timezone.utc).isoformat(),),
+ )
+ return get_setup_state()
diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py
new file mode 100644
index 0000000..5d4122c
--- /dev/null
+++ b/backend/app/services/snapshot.py
@@ -0,0 +1,1715 @@
+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_recent_actions,
+ get_request_cache_payload,
+ get_request_cache_by_id,
+ get_request_download_evidence,
+ get_request_repairs,
+ complete_request_repair,
+ 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
+from .collector_search import read_search_status
+from .media_repair import current_cycle_torrents, evaluate_media_repair
+from .download_labels import label_episode_downloads
+from .arr import RootFolderNotFoundError, resolve_root_folder_path
+
+logger = logging.getLogger(__name__)
+
+JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
+_jellyfin_scan_key = "jellyfin_scan_last_at"
+REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
+REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
+
+
+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 _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None:
+ """Use the collector's authoritative identity when cached Seerr metadata is sparse."""
+ if not isinstance(arr_item, dict):
+ return
+ if snapshot.title in {None, "", "Unknown"}:
+ title = arr_item.get("title") or arr_item.get("seriesTitle")
+ if isinstance(title, str) and title.strip():
+ snapshot.title = title.strip()
+ if not snapshot.year:
+ year = arr_item.get("year")
+ try:
+ snapshot.year = int(year) if year else snapshot.year
+ except (TypeError, ValueError):
+ pass
+
+
+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)
+
+ shared = set(request_provider_ids) & set(item_provider_ids)
+ if shared:
+ # Conflicting metadata must never fall through to title matching.
+ return all(request_provider_ids[key] == item_provider_ids[key] for key in shared)
+
+ 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
+
+ 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:
+ collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
+ collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
+ collector_has_file = bool(
+ isinstance(collector_item, dict)
+ and (
+ collector_item.get("hasFile")
+ or snapshot.request_type == RequestType.tv
+ and isinstance(collector_stats, dict)
+ and collector_stats.get("episodeFileCount")
+ )
+ )
+ if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
+ snapshot.state == NormalizedState.importing and collector_has_file
+ ):
+ 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:
+ previous_payload = previous[0].get("payload") or {}
+ previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
+ if previous_jellyfin.get("found"):
+ 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 _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]:
+ """Describe regular Sonarr seasons that can be added to an existing request."""
+ if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
+ return []
+ episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else []
+ options: List[Dict[str, int]] = []
+ for season in series["seasons"]:
+ if not isinstance(season, dict) or season.get("monitored") is not False:
+ continue
+ season_number = season.get("seasonNumber")
+ if not isinstance(season_number, int) or season_number <= 0:
+ continue
+ matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number]
+ statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {}
+ episode_count = statistics.get("totalEpisodeCount")
+ if not isinstance(episode_count, int):
+ episode_count = statistics.get("episodeCount")
+ if not isinstance(episode_count, int):
+ episode_count = len(matching)
+ available = statistics.get("episodeFileCount")
+ if not isinstance(available, int):
+ available = sum(1 for episode in matching if episode.get("hasFile") is True)
+ options.append(
+ {
+ "seasonNumber": season_number,
+ "episodeCount": max(0, episode_count),
+ "available": max(0, available),
+ }
+ )
+ return sorted(options, key=lambda item: item["seasonNumber"])
+
+
+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[float]:
+ progress = torrent.get("progress")
+ try:
+ numeric = float(progress)
+ except (TypeError, ValueError):
+ numeric = -1
+ if 0 <= numeric <= 1:
+ return round(numeric * 100, 1)
+ 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.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
+
+
+def _parse_action_time(value: Any) -> Optional[datetime]:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
+def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
+ current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
+ for action in get_recent_actions(request_id, 25):
+ if action.get("action_id") not in REPAIR_ACTION_IDS:
+ continue
+ created_at = _parse_action_time(action.get("created_at"))
+ if created_at is None:
+ continue
+ age_seconds = (current_time - created_at).total_seconds()
+ if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
+ return action
+ return None
+
+
+def _build_repair_activity(
+ snapshot: Snapshot,
+ *,
+ action: Optional[Dict[str, Any]],
+ arr_state: str,
+ arr_details: Dict[str, Any],
+ download: Dict[str, Any],
+ jellyfin_found: bool,
+) -> Optional[Dict[str, Any]]:
+ if not action:
+ return None
+
+ action_id = str(action.get("action_id") or "")
+ collector = (
+ "Bazarr"
+ if action_id == "repair_subtitles"
+ else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
+ )
+ action_ok = str(action.get("status") or "").lower() == "ok"
+ action_message = str(action.get("message") or "The repair action was recorded.")
+ download_state = str(download.get("state") or "not_started")
+ download_visible = bool(download.get("visible"))
+ availability = arr_details.get("availability")
+ if not isinstance(availability, dict):
+ availability = {}
+ missing = int(availability.get("missing") or 0)
+ total = int(availability.get("total") or 0)
+ collection_complete = arr_state == "available" and (
+ snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
+ )
+
+ submitted_step = {
+ "id": "submitted",
+ "label": "Repair requested",
+ "state": "complete",
+ "detail": "Magent recorded the issue and started the selected repair.",
+ }
+
+ if not action_ok:
+ return {
+ "visible": True,
+ "actionId": action_id,
+ "state": "attention",
+ "headline": "Repair needs attention",
+ "message": action_message,
+ "service": collector,
+ "updatedAt": action.get("created_at"),
+ "steps": [
+ submitted_step,
+ {
+ "id": "collector",
+ "label": f"{collector} hand-off",
+ "state": "attention",
+ "detail": action_message,
+ },
+ ],
+ }
+
+ if action_id == "repair_subtitles":
+ return {
+ "visible": True,
+ "actionId": action_id,
+ "state": "searching",
+ "headline": "Subtitle repair is running",
+ "message": (
+ f"{action_message} Bazarr is checking the configured subtitle providers; "
+ "the issue can be confirmed once the replacement track is available."
+ ),
+ "service": collector,
+ "updatedAt": action.get("created_at"),
+ "steps": [
+ submitted_step,
+ {
+ "id": "collector",
+ "label": "Bazarr accepted the search",
+ "state": "complete",
+ "detail": action_message,
+ },
+ {
+ "id": "result",
+ "label": "Subtitle result",
+ "state": "active",
+ "detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
+ },
+ ],
+ }
+
+ if collection_complete:
+ headline = "Repair collected"
+ message = (
+ f"{collector} now reports the replacement file as collected. "
+ + (
+ "It is also available in Jellyfin."
+ if jellyfin_found
+ else "Jellyfin is indexing the updated file now."
+ )
+ )
+ state = "complete" if jellyfin_found else "indexing"
+ download_step_state = "complete"
+ download_step_detail = f"{collector} reports the replacement file as collected and imported."
+ available_step_state = "complete" if jellyfin_found else "active"
+ elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
+ state = {
+ "downloading": "downloading",
+ "completed": "importing",
+ "paused": "attention",
+ "error": "attention",
+ "missing": "attention",
+ }[download_state]
+ headline = {
+ "downloading": "Replacement download in progress",
+ "completed": "Replacement downloaded — waiting for import",
+ "paused": "Replacement download paused",
+ "error": "Replacement download cannot be checked",
+ "missing": "Replacement hand-off needs checking",
+ }[download_state]
+ message = {
+ "downloading": "The replacement is downloading now.",
+ "paused": "The replacement download is paused and needs attention.",
+ "completed": f"The download has finished and is waiting for {collector} to import it.",
+ "error": "Magent cannot currently read the replacement download from qBittorrent.",
+ "missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
+ }[download_state]
+ download_step_state = "active" if download_state == "downloading" else (
+ "complete" if download_state == "completed" else "attention"
+ )
+ download_step_detail = str(
+ download.get("summary") or "Magent found the replacement download in qBittorrent."
+ )
+ available_step_state = "waiting"
+ else:
+ state = "searching"
+ headline = "Replacement search in progress"
+ message = (
+ f"{action_message} {collector} has accepted the search, but no replacement download "
+ "has been selected yet. Magent will keep checking."
+ )
+ download_step_state = "waiting"
+ download_step_detail = "Waiting for a suitable release to be selected."
+ available_step_state = "waiting"
+
+ return {
+ "visible": True,
+ "actionId": action_id,
+ "state": state,
+ "headline": headline,
+ "message": message,
+ "service": collector,
+ "updatedAt": action.get("created_at"),
+ "steps": [
+ submitted_step,
+ {
+ "id": "collector",
+ "label": f"{collector} accepted the search",
+ "state": "complete",
+ "detail": action_message,
+ },
+ {
+ "id": "download",
+ "label": "Replacement download",
+ "state": download_step_state,
+ "detail": download_step_detail,
+ },
+ {
+ "id": "available",
+ "label": "Updated media available",
+ "state": available_step_state,
+ "detail": (
+ "The repaired title is available in Jellyfin."
+ if jellyfin_found and collection_complete
+ else (
+ "The media server is indexing the replacement."
+ if collection_complete
+ else "Waiting for download and import to finish."
+ )
+ ),
+ },
+ ],
+ }
+
+
+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
+ jellyfin_partial = bool(
+ jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0
+ )
+ fully_available = bool(jellyfin_found and not jellyfin_partial)
+ download_visible = bool(download.get("visible"))
+ download_state = str(download.get("state") or "not_started")
+ search_status = str((arr_details.get("search") or {}).get("state") or "unavailable")
+ search_in_progress = search_status in {"searching", "queued"}
+ search_label = {
+ "searching": "Searching",
+ "queued": "Search queued",
+ "idle": "Not searching",
+ }.get(search_status, "Search unknown")
+ search_target = "episode releases" if snapshot.request_type == RequestType.tv else "a matching release"
+ search_detail = {
+ "searching": f"{collector} is searching for {search_target}.",
+ "queued": f"Search queued — waiting for {collector} to start.",
+ "idle": f"Not currently searching for this {'series' if snapshot.request_type == RequestType.tv else 'movie'}.",
+ }.get(search_status, f"Search status unavailable — unable to check {collector}.")
+
+ 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_partial:
+ status_label = f"Partially available — {available} of {total} episodes collected"
+ meaning = (
+ f"Some of this request is ready to watch; {missing} episode{'s' if missing != 1 else ''} "
+ f"{'is' if missing == 1 else 'are'} still missing. {search_detail}"
+ )
+ 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:
+ if arr_state == "available" and not jellyfin_found:
+ status_label = "Collected — waiting for the media server"
+ meaning = (
+ f"{collector} has collected and imported this title, but it is not visible on "
+ "the media server yet."
+ )
+ else:
+ 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 fully_available:
+ next_title = "Ready to watch"
+ next_description = "Collection is complete. Open the title on the media server when you are ready."
+ recommended = []
+ elif "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_in_progress and arr_state != "available":
+ next_title = "Wait for the search results" if search_status == "searching" else "Wait for the queued search"
+ next_description = f"{search_detail} This page will update automatically."
+ recommended = []
+ 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 == NormalizedState.importing and arr_state == "available":
+ next_title = "Wait for the media server to index this title"
+ next_description = (
+ f"{collector} has completed its work. Use Recheck request to see whether the title "
+ "has appeared on the media server."
+ )
+ 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",
+ }
+ library_state_label = None
+ 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 fully_available or (arr_state == "available" and not partial):
+ library_state, library_summary = "complete", "Collection complete — no search needed"
+ elif arr_state in {"added", "searching", "available"}:
+ library_state = "active" if search_in_progress else "attention" if search_status == "unavailable" else "waiting"
+ library_state_label = search_label
+ library_summary = search_detail
+ if download_visible and download_state == "downloading" and not search_in_progress:
+ library_state, library_state_label = "active", "Downloading"
+ library_summary = f"Download in progress. {search_detail}"
+ if partial:
+ library_state = "partial"
+ library_summary = f"{available} of {total} episodes collected. {library_summary}"
+ else:
+ library_state, library_summary = "waiting", "Waiting for collector information"
+
+ if fully_available:
+ search_state, search_summary = "complete", "No further search needed"
+ elif arr_state == "available" and not partial:
+ search_state, search_summary = "complete", "A release was collected"
+ elif search_in_progress:
+ search_state, search_summary = "active", search_detail
+ elif download_visible and download_state in {"downloading", "paused", "completed"} and not partial:
+ 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 = "waiting" if search_status == "idle" and prowlarr_state == "ok" else "attention"
+ search_summary = search_detail
+ else:
+ search_state, search_summary = "waiting", "Search has not started"
+
+ completed_download_summary = (
+ "The requested content has been collected and is available to watch. "
+ "No further action is needed."
+ )
+ if fully_available:
+ download_stage_state, download_summary = "complete", completed_download_summary
+ pipeline_download_visible = False
+ pipeline_torrents: List[Dict[str, Any]] = []
+ elif arr_state == "available":
+ download_stage_state = "complete"
+ download_summary = f"{collector} has imported the collected file"
+ pipeline_download_visible = False
+ pipeline_torrents = []
+ elif 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")
+ pipeline_download_visible = True
+ pipeline_torrents = download.get("torrents") or []
+ else:
+ download_stage_state, download_summary = "waiting", "No download attempt yet"
+ pipeline_download_visible = False
+ pipeline_torrents = []
+
+ if jellyfin_partial:
+ available_label = "Partially available"
+ available_state = "partial"
+ available_state_label = "Partly ready"
+ available_summary = f"{available} of {total} episodes are ready to watch in Jellyfin."
+ elif jellyfin_found:
+ available_label = "Available to watch"
+ available_state = "complete"
+ available_state_label = "Ready"
+ available_summary = "This title is ready to watch in Jellyfin."
+ elif arr_state == "available":
+ available_label = "Adding to Jellyfin"
+ available_state = "active"
+ available_state_label = "Indexing"
+ available_summary = "The download is complete. Jellyfin is indexing this title now."
+ else:
+ available_label = "Media server"
+ available_state = "waiting"
+ available_state_label = "Waiting"
+ available_summary = "This title has not reached Jellyfin yet."
+
+ display_download = dict(download)
+ if fully_available:
+ display_download.update(
+ {
+ "visible": False,
+ "state": "completed",
+ "summary": completed_download_summary,
+ "torrents": [],
+ }
+ )
+
+ return {
+ "status": {"label": status_label, "meaning": meaning},
+ "download": display_download,
+ "nextStep": {
+ "title": next_title,
+ "description": next_description,
+ "actionIds": recommended,
+ },
+ "pipeline": [
+ requested_stage,
+ approved_stage,
+ {
+ "id": "library",
+ "label": "Library collection",
+ "state": library_state,
+ "stateLabel": library_state_label or library_state,
+ "searchStatus": search_status,
+ "summary": library_summary,
+ "available": available,
+ "missing": missing,
+ "total": total,
+ "seasons": availability.get("seasons") or [],
+ "unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [],
+ "missingEpisodes": arr_details.get("missingEpisodes") or {},
+ },
+ {
+ "id": "search",
+ "label": "Release search",
+ "state": search_state,
+ "summary": search_summary,
+ "actionIds": [] if fully_available or search_in_progress else [
+ action_id
+ for action_id in ("search_auto", "search_releases")
+ if action_id in action_ids
+ ],
+ },
+ {
+ "id": "download",
+ "label": "Download complete" if fully_available else "Download",
+ "state": download_stage_state,
+ "summary": download_summary,
+ "visible": pipeline_download_visible,
+ "torrents": pipeline_torrents,
+ },
+ {
+ "id": "available",
+ "label": available_label,
+ "state": available_state,
+ "stateLabel": available_state_label,
+ "summary": available_summary,
+ "link": jellyfin_link,
+ },
+ ],
+ }
+
+
+def _apply_repair_presentation(
+ snapshot: Snapshot, repairs: List[Dict[str, Any]], arr_details: Dict[str, Any],
+ arr_state: str, download: Dict[str, Any], catalog_found: bool,
+ jellyfin_item: Any, public_url: Optional[str],
+) -> None:
+ """Describe the replacement, without erasing approval or unaffected episodes."""
+ imported = all(repair.get("phase") == "indexing" for repair in repairs)
+ unavailable = arr_state == "error" or any(repair.get("phase") == "unavailable" for repair in repairs)
+ latest = repairs[-1]
+ activity = _build_repair_activity(
+ snapshot,
+ action={"action_id": latest.get("actionId"), "status": "ok",
+ "created_at": latest.get("startedAt"), "message": "A new collection cycle was requested."},
+ arr_state="available" if imported else arr_state,
+ arr_details={"availability": {"total": 1, "missing": 0}} if imported else arr_details,
+ download=download, jellyfin_found=False,
+ ) or {}
+ search = (arr_details.get("search") or {}).get("state")
+ pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]}
+ if imported:
+ label = "Replacement collected — updating Jellyfin"
+ meaning = "The replacement has been imported. Waiting for Jellyfin to index the updated file."
+ snapshot.state = NormalizedState.importing
+ pipeline["download"].update(state="complete", summary="The replacement has been imported.", torrents=[], visible=False)
+ pipeline["available"].update(label="Updating Jellyfin", state="active", stateLabel="Indexing", summary=meaning)
+ snapshot.presentation["nextStep"] = {
+ "title": "Wait for the updated file", "description": "This page will update when Jellyfin confirms the replacement.", "actionIds": [],
+ }
+ elif unavailable:
+ label = "Repair status temporarily unavailable"
+ meaning = "Magent cannot verify the replacement right now. The old library entry is not confirmation that the repair is complete."
+ snapshot.state = NormalizedState.unknown
+ pipeline["available"].update(state="waiting", stateLabel="Unconfirmed", summary="Waiting for the replacement to be verified.")
+ snapshot.presentation["nextStep"] = {"title": "Recheck the request", "description": "Magent will retry automatically. You can also use Recheck request.", "actionIds": []}
+ activity.update(state="attention", headline=label, message=meaning)
+ elif download.get("visible"):
+ label = activity.get("headline", "Replacement in progress")
+ meaning = activity.get("message", "Magent is tracking the replacement download.")
+ snapshot.state = NormalizedState.importing if download.get("state") == "completed" else NormalizedState.downloading
+ else:
+ label = "Searching for a replacement" if search == "searching" else "Replacement search queued" if search == "queued" else "Waiting for a replacement"
+ meaning = "The affected content is being replaced. " + {
+ "searching": "The collector is looking for a suitable release.",
+ "queued": "The collector has queued the search.",
+ "idle": "No download has started and the collector is not currently searching.",
+ }.get(search, "Magent cannot currently confirm the search status.")
+ snapshot.state = NormalizedState.searching if search in {"searching", "queued"} else NormalizedState.added_to_arr
+ pipeline["download"].update(label="Replacement download", state="waiting", stateLabel="Pending",
+ summary="Waiting for a replacement download to start.", torrents=[], visible=False)
+ activity.update(state="searching" if search in {"searching", "queued"} else "waiting", headline=label, message=meaning)
+ previous_activity = snapshot.presentation.get("repairActivity") or {}
+ if search not in {"searching", "queued"} and previous_activity.get("state") == "attention" and (previous_activity.get("updatedAt") or "") >= latest["startedAt"]:
+ label, meaning = "Repair needs attention", str(previous_activity.get("message") or meaning)
+ activity.update(state="attention", headline=label, message=meaning)
+ snapshot.presentation["status"] = {"label": label, "meaning": meaning}
+ snapshot.presentation["repairActivity"] = activity
+ if not imported:
+ pipeline["available"].update(summary="The affected content will be available after the replacement is imported and indexed.")
+
+ counts = arr_details.get("availability") or {}
+ targets = {episode.get("id") for repair in repairs for episode in repair.get("episodes", [])}
+ # For a series, keep a route to unaffected episodes without claiming that the
+ # repaired ones are ready (even while a stale series entry remains indexed).
+ has_unaffected = snapshot.request_type == RequestType.tv and int(counts.get("available") or 0) > (len(targets) if imported else 0)
+ if has_unaffected and catalog_found and isinstance(jellyfin_item, dict) and jellyfin_item.get("Id"):
+ link = f"{public_url.rstrip('/')}/web/index.html#!/details?id={quote(str(jellyfin_item['Id']))}" if public_url else None
+ pipeline["available"].update(label="Partially available", state="partial", stateLabel="Repair in progress",
+ summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Jellyfin to index the repaired episodes.", link=link)
+ snapshot.raw["jellyfin"].update(partial=True, link=link)
+
+
+async def build_snapshot(request_id: str) -> Snapshot:
+ timeline = []
+ runtime = get_runtime_settings()
+ repair_records = await asyncio.to_thread(get_request_repairs, request_id, active_only=False)
+ repair_cycle = repair_records[-1]["startedAt"] if repair_records else None
+ active_repairs = [record for record in repair_records if not record.get("completedAt")]
+
+ 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 jellyseerr.configured():
+ 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
+ episodes = 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["search"] = {
+ "state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
+ }
+ arr_details["availability"] = _episode_availability(episodes)
+ arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, episodes)
+ counts = arr_details["availability"]
+ arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
+ 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"
+ 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
+ arr_details["search"] = {
+ "state": await read_search_status(radarr, RequestType.movie, int(arr_item["id"]))
+ }
+ except Exception as exc:
+ arr_state = "error"
+ arr_details["error"] = str(exc)
+
+ if arr_state is None:
+ arr_state = "unknown"
+ if arr_state == "added" and (arr_details.get("search") or {}).get("state") == "searching":
+ arr_state = "searching"
+
+ _apply_arr_identity(snapshot, arr_item)
+ 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 not active_repairs 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():
+ try:
+ root_folder = await resolve_root_folder_path(
+ radarr_client, runtime.radarr_root_folder, "Radarr"
+ )
+ except RootFolderNotFoundError as exc:
+ logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc)
+ root_folder = ""
+ tmdb_id = jelly_request.get("media", {}).get("tmdbId")
+ if tmdb_id and root_folder:
+ 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():
+ try:
+ root_folder = await resolve_root_folder_path(
+ sonarr_client, runtime.sonarr_root_folder, "Sonarr"
+ )
+ except RootFolderNotFoundError as exc:
+ logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc)
+ root_folder = ""
+ tvdb_id = jelly_request.get("media", {}).get("tvdbId")
+ if tvdb_id and root_folder:
+ try:
+ await sonarr_client.add_series(
+ int(tvdb_id),
+ runtime.sonarr_quality_profile_id,
+ root_folder,
+ monitored=False,
+ search_missing=False,
+ )
+ except Exception:
+ pass
+
+ catalog_found = jellyfin_available
+ pending_repairs = []
+ for repair in active_repairs:
+ try:
+ evidence = await evaluate_media_repair(
+ repair, arr_item, {"found": catalog_found, "item": jellyfin_item}, episodes=episodes,
+ )
+ except Exception:
+ logger.warning("Unable to verify replacement request_id=%s", request_id)
+ evidence = {"complete": False, "phase": "unavailable"}
+ if evidence.get("complete"):
+ await asyncio.to_thread(complete_request_repair, repair["id"])
+ else:
+ pending_repairs.append({**repair, "phase": evidence.get("phase")})
+ repair_imported = bool(pending_repairs) and all(r["phase"] == "indexing" for r in pending_repairs)
+ if pending_repairs:
+ # Jellyfin can retain the original item while its replacement is missing.
+ jellyfin_available = False
+ if arr_state == "available" and not repair_imported:
+ arr_state = "added"
+ elif snapshot.request_type == RequestType.movie and arr_state == "added":
+ # Also reconcile externally removed files, not only Magent repairs.
+ jellyfin_available = False
+
+ 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(h.lower() for h in 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 []
+ label_episode_downloads(torrent_list, arr_queue)
+ unfiltered_torrents = torrent_list
+ torrent_list = current_cycle_torrents(torrent_list, repair_cycle)
+ discarded_hashes = {str(t.get("hash") or "").lower() for t in unfiltered_torrents if t not in torrent_list}
+ if repair_cycle and not download_history.get("observed"):
+ current_hashes = {str(t.get("hash") or "").lower() for t in torrent_list}
+ discarded_hashes.update(
+ str(h).lower() for repair in active_repairs for h in repair.get("previousDownloadIds", [])
+ if str(h).lower() not in current_hashes
+ )
+ download_ids = [h for h in download_ids if h.lower() not in discarded_hashes]
+ download_visible = bool(download_ids) or bool(download_history.get("observed"))
+ 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.importing
+ snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
+ 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.importing
+ snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
+ 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.",
+ )
+ )
+
+ if download_ids and qbittorrent.configured() and qbit_state == "paused":
+ 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 snapshot.request_type == RequestType.tv
+ and int(availability.get("missing") or 0) > 0
+ )
+ if jellyfin_available and not is_partial:
+ snapshot.actions = []
+ snapshot.raw = {
+ "repairCycle": repair_cycle,
+ "jellyseerr": jelly_request,
+ "arr": {
+ "item": arr_item,
+ "queue": arr_queue,
+ "episodes": episodes,
+ },
+ "jellyfin": {
+ "catalogFound": catalog_found,
+ "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,
+ )
+ repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
+ repair_activity = _build_repair_activity(
+ snapshot,
+ action=repair_action,
+ arr_state=arr_state,
+ arr_details=arr_details,
+ download=download_presentation,
+ jellyfin_found=jellyfin_available,
+ )
+ if repair_activity:
+ snapshot.presentation["repairActivity"] = repair_activity
+ snapshot.presentation["repairCycle"] = repair_cycle
+ if pending_repairs:
+ _apply_repair_presentation(
+ snapshot, pending_repairs, arr_details, arr_state, download_presentation,
+ catalog_found, jellyfin_item, runtime.jellyfin_public_url,
+ )
+ 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-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..f222849
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,4 @@
+-r requirements.txt
+coverage==7.16.1
+pip-audit==2.10.1
+ruff==0.16.8
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..946e626
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,12 @@
+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
+argon2-cffi==25.1.0
+cryptography==50.0.1
+python-multipart==0.0.31
+Pillow==12.3.0
+prometheus-client==0.22.1
diff --git a/backend/tests/test_api_models.py b/backend/tests/test_api_models.py
new file mode 100644
index 0000000..bf94c89
--- /dev/null
+++ b/backend/tests/test_api_models.py
@@ -0,0 +1,24 @@
+import unittest
+
+from pydantic import ValidationError
+
+from backend.app.api_models import PasswordResetRequest, SignupRequest
+
+
+class ApiRequestModelTests(unittest.TestCase):
+ def test_signup_rejects_unknown_fields(self) -> None:
+ with self.assertRaises(ValidationError):
+ SignupRequest(
+ invite_code="invite",
+ username="viewer",
+ password="strong password",
+ unexpected="value",
+ )
+
+ def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
+ request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
+ self.assertEqual(request.new_password, " leading and trailing ")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_arr_helpers.py b/backend/tests/test_arr_helpers.py
new file mode 100644
index 0000000..df50431
--- /dev/null
+++ b/backend/tests/test_arr_helpers.py
@@ -0,0 +1,24 @@
+import unittest
+
+from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
+
+
+class _ArrClient:
+ async def get_root_folders(self):
+ return [{"id": 7, "path": "/media/tv"}]
+
+
+class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
+ async def test_resolves_numeric_root_folder_id(self) -> None:
+ self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
+
+ async def test_preserves_configured_path(self) -> None:
+ self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
+
+ async def test_rejects_missing_root_folder_id(self) -> None:
+ with self.assertRaises(RootFolderNotFoundError):
+ await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py
new file mode 100644
index 0000000..3cd4c6d
--- /dev/null
+++ b/backend/tests/test_backend_quality.py
@@ -0,0 +1,2760 @@
+import os
+from types import SimpleNamespace
+import tempfile
+import unittest
+from unittest.mock import AsyncMock, call, patch
+
+import httpx
+from fastapi import HTTPException
+from passlib.context import CryptContext
+from starlette.requests import Request
+
+from backend.app import db
+from backend.app.clients.base import _operation_error_message, _operation_result_message
+from backend.app.clients.jellyfin import _availability_message
+from backend.app.clients.qbittorrent import _torrent_result_message
+from backend.app.auth import _load_current_user_from_token, require_admin
+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 ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
+from backend.app.routers import auth as auth_router
+from backend.app.routers import admin as admin_router
+from backend.app.routers import branding as branding_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, create_access_token, validate_password_policy
+from backend.app.services import password_reset
+from backend.app.services import issue_resolution
+from backend.app.services.operation_progress import (
+ begin_operation,
+ finish_operation,
+ finish_remote_call,
+ get_operation,
+ reset_operation,
+ start_remote_call,
+)
+from backend.app.services.snapshot import (
+ _apply_arr_identity,
+ _build_presentation,
+ _build_repair_activity,
+ _episode_availability,
+ _torrent_progress,
+ _unmonitored_season_options,
+)
+
+
+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")
+ self._original_settings_encryption_key = settings.settings_encryption_key
+ settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
+ settings.sqlite_journal_mode = "DELETE"
+ settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
+ db.init_db()
+
+ def tearDown(self) -> None:
+ settings.sqlite_path = self._original_sqlite_path
+ settings.sqlite_journal_mode = self._original_journal_mode
+ settings.settings_encryption_key = self._original_settings_encryption_key
+ 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(" password1234 "), "password1234")
+
+
+class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ self._jwt_secret = patch.object(
+ settings, "jwt_secret", "security-hardening-tests-secret-123456789"
+ )
+ self._jwt_secret.start()
+ self.addCleanup(self._jwt_secret.stop)
+
+ def test_sensitive_settings_are_encrypted_at_rest(self) -> None:
+ db.set_setting("jellyfin_api_key", "private-api-key")
+
+ with db._connect() as conn:
+ stored = conn.execute(
+ "SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",)
+ ).fetchone()[0]
+
+ self.assertTrue(stored.startswith("enc:v1:"))
+ self.assertNotIn("private-api-key", stored)
+ self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key")
+
+ def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None:
+ created = db.create_signup_invite(code="TopSecretInvite42")
+ invite_id = int(created["id"])
+
+ with db._connect() as conn:
+ stored = conn.execute(
+ "SELECT code FROM signup_invites WHERE id = ?", (invite_id,)
+ ).fetchone()[0]
+
+ self.assertTrue(stored.startswith("sha256:"))
+ self.assertNotIn("TOPSECRETINVITE42", stored.upper())
+ self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"])
+ self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42"))
+
+ rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99")
+ self.assertTrue(rotated["code_available"])
+ self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42"))
+ self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99"))
+
+ def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None:
+ created = db.create_signup_invite(code="TemporaryInvite77")
+ with db._connect() as conn:
+ conn.execute(
+ "UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?",
+ ("Legacy-Code-77", int(created["id"])),
+ )
+ conn.execute(
+ "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
+ ("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"),
+ )
+
+ db.init_db()
+
+ migrated = db.get_signup_invite_by_code("Legacy-Code-77")
+ self.assertEqual(migrated["id"], created["id"])
+ self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key")
+ with db._connect() as conn:
+ invite_code = conn.execute(
+ "SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),)
+ ).fetchone()[0]
+ stored_setting = conn.execute(
+ "SELECT value FROM settings WHERE key = 'radarr_api_key'"
+ ).fetchone()[0]
+ self.assertTrue(invite_code.startswith("sha256:"))
+ self.assertTrue(stored_setting.startswith("enc:v1:"))
+
+ def test_legacy_password_hash_is_replaced_with_argon2(self) -> None:
+ password = "Example-password123!"
+ db.create_user("legacy", password)
+ legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password)
+ with db._connect() as conn:
+ conn.execute(
+ "UPDATE users SET password_hash = ? WHERE username = ?",
+ (legacy_hash, "legacy"),
+ )
+
+ self.assertIsNotNone(db.verify_user_password("legacy", password))
+ self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2"))
+
+ def test_auth_version_revokes_existing_token(self) -> None:
+ db.create_user("viewer", "Example-password123!")
+ user = db.get_user_by_username("viewer")
+ token = create_access_token(
+ "viewer", "user", auth_version=int(user["auth_version"])
+ )
+ self.assertEqual(_load_current_user_from_token(token)["username"], "viewer")
+
+ db.increment_user_auth_version("viewer")
+ with self.assertRaises(HTTPException) as context:
+ _load_current_user_from_token(token)
+ self.assertEqual(context.exception.status_code, 401)
+
+ async def test_request_mutations_require_owner_or_admin(self) -> None:
+ runtime = SimpleNamespace(
+ jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret"
+ )
+ client = SimpleNamespace(
+ configured=lambda: True,
+ get_request=AsyncMock(
+ return_value={"id": 42, "requestedBy": {"username": "owner"}}
+ ),
+ )
+ with patch.object(requests_router, "JellyseerrClient", return_value=client):
+ with self.assertRaises(HTTPException) as context:
+ await requests_router._ensure_request_mutation_access(
+ runtime, 42, {"username": "someone-else", "role": "user"}
+ )
+ self.assertEqual(context.exception.status_code, 403)
+ owned = await requests_router._ensure_request_mutation_access(
+ runtime, 42, {"username": "owner", "role": "user"}
+ )
+ self.assertEqual(owned["id"], 42)
+
+ self.assertIsNone(
+ await requests_router._ensure_request_mutation_access(
+ SimpleNamespace(), 42, {"username": "admin", "role": "admin"}
+ )
+ )
+
+ def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None:
+ db.create_user(
+ "viewer", "Example-password123!", email="viewer@example.test"
+ )
+ user = db.get_user_by_username("viewer")
+ now = "2026-09-17T00:00:00+00:00"
+ db.upsert_request_cache(
+ 42,
+ 99,
+ "movie",
+ 2,
+ "Example",
+ 2026,
+ "viewer",
+ "viewer",
+ int(user["id"]),
+ now,
+ now,
+ '{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
+ )
+ with db._connect() as conn:
+ conn.execute(
+ "INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)",
+ (
+ "42",
+ "available",
+ now,
+ '{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
+ ),
+ )
+ db.save_action("42", "created", "Created", "ok", "Created by viewer")
+ item = db.create_portal_item(
+ kind="issue",
+ title="Example",
+ description="Example",
+ created_by_username="viewer",
+ created_by_id=int(user["id"]),
+ )
+
+ result = db.delete_user_data_by_username("viewer")
+
+ self.assertTrue(result["deleted"])
+ self.assertIsNone(db.get_user_by_username("viewer"))
+ with db._connect() as conn:
+ request_row = conn.execute(
+ "SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42"
+ ).fetchone()
+ snapshot_json = conn.execute(
+ "SELECT payload_json FROM snapshots WHERE request_id = '42'"
+ ).fetchone()[0]
+ action_message = conn.execute(
+ "SELECT message FROM actions WHERE request_id = '42'"
+ ).fetchone()[0]
+ portal_owner = conn.execute(
+ "SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?",
+ (item["id"],),
+ ).fetchone()
+ self.assertEqual(request_row[0], "Deleted user")
+ self.assertIsNone(request_row[1])
+ self.assertNotIn("viewer", request_row[2].lower())
+ self.assertNotIn("viewer", snapshot_json.lower())
+ self.assertNotIn("viewer", action_message.lower())
+ self.assertTrue(portal_owner[0].startswith("deleted-user-"))
+ self.assertIsNone(portal_owner[1])
+
+ async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None:
+ upload = SimpleNamespace(
+ filename="logo.png",
+ content_type="image/png",
+ read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)),
+ )
+ with self.assertRaises(HTTPException) as context:
+ await branding_router.save_branding_image(upload)
+ self.assertEqual(context.exception.status_code, 413)
+ upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1)
+
+
+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):
+ def test_status_router_requires_admin(self) -> None:
+ dependencies = [getattr(dependency, "dependency", None) for dependency in status_router.router.dependencies]
+
+ self.assertIn(require_admin, dependencies)
+
+ 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 OperationProgressTests(unittest.TestCase):
+ def test_remote_interaction_is_visible_until_operation_completes(self) -> None:
+ operation_id = "operation-progress-test"
+ token = begin_operation(
+ operation_id,
+ label="Recheck request status",
+ path="/requests/3914/actions/recheck",
+ )
+ try:
+ event_id = start_remote_call("Radarr")
+ active = get_operation(operation_id)
+ self.assertEqual(active["status"], "running")
+ self.assertEqual(active["events"][-1]["service"], "Radarr")
+ self.assertEqual(active["events"][-1]["state"], "active")
+
+ finish_remote_call(
+ event_id,
+ success=True,
+ status_code=200,
+ message="Radarr responded in 0.2s.",
+ )
+ finish_operation(operation_id, success=True, status_code=200)
+ finally:
+ reset_operation(token)
+
+ completed = get_operation(operation_id)
+ self.assertEqual(completed["status"], "complete")
+ self.assertEqual(completed["events"][-2]["state"], "complete")
+ self.assertEqual(completed["events"][-2]["status_code"], 200)
+ self.assertEqual(completed["events"][-1]["service"], "Magent")
+
+
+class OperationMessageTests(unittest.TestCase):
+ def test_radarr_lookup_explains_whether_movie_was_found(self) -> None:
+ found = _operation_result_message(
+ "Radarr",
+ "GET",
+ "/api/v3/movie",
+ [{"title": "Arrival"}],
+ )
+ missing = _operation_result_message(
+ "Radarr",
+ "GET",
+ "/api/v3/movie",
+ [],
+ )
+
+ self.assertEqual(found, 'Radarr found "Arrival" in its library list.')
+ self.assertEqual(missing, "This movie is not currently in Radarr.")
+
+ def test_radarr_add_explains_that_download_search_started(self) -> None:
+ message = _operation_result_message(
+ "Radarr",
+ "POST",
+ "/api/v3/movie",
+ {"title": "Arrival", "id": 42},
+ payload={
+ "title": "Arrival",
+ "addOptions": {"searchForMovie": True},
+ },
+ )
+
+ self.assertEqual(message, 'Radarr added "Arrival" and started looking for a download.')
+
+ def test_queue_and_indexer_health_results_are_summarized(self) -> None:
+ queue_message = _operation_result_message(
+ "Sonarr",
+ "GET",
+ "/api/v3/queue",
+ {"totalRecords": 0, "records": []},
+ )
+ health_message = _operation_result_message(
+ "Prowlarr",
+ "GET",
+ "/api/v1/health",
+ [],
+ )
+
+ self.assertEqual(queue_message, "Sonarr has no matching downloads in its queue.")
+ self.assertEqual(health_message, "The download search sources are working normally.")
+
+ def test_download_and_jellyfin_results_include_actual_state(self) -> None:
+ torrent_message = _torrent_result_message(
+ [{"name": "Arrival.2016", "state": "downloading", "progress": 0.42}]
+ )
+
+ self.assertEqual(
+ torrent_message,
+ 'Downloading — 42% complete.',
+ )
+ self.assertEqual(
+ _availability_message({"TotalRecordCount": 0, "Items": []}),
+ "Jellyfin did not find this title in its library search.",
+ )
+
+ def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
+ message = _operation_result_message(
+ "Bazarr",
+ "PATCH",
+ "/api/episodes/subtitles",
+ {"status": True},
+ params={"language": "en", "episodeid": 42},
+ )
+
+ self.assertEqual(
+ message,
+ "Bazarr accepted a fresh EN subtitle search for the selected episode.",
+ )
+
+ def test_library_search_does_not_claim_playable_media(self) -> None:
+ message = _availability_message({"TotalRecordCount": 1, "Items": [{"Name": "Example"}]})
+ self.assertIn("still needs to check the exact title and file", message)
+ self.assertNotIn("available to watch", message)
+
+ def test_finished_or_paused_download_is_not_described_as_stuck(self) -> None:
+ for state in ["stalledUP", "stoppedUP", "pausedUP"]:
+ self.assertIn("finished", _torrent_result_message([{"state": state, "progress": 1}]))
+ self.assertIn("paused", _torrent_result_message([{"state": "stoppedDL", "progress": .3}]))
+
+ def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
+ self.assertEqual(
+ _operation_error_message("Radarr", 500),
+ "Radarr encountered an internal error while processing the request.",
+ )
+ self.assertEqual(
+ _operation_error_message("Sonarr", 401),
+ "Sonarr rejected Magent's login details.",
+ )
+
+
+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_banner_background_color=None,
+ site_banner_border_color=None,
+ site_login_message="",
+ 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})
+
+ def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
+ runtime = settings.model_copy(update={
+ "site_banner_enabled": True,
+ "site_banner_message": "Planned maintenance",
+ "site_banner_tone": "warning",
+ "site_banner_background_color": "#123ABC",
+ "site_banner_border_color": "red",
+ "site_login_message": "Use your Grizzlyflix account to sign in.",
+ })
+
+ with patch.object(site_router, "get_runtime_settings", return_value=runtime):
+ info = site_router._build_site_info(False)
+
+ self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
+ self.assertIsNone(info["banner"]["borderColor"])
+ self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
+
+
+class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
+ async def test_banner_colours_are_normalized_before_saving(self) -> None:
+ with patch.object(admin_router, "set_setting") as save:
+ result = await admin_router.update_settings({
+ "site_banner_background_color": "#A1B2C3",
+ "site_banner_border_color": "#010203",
+ })
+
+ self.assertEqual(result, {"status": "ok", "updated": 2})
+ self.assertEqual(
+ save.call_args_list,
+ [
+ call("site_banner_background_color", "#a1b2c3"),
+ call("site_banner_border_color", "#010203"),
+ ],
+ )
+
+ async def test_banner_colours_reject_unsafe_css_values(self) -> None:
+ with self.assertRaises(HTTPException) as raised:
+ await admin_router.update_settings({
+ "site_banner_border_color": "red; background: url(example)",
+ })
+
+ self.assertEqual(raised.exception.status_code, 400)
+
+
+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 RequestVisibilityTests(TempDatabaseMixin, unittest.TestCase):
+ def test_non_admin_snapshot_excludes_advanced_identifying_data(self) -> None:
+ snapshot = Snapshot(
+ request_id="3925",
+ title="Example",
+ timeline=[
+ TimelineHop(
+ service="Seerr",
+ status="approved",
+ details={"requestedBy": "viewer@example.com"},
+ )
+ ],
+ raw={
+ "jellyseerr": {"requestedBy": {"email": "viewer@example.com"}},
+ "qbittorrent": {"downloadIds": ["secret-hash"]},
+ },
+ actions=[
+ ActionOption(
+ id="search_releases",
+ label="Search and choose a download",
+ risk="safe",
+ )
+ ],
+ presentation={"status": {"label": "Needs attention"}},
+ )
+
+ filtered = requests_router._filter_snapshot_for_user(
+ snapshot, {"username": "helper", "role": "user"}
+ )
+
+ self.assertEqual(filtered.timeline, [])
+ self.assertEqual(filtered.raw, {})
+ self.assertEqual([action.id for action in filtered.actions], ["search_releases"])
+ self.assertEqual(filtered.presentation["status"]["label"], "Needs attention")
+
+ def test_admin_snapshot_retains_advanced_diagnostics(self) -> None:
+ snapshot = Snapshot(
+ request_id="3925",
+ title="Example",
+ timeline=[TimelineHop(service="Seerr", status="approved")],
+ raw={"jellyseerr": {"id": 3925}},
+ )
+
+ filtered = requests_router._filter_snapshot_for_user(
+ snapshot, {"username": "admin", "role": "admin"}
+ )
+
+ self.assertEqual(len(filtered.timeline), 1)
+ self.assertEqual(filtered.raw["jellyseerr"]["id"], 3925)
+
+ def test_non_admin_cannot_request_advanced_history(self) -> None:
+ with self.assertRaises(HTTPException) as context:
+ requests_router._require_advanced_request_access(
+ {"username": "helper", "role": "user"}
+ )
+
+ self.assertEqual(context.exception.status_code, 403)
+
+ def test_my_requests_cache_only_returns_signed_in_users_rows(self) -> None:
+ previous = dict(requests_router._recent_cache)
+ requests_router._recent_cache["items"] = [
+ {"request_id": 100, "requested_by_id": 7, "requested_by_norm": "zak"},
+ {"request_id": 101, "requested_by_id": 8, "requested_by_norm": "someone-else"},
+ ]
+ try:
+ rows = requests_router._get_recent_from_cache(
+ requested_by_norm="zak",
+ requested_by_id=7,
+ limit=10,
+ offset=0,
+ since_iso=None,
+ )
+ finally:
+ requests_router._recent_cache.clear()
+ requests_router._recent_cache.update(previous)
+
+ self.assertEqual([row["request_id"] for row in rows], [100])
+
+
+class RequestPresentationTests(unittest.TestCase):
+ def test_repair_activity_shows_collector_search_before_download(self) -> None:
+ snapshot = Snapshot(
+ request_id="144",
+ title="Toy Story 2",
+ request_type=RequestType.movie,
+ state=NormalizedState.searching,
+ )
+
+ activity = _build_repair_activity(
+ snapshot,
+ action={
+ "action_id": "replace_media",
+ "status": "ok",
+ "message": "Radarr removed the file and started a replacement search.",
+ "created_at": "2026-09-01T09:06:55+00:00",
+ },
+ arr_state="searching",
+ arr_details={"availability": {"available": 0, "missing": 1, "total": 1}},
+ download={"visible": False, "state": "not_started", "torrents": []},
+ jellyfin_found=False,
+ )
+
+ self.assertIsNotNone(activity)
+ self.assertEqual(activity["state"], "searching")
+ self.assertEqual(activity["headline"], "Replacement search in progress")
+ self.assertEqual(activity["steps"][1]["state"], "complete")
+ self.assertEqual(activity["steps"][2]["state"], "waiting")
+
+ def test_repair_activity_tracks_replacement_download(self) -> None:
+ snapshot = Snapshot(
+ request_id="144",
+ title="Toy Story 2",
+ request_type=RequestType.movie,
+ state=NormalizedState.downloading,
+ )
+
+ activity = _build_repair_activity(
+ snapshot,
+ action={
+ "action_id": "replace_media",
+ "status": "ok",
+ "message": "Radarr started a replacement search.",
+ "created_at": "2026-09-01T09:06:55+00:00",
+ },
+ arr_state="searching",
+ arr_details={"availability": {"available": 0, "missing": 1, "total": 1}},
+ download={
+ "visible": True,
+ "state": "downloading",
+ "summary": "Downloading (1 active).",
+ "torrents": [{"progress": 0.25}],
+ },
+ jellyfin_found=False,
+ )
+
+ self.assertIsNotNone(activity)
+ self.assertEqual(activity["state"], "downloading")
+ self.assertEqual(activity["headline"], "Replacement download in progress")
+ self.assertEqual(activity["steps"][2]["state"], "active")
+
+ def test_repair_activity_reports_collected_file_and_media_index(self) -> None:
+ snapshot = Snapshot(
+ request_id="144",
+ title="Toy Story 2",
+ request_type=RequestType.movie,
+ state=NormalizedState.importing,
+ )
+
+ activity = _build_repair_activity(
+ snapshot,
+ action={
+ "action_id": "replace_media",
+ "status": "ok",
+ "message": "Radarr started a replacement search.",
+ "created_at": "2026-09-01T09:06:55+00:00",
+ },
+ arr_state="available",
+ arr_details={"availability": {"available": 1, "missing": 0, "total": 1}},
+ download={"visible": False, "state": "not_started", "torrents": []},
+ jellyfin_found=False,
+ )
+
+ self.assertIsNotNone(activity)
+ self.assertEqual(activity["state"], "indexing")
+ self.assertEqual(activity["steps"][2]["state"], "complete")
+ self.assertEqual(
+ activity["steps"][2]["detail"],
+ "Radarr reports the replacement file as collected and imported.",
+ )
+ self.assertEqual(activity["steps"][3]["state"], "active")
+
+ def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
+ self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
+
+ 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_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None:
+ series = {
+ "seasons": [
+ {"seasonNumber": 0, "monitored": False},
+ {"seasonNumber": 7, "monitored": True},
+ {
+ "seasonNumber": 8,
+ "monitored": False,
+ "statistics": {"episodeCount": 16, "episodeFileCount": 2},
+ },
+ {"seasonNumber": 9, "monitored": False},
+ ]
+ }
+ episodes = [
+ {"seasonNumber": 9, "episodeNumber": 1, "hasFile": True},
+ {"seasonNumber": 9, "episodeNumber": 2, "hasFile": False},
+ ]
+
+ options = _unmonitored_season_options(series, episodes)
+
+ self.assertEqual(
+ options,
+ [
+ {"seasonNumber": 8, "episodeCount": 16, "available": 2},
+ {"seasonNumber": 9, "episodeCount": 2, "available": 1},
+ ],
+ )
+
+ 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")
+
+ def test_available_content_replaces_stale_download_warning_with_completion(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Example",
+ request_type=RequestType.tv,
+ state=NormalizedState.completed,
+ actions=[],
+ )
+
+ presentation = _build_presentation(
+ snapshot,
+ approved=True,
+ arr_state="available",
+ arr_details={
+ "availability": {"available": 6, "missing": 0, "total": 6, "seasons": []}
+ },
+ prowlarr_state="ok",
+ download={
+ "visible": True,
+ "state": "missing",
+ "summary": "A previous download was observed, but it is not currently visible in qBittorrent.",
+ "torrents": [],
+ },
+ jellyfin_found=True,
+ jellyfin_link="https://media.test/title/3909",
+ )
+
+ self.assertFalse(presentation["download"]["visible"])
+ self.assertEqual(presentation["download"]["state"], "completed")
+ self.assertEqual(presentation["nextStep"]["title"], "Ready to watch")
+ self.assertEqual(presentation["nextStep"]["actionIds"], [])
+ download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
+ self.assertEqual(download_stage["label"], "Download complete")
+ self.assertEqual(download_stage["state"], "complete")
+ self.assertFalse(download_stage["visible"])
+ self.assertEqual(
+ download_stage["summary"],
+ "The requested content has been collected and is available to watch. No further action is needed.",
+ )
+ search_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "search")
+ available_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "available")
+ self.assertEqual(search_stage["state"], "complete")
+ self.assertEqual(search_stage["actionIds"], [])
+ self.assertEqual(available_stage["state"], "complete")
+ self.assertEqual(available_stage["stateLabel"], "Ready")
+ self.assertEqual(available_stage["label"], "Available to watch")
+ self.assertEqual(available_stage["summary"], "This title is ready to watch in Jellyfin.")
+ self.assertEqual(available_stage["link"], "https://media.test/title/3909")
+
+ def test_partially_available_content_keeps_missing_download_attention(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Example",
+ request_type=RequestType.tv,
+ state=NormalizedState.importing,
+ actions=[],
+ )
+
+ presentation = _build_presentation(
+ snapshot,
+ approved=True,
+ arr_state="added",
+ arr_details={
+ "availability": {"available": 3, "missing": 3, "total": 6, "seasons": []}
+ },
+ prowlarr_state="ok",
+ download={
+ "visible": True,
+ "state": "missing",
+ "summary": "A previous download is no longer visible.",
+ "torrents": [],
+ },
+ jellyfin_found=True,
+ jellyfin_link="https://media.test/title/3909",
+ )
+
+ self.assertTrue(presentation["download"]["visible"])
+ download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
+ self.assertEqual(download_stage["label"], "Download")
+ self.assertEqual(download_stage["state"], "attention")
+ self.assertTrue(download_stage["visible"])
+
+ def test_collector_file_waits_for_media_server_before_marking_available(self) -> None:
+ snapshot = Snapshot(
+ request_id="3914",
+ title="I See You",
+ request_type=RequestType.movie,
+ state=NormalizedState.importing,
+ actions=[],
+ )
+
+ presentation = _build_presentation(
+ snapshot,
+ approved=True,
+ arr_state="available",
+ arr_details={
+ "availability": {"available": 1, "missing": 0, "total": 1, "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.assertEqual(presentation["status"]["label"], "Collected — waiting for the media server")
+ self.assertEqual(presentation["nextStep"]["title"], "Wait for the media server to index this title")
+ search_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "search")
+ download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
+ available_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "available")
+ self.assertEqual(search_stage["state"], "complete")
+ self.assertEqual(download_stage["state"], "complete")
+ self.assertEqual(available_stage["state"], "active")
+ self.assertEqual(available_stage["stateLabel"], "Indexing")
+ self.assertEqual(available_stage["label"], "Adding to Jellyfin")
+ self.assertEqual(
+ available_stage["summary"],
+ "The download is complete. Jellyfin is indexing this title now.",
+ )
+
+
+class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
+ def test_sparse_seerr_request_is_enriched_with_media_lookup(self) -> None:
+ sparse = {
+ "id": 3925,
+ "type": "movie",
+ "status": 2,
+ "media": {"id": 2444, "mediaType": "movie", "tmdbId": 209112},
+ }
+ details = {
+ "title": "Batman v Superman: Dawn of Justice",
+ "releaseDate": "2016-03-23",
+ "posterPath": "/poster.jpg",
+ }
+
+ enriched = requests_router._merge_request_media_details(sparse, details)
+ parsed = requests_router._parse_request_payload(enriched)
+
+ self.assertEqual(parsed["title"], "Batman v Superman: Dawn of Justice")
+ self.assertEqual(parsed["year"], 2016)
+ self.assertEqual(enriched["media"]["posterPath"], "/poster.jpg")
+ self.assertNotIn("title", sparse["media"])
+
+ async def test_seerr_search_percent_encodes_multi_word_titles(self) -> None:
+ client = requests_router.JellyseerrClient("http://seerr.test", "key")
+ client.get = AsyncMock(return_value={"results": []})
+
+ await client.search("Ricky Gervais Alley Cats", page=2)
+
+ client.get.assert_awaited_once_with(
+ "/api/v1/search?query=Ricky%20Gervais%20Alley%20Cats&page=2"
+ )
+
+ async def test_seerr_request_includes_validated_destination_and_profile(self) -> None:
+ client = requests_router.JellyseerrClient("http://seerr.test", "key")
+ client.post = AsyncMock(return_value={"id": 42})
+
+ await client.create_request(
+ media_type="tv",
+ media_id=123,
+ seasons=[1, 2],
+ server_id=0,
+ profile_id=7,
+ root_folder="/TV98",
+ )
+
+ client.post.assert_awaited_once_with(
+ "/api/v1/request",
+ payload={
+ "mediaType": "tv",
+ "mediaId": 123,
+ "seasons": [1, 2],
+ "serverId": 0,
+ "profileId": 7,
+ "rootFolder": "/TV98",
+ },
+ )
+
+ async def test_seerr_write_completes_csrf_cookie_handshake(self) -> None:
+ observed: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ observed.append(request)
+ if request.method == "GET":
+ return httpx.Response(
+ 200,
+ headers=[
+ ("set-cookie", "_csrf=secret-value; Path=/; Secure; HttpOnly; SameSite=Strict"),
+ ("set-cookie", "XSRF-TOKEN=csrf%2Etoken; Path=/; Secure; SameSite=Strict"),
+ ],
+ json={"id": 1},
+ )
+ return httpx.Response(201, json={"id": 42})
+
+ transport = httpx.MockTransport(handler)
+ seerr = requests_router.JellyseerrClient("https://seerr.test", "api-key")
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await seerr._send_request(
+ client,
+ "POST",
+ "https://seerr.test/api/v1/request",
+ headers=seerr.headers(),
+ params=None,
+ payload={"mediaType": "movie", "mediaId": 209112},
+ )
+
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual([request.method for request in observed], ["GET", "POST"])
+ write_request = observed[1]
+ self.assertEqual(write_request.headers.get("XSRF-TOKEN"), "csrf.token")
+ self.assertEqual(write_request.headers.get("Origin"), "https://seerr.test")
+ self.assertIn("_csrf=secret-value", write_request.headers.get("Cookie", ""))
+ self.assertIn("XSRF-TOKEN=csrf%2Etoken", write_request.headers.get("Cookie", ""))
+
+ async def test_base_request_passes_payload_to_transport_hook(self) -> None:
+ captured: dict = {}
+
+ async def send_request(
+ _client: httpx.AsyncClient,
+ method: str,
+ url: str,
+ *,
+ headers: dict,
+ params: dict | None,
+ payload: dict | None,
+ ) -> httpx.Response:
+ captured.update(
+ method=method,
+ url=url,
+ headers=headers,
+ params=params,
+ payload=payload,
+ )
+ return httpx.Response(200, request=httpx.Request(method, url), json={"ok": True})
+
+ client = requests_router.JellyseerrClient("https://seerr.test", "api-key")
+ with patch.object(client, "_send_request", new=send_request):
+ result = await client._request(
+ "POST",
+ "/api/v1/request",
+ payload={"mediaType": "movie", "mediaId": 209112},
+ )
+
+ self.assertEqual(result, {"ok": True})
+ self.assertEqual(captured["payload"], {"mediaType": "movie", "mediaId": 209112})
+
+ async def test_request_destination_uses_admin_default_before_seerr(self) -> None:
+ runtime = SimpleNamespace(
+ sonarr_base_url="http://sonarr.test",
+ sonarr_api_key="key",
+ sonarr_quality_profile_id=7,
+ sonarr_root_folder="/tv",
+ )
+ seerr = SimpleNamespace(
+ get_service_settings=AsyncMock(
+ return_value=[
+ {
+ "id": 4,
+ "name": "Main Sonarr",
+ "isDefault": True,
+ "is4k": False,
+ "activeProfileId": 10,
+ "activeDirectory": "/tv",
+ }
+ ]
+ )
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_quality_profiles=AsyncMock(
+ return_value=[{"id": 7, "name": "WEB-1080p"}, {"id": 10, "name": "Optimal"}]
+ ),
+ get_root_folders=AsyncMock(return_value=[{"id": 1, "path": "/tv"}]),
+ )
+
+ with patch.object(requests_router, "SonarrClient", return_value=sonarr):
+ destination = await requests_router._resolve_request_destination(
+ runtime, seerr, "tv"
+ )
+
+ self.assertEqual(destination["profile_id"], 7)
+ self.assertEqual(destination["default_profile_id"], 7)
+ self.assertEqual(destination["root_folder"], "/tv")
+ self.assertEqual(destination["profiles"], [
+ {"id": 7, "name": "WEB-1080p"},
+ {"id": 10, "name": "Optimal"},
+ ])
+
+ async def test_request_defaults_inherit_seerr_only_when_unset(self) -> None:
+ for media_type, service in [('movie', 'radarr'), ('tv', 'sonarr')]:
+ runtime = SimpleNamespace(**{
+ service + '_base_url': 'http://collector.test', service + '_api_key': 'key',
+ service + '_quality_profile_id': None, service + '_root_folder': '/media',
+ })
+ seerr = SimpleNamespace(get_service_settings=AsyncMock(return_value=[{
+ 'id': 1, 'isDefault': True, 'activeProfileId': 7, 'activeDirectory': '/media',
+ }]))
+ collector = SimpleNamespace(configured=lambda: True,
+ get_quality_profiles=AsyncMock(return_value=[{'id': 7, 'name': 'HD'}]),
+ get_root_folders=AsyncMock(return_value=[{'path': '/media'}]))
+ with patch.object(requests_router, 'RadarrClient' if service == 'radarr' else 'SonarrClient', return_value=collector):
+ result = await requests_router._resolve_request_destination(runtime, seerr, media_type)
+ self.assertEqual(result['profile_id'], 7)
+ seerr.get_service_settings.return_value[0]['activeProfileId'] = 999
+ with self.assertRaises(HTTPException):
+ await requests_router._resolve_request_destination(runtime, seerr, media_type)
+
+ async def test_request_creation_ignores_browser_quality_override(self) -> None:
+ runtime = SimpleNamespace(jellyseerr_base_url='http://seerr.test', jellyseerr_api_key='key')
+ seerr = SimpleNamespace(configured=lambda: True,
+ get_movie=AsyncMock(return_value={'title': 'Movie'}),
+ create_request=AsyncMock(return_value={'status': 1}))
+ destination = {'server_id': 1, 'profile_id': 7, 'root_folder': '/movies'}
+ with patch.object(requests_router, 'get_runtime_settings', return_value=runtime), \
+ patch.object(requests_router, 'JellyseerrClient', return_value=seerr), \
+ patch.object(requests_router, '_resolve_request_destination', new_callable=AsyncMock, return_value=destination) as resolve:
+ await requests_router.create_request({'mediaType': 'movie', 'tmdbId': 123, 'profileId': 999}, {'username': 'viewer'})
+ resolve.assert_awaited_once_with(runtime, seerr, 'movie')
+ self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], 7)
+
+ async def test_request_destination_rejects_stale_profile_id(self) -> None:
+ runtime = SimpleNamespace(
+ radarr_base_url="http://radarr.test",
+ radarr_api_key="key",
+ radarr_quality_profile_id=999,
+ radarr_root_folder="/movies",
+ )
+ seerr = SimpleNamespace(
+ get_service_settings=AsyncMock(
+ return_value=[
+ {
+ "id": 2,
+ "name": "Main Radarr",
+ "isDefault": True,
+ "is4k": False,
+ "activeProfileId": 6,
+ "activeDirectory": "/movies",
+ }
+ ]
+ )
+ )
+ radarr = SimpleNamespace(
+ configured=lambda: True,
+ get_quality_profiles=AsyncMock(return_value=[{"id": 6, "name": "HD"}]),
+ get_root_folders=AsyncMock(return_value=[{"id": 1, "path": "/movies"}]),
+ )
+
+ with patch.object(requests_router, "RadarrClient", return_value=radarr):
+ with self.assertRaises(HTTPException) as context:
+ await requests_router._resolve_request_destination(
+ runtime, seerr, "movie"
+ )
+
+ self.assertEqual(context.exception.status_code, 409)
+ self.assertIn("not available in Radarr", context.exception.detail)
+
+
+class RequestRecheckTests(unittest.IsolatedAsyncioTestCase):
+ async def test_recheck_refreshes_seerr_cache_and_returns_rebuilt_snapshot(self) -> None:
+ runtime = SimpleNamespace(
+ jellyseerr_base_url="http://seerr.test",
+ jellyseerr_api_key="seerr-key",
+ )
+ fresh_request = {
+ "id": 3914,
+ "type": "movie",
+ "status": 2,
+ "createdAt": "2026-08-30T00:00:00Z",
+ "updatedAt": "2026-08-30T01:00:00Z",
+ "requestedBy": {"username": "viewer"},
+ "media": {
+ "id": 9001,
+ "mediaType": "movie",
+ "tmdbId": 524251,
+ "title": "I See You",
+ "year": 2019,
+ },
+ }
+ seerr = SimpleNamespace(
+ configured=lambda: True,
+ get_request=AsyncMock(return_value=fresh_request),
+ )
+ snapshot = Snapshot(
+ request_id="3914",
+ title="I See You",
+ request_type=RequestType.movie,
+ state=NormalizedState.importing,
+ presentation={
+ "status": {"label": "Collected — waiting for the media server"},
+ },
+ )
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
+ requests_router, "JellyseerrClient", return_value=seerr
+ ), patch.object(requests_router, "upsert_request_cache") as upsert, patch.object(
+ requests_router, "_cache_set"
+ ) as cache_set, patch.object(requests_router, "_refresh_recent_cache_from_db"), patch.object(
+ requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
+ ), patch.object(requests_router, "save_action") as save_action:
+ result = await requests_router.action_recheck(
+ "3914", user={"username": "viewer", "role": "user"}
+ )
+
+ seerr.get_request.assert_awaited_once_with("3914")
+ upsert.assert_called_once()
+ cache_set.assert_called_once_with("request:3914", fresh_request)
+ save_action.assert_called_once()
+ self.assertEqual(result["status"], "ok")
+ self.assertIs(result["snapshot"], snapshot)
+
+ async def test_recheck_hydrates_sparse_seerr_request_before_caching(self) -> None:
+ runtime = SimpleNamespace(jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="key")
+ sparse_request = {
+ "id": 3925,
+ "type": "movie",
+ "status": 2,
+ "requestedBy": {"username": "viewer"},
+ "media": {"id": 2444, "mediaType": "movie", "tmdbId": 209112},
+ }
+ seerr = SimpleNamespace(
+ configured=lambda: True,
+ get_request=AsyncMock(return_value=sparse_request),
+ get_movie=AsyncMock(
+ return_value={
+ "title": "Batman v Superman: Dawn of Justice",
+ "releaseDate": "2016-03-23",
+ }
+ ),
+ )
+ snapshot = Snapshot(
+ request_id="3925",
+ title="Batman v Superman: Dawn of Justice",
+ request_type=RequestType.movie,
+ state=NormalizedState.downloading,
+ presentation={"status": {"label": "Download in progress"}},
+ )
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
+ requests_router, "JellyseerrClient", return_value=seerr
+ ), patch.object(
+ requests_router,
+ "_get_media_details",
+ new=AsyncMock(
+ return_value={
+ "title": "Batman v Superman: Dawn of Justice",
+ "releaseDate": "2016-03-23",
+ }
+ ),
+ ), patch.object(requests_router, "upsert_request_cache") as upsert, patch.object(
+ requests_router, "_cache_set"
+ ) as cache_set, patch.object(requests_router, "_refresh_recent_cache_from_db"), patch.object(
+ requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
+ ), patch.object(requests_router, "save_action"):
+ await requests_router.action_recheck(
+ "3925", user={"username": "viewer", "role": "user"}
+ )
+
+ cached_record = upsert.call_args.kwargs
+ self.assertEqual(cached_record["title"], "Batman v Superman: Dawn of Justice")
+ cached_payload = cache_set.call_args.args[1]
+ self.assertEqual(cached_payload["media"]["title"], "Batman v Superman: Dawn of Justice")
+
+
+class SnapshotIdentityTests(unittest.TestCase):
+ def test_radarr_identity_replaces_unknown_cached_title(self) -> None:
+ snapshot = Snapshot(request_id="3925", title="Unknown", request_type=RequestType.movie)
+
+ _apply_arr_identity(
+ snapshot,
+ {"title": "Batman v Superman: Dawn of Justice", "year": 2016},
+ )
+
+ self.assertEqual(snapshot.title, "Batman v Superman: Dawn of Justice")
+ self.assertEqual(snapshot.year, 2016)
+
+
+class LiveDownloadProgressTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
+ runtime = SimpleNamespace(
+ jellyseerr_base_url=None,
+ jellyseerr_api_key=None,
+ qbittorrent_base_url="http://qbittorrent.test",
+ qbittorrent_username="magent",
+ qbittorrent_password="secret",
+ )
+ evidence = {
+ "observed": True,
+ "torrents": [{"hash": "abc123", "progress": 0.12}],
+ }
+ current = [{"hash": "abc123", "name": "Example", "progress": 0.1344, "state": "downloading"}]
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
+ requests_router,
+ "get_request_download_evidence",
+ return_value=evidence,
+ ), patch.object(
+ requests_router.QBittorrentClient,
+ "get_torrents_by_hashes",
+ new=AsyncMock(return_value=current),
+ ) as get_torrents:
+ result = await requests_router.get_download_progress(
+ "3909", user={"username": "viewer", "role": "user"}
+ )
+
+ get_torrents.assert_awaited_once_with("abc123")
+ self.assertEqual(result["state"], "downloading")
+ self.assertEqual(result["torrents"][0]["progressPercent"], 13.4)
+
+
+class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
+ async def test_radarr_add_resolves_title_before_posting_movie(self) -> None:
+ client = requests_router.RadarrClient("http://radarr.test", "radarr-key")
+ with patch.object(
+ client,
+ "get",
+ new=AsyncMock(return_value={"title": "A Grand Day Out", "tmdbId": 530}),
+ ) as lookup, patch.object(
+ client,
+ "post",
+ new=AsyncMock(return_value={"id": 12, "title": "A Grand Day Out"}),
+ ) as create:
+ result = await client.add_movie(530, 1, "/movies")
+
+ lookup.assert_awaited_once_with("/api/v3/movie/lookup/tmdb", params={"tmdbId": 530})
+ payload = create.await_args.kwargs["payload"]
+ self.assertEqual(payload["title"], "A Grand Day Out")
+ self.assertEqual(payload["tmdbId"], 530)
+ self.assertEqual(result["id"], 12)
+
+ async def test_sonarr_add_resolves_matching_series_title_before_posting(self) -> None:
+ client = requests_router.SonarrClient("http://sonarr.test", "sonarr-key")
+ lookup_response = [
+ {"title": "Wrong Show", "tvdbId": 111},
+ {"title": "Example Show", "tvdbId": 222},
+ ]
+ with patch.object(
+ client,
+ "get",
+ new=AsyncMock(return_value=lookup_response),
+ ) as lookup, patch.object(
+ client,
+ "post",
+ new=AsyncMock(return_value={"id": 42, "title": "Example Show"}),
+ ) as create:
+ result = await client.add_series(222, 2, "/television")
+
+ lookup.assert_awaited_once_with("/api/v3/series/lookup", params={"term": "tvdb:222"})
+ payload = create.await_args.kwargs["payload"]
+ self.assertEqual(payload["title"], "Example Show")
+ self.assertEqual(payload["tvdbId"], 222)
+ self.assertEqual(result["id"], 42)
+
+ def test_arr_error_message_does_not_expose_upstream_stack_trace(self) -> None:
+ response = httpx.Response(
+ 500,
+ request=httpx.Request("POST", "http://radarr.test/api/v3/movie"),
+ json={
+ "message": "Object reference not set to an instance of an object.",
+ "description": "System.NullReferenceException\n at Radarr.Internal.SecretMethod()",
+ },
+ )
+ error = httpx.HTTPStatusError("Radarr failed", request=response.request, response=response)
+
+ message = requests_router._format_upstream_error("Radarr", error)
+
+ self.assertIn("Object reference", message)
+ self.assertNotIn("NullReferenceException", message)
+ self.assertNotIn("SecretMethod", message)
+
+
+class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ from backend.app.config import settings
+ secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
+ secret.start()
+ self.addCleanup(secret.stop)
+ access = patch.object(
+ requests_router,
+ "_ensure_request_mutation_access",
+ new=AsyncMock(return_value=None),
+ )
+ access.start()
+ self.addCleanup(access.stop)
+
+ def selection(self, payload, request_id, source):
+ payload['selectionToken'] = requests_router.manual_releases.issue_selection(
+ {**payload, 'requiresOverride': False, 'rejections': []}, request_id,
+ {'username': 'viewer'}, source, None)
+ return payload
+
+ @staticmethod
+ def _runtime() -> SimpleNamespace:
+ return SimpleNamespace(
+ jellyseerr_base_url=None,
+ jellyseerr_api_key=None,
+ sonarr_base_url="http://sonarr.test",
+ sonarr_api_key="sonarr-key",
+ radarr_base_url="http://radarr.test",
+ radarr_api_key="radarr-key",
+ )
+
+ async def test_tv_manual_search_uses_sonarr_and_keeps_season_packs(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Example Show",
+ request_type=RequestType.tv,
+ raw={"arr": {"item": {"id": 42}}},
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_episodes=AsyncMock(
+ return_value=[
+ {"id": 101, "seasonNumber": 1, "monitored": True, "hasFile": False},
+ {"id": 201, "seasonNumber": 2, "monitored": True, "hasFile": False},
+ {"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
+ ]
+ ),
+ search_episode_releases=AsyncMock(
+ side_effect=[
+ [
+ {
+ "title": "Example.Show.S01.1080p",
+ "guid": "season-one",
+ "indexerId": 7,
+ "indexer": "Prowlarr",
+ "protocol": "torrent",
+ "fullSeason": True,
+ "seasonNumber": 1,
+ "approved": True,
+ "rejected": False,
+ "downloadAllowed": True,
+ "quality": {"quality": {"name": "WEBDL-1080p"}},
+ },
+ {
+ "title": "Example.Show.S01.2160p",
+ "guid": "outside-profile",
+ "indexerId": 7,
+ "approved": False,
+ "rejected": True,
+ "rejections": ["Quality is not wanted in profile"],
+ }
+ ],
+ [],
+ ]
+ ),
+ )
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
+ requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
+ ), patch.object(requests_router, "SonarrClient", return_value=sonarr), patch.object(
+ requests_router, "save_action"
+ ):
+ result = await requests_router.action_search(
+ "3909", user={"username": "viewer", "role": "user"}
+ )
+
+ sonarr.search_episode_releases.assert_any_await(101)
+ sonarr.search_episode_releases.assert_any_await(201)
+ self.assertEqual(result["collector"], "Sonarr")
+ self.assertEqual(len(result["releases"]), 2)
+ self.assertTrue(result["releases"][0]["fullSeason"])
+ self.assertEqual(result["releases"][0]["seasonNumber"], 1)
+ self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
+ self.assertTrue(result["releases"][0]["bestPick"])
+ self.assertFalse(result["qualityFiltered"])
+ self.assertNotIn("selectionToken", result["releases"][1])
+ self.assertTrue(result["releases"][1]["requiresOverride"])
+
+ async def test_movie_manual_search_uses_radarr(self) -> None:
+ snapshot = Snapshot(
+ request_id="4000",
+ title="Example Movie",
+ request_type=RequestType.movie,
+ raw={"arr": {"item": {"id": 84}}},
+ )
+ radarr = SimpleNamespace(
+ configured=lambda: True,
+ search_releases=AsyncMock(
+ return_value=[
+ {
+ "title": "Example.Movie.2026.1080p",
+ "guid": "movie-release",
+ "indexerId": 9,
+ "indexer": "Prowlarr",
+ "protocol": "torrent",
+ "approved": True,
+ "rejected": False,
+ "downloadAllowed": True,
+ }
+ ]
+ ),
+ )
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
+ requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
+ ), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
+ requests_router, "save_action"
+ ):
+ result = await requests_router.action_search(
+ "4000", user={"username": "viewer", "role": "user"}
+ )
+
+ radarr.search_releases.assert_awaited_once_with(84)
+ self.assertEqual(result["collector"], "Radarr")
+ self.assertEqual(result["releases"][0]["guid"], "movie-release")
+ self.assertTrue(result["releases"][0]["bestPick"])
+
+ def test_manual_release_filter_requires_explicit_arr_approval(self) -> None:
+ releases = requests_router._filter_arr_release_results(
+ [
+ {"title": "Missing decision", "guid": "missing", "indexerId": 1},
+ {
+ "title": "Temporarily rejected",
+ "guid": "temporary",
+ "indexerId": 1,
+ "approved": True,
+ "temporarilyRejected": True,
+ },
+ {
+ "title": "Approved release",
+ "guid": "approved",
+ "indexerId": 1,
+ "approved": True,
+ "rejected": False,
+ "downloadAllowed": True,
+ },
+ ]
+ )
+
+ self.assertEqual([release["guid"] for release in releases], ["approved"])
+ self.assertTrue(releases[0]["bestPick"])
+
+ async def test_tv_manual_grab_is_sent_to_sonarr_not_qbittorrent(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Example Show",
+ request_type=RequestType.tv,
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ grab_release=AsyncMock(return_value={"guid": "season-one", "indexerId": 7}),
+ push_release=AsyncMock(),
+ )
+ payload = {
+ "title": "Example.Show.S01.1080p",
+ "guid": "season-one",
+ "indexerId": 7,
+ "protocol": "torrent",
+ }
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
+ requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
+ ), patch.object(requests_router, "SonarrClient", return_value=sonarr), patch.object(
+ requests_router, "save_action"
+ ):
+ result = await requests_router.action_grab(
+ "3909", self.selection(payload, "3909", self._runtime().sonarr_base_url), user={"username": "viewer", "role": "user"}
+ )
+
+ sonarr.grab_release.assert_awaited_once_with("season-one", 7)
+ sonarr.push_release.assert_not_awaited()
+ self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
+
+ async def test_stale_movie_release_requires_fresh_search(self) -> None:
+ snapshot = Snapshot(
+ request_id="4000",
+ title="Example Movie",
+ request_type=RequestType.movie,
+ )
+ response = httpx.Response(
+ 404,
+ request=httpx.Request("POST", "http://radarr.test/api/v3/release"),
+ json={"message": "release cache expired"},
+ )
+ cache_miss = httpx.HTTPStatusError(
+ "release cache expired",
+ request=response.request,
+ response=response,
+ )
+ radarr = SimpleNamespace(
+ configured=lambda: True,
+ grab_release=AsyncMock(side_effect=cache_miss),
+ push_release=AsyncMock(return_value=[{"approved": True, "downloadAllowed": True}]),
+ )
+ payload = {
+ "title": "Example.Movie.2026.1080p",
+ "guid": "stale-release",
+ "indexerId": 9,
+ "indexer": "Prowlarr",
+ "protocol": "torrent",
+ "publishDate": "2026-08-29T00:00:00Z",
+ "downloadUrl": "http://prowlarr.test/download/1",
+ }
+
+ with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
+ requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
+ ), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
+ requests_router, "save_action"
+ ):
+ with self.assertRaises(HTTPException) as error:
+ await requests_router.action_grab(
+ "4000", self.selection(payload, "4000", self._runtime().radarr_base_url), user={"username": "viewer", "role": "user"})
+ self.assertEqual(error.exception.status_code, 409)
+ radarr.push_release.assert_not_awaited()
+
+
+
+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 AdminUserEmailTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ async def test_admin_can_add_and_remove_user_email(self) -> None:
+ db.create_user_if_missing("Viewer", "password123", auth_provider="local")
+
+ saved = await admin_router.update_user_email("viewer", {"email": "viewer@example.com"})
+ self.assertEqual(saved["user"]["email"], "viewer@example.com")
+
+ cleared = await admin_router.update_user_email("VIEWER", {"email": None})
+ self.assertIsNone(cleared["user"]["email"])
+
+ async def test_admin_cannot_assign_duplicate_user_email(self) -> None:
+ db.create_user_if_missing(
+ "FirstViewer", "password123", email="shared@example.com", auth_provider="local"
+ )
+ db.create_user_if_missing("SecondViewer", "password123", auth_provider="local")
+
+ with self.assertRaises(HTTPException) as context:
+ await admin_router.update_user_email(
+ "SecondViewer", {"email": "SHARED@example.com"}
+ )
+
+ self.assertEqual(context.exception.status_code, 409)
+ self.assertIn("another user", str(context.exception.detail))
+
+ async def test_admin_user_email_requires_valid_address(self) -> None:
+ db.create_user_if_missing("Viewer", "password123", auth_provider="local")
+
+ with self.assertRaises(HTTPException) as context:
+ await admin_router.update_user_email("Viewer", {"email": "not-an-email"})
+
+ self.assertEqual(context.exception.status_code, 400)
+
+
+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_user_can_manage_own_profile_email(self) -> None:
+ db.create_user_if_missing("ProfileViewer", "password123", auth_provider="local")
+ current_user = {"username": "ProfileViewer", "role": "user"}
+
+ saved = await auth_router.update_profile_email(
+ {"email": "viewer@example.com"}, current_user
+ )
+ self.assertEqual(saved["email"], "viewer@example.com")
+ self.assertEqual(
+ db.get_user_by_username("profileviewer").get("email"),
+ "viewer@example.com",
+ )
+
+ cleared = await auth_router.update_profile_email({"email": None}, current_user)
+ self.assertIsNone(cleared["email"])
+ self.assertIsNone(db.get_user_by_username("ProfileViewer").get("email"))
+
+ async def test_user_cannot_claim_another_accounts_email(self) -> None:
+ db.create_user_if_missing(
+ "FirstViewer", "password123", email="shared@example.com", auth_provider="local"
+ )
+ db.create_user_if_missing("SecondViewer", "password123", auth_provider="local")
+
+ with self.assertRaises(HTTPException) as context:
+ await auth_router.update_profile_email(
+ {"email": "SHARED@example.com"},
+ {"username": "SecondViewer", "role": "user"},
+ )
+
+ self.assertEqual(context.exception.status_code, 409)
+
+ async def test_profile_email_requires_valid_address(self) -> None:
+ db.create_user_if_missing("ProfileViewer", "password123", auth_provider="local")
+
+ with self.assertRaises(HTTPException) as context:
+ await auth_router.update_profile_email(
+ {"email": "not-an-email"},
+ {"username": "ProfileViewer", "role": "user"},
+ )
+
+ self.assertEqual(context.exception.status_code, 400)
+
+ 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_manual_invite_does_not_require_recipient_email(self) -> None:
+ current_user = {
+ "username": "invite-owner",
+ "role": "user",
+ "invite_management_enabled": True,
+ "profile_id": None,
+ }
+ result = await auth_router.create_profile_invite(
+ {"label": "Family", "recipient_email": None, "send_email": False},
+ current_user,
+ )
+
+ self.assertEqual(result["status"], "ok")
+ self.assertIsNone(result["invite"]["recipient_email"])
+ self.assertIsNone(result["email"])
+
+ async def test_profile_email_delivery_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", "send_email": True},
+ 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 MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ access = patch.object(
+ requests_router,
+ "_ensure_request_mutation_access",
+ new=AsyncMock(return_value=None),
+ )
+ access.start()
+ self.addCleanup(access.stop)
+
+ def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
+ issue = {"id": 12, "status": "in_progress"}
+ with (
+ patch.object(requests_router, "update_portal_item") as update_issue,
+ patch.object(requests_router, "add_portal_item_activity"),
+ ):
+ requests_router._record_replacement_activity(
+ issue,
+ user={"username": "viewer", "role": "user"},
+ event_type="replacement_failed",
+ message="Radarr could not start the replacement.",
+ )
+
+ update_issue.assert_called_once_with(12, status="blocked", issue_resolved_at=None)
+
+ async def test_movie_replacement_validates_file_then_deletes_and_searches(self) -> None:
+ snapshot = Snapshot(
+ request_id="3914",
+ title="Replacement Movie",
+ request_type=RequestType.movie,
+ state=NormalizedState.available,
+ raw={
+ "arr": {
+ "item": {
+ "id": 44,
+ "movieFile": {
+ "id": 77,
+ "relativePath": "Replacement.Movie.1080p.mkv",
+ },
+ }
+ }
+ },
+ )
+ radarr = SimpleNamespace(
+ configured=lambda: True,
+ monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}),
+ delete_movie_file=AsyncMock(return_value=None),
+ search=AsyncMock(return_value={"id": 1}),
+ )
+ runtime = SimpleNamespace(
+ jellyseerr_base_url="http://seerr",
+ jellyseerr_api_key="secret",
+ radarr_base_url="http://radarr",
+ radarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "RadarrClient", return_value=radarr),
+ patch.object(requests_router, "save_action"),
+ patch.object(
+ requests_router,
+ "get_portal_item",
+ return_value={
+ "id": 12,
+ "kind": "issue",
+ "external_ref": "/requests/3914",
+ "created_by_username": "admin",
+ },
+ ),
+ patch.object(requests_router, "update_portal_item") as update_issue,
+ patch.object(requests_router, "add_portal_item_activity") as add_activity,
+ ):
+ result = await requests_router.action_replace_media(
+ "3914",
+ {"file_id": 77, "confirmed": True, "issue_id": 12},
+ {"username": "admin", "role": "admin", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["status"], "ok")
+ radarr.monitor_movie.assert_awaited_once_with(44, True)
+ radarr.delete_movie_file.assert_awaited_once_with(77)
+ radarr.search.assert_awaited_once_with(44)
+ add_activity.assert_called_once()
+ self.assertEqual(add_activity.call_args.kwargs["event_type"], "replacement_started")
+ self.assertIn('"repairTracking"', add_activity.call_args.kwargs["metadata_json"])
+ self.assertIn('"originalFileIds":[77]', add_activity.call_args.kwargs["metadata_json"])
+ update_issue.assert_called_once_with(12, status="in_progress", issue_resolved_at=None)
+
+ async def test_tv_replacement_options_return_only_safe_file_details(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Replacement Series",
+ request_type=RequestType.tv,
+ state=NormalizedState.available,
+ raw={"arr": {"item": {"id": 22}}},
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_episode_files=AsyncMock(return_value=[{
+ "id": 88,
+ "seasonNumber": 2,
+ "path": "/private/library/Replacement.Series.S02E03.mkv",
+ "size": 1024,
+ "quality": {"quality": {"name": "WEBDL-1080p"}},
+ }]),
+ get_episodes=AsyncMock(return_value=[{
+ "id": 101,
+ "episodeFileId": 88,
+ "seasonNumber": 2,
+ "episodeNumber": 3,
+ }]),
+ )
+ runtime = SimpleNamespace(
+ jellyseerr_base_url="http://seerr",
+ jellyseerr_api_key="secret",
+ sonarr_base_url="http://sonarr",
+ sonarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "SonarrClient", return_value=sonarr),
+ ):
+ result = await requests_router.replacement_options(
+ "3909",
+ {"username": "viewer", "role": "user", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["files"][0]["name"], "Replacement.Series.S02E03.mkv")
+ self.assertEqual(result["files"][0]["episodes"], ["S02E03"])
+ self.assertNotIn("/private/library", str(result))
+
+ async def test_issue_options_mark_released_missing_episodes_as_best_fit(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Target Series",
+ request_type=RequestType.tv,
+ state=NormalizedState.downloading,
+ raw={"arr": {"item": {"id": 22}}},
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_episodes=AsyncMock(return_value=[
+ {
+ "id": 101,
+ "seasonNumber": 1,
+ "episodeNumber": 1,
+ "title": "Collected",
+ "monitored": True,
+ "hasFile": True,
+ "episodeFileId": 88,
+ "airDateUtc": "2020-01-01T00:00:00Z",
+ },
+ {
+ "id": 102,
+ "seasonNumber": 1,
+ "episodeNumber": 2,
+ "title": "Missing",
+ "monitored": True,
+ "hasFile": False,
+ "episodeFileId": 0,
+ "airDateUtc": "2020-01-08T00:00:00Z",
+ },
+ {
+ "id": 103,
+ "seasonNumber": 1,
+ "episodeNumber": 3,
+ "title": "Missing and unmonitored",
+ "monitored": False,
+ "hasFile": False,
+ "episodeFileId": 0,
+ "airDateUtc": "2020-01-15T00:00:00Z",
+ },
+ ]),
+ )
+ runtime = SimpleNamespace(
+ jellyseerr_base_url=None,
+ jellyseerr_api_key=None,
+ sonarr_base_url="http://sonarr",
+ sonarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "SonarrClient", return_value=sonarr),
+ ):
+ result = await requests_router.issue_target_options(
+ "3909",
+ {"username": "viewer", "role": "user", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["seasons"][0]["missing_count"], 2)
+ self.assertTrue(result["seasons"][0]["best_fit"])
+ missing = next(item for item in result["episodes"] if item["id"] == 102)
+ self.assertTrue(missing["missing"])
+ self.assertTrue(missing["best_fit"])
+ unmonitored = next(item for item in result["episodes"] if item["id"] == 103)
+ self.assertFalse(unmonitored["monitored"])
+ self.assertTrue(unmonitored["missing"])
+ self.assertTrue(unmonitored["best_fit"])
+ collected = next(item for item in result["episodes"] if item["id"] == 101)
+ self.assertEqual(collected["file_id"], 88)
+ self.assertNotIn("file_name", collected)
+ self.assertNotIn("quality", collected)
+
+ async def test_tv_replacement_accepts_multiple_selected_episode_files(self) -> None:
+ snapshot = Snapshot(
+ request_id="3909",
+ title="Replacement Series",
+ request_type=RequestType.tv,
+ state=NormalizedState.available,
+ raw={"arr": {"item": {"id": 22}}},
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_episode_files=AsyncMock(return_value=[
+ {"id": 88, "relativePath": "S01E01.mkv"},
+ {"id": 89, "relativePath": "S01E02.mkv"},
+ ]),
+ get_episodes=AsyncMock(return_value=[
+ {"id": 101, "episodeFileId": 88, "seasonNumber": 1, "episodeNumber": 1},
+ {"id": 102, "episodeFileId": 89, "seasonNumber": 1, "episodeNumber": 2},
+ ]),
+ monitor_episodes=AsyncMock(return_value={"monitored": True}),
+ delete_episode_file=AsyncMock(return_value=None),
+ search_episodes=AsyncMock(return_value={"id": 1}),
+ )
+ runtime = SimpleNamespace(
+ jellyseerr_base_url=None,
+ jellyseerr_api_key=None,
+ sonarr_base_url="http://sonarr",
+ sonarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "SonarrClient", return_value=sonarr),
+ patch.object(requests_router, "save_action"),
+ patch.object(requests_router, "get_portal_item", return_value={
+ "id": 12,
+ "kind": "issue",
+ "external_ref": "/requests/3909",
+ "created_by_username": "viewer",
+ }),
+ patch.object(requests_router, "update_portal_item"),
+ patch.object(requests_router, "add_portal_item_activity"),
+ ):
+ result = await requests_router.action_replace_media(
+ "3909",
+ {"file_ids": [88, 89], "confirmed": True, "issue_id": 12},
+ {"username": "viewer", "role": "user", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["file_ids"], [88, 89])
+ sonarr.monitor_episodes.assert_awaited_once_with([101, 102], True)
+ self.assertEqual(sonarr.delete_episode_file.await_count, 2)
+ sonarr.search_episodes.assert_awaited_once_with([101, 102])
+
+ async def test_missing_episode_search_monitors_explicit_unmonitored_episode(self) -> None:
+ snapshot = Snapshot(
+ request_id="113",
+ title="Family Guy",
+ request_type=RequestType.tv,
+ state=NormalizedState.available,
+ raw={"arr": {"item": {"id": 540}}},
+ )
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_episodes=AsyncMock(return_value=[{
+ "id": 36899,
+ "seasonNumber": 5,
+ "episodeNumber": 9,
+ "monitored": False,
+ "hasFile": False,
+ "episodeFileId": 0,
+ "airDateUtc": "2006-12-17T00:00:00Z",
+ }]),
+ monitor_episodes=AsyncMock(return_value={"monitored": True}),
+ search_episodes=AsyncMock(return_value={"id": 9001}),
+ search=AsyncMock(return_value={"id": 9002}),
+ )
+ runtime = SimpleNamespace(
+ sonarr_base_url="http://sonarr",
+ sonarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "SonarrClient", return_value=sonarr),
+ patch.object(requests_router, "save_action"),
+ ):
+ result = await requests_router.action_search_missing_media(
+ "113",
+ {"episode_ids": [36899], "season_numbers": [5]},
+ {"username": "admin", "role": "admin", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["episode_ids"], [36899])
+ sonarr.monitor_episodes.assert_awaited_once_with([36899], True)
+ sonarr.search_episodes.assert_awaited_once_with([36899])
+ sonarr.search.assert_not_awaited()
+
+ async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None:
+ snapshot = Snapshot(
+ request_id="3580",
+ title="Suits",
+ request_type=RequestType.tv,
+ state=NormalizedState.available,
+ raw={"arr": {"item": {"id": 540}}},
+ )
+ refreshed = Snapshot(
+ request_id="3580",
+ title="Suits",
+ request_type=RequestType.tv,
+ state=NormalizedState.importing,
+ presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]},
+ )
+ original_series = {
+ "id": 540,
+ "monitored": True,
+ "qualityProfileId": 7,
+ "seasons": [
+ {"seasonNumber": 7, "monitored": True},
+ {"seasonNumber": 8, "monitored": False},
+ {"seasonNumber": 9, "monitored": False},
+ ],
+ }
+ updated_series = {
+ **original_series,
+ "seasons": [
+ {"seasonNumber": 7, "monitored": True},
+ {"seasonNumber": 8, "monitored": True},
+ {"seasonNumber": 9, "monitored": True},
+ ],
+ }
+ episodes = [
+ {
+ "id": 801,
+ "seasonNumber": 8,
+ "episodeNumber": 1,
+ "monitored": False,
+ "hasFile": False,
+ "airDateUtc": "2018-07-18T00:00:00Z",
+ },
+ {
+ "id": 802,
+ "seasonNumber": 8,
+ "episodeNumber": 2,
+ "monitored": False,
+ "hasFile": True,
+ "episodeFileId": 88,
+ },
+ {
+ "id": 901,
+ "seasonNumber": 9,
+ "episodeNumber": 1,
+ "monitored": False,
+ "hasFile": False,
+ "airDateUtc": "2019-07-17T00:00:00Z",
+ },
+ ]
+ verified_episodes = [{**episode, "monitored": True} for episode in episodes]
+ sonarr = SimpleNamespace(
+ configured=lambda: True,
+ get_series=AsyncMock(side_effect=[original_series, updated_series]),
+ update_series=AsyncMock(return_value=updated_series),
+ get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]),
+ monitor_episodes=AsyncMock(return_value={"monitored": True}),
+ search_episodes=AsyncMock(return_value={"id": 9001}),
+ )
+ runtime = SimpleNamespace(
+ jellyseerr_base_url=None,
+ jellyseerr_api_key=None,
+ sonarr_base_url="http://sonarr",
+ sonarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(
+ requests_router,
+ "build_snapshot",
+ new=AsyncMock(side_effect=[snapshot, refreshed]),
+ ),
+ patch.object(requests_router, "SonarrClient", return_value=sonarr),
+ patch.object(requests_router, "save_action"),
+ ):
+ result = await requests_router.action_add_seasons(
+ "3580",
+ {"season_numbers": [8, 9]},
+ {"username": "viewer", "role": "user", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["season_numbers"], [8, 9])
+ self.assertEqual(result["searched_episode_count"], 2)
+ self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"])
+ sonarr.update_series.assert_awaited_once_with(updated_series)
+ sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True)
+ sonarr.search_episodes.assert_awaited_once_with([801, 901])
+
+ async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
+ snapshot = Snapshot(
+ request_id="3914",
+ title="Missing Movie",
+ request_type=RequestType.movie,
+ state=NormalizedState.available,
+ raw={"arr": {"item": {"id": 44}}},
+ )
+ radarr = SimpleNamespace(
+ configured=lambda: True,
+ monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}),
+ search=AsyncMock(return_value={"id": 9003}),
+ )
+ runtime = SimpleNamespace(
+ radarr_base_url="http://radarr",
+ radarr_api_key="secret",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "RadarrClient", return_value=radarr),
+ patch.object(requests_router, "save_action"),
+ ):
+ result = await requests_router.action_search_missing_media(
+ "3914",
+ {"episode_ids": [], "season_numbers": []},
+ {"username": "admin", "role": "admin", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["status"], "ok")
+ radarr.monitor_movie.assert_awaited_once_with(44, True)
+ radarr.search.assert_awaited_once_with(44)
+
+ async def test_movie_subtitle_issue_starts_bazarr_search_without_replacement(self) -> None:
+ snapshot = Snapshot(
+ request_id="3914",
+ title="Subtitle Movie",
+ request_type=RequestType.movie,
+ state=NormalizedState.available,
+ raw={"arr": {"item": {"id": 44, "movieFile": {"id": 77}}}},
+ )
+ bazarr = SimpleNamespace(
+ configured=lambda: True,
+ search_movie_subtitles=AsyncMock(return_value={"status": True}),
+ )
+ runtime = SimpleNamespace(
+ bazarr_base_url="http://bazarr",
+ bazarr_api_key="secret",
+ bazarr_default_language="en",
+ )
+ with (
+ patch.object(requests_router, "get_runtime_settings", return_value=runtime),
+ patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
+ patch.object(requests_router, "BazarrClient", return_value=bazarr),
+ patch.object(
+ requests_router,
+ "_ensure_request_mutation_access",
+ new=AsyncMock(return_value=None),
+ ),
+ patch.object(requests_router, "save_action"),
+ patch.object(requests_router, "get_portal_item", return_value={
+ "id": 12,
+ "kind": "issue",
+ "external_ref": "/requests/3914",
+ "created_by_username": "viewer",
+ }),
+ patch.object(requests_router, "update_portal_item") as update_issue,
+ patch.object(requests_router, "add_portal_item_activity") as add_activity,
+ ):
+ result = await requests_router.action_repair_subtitles(
+ "3914",
+ {"issue_id": 12, "episode_ids": [], "forced": True},
+ {"username": "viewer", "role": "user", "auto_search_enabled": True},
+ )
+
+ self.assertEqual(result["status"], "ok")
+ bazarr.search_movie_subtitles.assert_awaited_once_with(44, language="en", forced=True)
+ self.assertEqual(add_activity.call_args.kwargs["event_type"], "subtitle_repair_started")
+ update_issue.assert_called_once_with(12, status="in_progress", issue_resolved_at=None)
+
+
+class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
+
+ async def test_media_status_is_live_and_removes_session_identity(self) -> None:
+ client = SimpleNamespace(
+ configured=lambda: True,
+ get_system_info=AsyncMock(
+ return_value={
+ "Version": "10.10.7",
+ "HasPendingRestart": False,
+ "WanAddress": "https://private.example",
+ }
+ ),
+ get_sessions=AsyncMock(
+ return_value=[
+ {
+ "UserName": "private-user",
+ "DeviceName": "Living room television",
+ "NowPlayingItem": {"Name": "Private title"},
+ "TranscodingInfo": {"VideoCodec": "h264"},
+ }
+ ]
+ ),
+ )
+ with (
+ patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
+ jellyfin_base_url="http://jellyfin",
+ jellyfin_api_key="secret",
+ )),
+ patch.object(portal_router, "JellyfinClient", return_value=client),
+ ):
+ result = await portal_router.portal_media_status()
+
+ self.assertEqual(result["status"], "up")
+ self.assertEqual(result["activity"]["active_streams"], 1)
+ self.assertEqual(result["activity"]["transcoding_streams"], 1)
+ serialized = str(result)
+ self.assertNotIn("private-user", serialized)
+ self.assertNotIn("Living room television", serialized)
+ self.assertNotIn("Private title", serialized)
+ self.assertNotIn("private.example", serialized)
+
+ async def test_media_status_reports_unavailable_without_exposing_exception(self) -> None:
+ client = SimpleNamespace(
+ configured=lambda: True,
+ get_system_info=AsyncMock(side_effect=RuntimeError("secret upstream failure")),
+ )
+ with (
+ patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
+ jellyfin_base_url="http://jellyfin",
+ jellyfin_api_key="secret",
+ )),
+ patch.object(portal_router, "JellyfinClient", return_value=client),
+ ):
+ result = await portal_router.portal_media_status()
+
+ self.assertEqual(result["status"], "down")
+ self.assertNotIn("secret upstream failure", str(result))
+
+
+class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ async def test_manual_invite_can_be_created_without_recipient_email(self) -> None:
+ payload = await admin_router.create_invite(
+ {
+ "label": "The neighbour",
+ "recipient_email": None,
+ "send_email": False,
+ "max_uses": 1,
+ },
+ {"username": "admin", "role": "admin"},
+ )
+
+ self.assertEqual(payload["status"], "ok")
+ self.assertEqual(payload["invite"]["label"], "The neighbour")
+ self.assertIsNone(payload["invite"]["recipient_email"])
+ self.assertTrue(payload["invite"]["enabled"])
+
+ async def test_email_delivery_still_requires_valid_recipient(self) -> None:
+ with self.assertRaises(HTTPException) as context:
+ await admin_router.create_invite(
+ {"label": "Family", "send_email": True},
+ {"username": "admin", "role": "admin"},
+ )
+
+ self.assertEqual(context.exception.status_code, 400)
+ self.assertIn("required for email delivery", str(context.exception.detail))
+
+ async def test_invite_list_reports_automatic_operational_states(self) -> None:
+ ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
+ disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
+ used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
+ db.increment_signup_invite_use(int(used["id"]))
+ expired = db.create_signup_invite(
+ code="EXPIRED",
+ expires_at="2000-01-01T00:00:00+00:00",
+ recipient_email="expired@example.com",
+ )
+ no_profile = db.create_signup_invite(
+ code="NO-PROFILE",
+ profile_id=999,
+ recipient_email="profile@example.com",
+ )
+
+ payload = await admin_router.get_invites()
+ states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]}
+
+ self.assertEqual(states[ready["id"]], "ready")
+ self.assertEqual(states[disabled["id"]], "disabled")
+ self.assertEqual(states[used["id"]], "exhausted")
+ self.assertEqual(states[expired["id"]], "expired")
+ self.assertEqual(states[no_profile["id"]], "profile_unavailable")
+ self.assertEqual(payload["summary"]["total"], 5)
+ self.assertEqual(payload["summary"]["ready"], 1)
+ self.assertEqual(payload["summary"]["attention"], 4)
+ self.assertEqual(payload["summary"]["used_signups"], 1)
+
+
+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_issue_status_maps_to_public_workflow_progress(self) -> None:
+ item = {
+ "kind": "issue",
+ "status": "blocked",
+ "issue_type": "playback",
+ "created_by_username": "tester",
+ }
+ serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
+ workflow = (serialized.get("issue") or {}).get("workflow") or {}
+
+ self.assertEqual(workflow.get("current_step"), 4)
+ self.assertEqual(workflow.get("stage"), "repair")
+ self.assertEqual(workflow.get("state"), "attention")
+ self.assertEqual(len(workflow.get("steps") or []), 6)
+ self.assertEqual((workflow.get("steps") or [])[3].get("state"), "attention")
+
+ def test_resolved_issue_completes_the_public_workflow(self) -> None:
+ workflow = portal_router._issue_workflow_payload("closed")
+
+ self.assertEqual(workflow.get("current_step"), 6)
+ self.assertEqual(workflow.get("state"), "complete")
+ self.assertTrue(all(step.get("state") == "complete" for step in workflow.get("steps") or []))
+
+ 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)
+
+
+class PortalIssueDeletionTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ def _create_issue(self) -> dict:
+ issue = db.create_portal_item(
+ kind="issue",
+ title="Delete this issue",
+ description="No longer required",
+ created_by_username="reporter",
+ created_by_id=None,
+ issue_type="playback",
+ )
+ db.add_portal_comment(
+ int(issue["id"]),
+ author_username="reporter",
+ author_role="user",
+ message="Issue detail",
+ )
+ db.add_portal_item_activity(
+ int(issue["id"]),
+ event_type="item_created",
+ actor_username="reporter",
+ actor_role="user",
+ message="Issue created",
+ )
+ return issue
+
+ async def test_only_admin_can_delete_an_issue(self) -> None:
+ issue = self._create_issue()
+
+ with self.assertRaises(HTTPException) as context:
+ await portal_router.portal_delete_item(
+ int(issue["id"]),
+ current_user={"username": "reporter", "role": "user"},
+ )
+
+ self.assertEqual(context.exception.status_code, 403)
+ self.assertIsNotNone(db.get_portal_item(int(issue["id"])))
+
+ async def test_delete_issue_removes_its_comments_and_activity(self) -> None:
+ issue = self._create_issue()
+ issue_id = int(issue["id"])
+
+ result = await portal_router.portal_delete_item(
+ issue_id,
+ current_user={"username": "admin", "role": "admin"},
+ )
+
+ self.assertEqual(result, {"status": "deleted", "item_id": issue_id})
+ self.assertIsNone(db.get_portal_item(issue_id))
+ self.assertEqual(db.list_portal_comments(issue_id), [])
+ self.assertEqual(db.list_portal_item_activity(issue_id), [])
+
+ async def test_delete_endpoint_will_not_delete_a_request(self) -> None:
+ request_item = db.create_portal_item(
+ kind="request",
+ title="Keep this request",
+ description="The media workflow must remain intact",
+ created_by_username="reporter",
+ created_by_id=None,
+ )
+
+ with self.assertRaises(HTTPException) as context:
+ await portal_router.portal_delete_item(
+ int(request_item["id"]),
+ current_user={"username": "admin", "role": "admin"},
+ )
+
+ self.assertEqual(context.exception.status_code, 400)
+ self.assertIsNotNone(db.get_portal_item(int(request_item["id"])))
+
+
+class IssueResolutionWorkflowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ def _create_issue(self, *, status: str = "in_progress") -> dict:
+ return db.create_portal_item(
+ kind="issue",
+ title="Missing content: Test movie",
+ description="The title is missing.",
+ created_by_username="reporter",
+ created_by_id=None,
+ status=status,
+ issue_type="missing_content",
+ )
+
+ async def test_marking_issue_fixed_records_contact_and_confirmation(self) -> None:
+ issue = self._create_issue()
+ reporter = {"username": "reporter", "email": "reporter@example.com"}
+ with (
+ patch.object(issue_resolution, "_workflow_settings", return_value=(2, 3, "days")),
+ patch.object(issue_resolution, "get_user_by_username", return_value=reporter),
+ patch.object(issue_resolution, "resolve_user_delivery_email", return_value="reporter@example.com"),
+ patch.object(issue_resolution, "send_generic_email", new=AsyncMock(return_value=None)) as send_email,
+ ):
+ waiting = await issue_resolution.begin_issue_confirmation(
+ int(issue["id"]),
+ actor_username="admin",
+ actor_role="admin",
+ )
+
+ self.assertEqual(waiting["status"], "awaiting_confirmation")
+ state = issue_resolution.issue_resolution_state(waiting)
+ self.assertEqual(state["attemptsSent"], 1)
+ self.assertEqual(state["maximumAttempts"], 2)
+ send_email.assert_awaited_once()
+
+ activity = db.list_portal_item_activity(int(issue["id"]))
+ self.assertEqual(
+ [event["event_type"] for event in activity],
+ ["resolution_proposed", "confirmation_email_sent"],
+ )
+
+ closed = issue_resolution.respond_to_issue_confirmation(
+ int(issue["id"]),
+ resolved=True,
+ actor_username="reporter",
+ actor_role="user",
+ )
+ self.assertEqual(closed["status"], "closed")
+ self.assertIsNotNone(closed["issue_resolved_at"])
+ self.assertEqual(db.list_portal_item_activity(int(issue["id"]))[-1]["event_type"], "resolution_confirmed")
+
+ async def test_replacement_waits_for_jellyfin_to_refresh_existing_movie(self) -> None:
+ tracking = {
+ "requestId": "144",
+ "actionId": "replace_media",
+ "mediaType": "movie",
+ "collectorId": 12,
+ "originalFileIds": [40],
+ "episodes": [],
+ "jellyfinFoundAtStart": True,
+ "jellyfinBaseline": [{"Id": "jf-1", "Etag": "old", "Path": "/movies/test.mkv"}],
+ }
+ unchanged = Snapshot(
+ request_id="144",
+ title="Test movie",
+ raw={
+ "arr": {"item": {"id": 12, "hasFile": True, "movieFile": {"id": 41}}},
+ "jellyfin": {
+ "found": True,
+ "item": {"Id": "jf-1", "Etag": "old", "Path": "/movies/test.mkv"},
+ },
+ },
+ )
+ refreshed = unchanged.model_copy(deep=True)
+ refreshed.raw["jellyfin"]["item"]["Etag"] = "new"
+
+ with patch.object(issue_resolution, "build_snapshot", new=AsyncMock(return_value=unchanged)):
+ waiting = await issue_resolution._media_repair_evidence(tracking)
+ with patch.object(issue_resolution, "build_snapshot", new=AsyncMock(return_value=refreshed)):
+ complete = await issue_resolution._media_repair_evidence(tracking)
+
+ self.assertFalse(waiting["complete"])
+ self.assertEqual(waiting["phase"], "indexing")
+ self.assertTrue(complete["complete"])
+
+ async def test_verified_media_repair_starts_confirmation_without_admin(self) -> None:
+ issue = self._create_issue()
+ db.add_portal_item_activity(
+ int(issue["id"]),
+ event_type="replacement_started",
+ actor_username="reporter",
+ actor_role="user",
+ message="Radarr started the replacement.",
+ metadata_json=(
+ '{"repairTracking":{"requestId":"144","actionId":"replace_media",'
+ '"mediaType":"movie","collectorId":12,"originalFileIds":[40],'
+ '"episodes":[],"jellyfinFoundAtStart":true,'
+ '"jellyfinBaseline":[{"Id":"jf-1","Etag":"old"}]}}'
+ ),
+ )
+
+ with (
+ patch.object(
+ issue_resolution,
+ "_media_repair_evidence",
+ new=AsyncMock(
+ return_value={
+ "complete": True,
+ "phase": "complete",
+ "message": "Radarr imported the repaired movie and Jellyfin indexed it.",
+ }
+ ),
+ ),
+ patch.object(
+ issue_resolution,
+ "begin_issue_confirmation",
+ new=AsyncMock(return_value={"status": "awaiting_confirmation"}),
+ ) as begin_confirmation,
+ ):
+ result = await issue_resolution.process_active_media_repairs()
+
+ self.assertEqual(result["completed"], 1)
+ begin_confirmation.assert_awaited_once_with(
+ int(issue["id"]),
+ actor_username="Magent",
+ actor_role="system",
+ )
+ self.assertEqual(
+ db.list_portal_item_activity(int(issue["id"]))[-1]["event_type"],
+ "repair_verified",
+ )
+
+ async def test_zero_confirmation_emails_closes_issue_immediately(self) -> None:
+ issue = self._create_issue()
+ with patch.object(issue_resolution, "_workflow_settings", return_value=(0, 1, "days")):
+ closed = await issue_resolution.begin_issue_confirmation(
+ int(issue["id"]),
+ actor_username="admin",
+ actor_role="admin",
+ )
+
+ self.assertEqual(closed["status"], "closed")
+ self.assertEqual(
+ [event["event_type"] for event in db.list_portal_item_activity(int(issue["id"]))],
+ ["resolution_proposed", "issue_auto_closed"],
+ )
+
+ async def test_saving_waiting_issue_does_not_restart_confirmation_cycle(self) -> None:
+ issue = self._create_issue(status="awaiting_confirmation")
+ user = {"username": "admin", "role": "admin"}
+ with patch.object(portal_router, "begin_issue_confirmation", new=AsyncMock()) as begin_confirmation:
+ result = await portal_router.portal_update_item(
+ int(issue["id"]),
+ {"title": "Updated title", "status": "awaiting_confirmation"},
+ user,
+ )
+
+ self.assertEqual(result["item"]["title"], "Updated title")
+ begin_confirmation.assert_not_awaited()
+
+ def test_public_activity_hides_internal_notes_and_admin_identity(self) -> None:
+ issue = self._create_issue()
+ db.add_portal_item_activity(
+ int(issue["id"]),
+ event_type="internal_note_added",
+ actor_username="private-admin-name",
+ actor_role="admin",
+ message="Internal diagnostic detail",
+ )
+ db.add_portal_item_activity(
+ int(issue["id"]),
+ event_type="status_changed",
+ actor_username="private-admin-name",
+ actor_role="admin",
+ message="Status changed to in progress.",
+ metadata_json='{"private":true}',
+ )
+
+ public_activity = portal_router._activity_payload(issue)
+
+ self.assertNotIn("Internal diagnostic detail", str(public_activity))
+ self.assertNotIn("private-admin-name", str(public_activity))
+ self.assertNotIn("metadata_json", public_activity[-1])
+ self.assertEqual(public_activity[-1]["actor_username"], "Support team")
diff --git a/backend/tests/test_backups.py b/backend/tests/test_backups.py
new file mode 100644
index 0000000..fa252db
--- /dev/null
+++ b/backend/tests/test_backups.py
@@ -0,0 +1,336 @@
+from contextlib import closing
+import io
+import json
+from pathlib import Path
+import sqlite3
+import tempfile
+import unittest
+from unittest.mock import patch
+import zipfile
+
+from cryptography.fernet import Fernet
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from backend.app import db
+from backend.app.auth import get_current_user
+from backend.app.config import settings
+from backend.app.routers import backups as backup_router
+from backend.app.services import backups
+
+
+PASSPHRASE = "test backup passphrase with spaces"
+
+
+class BackupTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.database = self.root / "magent.db"
+ for key, value in {
+ "sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
+ "settings_encryption_key": Fernet.generate_key().decode(),
+ "jwt_secret": "source-installation-signing-secret-for-backup-tests",
+ "admin_username": "backup-admin", "admin_password": "a secure initial password",
+ "jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
+ "discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
+ }.items():
+ context = patch.object(settings, key, value)
+ context.start()
+ self.addCleanup(context.stop)
+ context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
+ context.start()
+ self.addCleanup(context.stop)
+ db.init_db()
+ db.set_setting("sonarr_api_key", "database-integration-secret")
+ db.set_setting("site_login_message", "Restored configuration")
+ db.set_setting("installation_setup", "complete")
+ with closing(sqlite3.connect(self.database)) as conn, conn:
+ conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
+ conn.execute(
+ "INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
+ )
+ self.assets = self.root / "assets"
+ (self.assets / "branding").mkdir(parents=True)
+ (self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
+ (self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
+ (self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
+
+ def export(self, include_cache=True):
+ content, filename = backups.create_backup(PASSPHRASE, include_cache)
+ self.assertTrue(filename.endswith(".magent-backup"))
+ return content
+
+ def rewrite_archive(self, content, change):
+ decrypted = backups._decrypt(content, PASSPHRASE)
+ with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
+ files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
+ change(files)
+ output = io.BytesIO()
+ with zipfile.ZipFile(output, "w") as archive:
+ for name, value in files.items():
+ archive.writestr(name, value)
+ return backups._encrypt(output.getvalue(), PASSPHRASE)
+
+ def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
+ content = self.export()
+ self.assertNotIn(b"database-integration-secret", content)
+ self.assertNotIn(b"environment-integration-secret", content)
+ original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
+ db.set_setting("site_login_message", "Live data before restart")
+ settings.settings_encryption_key = Fernet.generate_key().decode()
+ settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
+ # Simulate a different host with different env-backed integration settings.
+ settings.jellyfin_api_key = "destination-env-value"
+ metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ self.assertTrue(metadata["include_cache"])
+ self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
+ self.assertIsNotNone(backups.backup_status()["pending_restore"])
+ staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
+ self.assertNotIn(b"database-integration-secret", staged_bytes)
+ self.assertNotIn(b"environment-integration-secret", staged_bytes)
+ self.assertNotIn(b"legacy-private-token", staged_bytes)
+ (self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
+ (self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
+ self.assertTrue(backups.apply_pending_restore())
+ self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
+ self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
+ self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
+ self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
+ self.assertEqual(db.get_setting("installation_setup"), "complete")
+ self.assertIsNone(db.get_setting("setup_token"))
+ self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
+ self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
+ self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
+ with closing(sqlite3.connect(self.database)) as conn, conn:
+ self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
+ self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
+ self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
+ status = backups.backup_status()
+ self.assertIsNone(status["pending_restore"])
+ self.assertEqual(status["last_restore"]["status"], "restored")
+ self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
+ self.assertFalse(backups.apply_pending_restore())
+
+ def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
+ with closing(sqlite3.connect(self.database)) as writer:
+ writer.execute("PRAGMA journal_mode=WAL")
+ writer.execute("PRAGMA wal_autocheckpoint=0")
+ writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
+ writer.commit()
+ self.assertTrue(Path(str(self.database) + "-wal").exists())
+ content = self.export()
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ self.assertTrue(backups.apply_pending_restore())
+ with closing(sqlite3.connect(self.database)) as restored:
+ self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
+
+ def test_managed_restore_preserves_destination_application_origin(self):
+ db.set_setting("magent_application_url", "https://source.example.test")
+ content = self.export()
+ db.set_setting("magent_application_url", "https://destination.example.test")
+ with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}):
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ self.assertTrue(backups.apply_pending_restore())
+ self.assertEqual(db.get_setting("magent_application_url"), "https://destination.example.test")
+
+ def test_manual_restore_retains_legacy_application_url_behavior(self):
+ db.set_setting("magent_application_url", "https://source.example.test")
+ content = self.export()
+ db.set_setting("magent_application_url", "https://destination.example.test")
+ with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": ""}):
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ self.assertTrue(backups.apply_pending_restore())
+ self.assertEqual(db.get_setting("magent_application_url"), "https://source.example.test")
+
+ def test_managed_restore_without_destination_origin_does_not_stage(self):
+ content = self.export()
+ with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}), \
+ patch("backend.app.services.public_urls.magent_public_url", return_value=""):
+ with self.assertRaisesRegex(backups.BackupError, "destination application address"):
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ self.assertIsNone(backups.backup_status()["pending_restore"])
+
+ def test_process_interruption_is_recovered_on_next_startup(self):
+ class ProcessStopped(BaseException):
+ pass
+
+ content = self.export()
+ db.set_setting("site_login_message", "Value before interrupted restart")
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
+ with self.assertRaises(ProcessStopped):
+ backups.apply_pending_restore()
+ self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
+ self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
+ self.assertFalse(backups.apply_pending_restore())
+ self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
+ self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
+ self.assertIsNone(backups.backup_status()["pending_restore"])
+
+ def test_crash_after_rollback_does_not_reapply_pending_restore(self):
+ class ProcessStopped(BaseException):
+ pass
+
+ content = self.export()
+ db.set_setting("site_login_message", "Value to retain")
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ replace_assets = backups._replace_assets
+ remove_tree = backups.shutil.rmtree
+ calls = 0
+
+ def fail_first_copy(source, target):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ raise OSError("failed apply")
+ return replace_assets(source, target)
+
+ def interrupt_cleanup(path, *args, **kwargs):
+ if Path(path).name == "pending":
+ raise ProcessStopped()
+ return remove_tree(path, *args, **kwargs)
+
+ with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
+ patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
+ with self.assertRaises(ProcessStopped):
+ backups.apply_pending_restore()
+ journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
+ self.assertEqual(journal["phase"], "rolled_back")
+ self.assertFalse(backups.apply_pending_restore())
+ self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
+ self.assertIsNone(backups.backup_status()["pending_restore"])
+
+ def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
+ directory = self.root / "schema-test"
+ directory.mkdir()
+ backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
+ source = directory / "database.sqlite3"
+ with closing(sqlite3.connect(source)) as conn, conn:
+ conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
+ with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
+ backups._validate_database(source)
+
+ def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
+ content = self.export()
+ db.set_setting("site_login_message", "Current data")
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ settings.settings_encryption_key = Fernet.generate_key().decode()
+ with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
+ backups.apply_pending_restore()
+ self.assertEqual(db.get_setting("site_login_message"), "Current data")
+ self.assertIsNotNone(backups.backup_status()["pending_restore"])
+
+ def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
+ with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
+ self.assertIn("database.sqlite3", archive.namelist())
+ self.assertIn("files/branding/logo.png", archive.namelist())
+ self.assertFalse(any("artwork" in name for name in archive.namelist()))
+
+ def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
+ content = self.export()
+ for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
+ with self.subTest(password=password):
+ with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
+ backups.stage_restore(io.BytesIO(bad_content), password)
+ self.assertIsNone(backups.backup_status()["pending_restore"])
+ self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
+
+ def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
+ content = self.export()
+ for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
+ with self.subTest(name=name):
+ malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
+ with self.assertRaises(backups.BackupError):
+ backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
+ malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
+ with self.assertRaises(backups.BackupError):
+ backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
+ self.assertFalse((self.root / "outside.txt").exists())
+
+ def test_size_limit_and_unsupported_schema_rejected(self):
+ content = self.export()
+ with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
+ with self.assertRaisesRegex(backups.BackupError, "upload limit"):
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
+ with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ with closing(sqlite3.connect(self.database)) as conn, conn:
+ conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
+ # Validate the original fixture to avoid executing the malicious trigger in export.
+ with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
+ backups._validate_database(self.database)
+
+ def test_unsupported_compression_is_rejected_before_expansion(self):
+ content = self.export()
+ rewritten = io.BytesIO()
+ with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
+ with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
+ for entry in original.infolist():
+ target.writestr(entry.filename, original.read(entry))
+ with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
+ backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
+
+ def test_cancel_is_idempotent_and_does_not_change_database(self):
+ content = self.export()
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ with self.assertRaisesRegex(backups.BackupError, "already staged"):
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ backups.cancel_restore()
+ backups.cancel_restore()
+ self.assertIsNone(backups.backup_status()["pending_restore"])
+ self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
+
+ def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
+ content = self.export()
+ db.set_setting("site_login_message", "Keep this current value")
+ (self.assets / "branding" / "logo.png").write_bytes(b"current logo")
+ backups.stage_restore(io.BytesIO(content), PASSPHRASE)
+ original = backups._replace_assets
+ calls = 0
+
+ def fail_once(source, target):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ raise OSError("simulated interrupted copy")
+ return original(source, target)
+
+ with patch.object(backups, "_replace_assets", side_effect=fail_once):
+ with self.assertRaisesRegex(OSError, "interrupted copy"):
+ backups.apply_pending_restore()
+ self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
+ self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
+ self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
+ self.assertFalse(backups.apply_pending_restore())
+
+ def test_api_requires_admin_and_restore_confirmation(self):
+ app = FastAPI()
+ app.include_router(backup_router.router)
+ with TestClient(app) as client:
+ self.assertEqual(client.get("/admin/backups").status_code, 401)
+ app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
+ self.assertEqual(client.get("/admin/backups").status_code, 403)
+ self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
+ app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
+ status = client.get("/admin/backups")
+ self.assertEqual(status.status_code, 200)
+ self.assertEqual(status.headers["cache-control"], "no-store")
+ self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
+ response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["cache-control"], "no-store")
+ rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
+ data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
+ self.assertEqual(rejected.status_code, 422)
+ restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
+ data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
+ self.assertEqual(restored.status_code, 202)
+ self.assertTrue(restored.json()["restart_required"])
+ self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_collector_search.py b/backend/tests/test_collector_search.py
new file mode 100644
index 0000000..92df2a7
--- /dev/null
+++ b/backend/tests/test_collector_search.py
@@ -0,0 +1,158 @@
+from contextlib import ExitStack
+from types import SimpleNamespace
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from backend.app.config import settings
+from backend.app.models import NormalizedState, RequestType, Snapshot
+from backend.app.services import snapshot as snapshot_service
+from backend.app.services.collector_search import read_search_status, search_status
+
+
+def command(name="MoviesSearch", status="started", **body):
+ return {"name": name, "status": status, "body": body}
+
+
+class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
+ def test_movie_search_is_scoped_to_the_movie(self):
+ self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
+ self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
+
+ def test_queued_search_and_running_search_priority(self):
+ queued = command(status="queued", movieIds=[12])
+ self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
+ self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
+
+ def test_terminal_commands_are_not_searching(self):
+ for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
+ with self.subTest(state=state):
+ self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
+ ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
+ self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
+
+ def test_numeric_statuses(self):
+ for state, expected in [(0, "queued"), (1, "searching")]:
+ self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
+
+ def test_series_and_season_searches(self):
+ for name in ["SeriesSearch", "SeasonSearch"]:
+ with self.subTest(name=name):
+ self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
+ self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
+
+ def test_episode_search_uses_episode_ids_not_numbers(self):
+ episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
+ for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
+ self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
+ self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
+
+ def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
+ for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
+ with self.subTest(name=name):
+ self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
+
+ def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
+ self.assertEqual(search_status([], RequestType.movie, 12), "idle")
+ for payload in [None, {}, {"error": "unavailable"}]:
+ self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
+
+ async def test_check_is_read_only_with_a_short_timeout(self):
+ client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
+ self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
+ client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
+
+ async def test_service_failure_is_unknown_not_idle(self):
+ client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
+ self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
+
+
+class LibrarySearchPresentationTests(unittest.TestCase):
+ def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
+ arr_state="added", download_state="not_started", jellyfin=False):
+ snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
+ state=NormalizedState.added_to_arr)
+ return snapshot_service._build_presentation(
+ snapshot, approved=True, arr_state=arr_state,
+ arr_details={"search": {"state": search}, "availability": {
+ "available": available, "missing": missing, "total": available + missing,
+ }}, prowlarr_state="ok",
+ download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
+ jellyfin_found=jellyfin, jellyfin_link=None,
+ )
+
+ def stage(self, presentation, stage_id="library"):
+ return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
+
+ def test_card_uses_actual_search_state(self):
+ for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
+ ("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
+ with self.subTest(state=state):
+ presentation = self.presentation(state)
+ library = self.stage(presentation)
+ self.assertEqual(library["stateLabel"], badge)
+ self.assertEqual(library["state"], style)
+ self.assertEqual(library["searchStatus"], state)
+ self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
+ self.assertEqual(library["available"], 0)
+ self.assertEqual(library["missing"], 1)
+
+ def test_partial_tv_retains_counts_and_search_activity(self):
+ for state in ["idle", "searching", "queued", "unavailable"]:
+ with self.subTest(state=state):
+ presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
+ library = self.stage(presentation)
+ self.assertEqual(library["state"], "partial")
+ self.assertEqual(library["searchStatus"], state)
+ self.assertIn("22 of 24 episodes collected", library["summary"])
+ self.assertNotIn("is still looking", presentation["status"]["meaning"])
+
+ def test_collected_titles_dont_look_stuck_searching(self):
+ for jellyfin in [True, False]:
+ library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
+ self.assertEqual(library["state"], "complete")
+ self.assertIn("no search needed", library["summary"])
+
+ def test_download_has_its_own_state_without_claiming_searching(self):
+ library = self.stage(self.presentation("idle", download_state="downloading"))
+ self.assertEqual(library["stateLabel"], "Downloading")
+ self.assertIn("Not currently searching", library["summary"])
+
+ def test_an_old_missing_download_does_not_mark_search_complete(self):
+ search = self.stage(self.presentation("idle", download_state="missing"), "search")
+ self.assertEqual(search["state"], "waiting")
+
+
+class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
+ async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
+ for media_type in [RequestType.movie, RequestType.tv]:
+ for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
+ with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
+ runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
+ item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
+ collector = SimpleNamespace(
+ get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
+ get_series_by_tvdb_id=AsyncMock(return_value=[item]),
+ get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
+ get_queue=AsyncMock(return_value={"records": []}),
+ get=AsyncMock(return_value=commands),
+ )
+ mocks = {
+ "get_runtime_settings": runtime,
+ "get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
+ "media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
+ "get_request_cache_by_id": None,
+ "JellyseerrClient": SimpleNamespace(configured=lambda: False),
+ "JellyfinClient": SimpleNamespace(configured=lambda: False),
+ "QBittorrentClient": SimpleNamespace(configured=lambda: False),
+ "SonarrClient": collector, "RadarrClient": collector,
+ "ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
+ "get_request_download_evidence": {}, "get_request_repairs": [], "_latest_repair_action": None, "save_snapshot": None,
+ }
+ for name, value in mocks.items():
+ stack.enter_context(patch.object(snapshot_service, name, return_value=value))
+ stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
+ snapshot = await snapshot_service.build_snapshot("12")
+ collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
+ self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
+ library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
+ self.assertEqual(library["searchStatus"], expected)
diff --git a/backend/tests/test_container_bootstrap.py b/backend/tests/test_container_bootstrap.py
new file mode 100644
index 0000000..22b5f77
--- /dev/null
+++ b/backend/tests/test_container_bootstrap.py
@@ -0,0 +1,519 @@
+"""Managed installation regression tests; use only disposable local files."""
+
+import base64
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import closing, redirect_stderr, redirect_stdout
+import io
+import json
+import os
+from pathlib import Path
+import sqlite3
+import stat
+import tempfile
+from threading import Barrier
+import unittest
+from unittest.mock import patch
+
+from backend.app import container_bootstrap as bootstrap
+
+
+class ContainerBootstrapTests(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.data = self.root / "data"
+ self.data.mkdir(mode=0o700)
+ self.state_path = self.data / bootstrap.STATE_FILENAME
+ self.database = self.data / "magent.db"
+ self.environment = {
+ "MAGENT_MANAGED_SECRETS": "true",
+ "MAGENT_APPLICATION_URL": "https://magent.example.test",
+ }
+
+ def prepare(self, **changes):
+ return bootstrap.prepare_environment({**self.environment, **changes}, self.data)
+
+ def state(self):
+ return json.loads(self.state_path.read_text(encoding="utf-8"))
+
+ def create_database(self, *, completed=0, admin=False):
+ with closing(sqlite3.connect(self.database)) as connection:
+ with connection:
+ connection.execute("CREATE TABLE installation_setup (id INTEGER PRIMARY KEY, completed INTEGER)")
+ connection.execute("INSERT INTO installation_setup VALUES (1, ?)", (completed,))
+ connection.execute("CREATE TABLE users (role TEXT)")
+ if admin:
+ connection.execute("INSERT INTO users VALUES ('ADMIN')")
+
+ def create_symlink(self, path, target, *, directory=False):
+ try:
+ path.symlink_to(target, target_is_directory=directory)
+ except (OSError, NotImplementedError) as exc:
+ self.skipTest(f"This platform cannot create test symlinks: {type(exc).__name__}")
+
+ def test_fresh_install_generates_independent_valid_random_secrets(self):
+ before = dict(self.environment)
+ prepared = self.prepare()
+ state = self.state()
+ self.assertEqual(self.environment, before)
+ self.assertEqual(set(state), {"version", *bootstrap.SECRET_NAMES})
+ self.assertEqual(state["version"], 1)
+ for name in ("JWT_SECRET", "SETUP_TOKEN"):
+ self.assertRegex(state[name], r"^[A-Za-z0-9_-]{64}$")
+ self.assertNotEqual(state["JWT_SECRET"], state["SETUP_TOKEN"])
+ self.assertEqual(len(base64.urlsafe_b64decode(state["SETTINGS_ENCRYPTION_KEY"])), 32)
+ for name in bootstrap.SECRET_NAMES:
+ self.assertEqual(prepared[name], state[name])
+ self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
+ self.assertFalse(self.database.exists())
+ self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
+
+ @unittest.skipUnless(os.name == "posix", "POSIX filesystem ownership/permissions")
+ def test_state_has_private_permissions_and_runtime_ownership(self):
+ self.prepare()
+ metadata = self.state_path.stat()
+ self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600)
+ self.assertEqual(metadata.st_uid, os.geteuid())
+
+ def test_separate_installations_get_different_secrets(self):
+ first = self.prepare()
+ other = self.root / "other"
+ other.mkdir(mode=0o700)
+ second = bootstrap.prepare_environment(self.environment, other)
+ for name in bootstrap.SECRET_NAMES:
+ self.assertNotEqual(first[name], second[name])
+
+ def test_restart_and_existing_database_reuse_exact_file_and_values(self):
+ first = self.prepare()
+ original = self.state_path.read_bytes()
+ original_modified = self.state_path.stat().st_mtime_ns
+ self.create_database(admin=True)
+ with patch.object(bootstrap.secrets, "token_bytes", side_effect=AssertionError("Must not regenerate")), \
+ patch.object(bootstrap.secrets, "token_urlsafe", side_effect=AssertionError("Must not regenerate")):
+ second = self.prepare()
+ self.assertEqual(first, second)
+ self.assertEqual(self.state_path.read_bytes(), original)
+ self.assertEqual(self.state_path.stat().st_mtime_ns, original_modified)
+
+ def test_disabled_mode_is_an_unchanged_copy_without_filesystem_access(self):
+ for value in (None, "false", "0", "no", "", " FALSE "):
+ with self.subTest(mode=value):
+ environment = {"JWT_SECRET": "legacy-key", "MAGENT_APPLICATION_URL": "invalid"}
+ if value is not None:
+ environment["MAGENT_MANAGED_SECRETS"] = value
+ result = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
+ self.assertEqual(result, environment)
+ self.assertIsNot(result, environment)
+ self.assertFalse(self.state_path.exists())
+
+ def test_invalid_managed_mode_fails_before_writing(self):
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare(MAGENT_MANAGED_SECRETS="perhaps")
+ self.assertFalse(self.state_path.exists())
+
+ def test_auto_mode_generates_fresh_install_keys_without_explicit_jwt(self):
+ prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
+ self.assertTrue(self.state_path.exists())
+ self.assertEqual(prepared["MAGENT_MANAGED_SECRETS"], "true")
+ self.assertEqual(prepared["MAGENT_RUNTIME_MANAGED"], "1")
+ for name in bootstrap.SECRET_NAMES:
+ self.assertEqual(prepared[name], self.state()[name])
+
+ def test_auto_mode_preserves_explicit_jwt_manual_install_without_filesystem_access(self):
+ environment = {
+ "MAGENT_MANAGED_SECRETS": "auto",
+ "JWT_SECRET": "legacy-explicit-signing-key",
+ "SQLITE_PATH": "/existing/custom-database.db",
+ "API_DOCS_ENABLED": "true",
+ "MAGENT_APPLICATION_URL": "https://legacy.example.test",
+ "CORS_ALLOW_ORIGIN": "https://legacy.example.test",
+ }
+ prepared = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
+ self.assertEqual(prepared, environment)
+ self.assertIsNot(prepared, environment)
+ self.assertNotIn("SETTINGS_ENCRYPTION_KEY", prepared)
+ self.assertNotIn("MAGENT_RUNTIME_MANAGED", prepared)
+ self.assertFalse(self.state_path.exists())
+
+ def test_auto_mode_whitespace_jwt_is_treated_as_unset(self):
+ prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto", "JWT_SECRET": " "}, self.data)
+ self.assertEqual(prepared["JWT_SECRET"], self.state()["JWT_SECRET"])
+
+ def test_absent_application_url_uses_fixed_defaults_without_claiming_an_origin(self):
+ prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
+ self.assertFalse(prepared.get("MAGENT_APPLICATION_URL"))
+ self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
+ self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
+ self.assertEqual(prepared["API_DOCS_ENABLED"], "false")
+ self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
+
+ def test_empty_application_url_is_deferred_to_setup(self):
+ prepared = self.prepare(MAGENT_APPLICATION_URL="")
+ self.assertEqual(prepared["MAGENT_APPLICATION_URL"], "")
+ self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
+ self.assertTrue(self.state_path.exists())
+
+ def test_managed_api_docs_cannot_be_enabled(self):
+ for value in ("true", "1", "yes", "on", "invalid"):
+ with self.subTest(value=value), self.assertRaisesRegex(bootstrap.BootstrapError, "API_DOCS_ENABLED"):
+ self.prepare(API_DOCS_ENABLED=value)
+ self.assertFalse(self.state_path.exists())
+
+ def test_saved_public_url_controls_restart_without_key_regeneration(self):
+ original = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
+ state_bytes = self.state_path.read_bytes()
+ self.create_database(admin=True)
+ with closing(sqlite3.connect(self.database)) as connection:
+ with connection:
+ connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
+ connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://saved.example.test')")
+ restarted = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
+ self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "https://saved.example.test")
+ self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "https://saved.example.test")
+ self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "true")
+ self.assertEqual(self.state_path.read_bytes(), state_bytes)
+ for name in bootstrap.SECRET_NAMES:
+ self.assertEqual(restarted[name], original[name])
+
+ def test_saved_public_url_wins_over_stale_deployment_url_on_restart(self):
+ self.prepare()
+ self.create_database(admin=True)
+ with closing(sqlite3.connect(self.database)) as connection:
+ with connection:
+ connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
+ connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'http://magent.lan:3000')")
+ restarted = self.prepare(CORS_ALLOW_ORIGIN="https://magent.example.test")
+ self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "http://magent.lan:3000")
+ self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "http://magent.lan:3000")
+ self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "false")
+
+ def test_invalid_saved_url_fails_closed_without_changing_keys(self):
+ self.prepare()
+ original = self.state_path.read_bytes()
+ self.create_database(admin=True)
+ with closing(sqlite3.connect(self.database)) as connection:
+ with connection:
+ connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
+ connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://user:secret@evil.test')")
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertEqual(self.state_path.read_bytes(), original)
+
+ def test_existing_database_or_recovery_sidecar_never_generates_replacement_keys(self):
+ for suffix in ("", "-wal", "-shm", "-journal"):
+ with self.subTest(suffix=suffix):
+ path = Path(str(self.database) + suffix)
+ path.write_bytes(b"existing installation data")
+ try:
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertEqual(path.read_bytes(), b"existing installation data")
+ self.assertFalse(self.state_path.exists())
+ finally:
+ path.unlink()
+
+ def test_lost_keys_after_initialization_are_not_recreated(self):
+ self.prepare()
+ self.create_database()
+ self.state_path.unlink()
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertFalse(self.state_path.exists())
+
+ def test_fresh_manual_secrets_conflict_without_writing_state(self):
+ for name in bootstrap.SECRET_NAMES:
+ with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError):
+ self.prepare(**{name: "synthetic-manual-secret"})
+ self.assertFalse(self.state_path.exists())
+
+ def test_matching_environment_values_are_accepted_but_conflicts_never_replace_file(self):
+ first = self.prepare()
+ original = self.state_path.read_bytes()
+ keys = {name: first[name] for name in bootstrap.SECRET_NAMES}
+ self.assertEqual(self.prepare(**keys), first)
+ for name in bootstrap.SECRET_NAMES:
+ with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError) as raised:
+ self.prepare(**{name: "conflicting-private-value"})
+ self.assertNotIn("conflicting-private-value", str(raised.exception))
+ self.assertEqual(self.state_path.read_bytes(), original)
+
+ def test_custom_database_location_is_rejected_without_touching_it(self):
+ custom = self.root / "other.db"
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare(SQLITE_PATH=str(custom))
+ self.assertFalse(custom.exists())
+ self.assertFalse(self.state_path.exists())
+
+ def test_missing_or_symlink_data_directory_is_rejected(self):
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.prepare_environment(self.environment, self.root / "missing")
+ linked = self.root / "linked-data"
+ self.create_symlink(linked, self.data, directory=True)
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.prepare_environment(self.environment, linked)
+ self.assertFalse(self.state_path.exists())
+
+ @unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
+ def test_shared_writable_data_directory_is_rejected(self):
+ self.data.chmod(0o777)
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertFalse(self.state_path.exists())
+
+ def test_malformed_json_oversized_and_invalid_schema_never_get_replaced(self):
+ self.prepare()
+ valid = self.state()
+ invalid_states = [
+ b"not-json", b"\xff", b"x" * (bootstrap.MAX_STATE_BYTES + 1), b"[]", b"{}",
+ json.dumps({**valid, "version": True}).encode(),
+ json.dumps({**valid, "version": 2}).encode(),
+ json.dumps({**valid, "unexpected": "value"}).encode(),
+ json.dumps({**valid, "JWT_SECRET": None}).encode(),
+ json.dumps({**valid, "JWT_SECRET": "a" * 64}).encode(),
+ json.dumps({**valid, "JWT_SECRET": "short"}).encode(),
+ json.dumps({**valid, "SETUP_TOKEN": valid["JWT_SECRET"]}).encode(),
+ json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": "invalid-key"}).encode(),
+ json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(b"short").decode()}).encode(),
+ ]
+ for index, payload in enumerate(invalid_states):
+ with self.subTest(case=index):
+ self.state_path.write_bytes(payload)
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertEqual(self.state_path.read_bytes(), payload)
+
+ def test_state_directory_is_not_replaced(self):
+ self.state_path.mkdir()
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertTrue(self.state_path.is_dir())
+
+ def test_state_symlink_is_not_followed_or_replaced(self):
+ self.prepare()
+ target = self.root / "original-secrets.json"
+ self.state_path.rename(target)
+ original = target.read_bytes()
+ self.create_symlink(self.state_path, target)
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertEqual(target.read_bytes(), original)
+ self.assertTrue(self.state_path.is_symlink())
+
+ @unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
+ def test_publicly_readable_secrets_are_rejected_without_fixing_or_overwriting_them(self):
+ self.prepare()
+ original = self.state_path.read_bytes()
+ self.state_path.chmod(0o644)
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o644)
+ self.assertEqual(self.state_path.read_bytes(), original)
+
+ @unittest.skipUnless(hasattr(os, "mkfifo"), "POSIX named pipes")
+ def test_named_pipe_state_is_rejected_without_blocking(self):
+ os.mkfifo(self.state_path, 0o600)
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ self.assertTrue(stat.S_ISFIFO(self.state_path.stat().st_mode))
+
+ def test_https_sets_matching_cors_and_secure_cookies(self):
+ prepared = self.prepare()
+ self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], self.environment["MAGENT_APPLICATION_URL"])
+ self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "true")
+
+ def test_explicit_http_lan_origin_disables_secure_cookie_flag_only(self):
+ prepared = self.prepare(MAGENT_APPLICATION_URL="http://192.0.2.10:3000")
+ self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://192.0.2.10:3000")
+ self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
+
+ def test_invalid_origin_fails_without_creating_keys(self):
+ origins = (
+ "not-a-url", "https://magent.example.test/", "https://magent.example.test/path",
+ "//magent.example.test", "ftp://magent.example.test", "http:/magent.example.test",
+ "https://user:password@magent.example.test", "https://@magent.example.test",
+ "https://magent.example.test?", "https://magent.example.test#",
+ "https://magent.example.test:0", "https://magent.example.test:65536",
+ "https://*.example.test", "https://magent.\ttest", "https://magent.example.test\\path",
+ " https://magent.example.test", "https://magent.example.test\x00",
+ )
+ for origin in origins:
+ with self.subTest(origin=repr(origin)), self.assertRaises(bootstrap.BootstrapError):
+ self.prepare(MAGENT_APPLICATION_URL=origin)
+ self.assertFalse(self.state_path.exists())
+
+ def test_cors_mismatch_or_cookie_scheme_conflict_fails_without_keys(self):
+ cases = (
+ {"CORS_ALLOW_ORIGIN": "https://elsewhere.example.test"},
+ {"AUTH_COOKIE_SECURE": "false"},
+ {"AUTH_COOKIE_SECURE": "0"},
+ {"AUTH_COOKIE_SECURE": "maybe"},
+ {"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "true"},
+ {"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "1"},
+ )
+ for changes in cases:
+ with self.subTest(changes=changes), self.assertRaises(bootstrap.BootstrapError):
+ self.prepare(**changes)
+ self.assertFalse(self.state_path.exists())
+
+ def test_racing_initializers_publish_and_return_one_complete_state(self):
+ barrier = Barrier(8)
+
+ def initialize(_):
+ barrier.wait(timeout=10)
+ return self.prepare()
+
+ with ThreadPoolExecutor(max_workers=8) as executor:
+ results = list(executor.map(initialize, range(8)))
+ for result in results:
+ self.assertEqual(result, results[0])
+ state = self.state()
+ for name in bootstrap.SECRET_NAMES:
+ self.assertEqual(state[name], results[0][name])
+ self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
+
+ def test_token_command_requires_managed_mode_and_does_not_create_state(self):
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token({}, self.data)
+ self.assertFalse(self.state_path.exists())
+ with self.assertRaises((bootstrap.BootstrapError, FileNotFoundError)):
+ bootstrap.setup_token(self.environment, self.data)
+ self.assertFalse(self.state_path.exists())
+ self.assertFalse(self.database.exists())
+
+ def test_token_command_does_not_create_an_uninitialized_database(self):
+ self.prepare()
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+ self.assertFalse(self.database.exists())
+
+ def test_token_command_returns_only_initial_token_using_readonly_closed_connection(self):
+ prepared = self.prepare()
+ self.create_database()
+ before = {path.name: path.read_bytes() for path in self.data.iterdir()}
+ connections = []
+ real_connect = sqlite3.connect
+
+ def connect(*args, **kwargs):
+ self.assertTrue(kwargs.get("uri"))
+ self.assertTrue(args[0].endswith("?mode=ro"))
+ connection = real_connect(*args, **kwargs)
+ with self.assertRaises(sqlite3.OperationalError):
+ connection.execute("INSERT INTO users VALUES ('admin')")
+ connections.append(connection)
+ return connection
+
+ with patch.object(bootstrap.sqlite3, "connect", side_effect=connect):
+ token = bootstrap.setup_token(self.environment, self.data)
+ self.assertEqual(token, prepared["SETUP_TOKEN"])
+ self.assertEqual({path.name: path.read_bytes() for path in self.data.iterdir()}, before)
+ for connection in connections:
+ with self.assertRaises(sqlite3.ProgrammingError):
+ connection.execute("SELECT 1")
+
+ def test_token_command_refuses_once_any_admin_exists_even_before_setup_completion(self):
+ self.prepare()
+ self.create_database(admin=True)
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+
+ def test_token_command_refuses_completed_setup_even_without_admin(self):
+ self.prepare()
+ self.create_database(completed=1)
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+
+ def test_token_command_refuses_unknown_or_invalid_database_state(self):
+ self.prepare()
+ for payload in (b"", b"not a SQLite database"):
+ with self.subTest(payload=payload):
+ self.database.write_bytes(payload)
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+ self.assertEqual(self.database.read_bytes(), payload)
+ self.database.unlink()
+ self.create_database()
+ with closing(sqlite3.connect(self.database)) as connection:
+ with connection:
+ connection.execute("DELETE FROM installation_setup")
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+
+ def test_existing_database_symlink_is_rejected_even_with_valid_state(self):
+ self.prepare()
+ self.create_database()
+ target = self.root / "other.db"
+ self.database.rename(target)
+ original = target.read_bytes()
+ self.create_symlink(self.database, target)
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+ self.assertEqual(target.read_bytes(), original)
+
+ def test_existing_database_directory_is_rejected_even_with_valid_state(self):
+ self.prepare()
+ self.database.mkdir()
+ with self.assertRaises(bootstrap.BootstrapError):
+ self.prepare()
+ with self.assertRaises(bootstrap.BootstrapError):
+ bootstrap.setup_token(self.environment, self.data)
+ self.assertTrue(self.database.is_dir())
+
+ def test_startup_passes_keys_to_runtime_without_printing_them(self):
+ prepared = self.prepare()
+ stdout, stderr = io.StringIO(), io.StringIO()
+ with patch.dict(os.environ, self.environment, clear=True), \
+ patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord", "-c", "config"]), \
+ patch.object(bootstrap, "prepare_environment", return_value=prepared), \
+ patch.object(bootstrap.os, "execvpe") as execute, \
+ redirect_stdout(stdout), redirect_stderr(stderr):
+ self.assertEqual(bootstrap.main(), 0)
+ execute.assert_called_once_with("supervisord", ["supervisord", "-c", "config"], prepared)
+ self.assertIn("setup-token", stdout.getvalue())
+ self.assertEqual(stderr.getvalue(), "")
+ for name in bootstrap.SECRET_NAMES:
+ self.assertNotIn(prepared[name], stdout.getvalue() + stderr.getvalue())
+
+ def test_disabled_startup_does_not_print_managed_install_instructions(self):
+ stdout, stderr = io.StringIO(), io.StringIO()
+ environment = {"JWT_SECRET": "manual-test-value"}
+ with patch.dict(os.environ, environment, clear=True), \
+ patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
+ patch.object(bootstrap.os, "execvpe") as execute, \
+ redirect_stdout(stdout), redirect_stderr(stderr):
+ self.assertEqual(bootstrap.main(), 0)
+ execute.assert_called_once_with("supervisord", ["supervisord"], environment)
+ self.assertEqual(stdout.getvalue() + stderr.getvalue(), "")
+
+ def test_cli_explicit_token_command_prints_only_token_not_other_keys(self):
+ prepared = self.prepare()
+ self.create_database()
+ retrieve = bootstrap.setup_token
+ stdout, stderr = io.StringIO(), io.StringIO()
+ with patch.dict(os.environ, self.environment, clear=True), \
+ patch.object(bootstrap.sys, "argv", ["bootstrap", "setup-token"]), \
+ patch.object(bootstrap, "setup_token", side_effect=lambda env: retrieve(env, self.data)), \
+ patch.object(bootstrap.os, "execvpe") as execute, \
+ redirect_stdout(stdout), redirect_stderr(stderr):
+ self.assertEqual(bootstrap.main(), 0)
+ execute.assert_not_called()
+ self.assertEqual(stdout.getvalue(), prepared["SETUP_TOKEN"] + "\n")
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertNotIn(prepared["JWT_SECRET"], stdout.getvalue())
+ self.assertNotIn(prepared["SETTINGS_ENCRYPTION_KEY"], stdout.getvalue())
+
+ def test_cli_unexpected_io_failure_never_logs_sensitive_exception_details(self):
+ stdout, stderr = io.StringIO(), io.StringIO()
+ with patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
+ patch.object(bootstrap, "prepare_environment", side_effect=OSError("private-secret-material")), \
+ redirect_stdout(stdout), redirect_stderr(stderr):
+ self.assertEqual(bootstrap.main(), 1)
+ self.assertEqual(stdout.getvalue(), "")
+ self.assertNotIn("private-secret-material", stderr.getvalue())
+ self.assertIn("Check volume permissions", stderr.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_container_packaging.py b/backend/tests/test_container_packaging.py
new file mode 100644
index 0000000..985bda7
--- /dev/null
+++ b/backend/tests/test_container_packaging.py
@@ -0,0 +1,122 @@
+"""Unit checks for the release smoke harness; no Docker or network required."""
+
+from email.message import Message
+from email.parser import BytesParser
+from email.policy import default
+import importlib.util
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+
+HELPER_PATH = Path(__file__).resolve().parents[2] / "scripts" / "container_smoke.py"
+SPEC = importlib.util.spec_from_file_location("magent_container_smoke", HELPER_PATH)
+smoke = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(smoke)
+
+
+def response_headers(**changes):
+ headers = Message()
+ for key, value in {
+ "Content-Type": "text/html; charset=utf-8",
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-test-nonce' 'strict-dynamic'",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ **changes,
+ }.items():
+ headers[key] = value
+ return headers
+
+
+class ContainerPackagingHarnessTests(unittest.TestCase):
+ def test_backup_multipart_preserves_binary_content_and_required_fields(self):
+ content = b"MAGENT-BACKUP\x00\x01\xff\r\n\x00encrypted"
+ body, content_type = smoke.backup_restore_upload(content, "synthetic backup passphrase")
+ parsed = BytesParser(policy=default).parsebytes(
+ f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + body,
+ )
+ fields = {part.get_param("name", header="content-disposition"): part
+ for part in parsed.iter_parts()}
+ self.assertEqual(set(fields), {"passphrase", "confirmation", "file"})
+ self.assertEqual(fields["passphrase"].get_payload(decode=True), b"synthetic backup passphrase")
+ self.assertEqual(fields["confirmation"].get_payload(decode=True), b"RESTORE")
+ self.assertEqual(fields["file"].get_payload(decode=True), content)
+ self.assertEqual(fields["file"].get_filename(), "smoke.magent-backup")
+
+ def test_http_rejects_conflicting_body_encodings_without_network(self):
+ with patch.object(smoke.request, "urlopen") as urlopen:
+ with self.assertRaisesRegex(AssertionError, "only one encoding"):
+ smoke.http("/test", payload={}, raw=b"binary")
+ urlopen.assert_not_called()
+
+ def page(self, *, nonce="test-nonce", source="/_next/static/app.js", extra=""):
+ return (
+ f''
+ f''
+ ' '
+ f"{extra}"
+ ).encode()
+
+ def test_static_assets_and_every_bootstrap_script_are_validated(self):
+ seen = []
+
+ def fake_http(path):
+ seen.append(path)
+ if path == "/login":
+ return self.page(), response_headers()
+ return b"static content", response_headers(**{"Content-Type": "application/javascript"})
+
+ with patch.object(smoke, "http", side_effect=fake_http):
+ assets = set()
+ self.assertEqual(smoke.check_page("/login", assets), "test-nonce")
+ self.assertEqual(assets, {"/_next/static/app.js", "/_next/static/app.css"})
+ self.assertEqual(seen, ["/login", "/_next/static/app.css", "/_next/static/app.js"])
+ smoke.check_page("/login", assets)
+ self.assertEqual(seen[-1], "/login")
+ self.assertEqual(len(seen), 4)
+
+ def test_nonce_mismatch_fails_before_fetching_assets(self):
+ with patch.object(smoke, "http", return_value=(self.page(nonce="wrong"), response_headers())):
+ with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
+ smoke.check_page("/login", set())
+
+ def test_missing_nonce_policy_is_rejected(self):
+ headers = response_headers(**{"Content-Security-Policy": "script-src 'self'"})
+ with patch.object(smoke, "http", return_value=(self.page(), headers)):
+ with self.assertRaisesRegex(AssertionError, "missing script nonce policy"):
+ smoke.check_page("/login", set())
+
+ def test_development_eval_policy_is_rejected(self):
+ headers = response_headers(**{
+ "Content-Security-Policy": "script-src 'nonce-test-nonce' 'strict-dynamic' 'unsafe-eval'",
+ })
+ with patch.object(smoke, "http", return_value=(self.page(), headers)):
+ with self.assertRaisesRegex(AssertionError, "development eval"):
+ smoke.check_page("/login", set())
+
+ def test_html_fallback_for_static_asset_is_rejected(self):
+ with patch.object(smoke, "http", return_value=(self.page(), response_headers())):
+ with self.assertRaisesRegex(AssertionError, "Asset returned HTML"):
+ smoke.check_page("/login", set())
+
+ def test_missing_executable_script_nonce_is_rejected(self):
+ page = self.page(extra='')
+ with patch.object(smoke, "http", return_value=(page, response_headers())):
+ with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
+ smoke.check_page("/login", set())
+
+ def test_inert_json_scripts_do_not_require_executable_nonce(self):
+ page = self.page(extra='')
+ with patch.object(smoke, "http", return_value=(page, response_headers())):
+ cache = {"/_next/static/app.js", "/_next/static/app.css"}
+ self.assertEqual(smoke.check_page("/login", cache), "test-nonce")
+
+ def test_external_scripts_are_not_followed_by_smoke_harness(self):
+ page = self.page(extra='')
+ with patch.object(smoke, "http", return_value=(page, response_headers())):
+ with self.assertRaisesRegex(AssertionError, "Unexpected external executable asset"):
+ smoke.check_page("/login", {"/_next/static/app.js", "/_next/static/app.css"})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_duplicate_accounts.py b/backend/tests/test_duplicate_accounts.py
new file mode 100644
index 0000000..b71abb9
--- /dev/null
+++ b/backend/tests/test_duplicate_accounts.py
@@ -0,0 +1,182 @@
+import json
+import sqlite3
+import unittest
+from unittest.mock import AsyncMock, patch
+from types import SimpleNamespace
+
+from fastapi import FastAPI, HTTPException
+from fastapi.testclient import TestClient
+from backend.app import db
+from backend.app.auth import get_current_user
+from backend.app.feature_access import permissions, update_permissions
+from backend.app.routers import identities
+from backend.app.services import duplicate_accounts as duplicates, identity_review as review
+from backend.app.services.jellyfin_identity import link_user
+from backend.tests.test_backend_quality import TempDatabaseMixin
+
+JF, SERVER = 'a' * 32, 'b' * 32
+
+
+class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ super().setUp()
+ db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
+ self.keep = db.get_user_by_username('Viewer')['id']
+ with db._connect() as conn:
+ self.extra = conn.execute("""INSERT INTO users(username,password_hash,role,auth_provider,
+ jellyseerr_user_id,created_at) VALUES('viewer ','old-hash','user','jellyfin',42,'2026-01-01')""").lastrowid
+ self.runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test',
+ jellyseerr_base_url='http://seerr', jellyseerr_api_key='test', jellystat_base_url='http://stats', jellystat_api_key='test')
+ link_user('Viewer', JF, 'http://jf')
+ self.jf = {'state': 'available', 'server_id': SERVER, 'users': [{'id': JF, 'name': 'Viewer'}]}
+ self.seerr = {'state': 'available', 'users': [{'id': 42, 'name': 'Viewer', 'jellyfin_id': JF}]}
+ for name, value in [('get_runtime_settings', self.runtime), ('jellyfin_directory', self.jf), ('seerr_directory', self.seerr)]:
+ mocked = patch.object(review, name, return_value=value)
+ mocked.start(); self.addCleanup(mocked.stop)
+ mocked = patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock,
+ return_value={JF: {'state': 'matched', 'id': JF}})
+ mocked.start(); self.addCleanup(mocked.stop)
+
+ async def test_consolidation_preserves_history_and_restrictive_access(self):
+ with db._connect() as conn:
+ conn.execute('UPDATE users SET auto_search_enabled=0,expires_at=? WHERE id=?', ('2026-01-01T00:00:00+00:00', self.extra))
+ conn.execute('INSERT INTO user_feature_permissions VALUES(?,?,?)', (self.extra, 'issues', 0))
+ db.upsert_user_activity('Viewer', '127.0.0.1', 'test')
+ db.upsert_user_activity('viewer ', '127.0.0.1', 'test')
+ item = db.create_portal_item(kind='issue', title='Issue', description='History', created_by_username='viewer ', created_by_id=42)
+ before = review.read_snapshot()
+ preview = await duplicates.repair_duplicates(self.extra)
+ self.assertEqual(review.read_snapshot(), before, 'Preview must not mutate accounts')
+ self.assertTrue(preview['can_confirm'], preview['issues'])
+ self.assertEqual(preview['keep_id'], self.keep)
+ self.assertNotIn('old-hash', json.dumps(preview))
+ result = await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
+ self.assertEqual(result['consolidated'], 1)
+ self.assertIsNone(db.get_user_by_id(self.extra))
+ user = db.get_user_by_username('Viewer')
+ self.assertEqual(user['id'], self.keep)
+ self.assertFalse(user['auto_search_enabled'])
+ self.assertFalse(permissions(user)['issues'])
+ self.assertTrue(user['is_expired'])
+ self.assertEqual(db.get_portal_item(item['id'])['created_by_username'], 'Viewer')
+ self.assertEqual(db.get_portal_item(item['id'])['created_by_id'], 42, 'IDs here belong to Seerr')
+ with db._connect() as conn:
+ self.assertEqual(conn.execute('SELECT SUM(hit_count) FROM user_activity').fetchone()[0], 2)
+ archive = json.loads(conn.execute('SELECT archive_json FROM user_duplicate_repairs').fetchone()[0])
+ self.assertEqual(len(archive['users']), 2)
+ self.assertEqual(conn.execute('SELECT local_user_id FROM jellyfin_user_links').fetchone()[0], self.keep)
+ report, _, _ = await review.review_identities()
+ self.assertEqual(next(row for row in report['rows'] if row['user']['id'] == self.keep)['state'], 'confirmed')
+ self.assertFalse(db.create_user_if_missing('VIEWER ', 'unused', auth_provider='jellyfin'))
+
+ async def test_choose_other_row_retains_its_settings_and_moves_link(self):
+ with db._connect() as conn:
+ conn.execute('UPDATE users SET email=? WHERE id=?', ('chosen@example.test', self.extra))
+ preview = await duplicates.repair_duplicates(self.keep, self.extra)
+ self.assertEqual(preview['proposed']['email'], 'chosen@example.test')
+ await duplicates.repair_duplicates(self.keep, self.extra, preview['revision'], {'username': 'admin'})
+ self.assertEqual(db.get_user_by_username('Viewer')['id'], self.extra)
+ self.assertEqual(db.get_user_by_id(self.extra)['username'], 'Viewer')
+
+ async def test_changed_permission_or_identity_rejects_stale_preview(self):
+ preview, report, local, runtime, state = await duplicates.prepare(self.keep)
+ update_permissions({'stats': False}, 'Viewer')
+ with self.assertRaises(HTTPException) as caught:
+ duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
+ self.assertEqual(caught.exception.status_code, 409)
+ self.assertIsNotNone(db.get_user_by_id(self.extra))
+ self.seerr['users'][0]['jellyfin_id'] = 'c' * 32
+ with self.assertRaises(HTTPException):
+ await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
+
+ async def test_conflicting_identities_admins_and_other_owners_are_blocked(self):
+ with db._connect() as conn:
+ conn.execute("UPDATE users SET role='admin' WHERE id=?", (self.extra,))
+ self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
+ with db._connect() as conn:
+ conn.execute("UPDATE users SET role='user',jellyseerr_user_id=99 WHERE id=?", (self.extra,))
+ self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
+ with db._connect() as conn:
+ conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
+ db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
+ self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
+ self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
+
+ async def test_transaction_rolls_back_archive_and_history_on_failure(self):
+ preview, report, local, runtime, state = await duplicates.prepare(self.keep)
+ with db._connect() as conn:
+ conn.execute("CREATE TRIGGER prevent_test_delete BEFORE DELETE ON users BEGIN SELECT RAISE(ABORT,'fixture failure'); END")
+ with self.assertRaises(sqlite3.IntegrityError):
+ duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
+ self.assertIsNotNone(db.get_user_by_id(self.extra))
+ with db._connect() as conn:
+ self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_duplicate_repairs').fetchone()[0], 0)
+
+ async def test_creation_rejects_case_and_whitespace_variants(self):
+ for name in ('viewer', 'VIEWER', ' Viewer '):
+ self.assertFalse(db.create_user_if_missing(name, 'unused'))
+ with self.assertRaises(sqlite3.IntegrityError):
+ db.create_user(name, 'unused')
+
+ async def test_unresolved_whitespace_accounts_keep_distinct_lookup(self):
+ self.assertEqual(db.get_user_by_username('Viewer')['id'], self.keep)
+ self.assertEqual(db.get_user_by_username('viewer ')['id'], self.extra)
+ self.assertIsNone(db.get_user_by_username(' Viewer '), 'Do not guess between unresolved identities')
+
+ async def test_concurrent_imports_create_only_one_normalized_account(self):
+ from concurrent.futures import ThreadPoolExecutor
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results = list(pool.map(lambda name: db.create_user_if_missing(name, 'Password-123456!'), ['New viewer', 'NEW VIEWER ']))
+ self.assertEqual(sorted(results), [False, True])
+
+ def seed_delivery(self, state='queued'):
+ with db._connect() as conn:
+ for prefix in ('email_recap', 'newsletter'):
+ for identity in (self.keep, self.extra):
+ conn.execute(f'''INSERT INTO {prefix}_subscriptions(user_id,state,email,identity_source,identity_id,
+ version,requested_at,unsubscribe_token) VALUES(?,?,?,?,?,?,?,?)''',
+ (identity, 'enabled', 'viewer@example.test', review.source_key('http://jf'), JF, str(identity), 1, prefix + str(identity)))
+ period = {'month': '2026-08'} if prefix == 'email_recap' else {'edition_id': 'edition', 'edition_revision': 1}
+ values = {'id': prefix, 'dedupe_key': prefix, 'user_id': self.extra, **period, 'kind': 'test',
+ 'email': 'viewer@example.test', 'subscription_version': str(self.extra), 'public_url': 'https://example.test',
+ 'state': state, 'created_at': 1, 'updated_at': 1, 'next_attempt_at': 1}
+ conn.execute(f"INSERT INTO {prefix}_deliveries({','.join(values)}) VALUES({','.join('?' for _ in values)})", tuple(values.values()))
+
+ async def test_email_history_retained_pending_cancelled_and_consent_not_inherited(self):
+ self.seed_delivery()
+ preview = await duplicates.repair_duplicates(self.extra)
+ await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
+ with db._connect() as conn:
+ for prefix in ('email_recap', 'newsletter'):
+ delivery = conn.execute(f'SELECT user_id,state FROM {prefix}_deliveries').fetchone()
+ self.assertEqual(delivery, (self.keep, 'cancelled'))
+ subs = conn.execute(f'SELECT user_id,state FROM {prefix}_subscriptions').fetchall()
+ self.assertEqual(subs, [(self.keep, 'enabled')])
+
+ async def test_sending_email_blocks_repair_without_removing_accounts(self):
+ self.seed_delivery('sending')
+ preview = await duplicates.repair_duplicates(self.extra)
+ with self.assertRaises(HTTPException) as caught:
+ await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
+ self.assertEqual(caught.exception.status_code, 409)
+ self.assertIsNotNone(db.get_user_by_id(self.extra))
+
+ async def test_duplicate_endpoints_are_admin_only(self):
+ app = FastAPI(); app.include_router(identities.router)
+ app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
+ with TestClient(app) as client:
+ for path in ('check', 'confirm'):
+ self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
+
+
+ async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
+ with db._connect() as conn:
+ conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
+ db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
+ preview = await duplicates.repair_duplicates(self.keep)
+ self.assertTrue(preview['can_confirm'], preview['issues'])
+ await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
+ self.assertIsNone(db.get_user_by_id(self.extra))
+ with db._connect() as conn:
+ self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
+ self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
diff --git a/backend/tests/test_email_recaps.py b/backend/tests/test_email_recaps.py
new file mode 100644
index 0000000..6e8b8d0
--- /dev/null
+++ b/backend/tests/test_email_recaps.py
@@ -0,0 +1,575 @@
+import asyncio
+import json
+import re
+import smtplib
+import socketserver
+import threading
+import time
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timedelta, timezone
+from email import policy
+from email.parser import BytesParser
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+from urllib.parse import parse_qs, urlsplit
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from backend.app import db
+from backend.app.auth import get_current_user
+from backend.app.clients.jellystat import HistoryLimitError, JellystatError
+from backend.app.routers import recaps as router
+from backend.app.services import email_recaps as recaps, recap_email as mail, recap_store as store
+from backend.app.services.jellyfin_identity import link_user, source_key
+from backend.app.services.monthly_reports import change, month_periods, shift_month
+from backend.tests.test_backend_quality import TempDatabaseMixin
+
+
+def fixture_report():
+ periods = month_periods(None, datetime.now(timezone.utc))
+ summary = dict(minutes=1500, movies=8, episodes=24, plays=35, active_days=20, longest_streak=6)
+ changes = {key: change(value, round(value / 2)) for key, value in summary.items()}
+ changes['requests'] = change(3, 2)
+ return {**periods, 'state': 'ready', 'summary': summary, 'changes': changes, 'requests': {'total': 3},
+ 'top_titles': [{'title': 'Severance', 'type': 'series', 'minutes': 460, 'plays': 10},
+ {'title': 'Arrival', 'type': 'movie', 'minutes': 116, 'plays': 1}],
+ 'recent': [{'artwork_url': '/insights/artwork/SECRET?token=PRIVATE-TOKEN'}]}
+
+
+def runtime():
+ return SimpleNamespace(jellyfin_base_url='http://jellyfin', jellystat_base_url='http://jellystat',
+ jellystat_api_key='PRIVATE-STATS-KEY', magent_notify_enabled=True, magent_notify_email_enabled=True,
+ magent_notify_email_smtp_host='127.0.0.1', magent_notify_email_smtp_port=1,
+ magent_notify_email_smtp_username='', magent_notify_email_smtp_password='',
+ magent_notify_email_from_address='magent@example.test', magent_notify_email_from_name='Magent',
+ magent_notify_email_use_tls=False, magent_notify_email_use_ssl=False)
+
+
+class RecapFixture(TempDatabaseMixin):
+ def setUp(self):
+ super().setUp()
+ db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
+ link_user('viewer', 'jf-viewer', 'http://jellyfin')
+ self.user = db.get_user_by_username('viewer')
+ self.runtime = runtime()
+ for target, name, value in [(recaps, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime),
+ (recaps, 'smtp_email_config_ready', (True, 'ok'))]:
+ mocked = patch.object(target, name, return_value=value)
+ mocked.start(); self.addCleanup(mocked.stop)
+ env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
+ env.start(); self.addCleanup(env.stop)
+ self.config = dict(enabled=False, day=2, hour=9, public_url='https://beta.example.test')
+ store.save_settings(self.config, datetime.now(timezone.utc))
+ self.report = fixture_report()
+
+ def subscribe(self, timestamp=None):
+ now = time.time() if timestamp is None else timestamp
+ token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', now)
+ sub = store.subscription(self.user['id'])
+ self.assertTrue(store.confirm(sub, now + 1))
+ return store.subscription(self.user['id']), token
+
+ def queue(self, sub=None, request_id='request-1'):
+ if sub is None:
+ sub, _ = self.subscribe()
+ return store.enqueue_test(sub, self.report['month'], request_id, self.config['public_url'], time.time())
+
+ def delivery(self, delivery_id):
+ return store.read_one('SELECT * FROM email_recap_deliveries WHERE id=?', (delivery_id,))
+
+
+class RecapConsentTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
+ async def test_opt_in_only_emails_confirmation_and_check_link_does_not_confirm(self):
+ with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
+ result = await recaps.subscribe(self.user)
+ self.assertEqual(result['state'], 'pending')
+ report.assert_not_called()
+ recipient, rendered, _ = sender.call_args.args
+ self.assertEqual(recipient, 'viewer@example.test')
+ self.assertNotIn('Severance', rendered['body_html'])
+ url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
+ token = parse_qs(urlsplit(url).fragment)['token'][0]
+ self.assertNotIn(token, store.subscription(self.user['id'])['confirmation_hash'])
+ self.assertEqual(recaps.token_action(token, 'confirm')['state'], 'ready')
+ self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
+ self.assertEqual(recaps.token_action(token, 'confirm', apply=True)['state'], 'enabled')
+ with self.assertRaises(recaps.RecapError):
+ recaps.token_action(token, 'confirm', apply=True)
+ with self.assertRaises(recaps.RecapError):
+ recaps.token_action(token, 'unsubscribe', apply=True)
+
+ async def test_confirmation_failure_is_pending_and_resend_is_rate_limited(self):
+ with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'unknown')):
+ with self.assertRaises(recaps.RecapError) as exc:
+ await recaps.subscribe(self.user)
+ self.assertEqual(exc.exception.status, 502)
+ self.assertEqual(recaps.preferences(self.user)['state'], 'pending')
+ with patch.object(mail, 'send_email') as sender:
+ with self.assertRaises(recaps.RecapError) as exc:
+ await recaps.subscribe(self.user)
+ self.assertEqual(exc.exception.status, 429)
+ sender.assert_not_called()
+
+ def test_unsubscribe_is_public_idempotent_and_cancels_queued_email(self):
+ sub, _ = self.subscribe()
+ delivery_id = self.queue(sub)
+ token = sub['unsubscribe_token']
+ self.assertEqual(recaps.token_action(token, 'unsubscribe')['state'], 'ready')
+ self.assertEqual(self.delivery(delivery_id)['state'], 'queued')
+ recaps.token_action(token, 'unsubscribe', apply=True)
+ self.assertEqual(recaps.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
+ self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
+
+ def test_expired_confirmation_does_not_subscribe(self):
+ token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time() - 90000)
+ self.assertEqual(recaps.preferences(self.user)['state'], 'expired')
+ with self.assertRaises(recaps.RecapError):
+ recaps.token_action(token, 'confirm', apply=True)
+
+ def test_email_change_back_does_not_restore_consent(self):
+ self.subscribe()
+ db.set_user_email('viewer', 'changed@example.test')
+ db.set_user_email('viewer', 'viewer@example.test')
+ self.assertEqual(recaps.preferences(self.user)['state'], 'off')
+
+ def test_changed_link_or_source_requires_new_consent(self):
+ self.subscribe()
+ with store.transaction() as conn:
+ conn.execute("UPDATE jellyfin_user_links SET jellyfin_user_id='new-identity' WHERE local_user_id=?", (self.user['id'],))
+ self.assertEqual(recaps.preferences(self.user)['state'], 'off')
+ with store.transaction() as conn:
+ conn.execute("UPDATE email_recap_subscriptions SET state='enabled'")
+ self.runtime.jellyfin_base_url = 'http://other-jellyfin'
+ self.assertEqual(recaps.preferences(self.user)['state'], 'off')
+
+ def test_missing_email_or_stored_identity_cannot_subscribe(self):
+ db.set_user_email('viewer', None)
+ self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
+ db.set_user_email('viewer', 'viewer@example.test')
+ with store.transaction() as conn:
+ conn.execute('DELETE FROM jellyfin_user_links')
+ self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
+
+ def test_confirmation_rechecks_email_atomically(self):
+ store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time())
+ old = store.subscription(self.user['id'])
+ db.set_user_email('viewer', 'different@example.test')
+ self.assertFalse(store.confirm(old, time.time()))
+
+
+class RecapScheduleTests(RecapFixture, unittest.TestCase):
+ def test_defaults_are_paused_and_no_users_are_opted_in(self):
+ self.assertFalse(store.settings()['enabled'])
+ self.assertEqual(store.history()['subscribers'], 0)
+ self.assertEqual(store.enqueue_due(datetime.now(timezone.utc)), 0)
+
+ def test_utc_next_send_month_end_leap_year_and_new_year(self):
+ for now, expected in [
+ (datetime(2026, 12, 31, tzinfo=timezone.utc), '2027-01-02T09:00:00+00:00'),
+ (datetime(2024, 2, 29, tzinfo=timezone.utc), '2024-03-02T09:00:00+00:00'),
+ (datetime(2026, 9, 2, 8, tzinfo=timezone.utc), '2026-09-02T09:00:00+00:00'),
+ (datetime(2026, 9, 2, 9, tzinfo=timezone.utc), '2026-10-02T09:00:00+00:00')]:
+ self.assertEqual(store.next_due(now, 2, 9).isoformat(), expected)
+
+ def test_schedule_catches_up_once_and_excludes_late_subscribers(self):
+ before = datetime(2026, 8, 30, tzinfo=timezone.utc)
+ self.subscribe(before.timestamp())
+ config = store.save_settings({**self.config, 'enabled': True}, before)
+ self.assertEqual(config['next_send_at'], datetime(2026, 9, 2, 9, tzinfo=timezone.utc).timestamp())
+ db.create_user('late', 'Example-Password123!', email='late@example.test')
+ late = db.get_user_by_username('late')
+ store.request_confirmation(late, 'source', 'late-id', datetime(2026, 9, 2, 10, tzinfo=timezone.utc).timestamp())
+ store.confirm(store.subscription(late['id']), datetime(2026, 9, 2, 11, tzinfo=timezone.utc).timestamp())
+ now = datetime(2026, 9, 5, tzinfo=timezone.utc)
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ counts = list(pool.map(store.enqueue_due, [now] * 4))
+ self.assertEqual(sum(counts), 1)
+ rows = store.history()['deliveries']
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]['month'], '2026-08')
+ self.assertEqual(rows[0]['email'], 'viewer@example.test')
+ # Revisit the same due date after a restart: the durable unique key still wins.
+ with store.transaction() as conn:
+ conn.execute('UPDATE email_recap_settings SET next_send_at=?', (config['next_send_at'],))
+ self.assertEqual(store.enqueue_due(now), 0)
+
+ def test_long_downtime_does_not_backfill_multiple_months(self):
+ before = datetime(2026, 5, 1, tzinfo=timezone.utc)
+ self.subscribe(before.timestamp())
+ store.save_settings({**self.config, 'enabled': True}, before)
+ self.assertEqual(store.enqueue_due(datetime(2026, 9, 9, tzinfo=timezone.utc)), 1)
+ self.assertEqual(store.history()['deliveries'][0]['month'], '2026-08')
+
+ def test_enable_after_due_date_waits_and_pause_cancels_pending_monthlies(self):
+ now = datetime(2026, 9, 9, tzinfo=timezone.utc)
+ self.subscribe(now.timestamp())
+ result = store.save_settings({**self.config, 'enabled': True}, now)
+ self.assertEqual(result['next_send_at'], datetime(2026, 10, 2, 9, tzinfo=timezone.utc).timestamp())
+ self.assertEqual(store.enqueue_due(now), 0)
+ store.enqueue_due(datetime(2026, 10, 3, tzinfo=timezone.utc))
+ store.save_settings(self.config, now)
+ self.assertEqual(store.history()['deliveries'][0]['state'], 'cancelled')
+ self.assertIsNone(store.settings()['next_send_at'])
+
+
+class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
+ async def run_claim(self):
+ delivery = store.claim_delivery(time.time())
+ self.assertIsNotNone(delivery)
+ await recaps.process_delivery(delivery)
+
+ async def test_private_report_is_delivered_once_using_confirmed_account(self):
+ delivery_id = self.queue()
+ sent = []
+ def capture(recipient, rendered, message_id, before_data):
+ before_data()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'sending')
+ sent.append((recipient, rendered, message_id))
+ with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email', side_effect=capture):
+ await recaps.run_once()
+ await recaps.run_once()
+ self.assertEqual(len(sent), 1)
+ self.assertEqual(sent[0][0], 'viewer@example.test')
+ self.assertIn(f'?month={self.report["month"]}', sent[0][1]['body_html'])
+ self.assertNotIn('PRIVATE-TOKEN', json.dumps(sent))
+ self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
+ self.assertEqual(self.delivery(delivery_id)['state'], 'sent')
+ self.assertNotIn('unsubscribe_token', json.dumps(store.history()))
+
+ def test_concurrent_claim_and_test_deduplication(self):
+ sub, _ = self.subscribe()
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ ids = list(pool.map(lambda _: self.queue(sub), range(4)))
+ rows = list(pool.map(lambda _: store.claim_delivery(time.time()), range(4)))
+ self.assertEqual(len(set(ids)), 1)
+ self.assertEqual(sum(row is not None for row in rows), 1)
+ with self.assertRaises(ValueError):
+ self.queue(sub, 'another-click')
+
+ async def test_unsubscribe_or_email_change_during_report_prevents_sending(self):
+ delivery_id = self.queue()
+ async def report(*args):
+ db.set_user_email('viewer', 'other@example.test')
+ return self.report
+ def transport(recipient, rendered, message_id, before_data):
+ before_data()
+ self.fail('Private data must not reach SMTP DATA after an address change')
+ with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
+ await self.run_claim()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
+
+ async def test_stats_permission_revoked_during_report_cancels_email(self):
+ from backend.app.feature_access import update_permissions
+ delivery_id = self.queue()
+ db.set_user_role('viewer', 'user')
+ async def report(*args):
+ update_permissions({'stats': False}, 'viewer')
+ return self.report
+ def transport(recipient, rendered, message_id, before_data):
+ before_data()
+ self.fail('Report must not be sent after stats permission is revoked')
+ with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
+ await self.run_claim()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
+
+ async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
+ for kind in ['blocked', 'expired', 'deleted']:
+ with self.subTest(kind=kind):
+ # Each subcase starts with a fresh account and confirmed subscription.
+ db.create_user(kind, 'Example-Password123!', email=f'{kind}@example.test')
+ account = db.get_user_by_username(kind)
+ link_user(kind, f'jf-{kind}', 'http://jellyfin')
+ store.request_confirmation(account, source_key('http://jellyfin'), f'jf-{kind}', time.time())
+ store.confirm(store.subscription(account['id']), time.time())
+ delivery_id = self.queue(store.subscription(account['id']), kind)
+ with store.transaction() as conn:
+ if kind == 'blocked': conn.execute('UPDATE users SET is_blocked=1 WHERE id=?', (account['id'],))
+ elif kind == 'expired': conn.execute("UPDATE users SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (account['id'],))
+ else: conn.execute('DELETE FROM users WHERE id=?', (account['id'],))
+ with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
+ await recaps.run_once()
+ sender.assert_not_called(); report.assert_not_called()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
+
+ async def test_known_temporary_failure_retries_three_times_with_stable_id(self):
+ delivery_id = self.queue()
+ with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('retry', 'SMTP 451')) as sender:
+ for attempt in range(1, 4):
+ await self.run_claim()
+ row = self.delivery(delivery_id)
+ self.assertEqual(row['attempts'], attempt)
+ self.assertEqual(row['state'], 'failed' if attempt == 3 else 'retry')
+ if attempt < 3:
+ self.assertGreater(row['next_attempt_at'], time.time() + 250)
+ with store.transaction() as conn:
+ conn.execute('UPDATE email_recap_deliveries SET next_attempt_at=0 WHERE id=?', (delivery_id,))
+ self.assertEqual(len(set(call.args[2] for call in sender.call_args_list)), 1)
+ self.assertIsNone(store.claim_delivery(time.time()))
+
+ async def test_ambiguous_smtp_failure_never_automatically_retries(self):
+ delivery_id = self.queue()
+ with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'Check mail logs')):
+ await self.run_claim()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
+ self.assertIsNone(store.claim_delivery(time.time() + 86400))
+
+ def test_stale_worker_claims_are_recovered_without_resending_uncertain_mail(self):
+ delivery_id = self.queue()
+ first = store.claim_delivery(time.time())
+ second = store.claim_delivery(time.time() + 1801)
+ self.assertNotEqual(first['claim'], second['claim'])
+ self.assertFalse(store.begin_sending(first, time.time()))
+ self.assertTrue(store.begin_sending(second, time.time()))
+ store.claim_delivery(time.time() + 1801)
+ self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
+ store.finish(first, 'sent', 'Old worker', time.time())
+ self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
+
+ async def test_partial_or_over_limit_report_is_not_emailed(self):
+ delivery_id = self.queue()
+ with patch.object(recaps, 'get_monthly_report', new=AsyncMock(side_effect=HistoryLimitError('limit'))), patch.object(mail, 'send_email') as sender:
+ await self.run_claim()
+ sender.assert_not_called()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'failed')
+
+
+class RecapApiTests(RecapFixture, unittest.TestCase):
+ def setUp(self):
+ super().setUp()
+ app = FastAPI()
+ app.include_router(router.router)
+ self.app = app
+ self.client = TestClient(app)
+ self.addCleanup(self.client.close)
+
+ def login(self, role='admin'):
+ self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role, 'features': {'stats': True}}
+
+ def test_authentication_roles_and_recipient_override(self):
+ self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
+ self.assertEqual(self.client.get('/profile/email-recaps').status_code, 401)
+ self.login('user')
+ self.assertEqual(self.client.get('/admin/email-recaps').status_code, 403)
+ self.assertEqual(self.client.get('/admin/email-recaps/preview').status_code, 403)
+ self.assertEqual(self.client.post('/admin/email-recaps/test', json={}).status_code, 403)
+ self.login()
+ result = self.client.get('/admin/email-recaps')
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.headers['cache-control'], 'no-store')
+ self.assertNotIn('PRIVATE-STATS-KEY', result.text)
+ result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'recipient_email': 'other@example.test'})
+ self.assertEqual(result.status_code, 422)
+ result = self.client.put('/profile/email-recaps', json={'enabled': False, 'user_id': 5})
+ self.assertEqual(result.status_code, 422)
+
+ def test_url_and_schedule_validation_do_not_write_partial_settings(self):
+ self.login()
+ for value in ['javascript:alert(1)', 'https://user:secret@example.test', 'https://example.test/path', 'https://example.test?token=secret', 'https://example.test#token', 'https://example.test:0', 'https://example.test\\evil']:
+ result = self.client.put('/admin/email-recaps', json={**self.config, 'public_url': value})
+ self.assertEqual(result.status_code, 422, value)
+ for field, value in [('day', 0), ('day', 29), ('hour', 24)]:
+ self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, field: value}).status_code, 422)
+ with patch.object(recaps, 'smtp_email_config_ready', return_value=(False, 'Email is disabled.')):
+ self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, 'enabled': True}).status_code, 409)
+ self.assertEqual(store.settings()['public_url'], self.config['public_url'])
+ self.assertFalse(store.settings()['enabled'])
+
+ def test_preview_uses_own_report_and_test_requires_confirmed_email(self):
+ self.login()
+ with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email') as sender:
+ result = self.client.get('/admin/email-recaps/preview')
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
+ self.assertNotIn('PRIVATE-TOKEN', result.text)
+ sender.assert_not_called()
+ payload = {'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': self.report['month']}
+ self.assertEqual(self.client.post('/admin/email-recaps/test', json=payload).status_code, 409)
+ self.subscribe()
+ with patch.object(mail, 'send_email') as sender:
+ first = self.client.post('/admin/email-recaps/test', json=payload)
+ second = self.client.post('/admin/email-recaps/test', json=payload)
+ self.assertEqual(first.status_code, 202)
+ self.assertEqual(first.json()['id'], second.json()['id'])
+ sender.assert_not_called()
+
+ def test_partial_month_test_rejected_and_public_get_does_not_mutate(self):
+ self.login(); sub, token = self.subscribe()
+ result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': datetime.now(timezone.utc).strftime('%Y-%m')})
+ self.assertEqual(result.status_code, 422)
+ self.assertEqual(self.client.get('/email-recaps/confirm').status_code, 405)
+ result = self.client.post('/email-recaps/check', json={'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
+
+
+class RecapEmailTests(unittest.TestCase):
+ def setUp(self):
+ self.runtime = runtime()
+ patched = patch.object(mail, 'get_runtime_settings', return_value=self.runtime)
+ patched.start(); self.addCleanup(patched.stop)
+ self.rendered = mail.render_recap(fixture_report(), 'Viewer', 'https://beta.example.test', 'https://beta.example.test/email-recaps#action=unsubscribe&token=fixture')
+
+ def fake_smtp(self):
+ smtp = MagicMock()
+ smtp.mail.return_value = (250, b'OK')
+ smtp.rcpt.return_value = (250, b'OK')
+ smtp.data.return_value = (250, b'Accepted')
+ return smtp
+
+ def test_render_escapes_names_and_titles_and_includes_no_artwork_credentials(self):
+ report = fixture_report()
+ report['top_titles'][0]['title'] = ' '
+ rendered = mail.render_recap(report, '', 'https://beta.example.test', 'https://beta.example.test/email-recaps#token=example')
+ self.assertNotIn('" },
+ ])("does not render or loosely match unexpected response bodies: %j", async (payload) => {
+ expect(await loginErrorMessage(errorResponse(403, payload))).toBe(
+ "This account cannot sign in. Please contact an administrator.",
+ );
+ });
+
+ it("handles a non-JSON proxy denial safely", async () => {
+ expect(await loginErrorMessage(new Response("Forbidden", { status: 403 }))).toBe(
+ "This account cannot sign in. Please contact an administrator.",
+ );
+ });
+
+ it.each([
+ [401, "Check your username and password, then try again."],
+ [400, "Check your username and password, then try again."],
+ [429, "Too many attempts. Please wait a moment and try again."],
+ [500, "Sign-in is temporarily unavailable. Please try again shortly."],
+ [502, "Sign-in is temporarily unavailable. Please try again shortly."],
+ ])("preserves the existing message for HTTP %s", async (status, expected) => {
+ expect(
+ await loginErrorMessage(errorResponse(status as number, { detail: "Cross-origin state change rejected" })),
+ ).toBe(expected);
+ });
+});
diff --git a/frontend/app/lib/login-errors.ts b/frontend/app/lib/login-errors.ts
new file mode 100644
index 0000000..7f38d27
--- /dev/null
+++ b/frontend/app/lib/login-errors.ts
@@ -0,0 +1,17 @@
+export async function loginErrorMessage(response: Response): Promise {
+ if (response.status === 429) return "Too many attempts. Please wait a moment and try again.";
+ if (response.status >= 500) return "Sign-in is temporarily unavailable. Please try again shortly.";
+ if (response.status === 403) {
+ const payload: unknown = await response.json().catch(() => null);
+ if (
+ payload !== null &&
+ typeof payload === "object" &&
+ "detail" in payload &&
+ payload.detail === "Cross-origin state change rejected"
+ ) {
+ return "Sign-in was blocked by the site's security configuration. Please contact an administrator.";
+ }
+ return "This account cannot sign in. Please contact an administrator.";
+ }
+ return "Check your username and password, then try again.";
+}
diff --git a/frontend/app/lib/request-results.test.ts b/frontend/app/lib/request-results.test.ts
new file mode 100644
index 0000000..7d5062c
--- /dev/null
+++ b/frontend/app/lib/request-results.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeRecentResults, normalizeSearchResults } from "./request-results";
+
+describe("request result normalization", () => {
+ it("replaces placeholder request titles", () => {
+ expect(normalizeRecentResults([{ id: 42, title: "Request 42", year: 2024 }])).toEqual([
+ expect.objectContaining({ id: 42, title: "Request #42", year: 2024 }),
+ ]);
+ });
+
+ it("drops malformed search results", () => {
+ expect(normalizeSearchResults([null, { title: "" }, { title: "Drive", requestId: 3991 }])).toEqual([
+ expect.objectContaining({ title: "Drive", requestId: 3991 }),
+ ]);
+ });
+});
diff --git a/frontend/app/lib/request-results.ts b/frontend/app/lib/request-results.ts
new file mode 100644
index 0000000..f564a27
--- /dev/null
+++ b/frontend/app/lib/request-results.ts
@@ -0,0 +1,74 @@
+export interface RecentRequest {
+ id: number;
+ title: string;
+ year?: number;
+ type?: string;
+ statusLabel?: string;
+ artwork?: { poster_url?: string; backdrop_url?: string };
+ createdAt?: string | null;
+}
+
+export interface RequestSearchResult {
+ title: string;
+ year?: number;
+ type?: string;
+ requestId?: number;
+ statusLabel?: string;
+ requestedBy?: string | null;
+ accessible?: boolean;
+}
+
+const recordValue = (value: unknown): Record | null =>
+ value !== null && typeof value === "object" ? (value as Record) : null;
+
+const optionalString = (value: unknown) => (typeof value === "string" ? value : undefined);
+const optionalNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
+
+export const normalizeRecentResults = (items: unknown): RecentRequest[] => {
+ if (!Array.isArray(items)) return [];
+ return items.flatMap((value) => {
+ const item = recordValue(value);
+ const id = optionalNumber(item?.id);
+ if (!item || id === undefined) return [];
+ const rawTitle = optionalString(item.title);
+ const placeholder = rawTitle?.trim().toLowerCase() === `request ${id}`;
+ const rawArtwork = recordValue(item.artwork);
+ const artwork = rawArtwork
+ ? {
+ poster_url: optionalString(rawArtwork.poster_url),
+ backdrop_url: optionalString(rawArtwork.backdrop_url),
+ }
+ : undefined;
+ return [
+ {
+ id,
+ title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
+ year: optionalNumber(item.year),
+ type: optionalString(item.type),
+ statusLabel: optionalString(item.statusLabel),
+ artwork,
+ createdAt: item.createdAt === null ? null : optionalString(item.createdAt),
+ },
+ ];
+ });
+};
+
+export const normalizeSearchResults = (items: unknown): RequestSearchResult[] => {
+ if (!Array.isArray(items)) return [];
+ return items.flatMap((value) => {
+ const item = recordValue(value);
+ const title = optionalString(item?.title);
+ if (!item || !title) return [];
+ return [
+ {
+ title,
+ year: optionalNumber(item.year),
+ type: optionalString(item.type),
+ requestId: optionalNumber(item.requestId),
+ statusLabel: optionalString(item.statusLabel),
+ requestedBy: item.requestedBy === null ? null : optionalString(item.requestedBy),
+ accessible: Boolean(item.accessible),
+ },
+ ];
+ });
+};
diff --git a/frontend/app/lib/scrollLock.ts b/frontend/app/lib/scrollLock.ts
new file mode 100644
index 0000000..a5909b6
--- /dev/null
+++ b/frontend/app/lib/scrollLock.ts
@@ -0,0 +1,15 @@
+let locks = 0;
+let previous = "";
+
+export function lockBodyScroll() {
+ if (locks++ === 0) {
+ previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ }
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ if (--locks === 0) document.body.style.overflow = previous;
+ };
+}
diff --git a/frontend/app/lib/user-view-policy.test.ts b/frontend/app/lib/user-view-policy.test.ts
new file mode 100644
index 0000000..0ce4632
--- /dev/null
+++ b/frontend/app/lib/user-view-policy.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { getEffectiveRole, isAdminPage } from "./user-view-policy";
+
+describe("user view preview policy", () => {
+ it("downgrades only the displayed administrator role during preview", () => {
+ expect(getEffectiveRole("admin", true)).toBe("user");
+ expect(getEffectiveRole("admin", false)).toBe("admin");
+ for (const role of ["user", null, undefined]) {
+ expect(getEffectiveRole(role, true)).toBe(role);
+ expect(getEffectiveRole(role, false)).toBe(role);
+ }
+ });
+ it("covers configuration, nested admin pages, user management and setup", () => {
+ for (const path of [
+ "/admin",
+ "/admin/",
+ "/admin/backups",
+ "/admin/recaps",
+ "/users",
+ "/users/42",
+ "/setup",
+ "/admin?section=site",
+ "/%61dmin/diagnostics",
+ ]) {
+ expect(isAdminPage(path), path).toBe(true);
+ }
+ });
+ it("does not restrict normal member pages or similarly named paths", () => {
+ for (const path of [
+ "/",
+ "/profile",
+ "/profile/invites",
+ "/portal/issues",
+ "/requests/3580",
+ "/insights",
+ "/administrator",
+ "/users-guide",
+ ]) {
+ expect(isAdminPage(path), path).toBe(false);
+ }
+ });
+ it("keeps public first-install setup separate from admin authentication", () => {
+ expect(isAdminPage("/setup", false)).toBe(false);
+ expect(isAdminPage("/admin/backups", false)).toBe(true);
+ });
+});
diff --git a/frontend/app/lib/user-view-policy.ts b/frontend/app/lib/user-view-policy.ts
new file mode 100644
index 0000000..c1fa42c
--- /dev/null
+++ b/frontend/app/lib/user-view-policy.ts
@@ -0,0 +1,16 @@
+// Preview never promotes a user or changes server-side account permissions.
+export function getEffectiveRole(role: string | null | undefined, preview: boolean) {
+ return preview && role === "admin" ? "user" : role;
+}
+
+export function isAdminPage(pathname: string, includeSetup = true): boolean {
+ let path = pathname.split(/[?#]/, 1)[0];
+ try {
+ path = decodeURIComponent(path);
+ } catch {
+ // Let the router handle malformed URLs; never infer a more privileged role.
+ }
+ path = path.replace(/\/{2,}/g, "/");
+ const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"];
+ return roots.some((root) => path === root || path.startsWith(`${root}/`));
+}
diff --git a/frontend/app/lib/viewMode.ts b/frontend/app/lib/viewMode.ts
new file mode 100644
index 0000000..d10690b
--- /dev/null
+++ b/frontend/app/lib/viewMode.ts
@@ -0,0 +1,68 @@
+"use client";
+
+import { useEffect, useSyncExternalStore } from "react";
+import { getEffectiveRole } from "./user-view-policy";
+
+const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
+const USER_VIEW_EVENT = "magent:user-view-change";
+let fallbackPreview = false;
+
+const readUserViewPreview = () => {
+ if (typeof window === "undefined") return false;
+ try {
+ return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
+ } catch {
+ return fallbackPreview;
+ }
+};
+
+const applyDocumentMode = (enabled: boolean) => {
+ if (typeof document === "undefined") return;
+ document.documentElement.dataset.userView = enabled ? "true" : "false";
+};
+
+export const setUserViewPreview = (enabled: boolean) => {
+ if (typeof window === "undefined") return;
+ fallbackPreview = enabled;
+ try {
+ if (enabled) {
+ window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
+ } else {
+ window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
+ }
+ } catch {
+ // Preview still works for this document when browser storage is unavailable.
+ }
+ applyDocumentMode(enabled);
+ window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
+};
+
+const subscribe = (notify: () => void) => {
+ window.addEventListener(USER_VIEW_EVENT, notify);
+ window.addEventListener("storage", notify);
+ return () => {
+ window.removeEventListener(USER_VIEW_EVENT, notify);
+ window.removeEventListener("storage", notify);
+ };
+};
+
+// Unknown during server rendering/initial hydration: admin pages must not mount
+// and fetch privileged data before the saved per-tab preview mode is known.
+const serverSnapshot = (): boolean | null => null;
+
+export const useUserViewState = () => {
+ const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot);
+
+ useEffect(() => {
+ if (value !== null) applyDocumentMode(value);
+ }, [value]);
+
+ return { enabled: value === true, ready: value !== null };
+};
+
+export const useUserViewPreview = () => useUserViewState().enabled;
+
+export const useEffectiveRole = (role?: string | null) => {
+ const { enabled, ready } = useUserViewState();
+ return getEffectiveRole(role, !ready || enabled);
+};
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
new file mode 100644
index 0000000..ae9eda0
--- /dev/null
+++ b/frontend/app/login/page.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import { type FormEvent, useEffect, useState } from "react";
+import { getApiBase, setToken } from "../lib/auth";
+import { loginErrorMessage } from "../lib/login-errors";
+import AuthLayout from "../ui/AuthLayout";
+
+type LoginMode = "jellyfin" | "local";
+type LoginOptions = {
+ showJellyfinLogin: boolean;
+ showLocalLogin: boolean;
+ showForgotPassword: boolean;
+ showSignupLink: boolean;
+};
+const DEFAULT_OPTIONS: LoginOptions = {
+ showJellyfinLogin: true,
+ showLocalLogin: true,
+ showForgotPassword: true,
+ showSignupLink: true,
+};
+
+export default function LoginPage() {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const [mode, setMode] = useState("jellyfin");
+ const [options, setOptions] = useState(DEFAULT_OPTIONS);
+ const [optionsReady, setOptionsReady] = useState(false);
+ const [loginMessage, setLoginMessage] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const canSignIn = options.showJellyfinLogin || options.showLocalLogin;
+ const selectedMode: LoginMode =
+ mode === "jellyfin" && options.showJellyfinLogin ? "jellyfin" : options.showLocalLogin ? "local" : "jellyfin";
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const load = async () => {
+ try {
+ const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal });
+ if (!response.ok) throw new Error("Options unavailable");
+ const data = await response.json();
+ if (controller.signal.aborted) return;
+ setOptions({
+ showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
+ showLocalLogin: data?.login?.showLocalLogin !== false,
+ showForgotPassword: data?.login?.showForgotPassword !== false,
+ showSignupLink: data?.login?.showSignupLink !== false,
+ });
+ setLoginMessage(typeof data?.login?.message === "string" ? data.login.message.trim() : "");
+ } catch {
+ // Keep the normal sign-in methods available during a settings outage.
+ } finally {
+ if (!controller.signal.aborted) setOptionsReady(true);
+ }
+ };
+ void load();
+ return () => controller.abort();
+ }, []);
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (loading || !canSignIn || !optionsReady) return;
+ setError("");
+ setLoading(true);
+ try {
+ const response = await fetch(
+ `${getApiBase()}${selectedMode === "jellyfin" ? "/auth/jellyfin/login" : "/auth/login"}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({ username: username.trim(), password }),
+ credentials: "include",
+ },
+ );
+ if (!response.ok) {
+ setError(await loginErrorMessage(response));
+ return;
+ }
+ const data = await response.json();
+ if (!data?.authenticated) {
+ setError("Could not sign in. Please try again.");
+ return;
+ }
+ setToken("cookie");
+ const next = new URLSearchParams(window.location.search).get("next") || "";
+ const allowedNext =
+ [
+ "/insights",
+ "/insights/reports",
+ "/profile",
+ "/profile#monthly-recaps",
+ "/profile#newsletters",
+ "/admin/recaps",
+ "/admin/newsletters",
+ "/setup",
+ "/admin/backups",
+ ].includes(next) ||
+ /^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
+ /^\/issues\/confirm\/\d+$/.test(next);
+ window.location.assign(allowedNext ? next : "/welcome");
+ } catch {
+ setError("Could not reach Magent. Check your connection and try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Have an invite?{" "}
+
+ Create an account ↗
+
+ >
+ )
+ }
+ >
+ {loginMessage && (
+
+ {loginMessage}
+
+ )}
+ {optionsReady && options.showJellyfinLogin && options.showLocalLogin && (
+
+ {
+ setMode("jellyfin");
+ setError("");
+ }}
+ >
+ Jellyfin
+
+ {
+ setMode("local");
+ setError("");
+ }}
+ >
+ Magent
+
+
+ )}
+ {!optionsReady ? (
+
+ Loading sign-in…
+
+ ) : !canSignIn ? (
+
+ Sign-in is currently unavailable. Please contact an administrator.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/app/new-requests/NewRequestClient.tsx b/frontend/app/new-requests/NewRequestClient.tsx
new file mode 100644
index 0000000..f407ffc
--- /dev/null
+++ b/frontend/app/new-requests/NewRequestClient.tsx
@@ -0,0 +1,740 @@
+"use client";
+
+import PageHeading from "../ui/PageHeading";
+import { lockBodyScroll } from "../lib/scrollLock";
+import "./request-progress.css";
+
+import { useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
+
+type MediaType = "movie" | "tv";
+
+type DiscoveryResult = {
+ title: string;
+ year?: number | null;
+ type: MediaType;
+ tmdbId: number;
+ requestId?: number | null;
+ statusLabel?: string | null;
+ overview?: string | null;
+ posterPath?: string | null;
+ backdropPath?: string | null;
+};
+
+type RequestOptions = {
+ media: DiscoveryResult & {
+ seasons: Array<{
+ seasonNumber: number;
+ name: string;
+ episodeCount: number;
+ airDate?: string | null;
+ }>;
+ originalLanguage?: { code: string } | null;
+ existingRequestId?: number | null;
+ };
+ destination: {
+ collector: "Sonarr" | "Radarr";
+ serverName: string;
+ defaultProfileId: number;
+ profiles: Array<{ id: number; name: string }>;
+ };
+};
+
+type OperationEvent = {
+ id: string;
+ service: string;
+ state: "active" | "complete" | "error";
+ message: string;
+ duration_ms?: number | null;
+ status_code?: number | null;
+};
+
+type OperationProgress = {
+ status: "running" | "complete" | "error";
+ duration_ms?: number | null;
+ events: OperationEvent[];
+};
+
+const mediaChoices: Array<{
+ type: MediaType;
+ eyebrow: string;
+ title: string;
+ description: string;
+ collector: "Radarr" | "Sonarr";
+ icon: string;
+}> = [
+ {
+ type: "movie",
+ eyebrow: "Film",
+ title: "Movie",
+ description: "Find a film and send it through Seerr to Radarr.",
+ collector: "Radarr",
+ icon: "/service-icons/radarr.svg",
+ },
+ {
+ type: "tv",
+ eyebrow: "Series",
+ title: "TV show",
+ description: "Choose a series, the seasons you want, and send it to Sonarr.",
+ collector: "Sonarr",
+ icon: "/service-icons/sonarr.svg",
+ },
+];
+
+const artworkUrl = (path?: string | null, size: "w185" | "w342" = "w342") => {
+ if (!path) return null;
+ return `https://image.tmdb.org/t/p/${size}${path.startsWith("/") ? path : `/${path}`}`;
+};
+
+const apiError = async (response: Response, fallback: string) => {
+ try {
+ 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;
+ } catch {
+ // The upstream response was not JSON. Use the friendly fallback below.
+ }
+ return fallback;
+};
+
+export default function NewRequestClient() {
+ const router = useRouter();
+ const searchSectionRef = useRef(null);
+ const resultsSectionRef = useRef(null);
+ const configureSectionRef = useRef(null);
+ const [mediaType, setMediaType] = useState(null);
+ const [query, setQuery] = useState("");
+ const [searching, setSearching] = useState(false);
+ const [searchAttempted, setSearchAttempted] = useState(false);
+ const [results, setResults] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [options, setOptions] = useState(null);
+ const [loadingOptions, setLoadingOptions] = useState(false);
+ const [selectedSeasons, setSelectedSeasons] = useState([]);
+ const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [progressOpen, setProgressOpen] = useState(false);
+ const progressDialog = useRef(null);
+ useEffect(() => {
+ if (!progressOpen) return;
+ const previous = document.activeElement as HTMLElement | null;
+ progressDialog.current?.showModal();
+ const unlock = lockBodyScroll();
+ return () => {
+ progressDialog.current?.close();
+ unlock();
+ previous?.focus();
+ };
+ }, [progressOpen]);
+
+ const [operation, setOperation] = useState(null);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ useEffect(() => {
+ if (!getToken()) router.push("/login");
+ }, [router]);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const requestedType = params.get("type");
+ const requestedQuery = params.get("query")?.trim();
+ if ((requestedType === "movie" || requestedType === "tv") && requestedQuery) {
+ setMediaType(requestedType);
+ setQuery(requestedQuery);
+ window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
+ }
+ }, []);
+
+ const selectedTitleId = selected?.tmdbId;
+ useEffect(() => {
+ if (selectedTitleId) configureSectionRef.current?.focus();
+ }, [selectedTitleId]);
+
+ const changeTitle = () => {
+ setSelected(null);
+ setOptions(null);
+ setAcceptOriginalLanguage(null);
+ setSelectedSeasons([]);
+ setOperation(null);
+ setError(null);
+ setSuccess(null);
+ window.requestAnimationFrame(() => document.getElementById("request-title-search")?.focus());
+ };
+
+ const resetAfterType = (nextType: MediaType) => {
+ setMediaType(nextType);
+ setQuery("");
+ setResults([]);
+ setSearchAttempted(false);
+ setSelected(null);
+ setOptions(null);
+ setAcceptOriginalLanguage(null);
+ setSelectedSeasons([]);
+ setOperation(null);
+ setError(null);
+ setSuccess(null);
+ window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
+ };
+
+ const runSearch = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!mediaType) return;
+ const term = query.trim();
+ if (!term) {
+ setError("Enter a title to search for.");
+ return;
+ }
+ setSearching(true);
+ setSearchAttempted(true);
+ setSelected(null);
+ setOptions(null);
+ setAcceptOriginalLanguage(null);
+ setOperation(null);
+ setError(null);
+ setSuccess(null);
+ try {
+ const baseUrl = getApiBase();
+ const params = new URLSearchParams({ query: term, media_type: mediaType });
+ const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`);
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`));
+ const payload = await response.json();
+ const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
+ ? payload.results
+ .filter((item: Record) => item.type === mediaType && Number(item.tmdbId) > 0)
+ .map((item: Record) => ({
+ title: String(item?.title || "Untitled"),
+ year: typeof item?.year === "number" ? item.year : null,
+ type: mediaType,
+ tmdbId: Number(item.tmdbId),
+ requestId: typeof item?.requestId === "number" ? item.requestId : null,
+ statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
+ overview: typeof item?.overview === "string" ? item.overview : null,
+ posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
+ backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null,
+ }))
+ : [];
+ setResults(mapped);
+ window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 80);
+ } catch (caught) {
+ setResults([]);
+ setError(caught instanceof Error ? caught.message : "Search is unavailable right now.");
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ const selectResult = async (item: DiscoveryResult) => {
+ setSelected(item);
+ setOptions(null);
+ setAcceptOriginalLanguage(null);
+ setSelectedSeasons([]);
+ setOperation(null);
+ setError(null);
+ setSuccess(null);
+ if (item.requestId) {
+ return;
+ }
+
+ setLoadingOptions(true);
+ try {
+ const baseUrl = getApiBase();
+ const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) });
+ const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`);
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!response.ok)
+ throw new Error(await apiError(response, `Could not load request options (${response.status}).`));
+ const payload = (await response.json()) as RequestOptions;
+ const refreshedSelection: DiscoveryResult = {
+ ...item,
+ title: payload.media.title || item.title,
+ year: payload.media.year ?? item.year,
+ overview: payload.media.overview || item.overview,
+ posterPath: payload.media.posterPath || item.posterPath,
+ backdropPath: payload.media.backdropPath || item.backdropPath,
+ requestId: payload.media.existingRequestId || item.requestId,
+ statusLabel: payload.media.existingRequestId ? "Already requested" : item.statusLabel,
+ };
+ setSelected(refreshedSelection);
+ if (payload.media.existingRequestId) {
+ setResults((current) =>
+ current.map((result) =>
+ result.tmdbId === item.tmdbId && result.type === item.type ? refreshedSelection : result,
+ ),
+ );
+ return;
+ }
+ setOptions(payload);
+ setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber));
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "Could not load request options.");
+ } finally {
+ setLoadingOptions(false);
+ }
+ };
+
+ const pollOperation = async (operationId: string) => {
+ try {
+ const response = await authFetch(`${getApiBase()}/operations/${operationId}`);
+ if (response.ok) setOperation((await response.json()) as OperationProgress);
+ } catch {
+ // The request response remains authoritative if a progress poll is interrupted.
+ }
+ };
+
+ const submitRequest = async () => {
+ if (!selected || !options || submitting) return;
+ if (options.media.originalLanguage && acceptOriginalLanguage === null) {
+ setError("Choose an audio language option before requesting.");
+ return;
+ }
+ if (selected.type === "tv" && selectedSeasons.length === 0) {
+ setError("Select at least one season.");
+ return;
+ }
+ setProgressOpen(true);
+ setSubmitting(true);
+ setError(null);
+ setSuccess(null);
+ const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`;
+ setOperation({ status: "running", events: [] });
+ const interval = window.setInterval(() => void pollOperation(operationId), 500);
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/create`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Magent-Operation-ID": operationId,
+ "X-Magent-Operation-Label": `Requesting ${selected.title}`,
+ },
+ body: JSON.stringify({
+ mediaType: selected.type,
+ tmdbId: selected.tmdbId,
+ acceptOriginalLanguage: acceptOriginalLanguage === true,
+ seasons: selected.type === "tv" ? selectedSeasons : undefined,
+ }),
+ });
+ await pollOperation(operationId);
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`));
+ const payload = await response.json();
+ const requestId = typeof payload?.requestId === "number" ? payload.requestId : null;
+ setSelected((current) =>
+ current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current,
+ );
+ setResults((current) =>
+ current.map((item) =>
+ item.tmdbId === selected.tmdbId && item.type === selected.type
+ ? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
+ : item,
+ ),
+ );
+ setSuccess("Your request has been received.");
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "The request could not be submitted.");
+ } finally {
+ window.clearInterval(interval);
+ await pollOperation(operationId);
+ setSubmitting(false);
+ }
+ };
+
+ const setEverySeason = (checked: boolean) => {
+ setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : []);
+ };
+
+ const selectedPoster = artworkUrl(selected?.posterPath, "w185");
+ const currentFlowStep = success ? 5 : selected ? 4 : searchAttempted ? 3 : mediaType ? 2 : 1;
+
+ return (
+
+ setProgressOpen(false)}
+ onClose={() => setProgressOpen(false)}
+ >
+
+ Request progress
+ setProgressOpen(false)}
+ aria-label="Close request progress"
+ >
+ Close
+
+
+
+ {submitting &&
}
+
+ {submitting ? "Sending your request" : success ? "Request received" : "Your request needs attention"}
+
+
{selected?.title}
+
+ {submitting
+ ? operation?.events.some((event) => event.service === "Sonarr" || event.service === "Radarr")
+ ? "Setting up your title for collection. Please wait."
+ : "Checking your selection and sending it to the request service. Please wait."
+ : success
+ ? "Your request is now in the pipeline. Follow it to see approval, download progress and when it is ready to watch."
+ : error || "We could not confirm the result. Check My requests before trying again."}
+
+ {submitting && (
+
+
+
+ )}
+ {success && (
+
+ Current stage
+ {selected?.statusLabel || "Request received"}
+
+ )}
+
+
+ {!submitting && (
+ router.push(selected?.requestId ? `/requests/${selected.requestId}` : "/")}
+ >
+ {success ? "Follow your request" : "Check My requests"} →
+
+ )}
+ {!submitting && (
+ setProgressOpen(false)}>
+ {success ? "Back to browsing" : "Back to request"}
+
+ )}
+ {submitting && (
+ You can close this window. Submission will continue while you stay on this page.
+ )}
+
+
+
+
+
+ {["Type", "Search", "Select", "Config", "Submit"].map((label, index) => {
+ const step = index + 1;
+ return (
+
+ {step < currentFlowStep ? "✓" : step}
+ {label}
+
+ );
+ })}
+
+
+ {error && {error}
}
+ {success && {success}
}
+
+ {!selected && (
+
+
+
01
+
+ Start here
+
What are you looking for?
+
+
+
+ {mediaChoices.map((choice) => (
+
resetAfterType(choice.type)}
+ aria-pressed={mediaType === choice.type}
+ >
+
+
+
+
+
+ {choice.eyebrow}
+ {choice.title}
+ {choice.description}
+ {mediaType === choice.type ? "Selected" : `Choose ${choice.title.toLowerCase()}`}
+
+
+
+ ))}
+
+
+ )}
+
+ {mediaType && !selected && (
+
+
+
02
+
+ {mediaType === "tv" ? "TV show selected" : "Movie selected"}
+
Search for the title
+
+
+
+
+ )}
+
+ {mediaType && !selected && searchAttempted && !searching && (
+
+
+
03
+
+ Search results
+
{results.length ? "Select the right title" : "No matches found"}
+
+
+ {results.length === 0 ? (
+
+
Nothing matched “{query.trim()}”.
+
Check the spelling or try a shorter title.
+
+ ) : (
+
+ {results.map((item) => {
+ const poster = artworkUrl(item.posterPath);
+ return (
+
void selectResult(item)}
+ >
+
+ {poster ? : No artwork }
+
+
+
+ {item.type === "tv" ? "TV show" : "Movie"}
+ {item.year ? ` · ${item.year}` : ""}
+
+ {item.title}
+ {item.overview || "Select this title to view the available request options."}
+ {item.requestId ? item.statusLabel || "Already requested" : "Select title"}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {selected && (
+
+
+
04
+
+ Final step
+
+
+
+
+
+ Change title
+
+
+
+
+ {selectedPoster ? : No artwork }
+
+
+
+ {selected.type === "tv" ? "TV show" : "Movie"}
+ {selected.year ? ` · ${selected.year}` : ""}
+
+
{selected.title}
+
{selected.overview || "Ready to configure."}
+
+
+
+ {selected.requestId ? (
+
+
+
Current status
+
{selected.statusLabel || "Already requested"}
+
Request #{selected.requestId} is already being tracked by Magent.
+
+
router.push(`/requests/${selected.requestId}`)}>
+ Open request
+
+
+ ) : loadingOptions ? (
+
+
Checking Seerr and {selected.type === "tv" ? "Sonarr" : "Radarr"}…
+
Preparing your request options.
+
+ ) : options ? (
+
+ {selected.type === "tv" && (
+
+ Which seasons?
+
+ setEverySeason(true)}>
+ Select all
+
+ setEverySeason(false)}>
+ Clear
+
+
+
+ {options.media.seasons.map((season) => (
+
+
+ setSelectedSeasons((current) =>
+ event.target.checked
+ ? [...current, season.seasonNumber].sort((a, b) => a - b)
+ : current.filter((value) => value !== season.seasonNumber),
+ )
+ }
+ />
+
+ {season.name}
+
+ {season.episodeCount} episode{season.episodeCount === 1 ? "" : "s"}
+
+
+
+ ))}
+
+
+ )}
+
+ {options.media.originalLanguage && (
+
+
Choose your audio language
+
+ This title’s original language is{" "}
+
+ {new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code) ||
+ options.media.originalLanguage.code}
+
+ . An English audio track may not be available. Title metadata does not confirm the audio or
+ subtitles in a download.
+
+
+ setAcceptOriginalLanguage(true)}
+ disabled={submitting}
+ />
+
+ Original{" "}
+ {new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code)}{" "}
+ audio — I’m happy to watch in the original language.
+
+
+
+ setAcceptOriginalLanguage(false)}
+ disabled={submitting}
+ />
+ Keep standard audio requirements. This title may remain waiting for an English release.
+
+
+ {acceptOriginalLanguage
+ ? selected.type === "movie"
+ ? "Search for original-language audio using the same quality requirements."
+ : "Continue with your selected seasons and the configured TV quality requirements."
+ : "Choose an option to continue. An English-only profile may leave this title waiting for a suitable release."}
+
+
+ )}
+
+
+ Delivery route
+ Seerr → {options.destination.collector} → Jellyfin
+ Your request uses the default quality set by your administrator.
+
+
void submitRequest()}
+ disabled={
+ submitting ||
+ (Boolean(options.media.originalLanguage) && acceptOriginalLanguage === null) ||
+ (selected.type === "tv" && selectedSeasons.length === 0)
+ }
+ >
+ {submitting ? "Sending request…" : `Request ${selected.type === "tv" ? "show" : "movie"}`}
+
+
+
+ ) : null}
+
+ {operation && (
+ setProgressOpen(true)}>
+ View request progress
+
+ )}
+
+ {success && selected.requestId && (
+
+ router.push(`/requests/${selected.requestId}`)}>
+ Follow your request
+
+ resetAfterType(selected.type)}>
+ Request something else
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/new-requests/page.tsx b/frontend/app/new-requests/page.tsx
new file mode 100644
index 0000000..95afdde
--- /dev/null
+++ b/frontend/app/new-requests/page.tsx
@@ -0,0 +1,9 @@
+import NewRequestClient from "./NewRequestClient";
+
+export const metadata = {
+ title: "New Requests | Magent",
+};
+
+export default function NewRequestsPage() {
+ return ;
+}
diff --git a/frontend/app/new-requests/request-progress.css b/frontend/app/new-requests/request-progress.css
new file mode 100644
index 0000000..62f7c36
--- /dev/null
+++ b/frontend/app/new-requests/request-progress.css
@@ -0,0 +1,18 @@
+.create-request-dialog { width:min(560px,calc(100vw - 32px)); max-height:90dvh; overflow:auto; padding:28px; border:1px solid #514d62; border-radius:20px; background:#1b1b22; color:#eeeaf5; box-shadow:0 30px 100px #0009; }
+.create-request-dialog::backdrop { background:#080910c9; backdrop-filter:blur(6px); }
+.create-progress-header { display:flex; align-items:center; justify-content:space-between; gap:16px; color:#79e0eb; font-size:13px; }
+.create-progress-body { padding:24px 0; }
+.create-progress-body h2 { font-size:clamp(25px,4vw,34px); margin:12px 0; }
+.create-progress-body p { color:#c3bfce; line-height:1.7; overflow-wrap:anywhere; }
+.create-progress-body .create-progress-title { font-size:20px; color:#fff; font-weight:600; }
+.create-progress-spinner { display:block; width:42px; height:42px; border:4px solid #ffffff20; border-top-color:#70e0e4; border-radius:50%; animation:create-spin .8s linear infinite; }
+.create-progress-track { height:6px; background:#ffffff15; overflow:hidden; border-radius:6px; margin-top:24px; }
+.create-progress-track span { display:block; width:35%; height:100%; background:#8edee5; animation:create-track 1.5s ease-in-out infinite alternate; }
+.create-progress-stage { display:grid; gap:8px; border:1px solid #4c536a; border-radius:12px; padding:18px; background:#242938; }
+.create-progress-stage span { color:#b9b6c6; font-size:12px; }
+.create-progress-actions { display:grid; gap:12px; }
+.create-progress-actions .create-progress-follow { padding:18px; background:#c5b8ff; color:#191629; font-size:18px; font-weight:700; border-radius:12px; }
+.create-progress-actions small { color:#b9b6c6; line-height:1.6; }
+@keyframes create-spin { to { transform:rotate(360deg); } }
+@keyframes create-track { to { transform:translateX(185%); } }
+@media(prefers-reduced-motion:reduce) { .create-progress-spinner,.create-progress-track span { animation:none; } }
diff --git a/frontend/app/newsletter-subscription/page.tsx b/frontend/app/newsletter-subscription/page.tsx
new file mode 100644
index 0000000..34bec7e
--- /dev/null
+++ b/frontend/app/newsletter-subscription/page.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { getApiBase } from "../lib/auth";
+import BrandingLogo from "../ui/BrandingLogo";
+import "../email-recaps/recaps.css";
+
+type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
+
+export default function NewsletterLinkPage() {
+ const [link, setLink] = useState(null);
+ const [state, setState] = useState("loading");
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const currentLink = useRef(null);
+
+ useEffect(() => {
+ let controller: AbortController | null = null;
+ const checkLink = () => {
+ controller?.abort();
+ const abort = new AbortController();
+ controller = abort;
+ setError("");
+ setState("loading");
+ setLink(null);
+ setBusy(false);
+ currentLink.current = null;
+ // Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
+ const params = new URLSearchParams(window.location.hash.slice(1));
+ const action = params.get("action");
+ const token = params.get("token") || "";
+ if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
+ setError("This email link is incomplete. Open Profile to manage your newsletters.");
+ setState("error");
+ return;
+ }
+ const payload = { action, token } as LinkAction;
+ currentLink.current = payload;
+ setLink(payload);
+ void fetch(`${getApiBase()}/newsletter-subscription/check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: abort.signal,
+ credentials: "omit",
+ })
+ .then(async (response) => {
+ const result = await response.json().catch(() => ({}));
+ if (!response.ok)
+ throw new Error(
+ typeof result.detail === "string"
+ ? result.detail
+ : "Could not check this email link. Please open it again.",
+ );
+ if (!abort.signal.aborted) setState(result.state);
+ })
+ .catch((err: Error) => {
+ if (!abort.signal.aborted) {
+ setError(err.message);
+ setState("error");
+ }
+ });
+ };
+ checkLink();
+ window.addEventListener("hashchange", checkLink);
+ return () => {
+ currentLink.current = null;
+ controller?.abort();
+ window.removeEventListener("hashchange", checkLink);
+ };
+ }, []);
+
+ const apply = async () => {
+ if (!link || busy) return;
+ const payload = link;
+ setBusy(true);
+ setError("");
+ try {
+ const response = await fetch(`${getApiBase()}/newsletter-subscription/confirm`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ credentials: "omit",
+ });
+ const result = await response.json().catch(() => ({}));
+ if (currentLink.current !== payload) return;
+ if (!response.ok)
+ throw new Error(
+ typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
+ );
+ setState(result.state);
+ window.history.replaceState(null, "", "/newsletter-subscription");
+ } catch (err) {
+ if (currentLink.current === payload)
+ setError(err instanceof Error ? err.message : "Could not update your preference.");
+ } finally {
+ if (currentLink.current === payload) setBusy(false);
+ }
+ };
+
+ const done = state === "enabled" || state === "off";
+ return (
+
+
+
+ Magent
+
+
+ Magent newsletters
+
+ {state === "enabled"
+ ? "You’re on the list."
+ : state === "off"
+ ? "Newsletters are turned off."
+ : state === "loading"
+ ? "Checking your email link"
+ : state === "error"
+ ? "This link needs another look"
+ : link?.action === "unsubscribe"
+ ? "Unsubscribe from newsletters?"
+ : "Your next watch starts here."}
+
+
+ {state === "enabled"
+ ? "Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs."
+ : state === "off"
+ ? "You won’t receive further monthly recaps. You can turn them back on in Profile."
+ : state === "ready" && link?.action === "unsubscribe"
+ ? "This turns off new-arrival newsletters. Your personal monthly recaps are managed separately."
+ : state === "ready"
+ ? "Confirm to receive new movies, TV updates and featured picks, with posters and links to watch."
+ : ""}
+
+ {error && (
+
+ {error}
+
+ )}
+ {state === "ready" && (
+ void apply()}>
+ {busy
+ ? "Updating…"
+ : link?.action === "unsubscribe"
+ ? "Unsubscribe from newsletters"
+ : "Confirm newsletter subscription"}
+
+ )}
+ {(done || state === "error") && (
+
+ Manage email preferences ↗
+
+ )}
+ {state === "loading" && One moment…
}
+
+
+ );
+}
diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx
new file mode 100644
index 0000000..dcc880a
--- /dev/null
+++ b/frontend/app/not-found.tsx
@@ -0,0 +1,13 @@
+import Link from "next/link";
+import PageHeading from "./ui/PageHeading";
+
+export default function NotFound() {
+ return (
+
+
+
+ ← Back to my requests
+
+
+ );
+}
diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css
new file mode 100644
index 0000000..de91888
--- /dev/null
+++ b/frontend/app/ops-redesign.css
@@ -0,0 +1,5142 @@
+/* Stitch production handoff: Media-Ops master system */
+* {
+ 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: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 var(--site-banner-border-color, var(--site-banner-tone-border, rgba(255, 208, 130, 0.28)));
+ background: var(--site-banner-background-color, var(--site-banner-tone-background, rgba(103, 75, 25, 0.38)));
+ color: #ffe4b3;
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.82rem;
+}
+.site-banner--info { --site-banner-tone-background: rgba(46, 92, 153, 0.34); --site-banner-tone-border: rgba(126, 184, 255, 0.38); }
+.site-banner--warning { --site-banner-tone-background: rgba(103, 75, 25, 0.38); --site-banner-tone-border: rgba(255, 208, 130, 0.28); }
+.site-banner--error { --site-banner-tone-background: rgba(104, 36, 43, 0.4); --site-banner-tone-border: rgba(255, 128, 139, 0.38); }
+.site-banner--maintenance { --site-banner-tone-background: rgba(98, 57, 27, 0.42); --site-banner-tone-border: rgba(255, 163, 92, 0.38); }
+
+.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);
+}
+
+.home-page {
+ display: grid;
+ gap: 18px;
+}
+
+.home-command {
+ display: grid;
+ grid-template-columns: minmax(0, 0.85fr) minmax(380px, 1.15fr);
+ align-items: end;
+ gap: clamp(24px, 5vw, 64px);
+ padding: clamp(24px, 4vw, 42px);
+ overflow: hidden;
+ border: 1px solid rgba(126, 215, 255, 0.22);
+ border-radius: var(--ops-radius-lg);
+ background:
+ radial-gradient(circle at 88% 20%, rgba(14, 165, 233, 0.18), transparent 42%),
+ linear-gradient(145deg, rgba(35, 74, 145, 0.18), rgba(9, 17, 36, 0.68));
+}
+
+.home-command-copy {
+ display: grid;
+ gap: 10px;
+}
+
+.home-command-copy h1 {
+ margin: 0;
+ font-size: clamp(2rem, 4.5vw, 3.8rem);
+ line-height: 0.98;
+ letter-spacing: -0.045em;
+}
+
+.home-command-copy p {
+ max-width: 52ch;
+ margin: 0;
+ color: var(--ops-muted);
+ line-height: 1.6;
+}
+
+.home-search {
+ display: grid;
+ gap: 9px;
+}
+
+.home-search > label {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.home-search-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+}
+
+.home-search-row input,
+.home-search-row button {
+ min-height: 52px;
+}
+
+.home-search-row input {
+ padding-inline: 17px;
+ border-color: rgba(126, 215, 255, 0.25);
+ background: rgba(3, 8, 20, 0.48);
+ font-size: 1rem;
+}
+
+.home-search-results,
+.home-recent {
+ padding: 20px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.024);
+}
+
+.home-section-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.home-section-heading > div:first-child {
+ display: grid;
+ gap: 5px;
+}
+
+.home-section-heading h2 {
+ margin: 0;
+}
+
+.home-result-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 10px;
+ margin-top: 16px;
+}
+
+.home-result-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 72px;
+ padding: 13px 15px;
+ text-align: left;
+ border: 1px solid var(--ops-line-soft);
+ background: rgba(255, 255, 255, 0.032);
+}
+
+.home-result-card > span:first-child {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.home-result-card small,
+.home-result-card > span:last-child {
+ color: var(--ops-muted);
+ font-size: 0.75rem;
+}
+
+.home-metric-strip {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.home-metric-strip > div {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ padding: 15px 18px;
+ border-right: 1px solid var(--ops-line-soft);
+}
+
+.home-metric-strip > div:last-child {
+ border-right: 0;
+}
+
+.home-metric-strip span {
+ color: var(--ops-muted);
+ font-size: 0.74rem;
+}
+
+.home-metric-strip strong {
+ color: var(--ops-text);
+ font-size: 1.15rem;
+}
+
+.home-recent .recent-header {
+ margin-bottom: 16px;
+}
+
+.home-recent-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.home-recent-grid .recent-card {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ min-height: 92px;
+ padding: 10px;
+}
+
+.recent-poster-placeholder {
+ display: grid;
+ place-items: center;
+ width: 52px;
+ height: 70px;
+ color: var(--ops-faint);
+ background: rgba(255, 255, 255, 0.035);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.68rem;
+}
+
+.recent-open-cue {
+ padding-right: 6px;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.66rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.home-empty-state {
+ display: grid;
+ gap: 6px;
+ place-items: center;
+ min-height: 170px;
+ color: var(--ops-muted);
+ text-align: center;
+ border: 1px dashed var(--ops-line);
+ border-radius: var(--ops-radius);
+}
+
+.home-empty-state strong {
+ color: var(--ops-text);
+}
+
+.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;
+}
+
+.config-subsection-nav {
+ display: grid;
+ gap: 11px;
+ padding: 14px 16px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.024);
+}
+
+.config-subsection-nav > span {
+ color: var(--ops-muted);
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.config-subsection-nav > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.config-subsection-nav a {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 36px;
+ padding: 7px 11px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+ color: var(--ops-text);
+ font-size: 0.76rem;
+ font-weight: 700;
+ text-decoration: none;
+ transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
+}
+
+.config-subsection-nav a:hover {
+ transform: translateY(-1px);
+ border-color: rgba(126, 215, 255, 0.38);
+ background: rgba(14, 165, 233, 0.09);
+}
+
+.config-subsection-nav a small {
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.64rem;
+}
+
+.config-subsection {
+ scroll-margin-top: 124px;
+}
+
+.config-subsection-heading {
+ display: grid;
+ gap: 4px;
+}
+
+.fleet-status-panel {
+ display: grid;
+ gap: 16px;
+ border-color: rgba(126, 215, 255, 0.2);
+ background:
+ radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.1), transparent 38%),
+ rgba(255, 255, 255, 0.024);
+}
+
+.fleet-status-header {
+ align-items: center;
+}
+
+.fleet-status-header > div {
+ display: grid;
+ gap: 5px;
+}
+
+.fleet-status-header h2 {
+ margin: 0;
+}
+
+.fleet-service-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.fleet-service-card {
+ display: grid;
+ gap: 13px;
+ min-width: 0;
+ padding: 15px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(3, 8, 20, 0.24);
+}
+
+.fleet-service-card.system-down {
+ border-color: rgba(255, 141, 141, 0.3);
+}
+
+.fleet-service-card.system-degraded {
+ border-color: rgba(255, 208, 130, 0.3);
+}
+
+.fleet-service-title {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 11px;
+}
+
+.fleet-service-title > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+}
+
+.fleet-service-title h3 {
+ margin: 0;
+}
+
+.fleet-service-card p {
+ min-height: 2.8em;
+ margin: 0;
+ color: var(--ops-muted);
+ font-size: 0.84rem;
+ line-height: 1.45;
+}
+
+.fleet-service-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding-top: 11px;
+ border-top: 1px solid var(--ops-line-soft);
+}
+
+.fleet-service-actions a {
+ color: var(--ops-cyan);
+ font-size: 0.78rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.fleet-service-actions button {
+ min-height: 34px;
+ padding: 7px 10px;
+ font-size: 0.75rem;
+}
+
+.admin-rail-action {
+ display: inline-flex;
+ margin-top: 12px;
+ color: var(--ops-cyan);
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.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);
+}
+
+.admin-form .admin-zone {
+ display: grid;
+ gap: 14px;
+ padding: 20px;
+}
+
+.admin-form .admin-grid {
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 12px;
+}
+
+.admin-form .admin-grid > label {
+ align-content: start;
+ min-width: 0;
+ padding: 14px;
+ text-align: left;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+}
+
+.admin-form .admin-grid > label:focus-within {
+ border-color: rgba(126, 215, 255, 0.38);
+ background: rgba(14, 165, 233, 0.055);
+}
+
+.admin-form .admin-grid label[data-helper]::after {
+ min-height: 2.8em;
+ color: var(--ops-muted);
+ font-family: Manrope, "Segoe UI", sans-serif;
+ font-size: 0.76rem;
+ font-weight: 500;
+ line-height: 1.4;
+ text-align: left;
+ text-transform: none;
+}
+
+.admin-form .label-row {
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.admin-form .label-row > span:first-child {
+ color: var(--ops-text);
+}
+
+.admin-form .label-row .meta {
+ flex: 0 0 auto;
+ padding: 3px 6px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: 999px;
+ color: var(--ops-faint);
+ font-size: 0.62rem;
+ line-height: 1.2;
+ text-transform: none;
+}
+
+.admin-form .admin-grid input,
+.admin-form .admin-grid select,
+.admin-form .admin-grid textarea {
+ width: 100%;
+}
+
+.admin-form .section-header h2 {
+ margin: 0;
+}
+
+.admin-form .settings-section-actions {
+ align-items: end;
+ justify-content: flex-end;
+}
+
+.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;
+ }
+
+ .home-command {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+}
+
+@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,
+ .home-metric-strip,
+ .home-recent-grid,
+ .fleet-service-grid,
+ .portal-overview-grid,
+ .status-box,
+ .history-grid,
+ .summary,
+ .ops-status-strip,
+ .pipeline-steps {
+ grid-template-columns: 1fr;
+ }
+
+ .home-metric-strip > div {
+ border-right: 0;
+ border-bottom: 1px solid var(--ops-line-soft);
+ }
+
+ .home-metric-strip > div:last-child {
+ border-bottom: 0;
+ }
+
+ .home-search-row {
+ grid-template-columns: 1fr;
+ }
+
+ .home-command,
+ .home-search-results,
+ .home-recent {
+ padding: 16px;
+ }
+
+ .home-section-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .home-recent-grid .recent-card {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .recent-open-cue {
+ display: none;
+ }
+
+ .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-repair-activity,
+.request-add-seasons,
+.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-repair-activity {
+ display: grid;
+ gap: 16px;
+ padding: 18px 20px;
+ border-color: rgba(126, 215, 255, 0.3);
+ background:
+ radial-gradient(circle at 6% 0%, rgba(14, 165, 233, 0.13), transparent 34%),
+ rgba(255, 255, 255, 0.018);
+}
+.request-repair-activity.is-complete { border-color: rgba(72, 224, 178, 0.34); }
+.request-repair-activity.is-attention { border-color: rgba(255, 141, 157, 0.4); }
+.request-repair-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+}
+.request-repair-heading h2 { margin: 0; font-size: clamp(1.15rem, 2vw, 1.55rem); }
+.request-repair-heading p { max-width: 88ch; margin: 7px 0 0; color: var(--ops-muted); line-height: 1.5; }
+.request-repair-meta { display: grid; justify-items: end; gap: 7px; color: var(--ops-muted); }
+.request-repair-meta small { white-space: nowrap; font-size: 0.7rem; }
+.request-repair-steps {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 9px;
+}
+.request-repair-step {
+ position: relative;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 9px;
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+}
+.request-repair-step > i {
+ width: 10px;
+ height: 10px;
+ margin-top: 3px;
+ border: 2px solid currentColor;
+ border-radius: 50%;
+ color: var(--ops-muted);
+}
+.request-repair-step.is-complete > i { color: var(--request-green); background: currentColor; }
+.request-repair-step.is-active > i {
+ color: var(--request-cyan);
+ box-shadow: 0 0 12px currentColor;
+ animation: request-operation-pulse 1.15s ease-in-out infinite;
+}
+.request-repair-step.is-attention > i { color: var(--request-red); background: currentColor; }
+.request-repair-step > div { display: grid; gap: 4px; min-width: 0; }
+.request-repair-step strong { color: var(--ops-text); font-size: 0.76rem; }
+.request-repair-step span { color: var(--ops-muted); font-size: 0.72rem; line-height: 1.4; }
+
+.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-next-step-main {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 28px;
+}
+.request-next-step-copy {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+.request-next-step-copy > strong { color: var(--ops-text); font-size: 1rem; line-height: 1.4; }
+.request-next-step-copy > p { margin: 0; }
+.request-watch-button {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-width: 240px;
+ min-height: 48px;
+ padding: 12px 20px;
+ border: 1px solid rgba(72, 224, 178, 0.62);
+ border-radius: var(--ops-radius);
+ background: linear-gradient(115deg, rgba(16, 185, 129, 0.92), rgba(14, 165, 233, 0.9));
+ color: #fff;
+ box-shadow: 0 10px 28px rgba(14, 165, 233, 0.16);
+ font-size: 0.84rem;
+ font-weight: 800;
+ text-decoration: none;
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
+}
+.request-watch-button:hover {
+ border-color: rgba(126, 255, 214, 0.9);
+ box-shadow: 0 12px 34px rgba(14, 165, 233, 0.24);
+ transform: translateY(-1px);
+}
+.request-watch-button > span { font-size: 1rem; }
+
+.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-row .request-recheck-button {
+ margin-left: auto;
+ border-color: var(--ops-line);
+ background: rgba(255, 255, 255, 0.035);
+ color: var(--ops-text);
+}
+.request-action-row .request-recheck-button:hover:not(:disabled) {
+ border-color: rgba(126, 215, 255, 0.48);
+ background: rgba(126, 215, 255, 0.09);
+}
+.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-add-seasons {
+ display: grid;
+ gap: 18px;
+ padding: 20px;
+ border-color: rgba(126, 215, 255, 0.32);
+ background:
+ radial-gradient(circle at 5% 0%, rgba(14, 165, 233, 0.13), transparent 34%),
+ rgba(255, 255, 255, 0.018);
+}
+.request-add-seasons-heading,
+.request-add-seasons-submit { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
+.request-add-seasons-heading > div,
+.request-add-seasons-submit > div { display: grid; gap: 5px; }
+.request-add-seasons-heading h2 { margin: 0; font-size: clamp(1.25rem, 2.2vw, 1.8rem); }
+.request-add-seasons-heading p { max-width: 82ch; margin: 0; color: var(--ops-muted); line-height: 1.5; }
+.request-add-seasons-submit {
+ padding: 15px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.024);
+}
+.request-add-seasons-submit small { color: var(--ops-muted); line-height: 1.45; }
+.request-add-seasons-submit button { flex: 0 0 auto; min-width: 190px; min-height: 44px; }
+
+.request-operation-progress {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 14px;
+ padding: 16px 18px;
+ border-top: 1px solid var(--ops-line-soft);
+ background: rgba(5, 9, 20, 0.42);
+}
+.request-operation-progress.is-running { background: rgba(14, 165, 233, 0.055); }
+.request-operation-progress.is-error { background: rgba(255, 86, 113, 0.055); }
+.request-operation-heading,
+.request-operation-heading-actions,
+.request-operation-event { display: flex; align-items: center; gap: 12px; }
+.request-operation-heading { justify-content: space-between; }
+.request-operation-heading > div:first-child { display: grid; gap: 4px; }
+.request-operation-heading > div:first-child > strong { color: var(--ops-text); font-size: 0.94rem; }
+.request-operation-heading-actions { color: var(--ops-muted); font-size: 0.7rem; }
+.request-operation-heading-actions button {
+ min-height: 30px;
+ padding: 5px 9px;
+ border: 1px solid var(--ops-line);
+ background: rgba(255, 255, 255, 0.035);
+ color: var(--ops-text);
+ font-size: 0.7rem;
+}
+.request-operation-status {
+ padding: 5px 8px;
+ border: 1px solid var(--ops-line);
+ border-radius: 999px;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.62rem;
+ font-weight: 750;
+ text-transform: uppercase;
+}
+.request-operation-status.is-running { border-color: rgba(126, 215, 255, 0.36); color: var(--ops-cyan); }
+.request-operation-status.is-complete { border-color: rgba(72, 224, 178, 0.3); color: var(--request-green); }
+.request-operation-status.is-error { border-color: rgba(255, 86, 113, 0.34); color: var(--request-red); }
+.request-operation-events { display: grid; gap: 7px; }
+.request-operation-event {
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+}
+.request-operation-event > i {
+ flex: 0 0 auto;
+ width: 9px;
+ height: 9px;
+ border: 2px solid currentColor;
+ border-radius: 50%;
+ color: var(--ops-muted);
+}
+.request-operation-event.is-active > i {
+ color: var(--ops-cyan);
+ box-shadow: 0 0 12px currentColor;
+ animation: request-operation-pulse 1.15s ease-in-out infinite;
+}
+.request-operation-event.is-complete > i { color: var(--request-green); background: currentColor; }
+.request-operation-event.is-error > i { color: var(--request-red); background: currentColor; }
+.request-operation-event > div { display: grid; gap: 2px; min-width: 0; }
+.request-operation-event strong { color: var(--ops-text); font-size: 0.75rem; }
+.request-operation-event span { color: var(--ops-muted); font-size: 0.75rem; }
+.request-operation-event small { margin-left: auto; color: var(--ops-muted); font-size: 0.66rem; white-space: nowrap; }
+@keyframes request-operation-pulse {
+ 0%, 100% { opacity: 0.5; transform: scale(0.8); }
+ 50% { opacity: 1; transform: scale(1); }
+}
+
+.request-journey { display: grid; gap: 18px; padding: 20px; }
+.request-journey-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
+.request-journey-heading h2 { 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-meter-track.is-live-download > span { transition: width 1.8s linear; will-change: width; }
+.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-modal-layer {
+ position: fixed;
+ z-index: 1200;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: max(18px, 4vh) 18px;
+}
+.request-release-modal-backdrop {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: rgba(2, 6, 18, 0.82);
+ backdrop-filter: blur(8px);
+ cursor: default;
+}
+.request-release-modal {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ width: min(980px, 100%);
+ max-height: min(820px, 92vh);
+ overflow: hidden;
+ border: 1px solid rgba(126, 215, 255, 0.42);
+ border-radius: var(--ops-radius-lg);
+ background: linear-gradient(145deg, rgba(19, 29, 48, 0.99), rgba(9, 15, 29, 0.99));
+ box-shadow: 0 34px 100px rgba(0, 0, 0, 0.68), inset 0 2px 0 rgba(126, 215, 255, 0.72);
+}
+.request-release-modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 20px;
+ border-bottom: 1px solid var(--ops-line);
+ background: rgba(13, 21, 39, 0.96);
+}
+.request-release-modal-header > div { display: grid; gap: 5px; }
+.request-release-modal-header h2 { margin: 0; font-size: clamp(1.35rem, 3vw, 2rem); }
+.request-release-modal-header p { margin: 0; color: var(--ops-muted); font-size: 0.8rem; }
+.request-release-modal-body { display: grid; gap: 12px; min-height: 0; padding: 16px; overflow: auto; }
+.request-release-list { display: grid; gap: 9px; }
+.request-release {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 16px;
+ padding: 14px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.025);
+}
+.request-release.is-best-pick {
+ border-color: rgba(72, 224, 178, 0.48);
+ background: linear-gradient(135deg, rgba(72, 224, 178, 0.13), rgba(14, 165, 233, 0.06));
+ box-shadow: inset 3px 0 0 var(--request-green);
+}
+.request-release-copy { display: grid; gap: 5px; min-width: 0; }
+.request-release-copy > strong { overflow: hidden; color: var(--ops-text); text-overflow: ellipsis; white-space: nowrap; }
+.request-release-copy > span,
+.request-release-copy > small { color: var(--ops-muted); font-size: 0.72rem; line-height: 1.4; }
+.request-release-copy > small { color: #a8cfc6; }
+.request-release-badges { display: flex; flex-wrap: wrap; gap: 6px; }
+.request-release-badges > span {
+ padding: 3px 7px;
+ border: 1px solid rgba(126, 215, 255, 0.22);
+ border-radius: 999px;
+ color: #b7dff2;
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.61rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+.request-release-badges > .request-release-best-badge {
+ border-color: rgba(72, 224, 178, 0.48);
+ background: rgba(72, 224, 178, 0.12);
+ color: var(--request-green);
+}
+.request-release-profile-note,
+.request-release-empty,
+.request-release-searching {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ padding: 14px;
+ border: 1px solid rgba(126, 215, 255, 0.24);
+ border-radius: var(--ops-radius);
+ background: rgba(14, 165, 233, 0.065);
+}
+.request-release-profile-note,
+.request-release-empty { display: grid; gap: 4px; }
+.request-release-profile-note strong,
+.request-release-empty strong,
+.request-release-searching strong { color: var(--ops-text); font-size: 0.82rem; }
+.request-release-profile-note span,
+.request-release-empty span,
+.request-release-searching span { color: var(--ops-muted); font-size: 0.75rem; line-height: 1.45; }
+.request-release-empty { min-height: 150px; align-content: center; justify-items: center; text-align: center; }
+.request-release-empty.is-error { border-color: rgba(255, 112, 131, 0.36); background: rgba(255, 112, 131, 0.07); }
+.request-release-searching > i {
+ flex: 0 0 16px;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(126, 215, 255, 0.22);
+ border-top-color: var(--request-cyan);
+ border-radius: 50%;
+ animation: request-release-spin 0.8s linear infinite;
+}
+.request-release-searching > div { display: grid; gap: 3px; }
+@keyframes request-release-spin { to { transform: rotate(360deg); } }
+
+.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; }
+ .request-repair-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@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 { 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-next-step-main { align-items: stretch; flex-direction: column; }
+ .request-watch-button { width: 100%; min-width: 0; }
+ .request-action-row button,
+ .request-release button { width: 100%; }
+ .request-release-modal-layer { padding: 10px; }
+ .request-release-modal { max-height: 94vh; }
+ .request-release-modal-header { padding: 16px; }
+ .request-release-modal-body { padding: 12px; }
+ .request-operation-heading { align-items: flex-start; flex-direction: column; }
+ .request-repair-heading { flex-direction: column; }
+ .request-repair-meta { justify-items: start; }
+ .request-repair-steps { grid-template-columns: 1fr; }
+ .request-operation-heading-actions { width: 100%; flex-wrap: wrap; }
+ .request-operation-event { align-items: flex-start; }
+}
+
+/* Progressive request portal */
+.request-portal-page {
+ display: grid;
+ gap: 18px;
+ padding: clamp(14px, 2vw, 24px);
+}
+
+.request-portal-hero {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ min-height: 132px;
+ padding: clamp(20px, 3vw, 34px);
+ overflow: hidden;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background:
+ radial-gradient(circle at 12% 0%, rgba(126, 215, 255, 0.17), transparent 32%),
+ linear-gradient(120deg, rgba(79, 70, 229, 0.13), transparent 50%),
+ var(--ops-panel);
+}
+.request-portal-hero > div:first-child { display: grid; gap: 7px; max-width: 720px; }
+.request-portal-hero h1 { font-size: clamp(1.75rem, 4vw, 3rem); letter-spacing: -0.045em; }
+.request-portal-hero p { max-width: 650px; margin: 0; color: var(--ops-muted); line-height: 1.55; }
+.request-portal-route { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
+.request-portal-route span {
+ padding: 7px 10px;
+ border: 1px solid var(--ops-line);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.035);
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.66rem;
+ font-weight: 750;
+ text-transform: uppercase;
+}
+.request-portal-route i { width: 18px; height: 1px; background: linear-gradient(90deg, var(--ops-line), var(--ops-cyan)); }
+.request-flow-alert { margin: 0; }
+
+.request-flow-stage {
+ position: relative;
+ display: grid;
+ gap: 20px;
+ padding: clamp(18px, 2.4vw, 28px);
+ 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));
+ animation: request-flow-arrive 0.34s ease both;
+}
+.request-flow-stage:not(:last-child)::after {
+ position: absolute;
+ bottom: -19px;
+ left: 44px;
+ width: 1px;
+ height: 19px;
+ background: linear-gradient(var(--ops-cyan), rgba(126, 215, 255, 0.15));
+ content: "";
+}
+.request-flow-stage.is-current { border-color: rgba(126, 215, 255, 0.24); }
+@keyframes request-flow-arrive {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.request-flow-heading { display: flex; align-items: center; gap: 13px; }
+.request-flow-heading > div { display: grid; gap: 2px; }
+.request-flow-heading > div > span,
+.request-selection-summary small,
+.request-existing-state span,
+.request-submit-bar span,
+.request-submit-progress header span {
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.66rem;
+ font-weight: 750;
+ letter-spacing: 0.055em;
+ text-transform: uppercase;
+}
+.request-flow-heading h2 { font-size: clamp(1.15rem, 2.2vw, 1.65rem); letter-spacing: -0.025em; }
+.request-flow-number {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border: 1px solid rgba(126, 215, 255, 0.34);
+ border-radius: 50%;
+ background: rgba(14, 165, 233, 0.1);
+ color: var(--ops-cyan);
+ box-shadow: 0 0 22px rgba(14, 165, 233, 0.09);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.7rem;
+ font-weight: 800;
+}
+
+.request-type-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.request-type-card {
+ display: block;
+ min-height: 174px;
+ padding: 20px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.025);
+ color: var(--ops-text);
+ text-align: left;
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
+}
+.request-type-card:hover { transform: translateY(-2px); border-color: rgba(126, 215, 255, 0.5); }
+.request-type-card.is-selected { border-color: rgba(72, 224, 178, 0.58); background: linear-gradient(135deg, rgba(72, 224, 178, 0.11), rgba(14, 165, 233, 0.06)); }
+.request-type-card-body { display: flex; align-items: center; gap: 20px; width: 100%; min-height: 132px; }
+.request-type-card-copy { display: grid; justify-items: start; gap: 7px; min-width: 0; }
+.request-type-card-copy > span:first-child { color: var(--ops-cyan); font-family: "JetBrains Mono", Consolas, monospace; font-size: 0.66rem; text-transform: uppercase; }
+.request-service-icon {
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ width: 70px;
+ height: 70px;
+ padding: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.44);
+ border-radius: 16px;
+ background: rgba(245, 248, 252, 0.94);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24);
+}
+.request-service-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
+.request-type-card.is-selected .request-service-icon { border-color: rgba(72, 224, 178, 0.72); box-shadow: 0 0 24px rgba(72, 224, 178, 0.18); }
+.request-type-card-copy strong { font-size: 1.35rem; }
+.request-type-description { max-width: 440px; color: var(--ops-muted); font-weight: 450; line-height: 1.5; text-transform: none !important; }
+.request-type-card-copy b { color: var(--request-green); font-size: 0.74rem; }
+
+.request-flow-search { display: grid; gap: 7px; }
+.request-flow-search > label,
+.request-profile-field > span,
+.request-season-picker legend { color: var(--ops-muted); font-size: 0.75rem; font-weight: 750; text-transform: uppercase; }
+.request-flow-search > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; }
+.request-flow-search input { width: 100%; min-height: 48px; }
+.request-flow-search button { min-width: 150px; }
+
+.request-flow-empty { display: grid; gap: 5px; padding: 20px; border: 1px dashed var(--ops-line); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.018); }
+.request-flow-empty p { margin: 0; color: var(--ops-muted); }
+.request-result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.request-result-card {
+ display: grid;
+ grid-template-columns: 96px minmax(0, 1fr);
+ gap: 15px;
+ min-width: 0;
+ min-height: 166px;
+ padding: 11px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.023);
+ color: var(--ops-text);
+ text-align: left;
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
+}
+.request-result-card:hover { transform: translateY(-2px); border-color: rgba(126, 215, 255, 0.4); }
+.request-result-card.is-selected { border-color: rgba(72, 224, 178, 0.55); background: rgba(72, 224, 178, 0.06); }
+.request-result-poster,
+.request-selection-poster { display: grid; place-items: center; overflow: hidden; border: 1px solid var(--ops-line); border-radius: calc(var(--ops-radius) - 2px); background: rgba(255, 255, 255, 0.035); }
+.request-result-poster { width: 96px; height: 144px; }
+.request-result-poster img,
+.request-selection-poster img { width: 100%; height: 100%; object-fit: cover; }
+.request-result-poster i,
+.request-selection-poster i { color: var(--ops-muted); font-size: 0.65rem; font-style: normal; }
+.request-result-copy { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 5px 4px 5px 0; }
+.request-result-copy small { color: var(--ops-cyan); font-size: 0.68rem; font-weight: 700; text-transform: uppercase; }
+.request-result-copy strong { font-size: 1rem; }
+.request-result-copy p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--ops-muted); font-size: 0.76rem; font-weight: 450; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
+.request-result-copy b { align-self: end; margin-top: auto; color: var(--request-green); font-size: 0.71rem; }
+
+.request-configure-stage { border-color: rgba(72, 224, 178, 0.28); }
+.request-selection-summary { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 16px; padding: 13px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius-lg); background: rgba(255, 255, 255, 0.022); }
+.request-selection-poster { width: 86px; height: 129px; }
+.request-selection-summary > div { display: grid; gap: 6px; }
+.request-selection-summary h3 { font-size: clamp(1.15rem, 2vw, 1.55rem); }
+.request-selection-summary p { max-width: 760px; margin: 0; color: var(--ops-muted); font-size: 0.82rem; line-height: 1.5; }
+.request-existing-state { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px; border: 1px solid rgba(72, 224, 178, 0.28); border-radius: var(--ops-radius-lg); background: rgba(72, 224, 178, 0.06); }
+.request-existing-state > div { display: grid; gap: 5px; }
+.request-existing-state p { margin: 0; color: var(--ops-muted); }
+
+.request-options-layout { display: grid; gap: 16px; }
+.request-season-picker { display: grid; gap: 11px; margin: 0; padding: 0; border: 0; }
+.request-season-actions { display: flex; gap: 7px; }
+.request-season-actions button { padding: 6px 9px; border-color: var(--ops-line); background: rgba(255, 255, 255, 0.025); color: var(--ops-muted); font-size: 0.67rem; }
+.request-season-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+.request-season-grid label { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 11px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.022); cursor: pointer; }
+.request-season-grid label.is-selected { border-color: rgba(72, 224, 178, 0.4); background: rgba(72, 224, 178, 0.07); }
+.request-season-grid label > span { display: grid; gap: 2px; min-width: 0; }
+.request-season-grid label strong { overflow: hidden; font-size: 0.78rem; text-overflow: ellipsis; white-space: nowrap; }
+.request-season-grid label small { color: var(--ops-muted); font-size: 0.67rem; }
+.request-profile-field { display: grid; gap: 7px; max-width: 620px; }
+.request-profile-field select { min-height: 46px; }
+.request-profile-field small,
+.request-submit-bar small { color: var(--ops-muted); font-size: 0.7rem; }
+.request-submit-bar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 17px; border: 1px solid rgba(126, 215, 255, 0.28); border-radius: var(--ops-radius-lg); background: linear-gradient(110deg, rgba(14, 165, 233, 0.09), rgba(79, 70, 229, 0.05)); }
+.request-submit-bar > div { display: grid; gap: 3px; }
+.request-submit-bar button { min-width: 180px; }
+
+.request-submit-progress { display: grid; gap: 10px; padding: 14px; border: 1px solid rgba(126, 215, 255, 0.3); border-radius: var(--ops-radius-lg); background: rgba(14, 165, 233, 0.055); }
+.request-submit-progress.is-complete { border-color: rgba(72, 224, 178, 0.3); background: rgba(72, 224, 178, 0.045); }
+.request-submit-progress.is-error { border-color: rgba(255, 86, 113, 0.35); background: rgba(255, 86, 113, 0.05); }
+.request-submit-progress header { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
+.request-submit-progress header > div { display: grid; gap: 3px; }
+.request-submit-progress header small { color: var(--ops-muted); }
+.request-submit-progress > div { display: grid; gap: 6px; }
+.request-submit-progress p { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; margin: 0; padding: 9px 10px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.02); }
+.request-submit-progress p > i { width: 8px; height: 8px; border-radius: 50%; background: var(--ops-muted); }
+.request-submit-progress p.is-active > i { background: var(--ops-cyan); box-shadow: 0 0 12px var(--ops-cyan); animation: request-operation-pulse 1.15s ease-in-out infinite; }
+.request-submit-progress p.is-complete > i { background: var(--request-green); }
+.request-submit-progress p.is-error > i { background: var(--request-red); }
+.request-submit-progress p > span { display: grid; gap: 1px; color: var(--ops-muted); font-size: 0.72rem; }
+.request-submit-progress p > span strong { color: var(--ops-text); font-size: 0.7rem; }
+.request-submit-progress p > small { color: var(--ops-muted); font-size: 0.65rem; }
+.request-complete-actions { display: flex; gap: 9px; justify-content: flex-end; }
+
+@media (max-width: 920px) {
+ .request-portal-hero { align-items: flex-start; flex-direction: column; }
+ .request-result-grid { grid-template-columns: 1fr; }
+ .request-season-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 640px) {
+ .request-portal-page { padding: 10px; }
+ .request-portal-route { width: 100%; overflow-x: auto; }
+ .request-type-grid,
+ .request-season-grid { grid-template-columns: 1fr; }
+ .request-type-card-body { align-items: flex-start; }
+ .request-service-icon { width: 58px; height: 58px; padding: 8px; }
+ .request-flow-search > div { grid-template-columns: 1fr; }
+ .request-flow-search button { width: 100%; }
+ .request-result-card { grid-template-columns: 76px minmax(0, 1fr); }
+ .request-result-poster { width: 76px; height: 114px; }
+ .request-selection-summary { grid-template-columns: 66px minmax(0, 1fr); }
+ .request-selection-poster { width: 66px; height: 99px; }
+ .request-existing-state,
+ .request-submit-bar { align-items: stretch; flex-direction: column; }
+ .request-existing-state button,
+ .request-submit-bar button { width: 100%; }
+ .request-submit-progress p { grid-template-columns: auto minmax(0, 1fr); }
+ .request-submit-progress p > small { grid-column: 2; }
+ .request-complete-actions { display: grid; }
+}
+
+/* ========================================================================== */
+/* Stitch master layout and component alignment */
+/* ========================================================================== */
+
+html,
+body {
+ background: var(--ops-bg);
+}
+
+body {
+ background-image: none;
+ font-family: Inter, "Segoe UI", Arial, sans-serif;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+h1,
+h2,
+h3,
+h4,
+.brand {
+ font-family: "DM Sans", "Segoe UI", sans-serif;
+}
+
+button,
+input,
+select,
+textarea {
+ font-family: Inter, "Segoe UI", sans-serif;
+}
+
+.page {
+ display: block;
+ width: 100%;
+ max-width: none;
+ min-height: 100vh;
+ margin: 0;
+ padding: 64px 0 88px;
+}
+
+.header {
+ position: fixed;
+ inset: 0 0 auto;
+ z-index: 1000;
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(480px, auto) minmax(220px, 1fr);
+ grid-template-rows: 64px;
+ align-items: center;
+ width: 100%;
+ height: 64px;
+ margin: 0;
+ padding: 0 24px;
+ border: 0;
+ border-bottom: 1px solid var(--ops-line);
+ background: rgba(14, 14, 16, 0.96);
+ backdrop-filter: blur(16px);
+}
+
+.header-left,
+.header-right,
+.header-nav {
+ grid-row: 1;
+ margin: 0;
+}
+
+.header-left { grid-column: 1; }
+.header-nav { grid-column: 2; }
+.header-right { grid-column: 3; justify-self: end; }
+.brand-link { gap: 10px; }
+.brand-logo--header { width: 32px; height: 32px; }
+.brand {
+ color: var(--ops-primary-2);
+ font-size: 1.7rem;
+ font-weight: 700;
+ text-transform: none;
+ text-shadow: none;
+}
+.tagline { display: none; }
+.beta-chip {
+ min-height: 28px;
+ padding: 4px 10px;
+ border-color: var(--ops-line);
+ background: var(--ops-panel-2);
+ color: var(--ops-primary-2);
+ font-size: 0.65rem;
+}
+.header-actions { gap: 4px; flex-wrap: nowrap; }
+.header-actions a {
+ min-height: 64px;
+ padding: 0 12px;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ background: transparent;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.76rem;
+}
+.header-actions a:hover,
+.header-actions a.is-active {
+ border-color: var(--ops-primary-2);
+ background: transparent;
+ color: var(--ops-primary-2);
+}
+.user-view-toggle {
+ min-height: 32px;
+ border-color: var(--ops-line);
+ background: var(--ops-primary);
+ color: var(--ops-primary-2);
+}
+.avatar-button {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ background: var(--ops-panel-3);
+}
+.signed-in-dropdown { background: #1c1b1d; }
+
+.workspace-sidebar {
+ position: fixed;
+ inset: 64px auto 0 0;
+ z-index: 90;
+ display: flex;
+ width: 280px;
+ padding: 24px 16px 18px;
+ flex-direction: column;
+ gap: 22px;
+ border-right: 1px solid var(--ops-line);
+ background: #201f21;
+}
+
+.workspace-sidebar-identity {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ align-items: center;
+ gap: 12px;
+}
+.workspace-sidebar-logo {
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+}
+.workspace-sidebar-identity > div { display: grid; gap: 2px; min-width: 0; }
+.workspace-sidebar-identity strong { font-family: "DM Sans", sans-serif; font-size: 1.05rem; }
+.workspace-sidebar-identity span {
+ overflow: hidden;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.64rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.workspace-new-request,
+.admin-new-request {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-height: 48px;
+ border: 1px solid #2f3455;
+ border-radius: 8px;
+ background: var(--ops-primary);
+ color: var(--ops-primary-2);
+ font-family: "DM Sans", sans-serif;
+ font-size: 1rem;
+ font-weight: 600;
+ text-decoration: none;
+}
+.workspace-new-request span,
+.admin-new-request span { font-size: 1.4rem; font-weight: 400; }
+.workspace-sidebar > nav { display: grid; gap: 5px; }
+.workspace-sidebar > nav a {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ min-height: 45px;
+ padding: 9px 13px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.78rem;
+ text-decoration: none;
+}
+.workspace-sidebar svg,
+.workspace-mobile-nav svg {
+ width: 21px;
+ height: 21px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.8;
+}
+.workspace-sidebar > nav a:hover,
+.workspace-sidebar > nav a.is-active {
+ border-color: rgba(194, 196, 229, 0.12);
+ background: #454652;
+ color: #e5e1e4;
+}
+.workspace-sidebar-footer {
+ display: grid;
+ gap: 4px;
+ margin-top: auto;
+ padding-top: 16px;
+ border-top: 1px solid var(--ops-line);
+}
+.workspace-sidebar-footer a {
+ padding: 9px 13px;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.75rem;
+ text-decoration: none;
+}
+.workspace-mobile-nav { display: none; }
+
+.page:has(.workspace-sidebar) > main,
+.page:has(.workspace-sidebar) > .user-view-banner,
+.page:has(.workspace-sidebar) > .site-banner {
+ width: auto;
+ margin-right: 24px;
+ margin-left: 304px;
+}
+.page:has(.workspace-sidebar) > main {
+ max-width: 1600px;
+ margin-top: 0;
+ padding: 32px 0;
+}
+.page > .user-view-banner,
+.page > .site-banner {
+ margin-top: 16px;
+}
+
+.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-color: var(--ops-line) !important;
+ background: var(--ops-panel) !important;
+}
+.page:has(.workspace-sidebar) > main.card,
+.admin-card {
+ border: 0 !important;
+ background: transparent !important;
+}
+button:not(.avatar-button),
+.button {
+ border-radius: 8px;
+}
+button:not(.ghost-button):not(.avatar-button):not(.issue-category-card):not(.request-type-card):not(.request-result-card):not(.recent-card),
+.settings-action-button {
+ border-color: #30365d;
+ background: var(--ops-primary);
+ color: var(--ops-primary-2);
+ box-shadow: none;
+}
+.ghost-button,
+button.ghost-button {
+ border-color: var(--ops-line);
+ background: var(--ops-panel-3);
+ color: var(--ops-text);
+}
+input,
+select,
+textarea {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-bg-2) !important;
+ color: var(--ops-text) !important;
+}
+input:focus,
+select:focus,
+textarea:focus {
+ border-color: var(--ops-primary-2) !important;
+ box-shadow: 0 0 0 1px rgba(194, 196, 229, 0.28) !important;
+}
+.section-kicker,
+.eyebrow,
+.request-overview-label,
+.request-stage-number,
+.request-stage-state,
+.request-flow-heading > div > span,
+.invite-flow-number,
+.admin-nav-title {
+ font-family: "JetBrains Mono", monospace !important;
+}
+
+/* My Requests masters */
+.home-page { max-width: 1400px !important; gap: 22px; }
+.home-command {
+ grid-template-columns: minmax(0, 1fr) minmax(380px, 0.8fr);
+ padding: 0 0 22px;
+ border: 0;
+ border-bottom: 1px solid var(--ops-line);
+ border-radius: 0;
+ background: transparent;
+}
+.home-command-copy h1 { font-size: 2.25rem; letter-spacing: -0.02em !important; }
+.home-command-copy p { font-size: 0.9rem; }
+.home-search-row input,
+.home-search-row button { min-height: 44px; }
+.home-metric-strip { display: none; }
+.home-recent {
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+.home-recent-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; }
+.home-recent-grid .recent-card {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr);
+ grid-template-rows: minmax(118px, 1fr) auto;
+ align-items: stretch;
+ min-height: 250px;
+ padding: 12px;
+ overflow: hidden;
+ border-color: var(--ops-line) !important;
+ border-radius: 12px !important;
+ background: var(--ops-panel-2) !important;
+}
+.home-recent-grid .recent-poster {
+ grid-column: 1;
+ grid-row: 1;
+ width: 72px;
+ height: 108px;
+ align-self: start;
+ border-radius: 7px !important;
+ object-fit: cover;
+}
+.home-recent-grid .recent-info {
+ display: flex;
+ grid-column: 2;
+ grid-row: 1;
+ flex-direction: column;
+ gap: 9px;
+ min-width: 0;
+ padding: 7px 3px;
+}
+.home-recent-grid .recent-title { font-family: "DM Sans", sans-serif; font-size: 1.08rem; }
+.home-recent-grid .recent-meta {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--ops-muted) !important;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.68rem;
+}
+.home-recent-grid .recent-meta::before {
+ content: none;
+}
+.home-recent-grid .recent-status-badge {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ gap: 9px;
+ max-width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #9c8044;
+ border-radius: 8px;
+ background: #352c1b;
+ color: #ffe0a0;
+ font-family: "DM Sans", sans-serif;
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.35;
+ text-align: left;
+ text-transform: none;
+ overflow-wrap: anywhere;
+}
+.home-recent-grid .recent-status-badge > span { flex: 0 0 auto; font-size: 1.2rem; }
+.home-recent-grid .is-ready .recent-status-badge { color: #a7f3cd; background: #16372b; border-color: #398663; }
+.home-recent-grid .is-processing .recent-status-badge { color: #b6e6ff; background: #183344; border-color: #448bad; }
+.home-recent-grid .is-attention .recent-status-badge { color: #ffd2b4; background: #40281e; border-color: #aa7150; }
+.home-recent-grid .is-processing .recent-meta::before { background: var(--ops-primary-2); }
+.home-recent-grid .is-attention .recent-meta::before { background: var(--ops-coral); }
+.home-recent-grid .is-ready .recent-meta::before { background: var(--ops-green); }
+.home-recent-grid .recent-open-cue {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ align-self: end;
+ padding: 9px;
+ border: 1px solid var(--ops-line);
+ border-radius: 6px;
+ background: var(--ops-primary);
+ color: var(--ops-primary-2);
+ text-align: center;
+}
+.request-filter-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 18px 0;
+}
+.request-filter-chips button {
+ min-height: 32px;
+ padding: 5px 15px;
+ border: 1px solid var(--ops-line) !important;
+ border-radius: 999px !important;
+ background: var(--ops-panel-3) !important;
+ color: var(--ops-muted) !important;
+ font-size: 0.74rem;
+}
+.request-filter-chips button.is-active {
+ border-color: #343b71 !important;
+ background: var(--ops-primary) !important;
+ color: var(--ops-primary-2) !important;
+}
+.request-filter-chips i {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-right: 6px;
+ border-radius: 50%;
+ background: var(--ops-coral);
+}
+
+/* New Request master */
+.request-portal-page { max-width: 1180px !important; }
+.request-portal-hero {
+ min-height: 0;
+ padding: 0 0 18px;
+ border-bottom: 1px solid var(--ops-line);
+ background: transparent;
+}
+.request-portal-hero h1 { font-size: 2rem; }
+.request-portal-route { background: var(--ops-panel-2); }
+.request-master-stepper {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ margin: 4px 0 16px;
+ padding: 0;
+ list-style: none;
+}
+.request-master-stepper::before {
+ position: absolute;
+ top: 15px;
+ right: 10%;
+ left: 10%;
+ height: 1px;
+ background: var(--ops-line);
+ content: "";
+}
+.request-master-stepper li {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ justify-items: center;
+ gap: 6px;
+ color: var(--ops-faint);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.68rem;
+}
+.request-master-stepper li > span {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border: 4px solid var(--ops-bg);
+ border-radius: 50%;
+ background: var(--ops-panel-3);
+}
+.request-master-stepper li.is-active,
+.request-master-stepper li.is-complete { color: var(--ops-primary-2); }
+.request-master-stepper li.is-active > span,
+.request-master-stepper li.is-complete > span {
+ background: var(--ops-primary-2);
+ color: #2b2f48;
+ box-shadow: 0 0 12px rgba(194, 196, 229, 0.22);
+}
+.request-flow-stage {
+ border-color: var(--ops-line);
+ background: var(--ops-panel);
+}
+.request-type-card,
+.request-result-card,
+.request-selection-summary,
+.request-existing-state,
+.request-submit-bar {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel-2) !important;
+}
+.request-type-card { min-height: 220px; }
+.request-type-card.is-selected,
+.request-result-card.is-selected {
+ border-color: var(--ops-primary-2) !important;
+ background: rgba(194, 196, 229, 0.055) !important;
+ box-shadow: 0 0 22px rgba(194, 196, 229, 0.07);
+}
+
+/* Request Details master */
+.request-detail-page { max-width: 1500px !important; gap: 24px; }
+.request-detail-page .request-header {
+ min-height: 180px;
+ padding: 24px;
+ border: 1px solid var(--ops-line);
+ border-radius: 12px;
+ background: var(--ops-panel);
+}
+.request-detail-page .request-poster { width: 92px; height: 138px; object-fit: cover; }
+.request-detail-page .request-header h1 { font-size: clamp(2rem, 4vw, 3rem); }
+.request-overview,
+.request-journey,
+.request-repair-activity,
+.request-advanced {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel) !important;
+}
+.request-overview { border-radius: 12px; overflow: hidden; }
+.request-stage-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
+.request-stage {
+ min-height: 180px;
+ border-color: var(--ops-line) !important;
+ border-radius: 10px;
+ background: var(--ops-panel-2) !important;
+}
+.request-stage.stage-complete {
+ border-top: 3px solid var(--ops-green) !important;
+ background: linear-gradient(180deg, rgba(20, 184, 166, 0.07), var(--ops-panel-2) 38%) !important;
+}
+.request-stage.stage-active {
+ border-color: rgba(194, 196, 229, 0.58) !important;
+ border-top: 3px solid var(--ops-primary-2) !important;
+ background: linear-gradient(180deg, rgba(194, 196, 229, 0.08), var(--ops-panel-2) 40%) !important;
+ box-shadow: 0 0 18px rgba(194, 196, 229, 0.08) !important;
+}
+.request-stage.stage-attention { border-top: 3px solid var(--ops-warn) !important; }
+.request-live-indicator { border-color: rgba(20, 184, 166, 0.4); color: #5eead4; }
+.request-meter-track { background: #353437; }
+.request-meter-track > span { background: var(--ops-primary-2); }
+.request-release-modal { background: #201f21; }
+
+/* Issues and Invites masters */
+.portal-page,
+.profile-invites-section { max-width: 1500px !important; }
+.issue-portal-page > .issue-portal-hero {
+ min-height: 0;
+ padding: 0 0 20px;
+ border: 0;
+ border-bottom: 1px solid var(--ops-line);
+ border-radius: 0;
+ background: transparent;
+}
+.issue-flow,
+.issue-reports-column,
+.profile-section,
+.invite-flow-step,
+.profile-invites-list {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel) !important;
+}
+.issue-category-card {
+ border-color: var(--ops-line-soft);
+ background: var(--ops-panel-2);
+}
+.issue-category-card.is-selected {
+ border-color: var(--ops-primary-2);
+ background: rgba(194, 196, 229, 0.055);
+ box-shadow: 0 0 16px rgba(194, 196, 229, 0.06);
+}
+.issue-category-marker { border-color: var(--ops-line); color: var(--ops-primary-2); }
+.issue-detail-modal { background: #201f21; }
+.issue-modal-backdrop { background: rgba(5, 5, 7, 0.76); backdrop-filter: blur(6px); }
+.invite-flow-route { border-color: var(--ops-line); background: var(--ops-bg-2); }
+.invite-flow-route li.is-active,
+.invite-flow-route li.is-complete { color: var(--ops-primary-2); }
+.invite-flow-step.is-active {
+ border-color: rgba(194, 196, 229, 0.55) !important;
+ box-shadow: 0 0 18px rgba(194, 196, 229, 0.06);
+}
+.invite-delivery-grid > button,
+.invite-policy-note,
+.invite-delivery-summary,
+.invite-created-card,
+.admin-list-item {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel-2) !important;
+}
+
+/* Configuration master: each concern is its own service/operational region */
+.admin-shell {
+ width: 100%;
+ max-width: none;
+ grid-template-columns: 280px minmax(0, 1fr);
+ gap: 0;
+ align-items: stretch;
+}
+.admin-shell.admin-shell--with-rail {
+ grid-template-columns: 280px minmax(0, 1fr) 320px;
+ gap: 0;
+}
+.admin-shell-nav {
+ position: sticky;
+ top: 64px;
+ align-self: start;
+ height: calc(100vh - 64px);
+}
+.admin-sidebar {
+ height: 100%;
+ padding: 24px 16px;
+ overflow-y: auto;
+ border: 0;
+ border-right: 1px solid var(--ops-line);
+ border-radius: 0;
+ background: #201f21;
+}
+.admin-sidebar-identity { display: grid; gap: 4px; }
+.admin-sidebar-identity strong { color: var(--ops-primary-2); font-family: "DM Sans", sans-serif; font-size: 1.35rem; }
+.admin-sidebar-identity span { color: var(--ops-muted); font-family: "JetBrains Mono", monospace; font-size: 0.65rem; }
+.admin-sidebar-identity i {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 7px;
+ border-radius: 50%;
+ background: var(--ops-green);
+}
+.admin-new-request { margin: 12px 0 4px; }
+.admin-nav-title {
+ color: var(--ops-faint);
+ font-size: 0.62rem;
+ letter-spacing: 0.08em !important;
+}
+.admin-nav-links a { min-height: 34px; padding: 7px 10px; font-family: "JetBrains Mono", monospace; font-size: 0.7rem; }
+.admin-nav-links a:hover,
+.admin-nav-links a.is-active { border-color: transparent; background: #454652; color: #e5e1e4; }
+.admin-card { padding: 32px 40px 60px; }
+.admin-header { align-items: end; }
+.admin-header h1 { color: var(--ops-primary-2); font-size: 2rem; }
+.admin-shell-rail {
+ top: 80px;
+ align-self: start;
+ padding: 24px 20px 24px 0;
+}
+.service-status-panel {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel-3) !important;
+}
+.service-status-grid > div,
+.config-subsection-nav,
+.config-subsection {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel) !important;
+}
+.config-subsection-nav {
+ position: sticky;
+ top: 72px;
+ z-index: 8;
+ backdrop-filter: blur(12px);
+}
+.config-subsection-nav a { border-color: var(--ops-line); background: var(--ops-panel-2); }
+.config-subsection-nav a small { color: var(--ops-primary-2); }
+.config-subsection {
+ padding: 24px !important;
+ border-radius: 12px !important;
+}
+.config-subsection .section-header {
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--ops-line);
+}
+.config-subsection .section-header h2 { font-size: 1.2rem; }
+.admin-form .admin-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+.admin-form .admin-grid > label {
+ min-height: 142px;
+ padding: 16px;
+ border-color: var(--ops-line-soft);
+ background: var(--ops-panel-2);
+}
+.admin-form .admin-grid > label.field-span-full { grid-column: 1 / -1; }
+.admin-form .label-row > span:first-child { font-family: "JetBrains Mono", monospace; font-size: 0.72rem; }
+.admin-form .admin-grid label[data-helper]::after { color: var(--ops-muted); font-family: Inter, sans-serif; }
+.settings-section-actions { margin-top: 4px; padding-top: 18px; }
+
+@media (max-width: 1250px) {
+ .header { grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr); }
+ .header-actions a { padding-inline: 8px; font-size: 0.7rem; }
+ .workspace-sidebar { width: 240px; }
+ .page:has(.workspace-sidebar) > main,
+ .page:has(.workspace-sidebar) > .user-view-banner,
+ .page:has(.workspace-sidebar) > .site-banner { margin-left: 264px; }
+ .request-stage-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .admin-shell,
+ .admin-shell.admin-shell--with-rail { grid-template-columns: 240px minmax(0, 1fr); }
+ .admin-shell-rail { display: none; }
+}
+
+@media (max-width: 980px) {
+ .header { grid-template-columns: minmax(0, 1fr) auto; padding-inline: 16px; }
+ .header-left { grid-column: 1; }
+ .header-right { grid-column: 2; }
+ .header-nav { display: none; }
+ .workspace-sidebar { display: none; }
+ .workspace-mobile-nav {
+ position: fixed;
+ inset: auto 0 0;
+ z-index: 1000;
+ display: flex;
+ min-height: 70px;
+ align-items: center;
+ justify-content: space-around;
+ padding: 7px 10px max(7px, env(safe-area-inset-bottom));
+ border-top: 1px solid var(--ops-line);
+ border-radius: 12px 12px 0 0;
+ background: #353437;
+ }
+ .workspace-mobile-nav a {
+ display: grid;
+ min-width: 58px;
+ justify-items: center;
+ gap: 3px;
+ padding: 7px 8px;
+ border-radius: 999px;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.58rem;
+ text-decoration: none;
+ }
+ .workspace-mobile-nav a.is-active { background: var(--ops-primary); color: var(--ops-primary-2); }
+ .workspace-mobile-nav svg { width: 22px; height: 22px; }
+ .page:has(.workspace-sidebar) > main,
+ .page:has(.workspace-sidebar) > .user-view-banner,
+ .page:has(.workspace-sidebar) > .site-banner {
+ width: auto;
+ margin-right: 14px;
+ margin-left: 14px;
+ }
+ .page:has(.workspace-sidebar) > main { padding-top: 18px; padding-bottom: 28px; }
+ .home-command { grid-template-columns: 1fr; }
+ .home-recent-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .admin-shell,
+ .admin-shell.admin-shell--with-rail { display: block; }
+ .admin-shell-nav { position: static; height: auto; }
+ .admin-sidebar { height: auto; border-right: 0; border-bottom: 1px solid var(--ops-line); }
+ .admin-sidebar-identity,
+ .admin-new-request { display: none; }
+ .admin-nav-group { min-width: 200px; }
+ .admin-sidebar { display: flex; overflow-x: auto; }
+ .admin-card { padding: 24px 18px 90px; }
+}
+
+@media (max-width: 680px) {
+ .page { padding-bottom: 78px; }
+ .header { height: 58px; grid-template-rows: 58px; }
+ .page { padding-top: 58px; }
+ .brand-logo--header { width: 28px; height: 28px; }
+ .brand { font-size: 1.35rem; }
+ .beta-chip { display: none; }
+ .user-view-toggle { max-width: 96px; overflow: hidden; padding-inline: 7px; font-size: 0.56rem; white-space: nowrap; }
+ .home-command { gap: 16px; }
+ .home-command-copy h1 { font-size: 1.75rem; }
+ .home-search-row { grid-template-columns: 1fr auto; }
+ .home-search-row button { padding-inline: 12px; }
+ .home-section-heading { align-items: flex-start; }
+ .home-recent-grid { grid-template-columns: 1fr; }
+ .home-recent-grid .recent-card {
+ grid-template-columns: 78px minmax(0, 1fr);
+ grid-template-rows: auto;
+ min-height: 128px;
+ }
+ .home-recent-grid .recent-open-cue { display: none; }
+ .request-filter-chips { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
+ .request-filter-chips button { flex: 0 0 auto; }
+ .request-master-stepper li strong { font-size: 0.58rem; }
+ .request-master-stepper li > span { width: 27px; height: 27px; }
+ .request-stage-grid { grid-template-columns: 1fr; }
+ .request-stage { min-height: 0; }
+ .admin-form .admin-grid { grid-template-columns: 1fr; }
+ .admin-form .admin-grid > label.field-span-full { grid-column: auto; }
+ .config-subsection { padding: 18px !important; }
+ .config-subsection-nav { top: 62px; }
+}
+
+/* Global title search */
+.header-nav {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+.header-nav .header-actions { flex: 0 0 auto; width: auto; }
+.global-search {
+ position: relative;
+ z-index: 20;
+ flex: 0 1 240px;
+ width: 240px;
+ min-width: 170px;
+}
+.global-search form {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+.global-search form > svg {
+ position: absolute;
+ left: 11px;
+ width: 17px;
+ height: 17px;
+ fill: none;
+ stroke: var(--ops-muted);
+ stroke-linecap: round;
+ stroke-width: 1.8;
+ pointer-events: none;
+}
+.global-search input {
+ width: 100%;
+ height: 36px;
+ padding: 7px 34px 7px 36px;
+ border: 1px solid var(--ops-line);
+ border-radius: 8px;
+ background: var(--ops-panel-2);
+ color: var(--ops-text);
+ font-size: 0.78rem;
+}
+.global-search input:focus {
+ border-color: rgba(126, 215, 255, 0.58);
+ outline: 2px solid rgba(14, 165, 233, 0.16);
+}
+.global-search input::placeholder { color: var(--ops-muted); }
+.global-search-spinner {
+ position: absolute;
+ right: 11px;
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--ops-line);
+ border-top-color: var(--ops-primary-2);
+ border-radius: 50%;
+ animation: request-operation-spin 0.75s linear infinite;
+}
+.global-search-results {
+ position: absolute;
+ inset: calc(100% + 8px) 0 auto;
+ display: grid;
+ max-height: min(440px, calc(100vh - 92px));
+ overflow-y: auto;
+ padding: 6px;
+ border: 1px solid var(--ops-line);
+ border-radius: 10px;
+ background: #1c1b1d;
+ box-shadow: 0 20px 48px rgba(0, 0, 0, 0.46);
+}
+.global-search-results button {
+ display: flex;
+ width: 100%;
+ min-height: 56px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 9px 10px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--ops-text);
+ text-align: left;
+}
+.global-search-results button:hover,
+.global-search-results button:focus-visible { background: var(--ops-panel-3); }
+.global-search-results button > span { display: grid; min-width: 0; gap: 2px; }
+.global-search-results strong { overflow: hidden; font-size: 0.8rem; text-overflow: ellipsis; white-space: nowrap; }
+.global-search-results small { color: var(--ops-muted); font-size: 0.66rem; }
+.global-search-results b { flex: 0 0 auto; color: var(--ops-primary-2); font-size: 0.63rem; }
+.global-search-results p { margin: 0; padding: 14px 10px; color: var(--ops-muted); font-size: 0.75rem; }
+
+/* The finished request becomes a compact watch-or-report destination. */
+.request-next-step.is-ready { padding: 0; }
+.request-ready-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.request-ready-actions > section {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ min-width: 0;
+ padding: 20px;
+}
+.request-ready-actions > section + section { border-left: 1px solid var(--ops-line-soft); }
+.request-ready-actions > section > strong { color: var(--ops-text); font-size: 1.12rem; }
+.request-ready-actions > section > p { min-height: 44px; margin: 0; color: var(--ops-muted); line-height: 1.5; }
+.request-ready-actions .request-watch-button,
+.request-problem-button {
+ width: 100%;
+ min-width: 0;
+ min-height: 46px;
+ margin-top: 5px;
+}
+.request-problem-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ border: 1px solid rgba(126, 215, 255, 0.36);
+ border-radius: var(--ops-radius);
+ background: rgba(79, 70, 229, 0.18);
+ color: var(--ops-text);
+ font-weight: 750;
+ text-decoration: none;
+}
+.request-problem-button:hover { border-color: rgba(126, 215, 255, 0.7); background: rgba(79, 70, 229, 0.28); }
+.request-ready-unavailable { margin-top: 5px; padding: 12px; border: 1px solid var(--ops-line); border-radius: var(--ops-radius); color: var(--ops-muted); font-size: 0.75rem; }
+
+.issue-prefilled-request {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 16px;
+ padding: 14px;
+ border: 1px solid rgba(126, 215, 255, 0.3);
+ border-radius: 9px;
+ background: rgba(14, 165, 233, 0.07);
+}
+.issue-prefilled-request > div { display: grid; gap: 4px; }
+.issue-prefilled-request strong { color: var(--ops-text); font-size: 1rem; }
+.issue-prefilled-request small { color: var(--ops-muted); }
+.issue-prefilled-request > a { flex: 0 0 auto; text-decoration: none; }
+
+@media (max-width: 1250px) {
+ .global-search { flex-basis: 190px; width: 190px; }
+}
+
+@media (max-width: 980px) {
+ .header { height: 108px; grid-template-rows: 58px 50px; }
+ .header-left,
+ .header-right { grid-row: 1; }
+ .header-nav {
+ display: flex;
+ grid-column: 1 / -1;
+ grid-row: 2;
+ width: 100%;
+ }
+ .header-nav .header-actions { display: none; }
+ .global-search { flex: 1 1 auto; width: 100%; max-width: none; }
+ .global-search-results { max-height: min(420px, calc(100vh - 190px)); }
+ .page { padding-top: 108px; }
+}
+
+@media (max-width: 680px) {
+ .header { height: 104px; grid-template-rows: 54px 50px; }
+ .page { padding-top: 104px; }
+ .global-search input { height: 38px; }
+ .request-ready-actions { grid-template-columns: 1fr; }
+ .request-ready-actions > section + section { border-top: 1px solid var(--ops-line-soft); border-left: 0; }
+ .request-ready-actions > section > p { min-height: 0; }
+ .request-add-seasons-heading,
+ .request-add-seasons-submit { align-items: stretch; flex-direction: column; }
+ .request-add-seasons-heading .request-live-indicator { align-self: flex-start; }
+ .request-add-seasons .request-season-grid { grid-template-columns: 1fr; }
+ .request-add-seasons-submit button { width: 100%; }
+ .issue-prefilled-request { align-items: stretch; flex-direction: column; }
+ .issue-prefilled-request > a { text-align: center; }
+}
+
+/* Guided issue reporting */
+.issue-portal-page {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(310px, 360px);
+ align-items: start;
+ gap: 20px;
+}
+
+.issue-portal-page > .issue-portal-hero,
+.issue-portal-page > .error-banner,
+.issue-portal-page > .status-banner {
+ grid-column: 1 / -1;
+}
+
+.issue-portal-page > .issue-flow {
+ grid-column: 1;
+}
+
+.issue-portal-page > .issue-reports-column {
+ grid-column: 2;
+}
+
+.portal-lower-content {
+ display: contents;
+}
+
+.issue-reports-column {
+ position: sticky;
+ top: 16px;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 10px;
+ min-width: 0;
+ height: calc(100vh - 32px);
+ overflow: hidden;
+}
+
+.issue-reports-column.has-open-modal {
+ z-index: 2000;
+}
+
+.issue-reports-column > .issue-history-heading {
+ align-items: center;
+ padding: 14px 15px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.018);
+}
+
+.issue-reports-column > .issue-history-heading h2 {
+ font-size: 1rem;
+}
+
+.issue-reports-column > .portal-toolbar {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 12px;
+}
+
+.issue-reports-column > .portal-toolbar .portal-mine-toggle {
+ align-self: end;
+ margin: 0 0 10px;
+ white-space: nowrap;
+}
+
+.issue-reports-column > .portal-workspace {
+ display: block;
+ min-width: 0;
+ min-height: 0;
+}
+
+.issue-portal-page .portal-list-panel {
+ height: 100%;
+ max-height: none;
+ padding: 13px;
+ overflow: hidden;
+}
+
+.issue-portal-page .portal-list-panel > .user-directory-panel-header {
+ display: none;
+}
+
+.issue-portal-page .portal-item-list {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 8px;
+ max-height: 100%;
+ padding-right: 4px;
+}
+
+.issue-portal-page .portal-item-row {
+ display: block;
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ min-height: min-content;
+ max-height: none;
+ padding: 14px;
+ white-space: normal;
+ text-align: left;
+ line-height: 1.45;
+}
+
+.issue-portal-page .portal-item-row-main {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.issue-portal-page .portal-item-row-title strong {
+ flex-basis: 100%;
+ min-width: 0;
+ font-size: 0.95rem;
+ line-height: 1.4;
+ overflow-wrap: anywhere;
+}
+
+.issue-portal-page .portal-item-row-meta {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid var(--ops-line-soft);
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.issue-portal-page .portal-item-row-meta > span:last-child {
+ flex-basis: 100%;
+}
+
+.issue-portal-page .portal-item-row p {
+ display: -webkit-box;
+ margin: 6px 0;
+ overflow: hidden;
+ font-size: 0.7rem;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.issue-portal-page .portal-item-row-meta {
+ gap: 5px 9px;
+ font-size: 0.62rem;
+}
+
+.issue-card-progress {
+ display: grid;
+ gap: 6px;
+ margin: 8px 0;
+}
+
+.issue-card-progress > span {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.issue-card-progress strong {
+ color: var(--ops-cyan);
+ font-size: 0.68rem;
+}
+
+.issue-card-progress small {
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.58rem;
+ white-space: nowrap;
+}
+
+.issue-card-progress > i {
+ display: block;
+ height: 3px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgba(126, 215, 255, 0.12);
+}
+
+.issue-card-progress > i > b {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--ops-cyan), var(--request-green));
+}
+
+.issue-card-progress.is-attention strong {
+ color: var(--request-amber);
+}
+
+.issue-card-progress.is-attention > i > b {
+ background: var(--request-amber);
+}
+
+.issue-card-progress.is-complete strong {
+ color: var(--request-green);
+}
+
+.issue-modal-backdrop {
+ position: fixed;
+ z-index: 998;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: rgba(2, 6, 18, 0.76);
+ backdrop-filter: blur(5px);
+ cursor: default;
+}
+
+.issue-detail-modal {
+ display: none;
+}
+
+.issue-detail-modal.is-open {
+ position: fixed;
+ z-index: 999;
+ inset: 5vh max(18px, calc((100vw - 1120px) / 2));
+ display: grid;
+ align-content: start;
+ max-height: 90vh;
+ padding: 0 20px 22px;
+ overflow: auto;
+ border-color: rgba(126, 215, 255, 0.36);
+ background: var(--ops-panel);
+ box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55);
+}
+
+.issue-modal-toolbar {
+ position: sticky;
+ z-index: 2;
+ top: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin: 0 -20px 4px;
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--ops-line);
+ background: rgba(13, 21, 39, 0.97);
+ backdrop-filter: blur(14px);
+}
+
+.issue-modal-toolbar > div {
+ display: grid;
+ gap: 2px;
+}
+
+.issue-modal-toolbar > .issue-modal-toolbar-actions {
+ display: flex;
+ grid-auto-flow: column;
+ gap: 8px;
+}
+
+.issue-delete-confirmation {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 16px;
+ border: 1px solid rgba(255, 86, 113, 0.42);
+ border-radius: var(--ops-radius-lg);
+ background: linear-gradient(120deg, rgba(255, 86, 113, 0.11), rgba(255, 86, 113, 0.035));
+}
+
+.issue-delete-confirmation > div:first-child {
+ display: grid;
+ gap: 5px;
+}
+
+.issue-delete-confirmation > div:last-child {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 8px;
+}
+
+.issue-delete-confirmation h3,
+.issue-delete-confirmation p {
+ margin: 0;
+}
+
+.issue-delete-confirmation p {
+ max-width: 720px;
+ color: var(--ops-muted);
+ font-size: 0.74rem;
+ line-height: 1.5;
+}
+
+.issue-pipeline-card {
+ display: grid;
+ gap: 16px;
+ padding: 16px;
+ border: 1px solid rgba(126, 215, 255, 0.3);
+ border-radius: var(--ops-radius-lg);
+ background: linear-gradient(120deg, rgba(14, 165, 233, 0.07), rgba(126, 215, 255, 0.025));
+}
+
+.issue-pipeline-card.is-attention {
+ border-color: rgba(255, 192, 84, 0.42);
+ background: linear-gradient(120deg, rgba(255, 192, 84, 0.08), rgba(255, 192, 84, 0.025));
+}
+
+.issue-pipeline-card.is-complete {
+ border-color: rgba(72, 224, 178, 0.38);
+ background: linear-gradient(120deg, rgba(72, 224, 178, 0.075), rgba(72, 224, 178, 0.025));
+}
+
+.issue-pipeline-card > header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.issue-pipeline-card > header > div {
+ display: grid;
+ gap: 4px;
+}
+
+.issue-pipeline-card h3,
+.issue-pipeline-card p {
+ margin: 0;
+}
+
+.issue-pipeline-card p {
+ color: var(--ops-muted);
+ font-size: 0.75rem;
+ line-height: 1.5;
+}
+
+.issue-pipeline-card > ol {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.issue-pipeline-card li {
+ position: relative;
+ display: grid;
+ justify-items: center;
+ gap: 7px;
+ min-width: 0;
+ color: var(--ops-muted);
+ font-size: 0.62rem;
+ text-align: center;
+}
+
+.issue-pipeline-card li::before {
+ position: absolute;
+ z-index: 0;
+ top: 12px;
+ right: 50%;
+ left: -50%;
+ height: 2px;
+ background: var(--ops-line-soft);
+ content: "";
+}
+
+.issue-pipeline-card li:first-child::before {
+ display: none;
+}
+
+.issue-pipeline-card li > i {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ width: 25px;
+ height: 25px;
+ place-items: center;
+ border: 1px solid var(--ops-line);
+ border-radius: 50%;
+ background: var(--ops-panel);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.58rem;
+ font-style: normal;
+}
+
+.issue-pipeline-card li.is-complete,
+.issue-pipeline-card li.is-active {
+ color: var(--ops-text);
+}
+
+.issue-pipeline-card li.is-complete::before,
+.issue-pipeline-card li.is-active::before,
+.issue-pipeline-card li.is-attention::before {
+ background: var(--ops-cyan);
+}
+
+.issue-pipeline-card li.is-complete > i {
+ border-color: var(--request-green);
+ color: #061813;
+ background: var(--request-green);
+}
+
+.issue-pipeline-card li.is-active > i {
+ border-color: var(--ops-cyan);
+ color: var(--ops-cyan);
+ box-shadow: 0 0 14px rgba(126, 215, 255, 0.28);
+}
+
+.issue-pipeline-card li.is-attention {
+ color: var(--request-amber);
+}
+
+.issue-pipeline-card li.is-attention > i {
+ border-color: var(--request-amber);
+ color: var(--request-amber);
+ box-shadow: 0 0 14px rgba(255, 192, 84, 0.25);
+}
+
+.issue-modal-toolbar strong {
+ font-size: 0.86rem;
+}
+
+.issue-portal-page > .issue-portal-hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ min-height: 0;
+ padding: 22px 24px;
+ background:
+ radial-gradient(circle at 100% 0%, rgba(126, 215, 255, 0.11), transparent 42%),
+ linear-gradient(135deg, rgba(90, 80, 240, 0.12), transparent 48%),
+ var(--ops-panel);
+}
+
+.issue-portal-hero > div:first-child {
+ display: grid;
+ gap: 5px;
+ max-width: 790px;
+}
+
+.issue-portal-hero h1 {
+ font-size: clamp(1.65rem, 3vw, 2.45rem);
+ letter-spacing: -0.035em;
+}
+
+.issue-portal-hero .lede {
+ max-width: 760px;
+ margin: 0;
+}
+
+.issue-hero-count {
+ display: grid;
+ flex: 0 0 auto;
+ min-width: 130px;
+ padding: 12px 16px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.issue-hero-count strong {
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 1.45rem;
+}
+
+.issue-hero-count span {
+ color: var(--ops-muted);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+}
+
+.issue-flow {
+ display: grid;
+ gap: 18px;
+ padding: 20px;
+ border: 1px solid var(--ops-line);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.018);
+}
+
+.issue-flow-heading {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 13px;
+}
+
+.issue-flow-heading > div {
+ display: grid;
+ gap: 4px;
+}
+
+.issue-flow-heading h2,
+.issue-resolution-card h2,
+.issue-history-heading h2,
+.media-status-heading h3 {
+ margin: 0;
+}
+
+.issue-flow-heading p,
+.issue-resolution-card p,
+.media-status-check > p {
+ margin: 0;
+ color: var(--ops-muted);
+ line-height: 1.5;
+}
+
+.issue-step-number {
+ display: grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ border: 1px solid rgba(126, 215, 255, 0.34);
+ border-radius: 50%;
+ background: rgba(14, 165, 233, 0.08);
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.7rem;
+ font-weight: 800;
+}
+
+.issue-category-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.issue-category-card {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ min-height: 190px;
+ padding: 16px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.022);
+ color: var(--ops-text);
+ text-align: left;
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
+}
+
+.issue-category-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(126, 215, 255, 0.42);
+}
+
+.issue-category-card.is-selected {
+ border-color: rgba(72, 224, 178, 0.58);
+ background: linear-gradient(145deg, rgba(72, 224, 178, 0.09), rgba(14, 165, 233, 0.035));
+ box-shadow: 0 0 24px rgba(72, 224, 178, 0.06);
+}
+
+.issue-category-card strong {
+ font-size: 0.98rem;
+}
+
+.issue-category-card p,
+.issue-category-card small {
+ margin: 0;
+ color: var(--ops-muted);
+ font-size: 0.75rem;
+ font-weight: 500;
+ line-height: 1.45;
+}
+
+.issue-category-card small {
+ align-self: end;
+ margin-top: auto;
+ padding-top: 9px;
+ border-top: 1px solid var(--ops-line-soft);
+ color: var(--request-green);
+ font-size: 0.68rem;
+}
+
+.issue-category-marker {
+ justify-self: start;
+ padding: 4px 7px;
+ border: 1px solid rgba(126, 215, 255, 0.24);
+ border-radius: 999px;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.58rem;
+ letter-spacing: 0.05em;
+}
+
+.issue-guided-form {
+ display: grid;
+ gap: 18px;
+ padding-top: 20px;
+ border-top: 1px solid var(--ops-line);
+}
+
+.issue-choice-field {
+ display: grid;
+ gap: 9px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.issue-choice-field legend,
+.issue-question-grid label > span {
+ margin-bottom: 7px;
+ color: var(--ops-muted);
+ font-size: 0.7rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.issue-choice-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.issue-choice-row button {
+ border-color: var(--ops-line-soft);
+ background: rgba(255, 255, 255, 0.025);
+ color: var(--ops-muted);
+}
+
+.issue-choice-row button.is-selected {
+ border-color: rgba(126, 215, 255, 0.48);
+ background: rgba(14, 165, 233, 0.12);
+ color: var(--ops-text);
+}
+
+.issue-question-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.issue-question-grid label {
+ display: grid;
+ align-content: start;
+}
+
+.issue-field-span-2 {
+ grid-column: span 2;
+}
+
+.issue-media-finder {
+ display: grid;
+ gap: 11px;
+ padding: 14px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.018);
+}
+
+.issue-media-search-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+}
+
+.issue-media-search-row button {
+ min-width: 120px;
+}
+
+.issue-media-results {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-auto-rows: minmax(98px, auto);
+ align-content: start;
+ gap: 8px;
+ max-height: 410px;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.issue-media-result {
+ display: grid;
+ grid-template-columns: 54px minmax(0, 1fr);
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ min-height: 98px;
+ padding: 8px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+ color: var(--ops-text);
+ text-align: left;
+}
+
+.issue-media-result:hover {
+ border-color: rgba(126, 215, 255, 0.42);
+ background: rgba(14, 165, 233, 0.07);
+}
+
+.issue-media-result > span:last-child {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ opacity: 1;
+ font-size: inherit;
+ text-align: left;
+ text-transform: none;
+}
+
+.issue-media-result > span:last-child strong,
+.issue-media-result > span:last-child small,
+.issue-media-result > span:last-child b {
+ overflow-wrap: anywhere;
+ text-align: left;
+ text-transform: none;
+}
+
+.issue-media-result small,
+.issue-media-result b,
+.issue-selected-media small {
+ color: var(--ops-muted);
+ font-size: 0.64rem;
+ font-weight: 600;
+}
+
+.issue-media-result b {
+ color: var(--request-green);
+}
+
+.issue-confirmation-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 18px;
+ border: 1px solid rgba(126, 215, 255, 0.34);
+ border-radius: var(--ops-radius-lg);
+ background: linear-gradient(120deg, rgba(14, 165, 233, 0.1), rgba(79, 70, 229, 0.07));
+}
+.issue-confirmation-card > div:first-child { display: grid; gap: 6px; }
+.issue-confirmation-card h3,
+.issue-confirmation-card p { margin: 0; }
+.issue-confirmation-card p,
+.issue-confirmation-card small { color: var(--ops-muted); line-height: 1.5; }
+.issue-confirmation-actions { display: flex; flex: 0 0 auto; gap: 8px; }
+.issue-activity-block {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(255, 255, 255, 0.018);
+}
+.issue-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.issue-activity-heading h3 { margin: 0; }
+.issue-activity-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
+.issue-activity-list li {
+ display: grid;
+ grid-template-columns: 12px minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 10px;
+ padding: 12px 0;
+ border-top: 1px solid var(--ops-line-soft);
+}
+.issue-activity-list li:first-child { border-top: 0; }
+.issue-activity-list i {
+ width: 9px;
+ height: 9px;
+ margin-top: 4px;
+ border: 2px solid rgba(126, 215, 255, 0.52);
+ border-radius: 50%;
+ background: rgba(14, 165, 233, 0.18);
+}
+.issue-activity-list li > div { display: grid; gap: 3px; min-width: 0; }
+.issue-activity-list strong { color: var(--ops-text); font-size: 0.78rem; line-height: 1.45; }
+.issue-activity-list span,
+.issue-activity-list time { color: var(--ops-muted); font-size: 0.67rem; }
+.issue-activity-list time { white-space: nowrap; }
+
+@media (max-width: 640px) {
+ .issue-confirmation-card {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .issue-confirmation-actions {
+ display: grid;
+ }
+
+ .issue-confirmation-actions button {
+ width: 100%;
+ }
+
+ .issue-activity-list li {
+ grid-template-columns: 12px minmax(0, 1fr);
+ }
+
+ .issue-activity-list time {
+ grid-column: 2;
+ white-space: normal;
+ }
+}
+
+.issue-media-poster {
+ display: grid;
+ place-items: center;
+ width: 54px;
+ height: 80px;
+ overflow: hidden;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: calc(var(--ops-radius) - 2px);
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.issue-media-poster img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.issue-media-poster i {
+ color: var(--ops-muted);
+ font-size: 0.58rem;
+ font-style: normal;
+ text-align: center;
+}
+
+.issue-selected-media {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+ padding: 13px;
+ border: 1px solid rgba(72, 224, 178, 0.34);
+ border-radius: var(--ops-radius);
+ background: rgba(72, 224, 178, 0.055);
+}
+
+.issue-selected-media > div {
+ display: grid;
+ gap: 3px;
+}
+
+.issue-target-picker,
+.issue-tv-targets {
+ display: grid;
+ gap: 12px;
+}
+
+.issue-target-picker {
+ padding-top: 2px;
+}
+
+.issue-choice-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.issue-choice-grid button,
+.issue-season-grid button,
+.issue-episode-grid button,
+.issue-movie-target {
+ min-width: 0;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+ color: var(--ops-text);
+ text-align: left;
+}
+
+.issue-choice-grid button {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ min-height: 52px;
+ padding: 10px 12px;
+}
+
+.issue-choice-grid button > span {
+ display: grid;
+ flex: 0 0 auto;
+ place-items: center;
+ width: 23px;
+ height: 23px;
+ border: 1px solid rgba(126, 215, 255, 0.3);
+ border-radius: 50%;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.66rem;
+}
+
+.issue-choice-grid button strong,
+.issue-season-grid button strong,
+.issue-episode-grid button strong,
+.issue-movie-target strong {
+ overflow-wrap: anywhere;
+ font-size: 0.76rem;
+ line-height: 1.35;
+ text-transform: none;
+}
+
+.issue-choice-grid button.is-selected,
+.issue-season-grid button.is-selected,
+.issue-episode-grid button.is-selected,
+.issue-movie-target.is-selected {
+ border-color: rgba(72, 224, 178, 0.55);
+ background: rgba(72, 224, 178, 0.08);
+ box-shadow: 0 0 20px rgba(72, 224, 178, 0.06);
+}
+
+.issue-movie-target {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ width: 100%;
+ padding: 13px;
+}
+
+.issue-movie-target > span {
+ flex: 0 0 auto;
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.65rem;
+}
+
+.issue-movie-target > div,
+.issue-season-grid button,
+.issue-episode-grid button {
+ display: grid;
+ gap: 4px;
+}
+
+.issue-movie-target small,
+.issue-season-grid button small,
+.issue-episode-grid button small {
+ color: var(--ops-muted);
+ font-size: 0.64rem;
+ font-weight: 600;
+ line-height: 1.4;
+ text-transform: none;
+}
+
+.issue-movie-target:disabled,
+.issue-episode-grid button:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.issue-target-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 2px;
+}
+
+.issue-target-heading strong {
+ font-size: 0.78rem;
+}
+
+.issue-target-heading small {
+ color: var(--ops-muted);
+ font-size: 0.66rem;
+}
+
+.issue-season-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.issue-season-grid button {
+ position: relative;
+ padding: 11px;
+}
+
+.issue-season-grid button b,
+.issue-episode-grid button b {
+ justify-self: start;
+ padding: 3px 6px;
+ border-radius: 999px;
+ background: rgba(72, 224, 178, 0.13);
+ color: var(--request-green);
+ font-size: 0.56rem;
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+
+.issue-season-tabs {
+ padding-top: 4px;
+ border-top: 1px solid var(--ops-line-soft);
+}
+
+.issue-episode-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ max-height: 390px;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.issue-episode-grid button {
+ align-content: start;
+ min-height: 91px;
+ padding: 11px;
+}
+
+.issue-episode-grid button > span {
+ color: var(--ops-cyan);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.62rem;
+}
+
+.issue-file-picker {
+ display: grid;
+ gap: 11px;
+ padding: 14px;
+ border: 1px solid rgba(126, 215, 255, 0.25);
+ border-radius: var(--ops-radius);
+ background: rgba(14, 165, 233, 0.035);
+}
+
+.issue-file-picker > div:first-child {
+ display: grid;
+ gap: 3px;
+}
+
+.issue-file-picker h3,
+.issue-file-picker p {
+ margin: 0;
+}
+
+.issue-file-picker p {
+ color: var(--ops-muted);
+ font-size: 0.72rem;
+}
+
+.issue-file-list {
+ display: grid;
+ gap: 7px;
+ max-height: 340px;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.issue-file-list > label {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+ cursor: pointer;
+}
+
+.issue-file-list > label.is-selected {
+ border-color: rgba(72, 224, 178, 0.45);
+ background: rgba(72, 224, 178, 0.055);
+}
+
+.issue-file-list > label > span {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.issue-file-list strong,
+.issue-file-list small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.issue-file-list small,
+.issue-file-list b {
+ color: var(--ops-muted);
+ font-size: 0.65rem;
+ font-weight: 600;
+}
+
+.issue-replacement-toggle {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 11px;
+ padding: 13px;
+ border: 1px solid rgba(255, 192, 84, 0.36);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 192, 84, 0.055);
+ cursor: pointer;
+}
+
+.issue-replacement-toggle > span {
+ display: grid;
+ gap: 3px;
+}
+
+.issue-replacement-toggle small {
+ color: var(--ops-muted);
+ font-size: 0.69rem;
+ line-height: 1.45;
+}
+
+.media-status-check {
+ display: grid;
+ gap: 13px;
+ padding: 16px;
+ border: 1px solid rgba(126, 215, 255, 0.28);
+ border-radius: var(--ops-radius-lg);
+ background: rgba(14, 165, 233, 0.045);
+}
+
+.media-status-check.media-status-up {
+ border-color: rgba(72, 224, 178, 0.34);
+ background: rgba(72, 224, 178, 0.045);
+}
+
+.media-status-check.media-status-degraded {
+ border-color: rgba(255, 192, 84, 0.36);
+ background: rgba(255, 192, 84, 0.05);
+}
+
+.media-status-check.media-status-down {
+ border-color: rgba(255, 86, 113, 0.38);
+ background: rgba(255, 86, 113, 0.05);
+}
+
+.media-status-heading,
+.issue-history-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.media-status-heading > div,
+.issue-history-heading > div {
+ display: grid;
+ gap: 4px;
+}
+
+.issue-live-scan {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: var(--ops-muted);
+ font-size: 0.75rem;
+}
+
+.issue-live-scan i {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--ops-cyan);
+ box-shadow: 0 0 14px var(--ops-cyan);
+ animation: request-operation-pulse 1.15s ease-in-out infinite;
+}
+
+.media-status-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.media-status-metrics > div {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: var(--ops-radius);
+ background: rgba(255, 255, 255, 0.022);
+}
+
+.media-status-metrics span {
+ color: var(--ops-muted);
+ font-size: 0.62rem;
+ text-transform: uppercase;
+}
+
+.media-status-metrics strong {
+ overflow: hidden;
+ font-size: 0.75rem;
+ text-overflow: ellipsis;
+}
+
+.issue-resolution-card {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid rgba(72, 224, 178, 0.3);
+ border-radius: var(--ops-radius-lg);
+ background: linear-gradient(110deg, rgba(72, 224, 178, 0.07), rgba(14, 165, 233, 0.035));
+}
+
+.issue-resolution-card > div {
+ display: grid;
+ gap: 4px;
+}
+
+.issue-resolution-card > button {
+ min-width: 150px;
+}
+
+.issue-history-heading {
+ padding-top: 4px;
+}
+
+.issue-history-heading > span {
+ padding: 5px 9px;
+ border: 1px solid var(--ops-line-soft);
+ border-radius: 999px;
+ color: var(--ops-muted);
+ font-family: "JetBrains Mono", Consolas, monospace;
+ font-size: 0.65rem;
+}
+
+.issue-linked-request {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 12px;
+ border: 1px solid rgba(126, 215, 255, 0.28);
+ border-radius: var(--ops-radius);
+ background: rgba(14, 165, 233, 0.045);
+}
+
+.issue-linked-request > div {
+ display: grid;
+ gap: 3px;
+}
+
+.issue-portal-page .portal-item-row p {
+ display: -webkit-box;
+ overflow: hidden;
+ white-space: pre-wrap;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+@media (max-width: 1100px) {
+ .issue-portal-page {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .issue-portal-page > .issue-portal-hero,
+ .issue-portal-page > .error-banner,
+ .issue-portal-page > .status-banner,
+ .issue-portal-page > .issue-flow,
+ .issue-portal-page > .issue-reports-column {
+ grid-column: 1;
+ grid-row: auto;
+ }
+
+ .issue-reports-column {
+ position: static;
+ height: auto;
+ overflow: visible;
+ }
+
+ .issue-portal-page .portal-list-panel {
+ position: static;
+ max-height: none;
+ }
+
+ .issue-portal-page .portal-item-list {
+ max-height: 440px;
+ }
+}
+
+@media (max-width: 980px) {
+ .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .issue-choice-grid,
+ .issue-episode-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .issue-season-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 640px) {
+ .issue-portal-page > .issue-portal-hero,
+ .media-status-heading,
+ .issue-history-heading { align-items: flex-start; flex-direction: column; }
+ .issue-hero-count { width: 100%; }
+ .issue-flow { padding: 14px; }
+ .issue-category-grid,
+ .issue-choice-grid,
+ .issue-season-grid,
+ .issue-episode-grid,
+ .issue-question-grid,
+ .issue-media-results,
+ .media-status-metrics { grid-template-columns: 1fr; }
+ .issue-field-span-2 { grid-column: span 1; }
+ .issue-category-card { min-height: 0; }
+ .issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
+ .issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
+ .issue-media-search-row { grid-template-columns: 1fr; }
+ .issue-selected-media { align-items: stretch; flex-direction: column; }
+ .issue-target-heading { align-items: flex-start; flex-direction: column; }
+ .issue-linked-request { align-items: stretch; flex-direction: column; }
+ .issue-file-list > label { grid-template-columns: auto minmax(0, 1fr); }
+ .issue-file-list > label > b { grid-column: 2; }
+ .issue-detail-modal.is-open {
+ inset: 10px;
+ max-height: calc(100vh - 20px);
+ padding-right: 13px;
+ padding-left: 13px;
+ }
+ .issue-modal-toolbar {
+ margin-right: -13px;
+ margin-left: -13px;
+ padding-right: 13px;
+ padding-left: 13px;
+ }
+ .issue-modal-toolbar > .issue-modal-toolbar-actions { display: flex; }
+ .issue-delete-confirmation { align-items: stretch; flex-direction: column; }
+ .issue-delete-confirmation > div:last-child { display: grid; }
+ .issue-delete-confirmation button { width: 100%; }
+ .issue-pipeline-card > header { flex-direction: column; }
+ .issue-pipeline-card > ol { grid-template-columns: 1fr; gap: 8px; }
+ .issue-pipeline-card li { grid-template-columns: 25px minmax(0, 1fr); align-items: center; justify-items: start; text-align: left; }
+ .issue-pipeline-card li::before { top: -9px; right: auto; left: 12px; width: 2px; height: 10px; }
+}
+
+/* Final screen-specific alignment: these rules intentionally follow the
+ legacy Issues styles so the Stitch tokens remain authoritative. */
+.issue-flow,
+.issue-reports-column,
+.issue-detail-modal.is-open,
+.issue-pipeline-card,
+.issue-resolution-card,
+.issue-media-search,
+.media-status-card {
+ border-color: var(--ops-line) !important;
+ background: var(--ops-panel) !important;
+}
+
+.issue-category-card,
+.issue-choice-card,
+.issue-season-card,
+.issue-episode-card {
+ border-color: var(--ops-line-soft);
+ background: var(--ops-panel-2);
+}
+
+.issue-category-card:hover,
+.issue-choice-card:hover,
+.issue-season-card:hover,
+.issue-episode-card:hover {
+ border-color: rgba(194, 196, 229, 0.48);
+}
+
+.issue-category-card.is-selected,
+.issue-choice-card.is-selected,
+.issue-season-card.is-selected,
+.issue-episode-card.is-selected {
+ border-color: rgba(194, 196, 229, 0.7);
+ background: rgba(194, 196, 229, 0.07);
+ box-shadow: 0 0 18px rgba(194, 196, 229, 0.06);
+}
+
+.issue-detail-modal.is-open {
+ top: max(80px, 5vh);
+ bottom: 4vh;
+ max-height: calc(96vh - 80px);
+}
+
+@media (max-width: 640px) {
+ .issue-detail-modal.is-open {
+ inset: 74px 10px 10px;
+ max-height: calc(100vh - 84px);
+ }
+}
+
+
+/* Release picker control: independent of the user-management page stylesheet. */
+.request-release-profile-toggle {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 20px;
+ padding: 14px 16px;
+ border: 1px solid var(--ops-border, #393941);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.025);
+ cursor: pointer;
+}
+.request-release-profile-toggle > span { display: grid; gap: 5px; min-width: 0; }
+.request-release-profile-toggle strong { font-size: 0.85rem; line-height: 1.4; }
+.request-release-profile-toggle small { color: var(--ops-muted); font-size: 0.75rem; line-height: 1.5; }
+.request-release-profile-toggle input[type="checkbox"] {
+ appearance: none;
+ position: relative;
+ display: block;
+ width: 38px;
+ min-width: 38px;
+ height: 22px;
+ min-height: 22px;
+ margin: 0;
+ padding: 0;
+ border: 1px solid #62616d !important;
+ border-radius: 999px !important;
+ background: #45444e !important;
+ box-shadow: none;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.request-release-profile-toggle input[type="checkbox"]::before {
+ content: "";
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ left: 3px;
+ top: 3px;
+ border-radius: 50%;
+ background: #eeedf5;
+ transition: transform 0.15s;
+}
+.request-release-profile-toggle input[type="checkbox"]:checked { background: #c7bdff !important; border-color: #c7bdff !important; }
+.request-release-profile-toggle input[type="checkbox"]:checked::before { transform: translateX(16px); background: #181529; }
+.request-release-profile-toggle input[type="checkbox"]:focus-visible { outline: 2px solid #c7bdff; outline-offset: 4px; }
+.request-release-profile-toggle input[type="checkbox"]:disabled { cursor: wait; opacity: 0.5; }
+
+/* Title artwork creates a cinematic opening while the pipeline stays readable. */
+.request-detail-page.request-cinematic { position: relative; isolation: isolate; background: transparent !important; }
+.request-cinematic-art { position: absolute; z-index: -1; pointer-events: none; inset: -24px -24px auto; height: 850px; overflow: hidden; border-radius: 20px 20px 0 0; background: radial-gradient(ellipse at 75% 10%, #31334a, #121214 65%); }
+.request-cinematic-art img { object-fit: cover; object-position: center top; }
+.request-cinematic-art::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(10,11,16,.88), rgba(10,11,16,.18) 75%), linear-gradient(180deg, rgba(12,13,18,.1), rgba(18,18,20,.5) 38%, #121214 88%); }
+.request-cinematic-title { min-height: 300px; display: flex; align-items: flex-end; padding: 28px 12px 16px; }
+.request-cinematic-title .page-heading { width: 100%; border: 0; background: transparent; margin: 0; }
+.request-cinematic-title .page-heading h1 { font-size: clamp(2rem, 4.5vw, 3.7rem); line-height: 1.08; max-width: 900px; text-wrap: balance; text-shadow: 0 2px 22px #0009; }
+.request-cinematic-title .page-heading p { color: #e3e3ec; text-shadow: 0 1px 8px #000; }
+.request-cinematic-title .request-poster { width: 100px; height: 150px; border-radius: 8px; box-shadow: 0 12px 36px #0008; }
+.request-cinematic:not(.has-backdrop) .request-cinematic-title { min-height: 180px; }
+@media (max-width: 640px) {
+ .request-cinematic-art { inset: -12px -12px auto; height: 650px; }
+ .request-cinematic-title { min-height: 240px; padding: 16px 0 8px; }
+ .request-cinematic-title .request-poster { width: 68px; height: 102px; }
+ .request-cinematic-title .page-heading h1 { font-size: clamp(1.8rem, 7vw, 2.5rem); }
+}
+
+/* Fade the entire artwork layer, including its tint, into the page surface. */
+.request-cinematic-art {
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 12%, #000 38%, transparent 100%), linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%);
+ -webkit-mask-composite: source-in;
+ mask-image: linear-gradient(to bottom, transparent 0%, #000 12%, #000 38%, transparent 100%), linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%);
+ mask-composite: intersect;
+}
+
+/* Diagnostics: scan each service from identity to result to action. */
+.diagnostics-page .diagnostics-grid { grid-template-columns: minmax(0, 1fr); }
+.diagnostics-page .diagnostic-card { display: grid; grid-template-columns: minmax(180px, 1.15fr) minmax(280px, 1.65fr) minmax(160px, 1fr) auto; align-items: center; gap: 20px; }
+.diagnostics-page .diagnostic-card-top { display: contents; }
+.diagnostics-page .diagnostic-card-copy { grid-column: 1; grid-row: 1; }
+.diagnostics-page .diagnostic-card .system-test { grid-column: 4; grid-row: 1; white-space: nowrap; }
+.diagnostics-page .diagnostic-meta-grid { grid-column: 2; grid-row: 1; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
+.diagnostics-page .diagnostic-message { grid-column: 3; grid-row: 1; margin: 0; }
+.diagnostics-page .diagnostic-detail-panel { grid-column: 1 / -1; border-top: 1px solid var(--ops-line-soft); padding-top: 14px; }
+.diagnostic-detail-panel > summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
+.diagnostics-page .diagnostic-detail-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
+.diagnostics-notification-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; padding: 16px; border: 1px solid var(--ops-line-soft); border-radius: 10px; }
+.diagnostics-notification-controls .diagnostics-email-recipient { flex: 1 1 280px; }
+.diagnostics-notification-controls p { flex: 1 1 220px; margin: 0; color: var(--ops-muted); font-size: .85rem; }
+@media (max-width: 1050px) {
+ .diagnostics-page .diagnostic-card { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto; gap: 14px; }
+ .diagnostics-page .diagnostic-card .system-test { grid-column: 3; }
+ .diagnostics-page .diagnostic-message { grid-column: 1 / -1; grid-row: 2; }
+}
+@media (max-width: 640px) {
+ .diagnostics-page .diagnostic-card { grid-template-columns: minmax(0, 1fr) auto; }
+ .diagnostics-page .diagnostic-card .system-test { grid-column: 2; }
+ .diagnostics-page .diagnostic-meta-grid { grid-column: 1 / -1; grid-row: 2; }
+ .diagnostics-page .diagnostic-message { grid-row: 3; }
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..bc77d49
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,9 @@
+import { redirect } from "next/navigation";
+import MyRequests from "./MyRequests";
+
+export const dynamic = "force-dynamic";
+
+export default function HomePage() {
+ if (process.env.MAGENT_COMING_SOON === "true") redirect("/coming-soon");
+ return ;
+}
diff --git a/frontend/app/portal/IssueFlowStep.tsx b/frontend/app/portal/IssueFlowStep.tsx
new file mode 100644
index 0000000..f21ac8a
--- /dev/null
+++ b/frontend/app/portal/IssueFlowStep.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+
+export default function IssueFlowStep({
+ number,
+ title,
+ summary,
+ active,
+ complete,
+ onEdit,
+ children,
+}: {
+ number: number;
+ title: string;
+ summary: string;
+ active: boolean;
+ complete: boolean;
+ onEdit: () => void;
+ children: ReactNode;
+}) {
+ const heading = useRef(null);
+ useEffect(() => {
+ if (!active || number === 1) return;
+ heading.current?.focus({ preventScroll: true });
+ heading.current?.scrollIntoView({ block: "nearest", behavior: "instant" });
+ }, [active, number]);
+
+ if (!active && !complete) return null;
+ return (
+
+ {active ? (
+ <>
+
+
+ {String(number).padStart(2, "0")}
+
+
+ {title}
+
+
+ {children}
+ >
+ ) : (
+
+
+ ✓
+
+
+ {title}
+ {summary}
+
+ Change
+
+ )}
+
+ );
+}
diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx
new file mode 100644
index 0000000..037f3c7
--- /dev/null
+++ b/frontend/app/portal/PortalClient.tsx
@@ -0,0 +1,2764 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useRef, useState } from "react";
+import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
+import { useEffectiveRole } from "../lib/viewMode";
+import PageHeading from "../ui/PageHeading";
+import ResolutionChoice from "../ui/ResolutionChoice";
+import IssueFlowStep from "./IssueFlowStep";
+
+type PortalPermissions = {
+ can_edit?: boolean;
+ can_comment?: boolean;
+ can_moderate?: boolean;
+ can_delete?: boolean;
+ can_confirm_resolution?: 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;
+ workflow?: {
+ current_step?: number;
+ total_steps?: number;
+ stage?: string;
+ stage_label?: string;
+ headline?: string;
+ message?: string;
+ state?: "active" | "attention" | "complete";
+ steps?: Array<{
+ key: string;
+ label: string;
+ state: "waiting" | "active" | "attention" | "complete";
+ }>;
+ };
+ confirmation?: {
+ status?: string | null;
+ attempts_sent?: number;
+ maximum_attempts?: number;
+ last_contact_at?: string | null;
+ next_contact_at?: string | null;
+ interval_value?: number | null;
+ interval_unit?: string | null;
+ last_delivery_succeeded?: boolean | null;
+ };
+ };
+};
+
+type PortalComment = {
+ id: number;
+ item_id: number;
+ author_username: string;
+ author_role: string;
+ message: string;
+ is_internal: boolean;
+ created_at: string;
+};
+
+type PortalActivity = {
+ id: number | string;
+ item_id: number;
+ event_type: string;
+ actor_username: string;
+ actor_role: string;
+ message: string;
+ 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;
+};
+
+type IssueCategoryId =
+ | "broken_media"
+ | "wrong_content"
+ | "missing_content"
+ | "audio"
+ | "subtitle"
+ | "playback"
+ | "service_unavailable";
+
+type MediaServerStatus = {
+ checked_at?: string;
+ status: "up" | "degraded" | "down" | "not_configured";
+ headline: string;
+ message: string;
+ latency_ms?: number | null;
+ server?: {
+ version?: string | null;
+ restart_pending?: boolean | null;
+ };
+ activity?: {
+ active_streams?: number | null;
+ transcoding_streams?: number | null;
+ available?: boolean;
+ };
+};
+
+type IssueEpisodeOption = {
+ id: number;
+ season_number: number;
+ episode_number: number;
+ code: string;
+ title: string;
+ released: boolean;
+ monitored: boolean;
+ has_file: boolean;
+ missing: boolean;
+ best_fit: boolean;
+ file_id?: number | null;
+};
+
+type IssueSeasonOption = {
+ season_number: number;
+ label: string;
+ episode_count: number;
+ available_count: number;
+ missing_count: number;
+ best_fit: boolean;
+};
+
+type IssueTargetOptions = {
+ request_id: string;
+ request_type: "movie" | "tv";
+ title: string;
+ collector_id?: number | null;
+ movie?: {
+ selected_label: string;
+ has_file: boolean;
+ missing: boolean;
+ best_fit: boolean;
+ file_id?: number | null;
+ } | null;
+ seasons: IssueSeasonOption[];
+ episodes: IssueEpisodeOption[];
+ can_act: boolean;
+ message?: string;
+};
+
+const ISSUE_CATEGORIES: Array<{
+ id: IssueCategoryId;
+ marker: string;
+ label: string;
+ description: string;
+ outcome: string;
+ issueType: string;
+ titlePrefix: string;
+}> = [
+ {
+ id: "broken_media",
+ marker: "REPLACE",
+ label: "Picture or file is broken",
+ description: "Corruption, visual artefacts, freezing, or playback stopping at the same point.",
+ outcome: "The affected file will be replaced automatically.",
+ issueType: "broken_media",
+ titlePrefix: "Replace media",
+ },
+ {
+ id: "wrong_content",
+ marker: "WRONG FILE",
+ label: "Wrong thing downloaded",
+ description: "The movie, episode, cut, or edition does not match what it should be.",
+ outcome: "The incorrectly matched file will be replaced automatically.",
+ issueType: "wrong_content",
+ titlePrefix: "Wrong download",
+ },
+ {
+ id: "missing_content",
+ marker: "MISSING",
+ label: "Movie or episode is missing",
+ description: "A title, season, episode, or expected part is not available in Jellyfin.",
+ outcome: "The selected missing content will be sent back to Sonarr or Radarr.",
+ issueType: "missing_content",
+ titlePrefix: "Missing content",
+ },
+ {
+ id: "audio",
+ marker: "AUDIO",
+ label: "Audio is wrong",
+ description: "No sound, wrong language, commentary only, distorted audio, or audio out of sync.",
+ outcome: "The affected file will be replaced automatically.",
+ issueType: "audio",
+ titlePrefix: "Audio problem",
+ },
+ {
+ id: "subtitle",
+ marker: "SUBS",
+ label: "Subtitles are wrong",
+ description: "Missing, incorrect, forced, unreadable, or out-of-sync subtitles.",
+ outcome: "Bazarr will find a fresh subtitle without replacing the video.",
+ issueType: "subtitle",
+ titlePrefix: "Subtitle problem",
+ },
+ {
+ id: "playback",
+ marker: "PLAYBACK",
+ label: "Playback or transcoding problem",
+ description: "The title will not start, constantly buffers, stops, or reports a transcode error.",
+ outcome: "Magent will check Jellyfin and replace only the selected file when appropriate.",
+ issueType: "playback",
+ titlePrefix: "Playback problem",
+ },
+ {
+ id: "service_unavailable",
+ marker: "SERVER",
+ label: "Nothing will play",
+ description: "Jellyfin will not open or every title fails across the device or household.",
+ outcome: "Magent will check Jellyfin and attach the result to the issue.",
+ issueType: "service_unavailable",
+ titlePrefix: "Media server unavailable",
+ },
+];
+
+const ISSUE_SYMPTOMS: Record = {
+ broken_media: [
+ "Visual artefacts or corruption",
+ "Freezes at the same point",
+ "Stops before the end",
+ "File will not play",
+ ],
+ wrong_content: [
+ "Different movie or show",
+ "Wrong episode",
+ "Episodes are labelled incorrectly",
+ "Wrong cut or edition",
+ ],
+ missing_content: ["Entire title is missing", "Season is missing", "Episode is missing", "Part or edition is missing"],
+ audio: ["No audio", "Wrong language", "Commentary track only", "Audio is out of sync", "Audio is distorted"],
+ subtitle: ["Subtitles are missing", "Wrong subtitles", "Subtitles are out of sync", "Forced subtitles are missing"],
+ playback: [
+ "Will not start",
+ "Constant buffering",
+ "Transcode error",
+ "Stops during playback",
+ "Only fails on one device",
+ ],
+ service_unavailable: [
+ "Jellyfin will not open",
+ "Every title fails",
+ "Login works but playback does not",
+ "Server error is shown",
+ ],
+};
+
+const DEVICE_OPTIONS = ["TV app", "Web browser", "Phone or tablet", "Multiple devices"] as const;
+type IssueStep = "problem" | "media" | "symptoms" | "targets" | "devices" | "review";
+
+const ISSUE_TYPE_LABELS: Record = {
+ general: "General",
+ playback: "Playback",
+ transcode: "Transcoding",
+ service_unavailable: "Server unavailable",
+ broken_media: "Broken media",
+ wrong_content: "Wrong download",
+ missing_content: "Missing content",
+ audio: "Audio",
+ subtitle: "Subtitles",
+ quality: "Quality",
+ metadata: "Metadata",
+ other: "Other",
+};
+
+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: "awaiting_confirmation", label: "Fixed - ask reporter to confirm" },
+ { value: "done", label: "Resolved (legacy)" },
+ { 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: "awaiting_confirmation", label: "Waiting for confirmation" },
+ { value: "done", label: "Previously resolved" },
+ { 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 formatIssueStatus = (value?: string | null) => {
+ const labels: Record = {
+ new: "New",
+ triaging: "Triaging",
+ planned: "Planned",
+ in_progress: "In progress",
+ blocked: "Blocked",
+ awaiting_confirmation: "Waiting for reporter confirmation",
+ done: "Resolved",
+ closed: "Closed",
+ };
+ return labels[String(value ?? "").toLowerCase()] ?? String(value ?? "Unknown").replaceAll("_", " ");
+};
+
+function IssuePipeline({ item, compact = false }: { item: PortalItem; compact?: boolean }) {
+ const workflow = item.issue?.workflow;
+ const steps = workflow?.steps ?? [];
+ const currentStep = workflow?.current_step ?? 1;
+ const totalSteps = workflow?.total_steps ?? 6;
+ const state = workflow?.state ?? "active";
+
+ if (compact) {
+ return (
+
+
+ {workflow?.stage_label ?? formatIssueStatus(item.status)}
+
+ Step {currentStep} of {totalSteps}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {steps.map((step, index) => (
+
+ {step.state === "complete" ? "✓" : index + 1}
+ {step.label}
+
+ ))}
+
+
+ );
+}
+
+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 [activity, setActivity] = 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 [respondingResolution, setRespondingResolution] = useState(false);
+ const [deleteConfirming, setDeleteConfirming] = useState(false);
+ const [deleting, setDeleting] = 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 [editIssueType, setEditIssueType] = useState("general");
+ 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 [issueCategory, setIssueCategory] = useState(null);
+ const [issueMediaTitle, setIssueMediaTitle] = useState("");
+ const [issueMediaType, setIssueMediaType] = useState<"movie" | "tv">("movie");
+ const [issueSymptoms, setIssueSymptoms] = useState([]);
+ const [issueDevices, setIssueDevices] = useState([]);
+ const [mediaServerStatus, setMediaServerStatus] = useState(null);
+ const [mediaServerChecking, setMediaServerChecking] = useState(false);
+ const [mediaServerError, setMediaServerError] = useState(null);
+ const [issueMediaQuery, setIssueMediaQuery] = useState("");
+ const [issueMediaSearching, setIssueMediaSearching] = useState(false);
+ const [issueMediaResults, setIssueMediaResults] = useState([]);
+ const [issueSelectedMedia, setIssueSelectedMedia] = useState(null);
+ const [issueOptions, setIssueOptions] = useState(null);
+ const [issueOptionsLoading, setIssueOptionsLoading] = useState(false);
+ const [issueOptionsMessage, setIssueOptionsMessage] = useState(null);
+ const [issueStep, setIssueStep] = useState("problem");
+ const issueOptionsVersion = useRef(0);
+ const [activeSeasonNumber, setActiveSeasonNumber] = useState(null);
+ const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState([]);
+ const [selectedEpisodeIds, setSelectedEpisodeIds] = useState([]);
+
+ const effectiveRole = useEffectiveRole(me?.role);
+ const isAdmin = effectiveRole === "admin";
+ const isOwner = (item: PortalItem) => me?.username === item.created_by_username;
+ const canConfirmResolution = (item: PortalItem) =>
+ Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item)));
+ const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem)));
+ const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate);
+ const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete);
+ const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal);
+ const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added");
+ const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
+ const workspaceLabel = workspace === "request" ? "request" : "issue";
+ const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
+ const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null;
+ const issueNeedsServerCheck = issueCategory === "playback" || issueCategory === "service_unavailable";
+ const issueNeedsDevices = issueNeedsServerCheck || issueCategory === "audio" || issueCategory === "subtitle";
+ const issueRequiresExistingFile =
+ issueCategory === "broken_media" ||
+ issueCategory === "wrong_content" ||
+ issueCategory === "audio" ||
+ issueCategory === "subtitle" ||
+ issueCategory === "playback";
+ const issueSupportsReplacement =
+ issueCategory === "broken_media" ||
+ issueCategory === "wrong_content" ||
+ issueCategory === "audio" ||
+ (issueCategory === "playback" && mediaServerStatus?.status !== "down");
+ const selectedEpisodeOptions = (issueOptions?.episodes ?? []).filter((episode) =>
+ selectedEpisodeIds.includes(episode.id),
+ );
+ const selectedReplacementFileIds = Array.from(
+ new Set(
+ selectedEpisodeOptions
+ .map((episode) => episode.file_id)
+ .filter((fileId): fileId is number => typeof fileId === "number" && fileId > 0),
+ ),
+ );
+ const missingEntireTitle = issueSymptoms.includes("Entire title is missing");
+ const missingSeasons = issueSymptoms.includes("Season is missing");
+ const missingEpisodes =
+ issueSymptoms.includes("Episode is missing") || issueSymptoms.includes("Part or edition is missing");
+ const selectedMissingEpisodeIds = (issueOptions?.episodes ?? [])
+ .filter((episode) => {
+ if (missingEntireTitle) return episode.missing;
+ if (missingEpisodes && selectedEpisodeIds.includes(episode.id)) return true;
+ return missingSeasons && episode.missing && selectedSeasonNumbers.includes(episode.season_number);
+ })
+ .map((episode) => episode.id);
+ const movieTargetAvailable = !issueRequiresExistingFile || Boolean(issueOptions?.movie?.has_file);
+ const issueTargetReady = Boolean(
+ issueOptions &&
+ issueSymptoms.length > 0 &&
+ (issueOptions.request_type === "movie"
+ ? movieTargetAvailable
+ : issueCategory === "missing_content"
+ ? missingEntireTitle ||
+ ((missingSeasons ? selectedSeasonNumbers.length > 0 : true) &&
+ (missingEpisodes ? selectedEpisodeIds.length > 0 : true))
+ : selectedEpisodeIds.length > 0),
+ );
+ const issueNeedsTvTargets = issueOptions?.request_type === "tv" && !missingEntireTitle;
+ const issueSteps: IssueStep[] = [
+ "problem",
+ "media",
+ "symptoms",
+ ...(issueNeedsTvTargets ? ["targets" as const] : []),
+ ...(issueNeedsDevices ? ["devices" as const] : []),
+ "review",
+ ];
+ const stepProps = (step: IssueStep) => ({
+ number: issueSteps.indexOf(step) + 1,
+ active: issueStep === step,
+ complete: issueSteps.indexOf(issueStep) > issueSteps.indexOf(step),
+ onEdit: () => {
+ if (issueOptionsLoading) {
+ issueOptionsVersion.current += 1;
+ setIssueOptionsLoading(false);
+ }
+ setIssueStep(step);
+ },
+ });
+ const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
+
+ useEffect(() => {
+ if (isAdmin) return;
+ setDeleteConfirming(false);
+ if (commentInternal) {
+ // Do not turn an unfinished internal note into a public comment when preview changes.
+ setCommentText("");
+ setCommentInternal(false);
+ }
+ }, [isAdmin, commentInternal]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(window.location.search);
+ const raw = params.get("item");
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
+ setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed);
+
+ const requestId = Number.parseInt(params.get("reportRequest") ?? "", 10);
+ const title = params.get("title")?.trim();
+ const type = params.get("type");
+ const rawYear = Number.parseInt(params.get("year") ?? "", 10);
+ if (requestId > 0 && title && (type === "movie" || type === "tv")) {
+ const media: DiscoveryResult = {
+ title,
+ year: rawYear >= 1870 && rawYear <= 2200 ? rawYear : null,
+ type,
+ requestId,
+ statusLabel: "Ready to watch",
+ accessible: true,
+ };
+ setIssueSelectedMedia(media);
+ setIssueMediaTitle(media.title);
+ setIssueMediaType(type);
+ setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ""}`);
+ setIssueStep("problem");
+ }
+ }, []);
+
+ 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?kind=${workspace}`);
+ 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([]);
+ setActivity([]);
+ 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 : []);
+ setActivity(Array.isArray(data?.activity) ? data.activity : []);
+ } 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 && workspace === "request") {
+ 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: Record) => ({
+ title: typeof item.title === "string" ? 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: typeof item.statusLabel === "string" ? item.statusLabel : null,
+ status: typeof item.status === "number" ? item.status : null,
+ accessible: Boolean(item.accessible),
+ posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
+ backdropPath: typeof item.backdropPath === "string" ? 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;
+ });
+ }
+ };
+
+ const loadIssueOptions = async (media: DiscoveryResult) => {
+ const version = ++issueOptionsVersion.current;
+ setIssueOptionsLoading(false);
+ setIssueOptions(null);
+ setActiveSeasonNumber(null);
+ setSelectedSeasonNumbers([]);
+ setSelectedEpisodeIds([]);
+ if (!media.requestId) {
+ setIssueOptionsMessage("This title is not linked to a Magent request yet.");
+ return;
+ }
+ setIssueOptionsLoading(true);
+ setIssueOptionsMessage(null);
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/${media.requestId}/issue-options`);
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ const text = await response.text();
+ throw new Error(text || "Could not load seasons and episodes from Sonarr/Radarr.");
+ }
+ const payload = (await response.json()) as IssueTargetOptions;
+ if (version !== issueOptionsVersion.current) return;
+ const verifiedMedia: DiscoveryResult = {
+ ...media,
+ title: payload.title,
+ type: payload.request_type,
+ };
+ setIssueSelectedMedia(verifiedMedia);
+ setIssueMediaTitle(verifiedMedia.title);
+ setIssueMediaType(payload.request_type);
+ setIssueOptions(payload);
+ setIssueOptionsMessage(payload.message ?? null);
+ const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0];
+ setActiveSeasonNumber(firstSeason?.season_number ?? null);
+ setIssueStep("symptoms");
+ } catch (err) {
+ if (version !== issueOptionsVersion.current) return;
+ console.error(err);
+ setIssueOptionsMessage(
+ err instanceof Error ? err.message : "Could not load seasons and episodes from Sonarr/Radarr.",
+ );
+ } finally {
+ if (version === issueOptionsVersion.current) setIssueOptionsLoading(false);
+ }
+ };
+
+ const searchIssueMedia = async (event?: React.FormEvent) => {
+ event?.preventDefault();
+ const query = issueMediaQuery.trim();
+ if (!query) {
+ setError("Enter the movie or TV show you are having trouble with.");
+ return;
+ }
+ setIssueMediaSearching(true);
+ setError(null);
+ setIssueMediaResults([]);
+ issueOptionsVersion.current += 1;
+ setIssueOptionsLoading(false);
+ setIssueSelectedMedia(null);
+ setIssueMediaTitle("");
+ setIssueOptions(null);
+ setActiveSeasonNumber(null);
+ setSelectedSeasonNumbers([]);
+ setSelectedEpisodeIds([]);
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`);
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ throw new Error("Magent could not search the media catalogue.");
+ }
+ const payload = await response.json();
+ const results: DiscoveryResult[] = Array.isArray(payload?.results)
+ ? payload.results
+ .filter((item: Record) => item.type === "movie" || item.type === "tv")
+ .map((item: Record) => ({
+ title: typeof item.title === "string" ? item.title : "Untitled",
+ year: typeof item.year === "number" ? item.year : null,
+ type: item.type,
+ tmdbId: typeof item.tmdbId === "number" ? item.tmdbId : null,
+ requestId: typeof item.requestId === "number" ? item.requestId : null,
+ statusLabel: typeof item.statusLabel === "string" ? item.statusLabel : null,
+ status: typeof item.status === "number" ? item.status : null,
+ accessible: Boolean(item.accessible),
+ posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
+ backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null,
+ }))
+ : [];
+ setIssueMediaResults(results.slice(0, 12));
+ if (results.length === 0) {
+ setError("No matching movie or TV show was found. Try the title without a year.");
+ }
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Magent could not search the media catalogue.");
+ } finally {
+ setIssueMediaSearching(false);
+ }
+ };
+
+ const selectIssueMedia = (media: DiscoveryResult) => {
+ setIssueSymptoms([]);
+ setIssueDevices([]);
+ setIssueSelectedMedia(media);
+ setIssueMediaTitle(media.title);
+ setIssueMediaType(media.type === "tv" ? "tv" : "movie");
+ setIssueMediaResults([]);
+ setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ""}`);
+ setError(null);
+ void loadIssueOptions(media);
+ };
+
+ const checkMediaServer = async () => {
+ setMediaServerChecking(true);
+ setMediaServerError(null);
+ try {
+ const response = await authFetch(`${getApiBase()}/portal/issues/media-status`);
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ throw new Error("The live media-server check is temporarily unavailable.");
+ }
+ const payload = (await response.json()) as MediaServerStatus;
+ setMediaServerStatus(payload);
+ } catch (err) {
+ console.error(err);
+ setMediaServerStatus(null);
+ setMediaServerError(
+ err instanceof Error ? err.message : "The live media-server check is temporarily unavailable.",
+ );
+ } finally {
+ setMediaServerChecking(false);
+ }
+ };
+
+ const chooseIssueCategory = (category: IssueCategoryId) => {
+ setIssueCategory(category);
+ setIssueSymptoms([]);
+ setIssueDevices([]);
+ setMediaServerStatus(null);
+ setMediaServerError(null);
+ setError(null);
+ setStatus(null);
+ const shouldLoadSelectedRequest = Boolean(issueSelectedMedia?.requestId && !issueOptions);
+ setIssueStep(issueSelectedMedia && issueOptions ? "symptoms" : "media");
+ setSelectedSeasonNumbers([]);
+ setSelectedEpisodeIds([]);
+ if (category === "playback" || category === "service_unavailable") {
+ void checkMediaServer();
+ }
+ if (shouldLoadSelectedRequest && issueSelectedMedia) void loadIssueOptions(issueSelectedMedia);
+ };
+
+ const toggleStringChoice = (value: string, setter: React.Dispatch>) => {
+ setter((current) => (current.includes(value) ? current.filter((item) => item !== value) : [...current, value]));
+ };
+
+ const runIssueAction = async (path: string, body: Record): Promise => {
+ const response = await authFetch(`${getApiBase()}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ let detail = "The follow-up action could not be started.";
+ try {
+ const payload = await response.json();
+ if (typeof payload?.detail === "string") detail = payload.detail;
+ } catch {
+ // Keep the plain-language fallback.
+ }
+ throw new Error(detail);
+ }
+ const payload = await response.json();
+ return typeof payload?.message === "string" ? payload.message : "The follow-up action started.";
+ };
+
+ const createGuidedIssue = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (creating || issueStep !== "review") return;
+ if (!selectedIssueDefinition || !issueCategory) {
+ setError("Choose the problem that best matches what you are seeing.");
+ return;
+ }
+ const cleanMediaTitle = issueMediaTitle.trim();
+ if (!cleanMediaTitle || !issueSelectedMedia?.requestId || !issueOptions) {
+ setError("Search for and select a tracked movie or TV show first.");
+ return;
+ }
+ if (issueSymptoms.length === 0) {
+ setError("Choose what needs to be corrected.");
+ return;
+ }
+ const isMovie = issueOptions.request_type === "movie";
+ if (isMovie && !movieTargetAvailable) {
+ setError("There is no managed movie file available for this repair. Report it as missing instead.");
+ return;
+ }
+ if (!isMovie && issueCategory === "missing_content" && missingSeasons && selectedSeasonNumbers.length === 0) {
+ setError("Choose at least one missing season.");
+ return;
+ }
+ if (!isMovie && issueCategory === "missing_content" && missingEpisodes && selectedEpisodeIds.length === 0) {
+ setError("Choose at least one missing episode.");
+ return;
+ }
+ if (!isMovie && issueCategory !== "missing_content" && selectedEpisodeIds.length === 0) {
+ setError("Choose at least one affected episode.");
+ return;
+ }
+ const movieFileId = issueOptions.movie?.file_id;
+ const actionFileIds = isMovie ? (typeof movieFileId === "number" ? [movieFileId] : []) : selectedReplacementFileIds;
+ if (issueSupportsReplacement && actionFileIds.length === 0) {
+ setError("Sonarr/Radarr does not report a replaceable file for the selected content.");
+ return;
+ }
+ setCreating(true);
+ setError(null);
+ setStatus(null);
+ try {
+ const diagnosticLines: string[] = [];
+ if (mediaServerStatus) {
+ diagnosticLines.push(
+ `Media server check: ${mediaServerStatus.headline}`,
+ `Checked: ${formatDate(mediaServerStatus.checked_at)}`,
+ );
+ if (typeof mediaServerStatus.latency_ms === "number") {
+ diagnosticLines.push(`Response time: ${mediaServerStatus.latency_ms} ms`);
+ }
+ if (mediaServerStatus.server?.restart_pending) {
+ diagnosticLines.push("Server restart pending: yes");
+ }
+ if (mediaServerStatus.activity?.available) {
+ diagnosticLines.push(
+ `Active streams: ${mediaServerStatus.activity.active_streams ?? 0}`,
+ `Active transcodes: ${mediaServerStatus.activity.transcoding_streams ?? 0}`,
+ );
+ }
+ } else if (issueNeedsServerCheck) {
+ diagnosticLines.push("Media server check: unavailable at the time of reporting");
+ }
+
+ const description = [
+ `Problem: ${selectedIssueDefinition.label}`,
+ `What needs correction: ${issueSymptoms.join(", ")}`,
+ cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
+ cleanMediaTitle ? `Media type: ${issueMediaType === "tv" ? "TV show" : "Movie"}` : null,
+ issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
+ selectedSeasonNumbers.length
+ ? `Seasons: ${selectedSeasonNumbers.map((season) => `Season ${season}`).join(", ")}`
+ : null,
+ selectedEpisodeOptions.length
+ ? `Episodes: ${selectedEpisodeOptions.map((episode) => episode.code).join(", ")}`
+ : null,
+ issueDevices.length ? `Devices: ${issueDevices.join(", ")}` : null,
+ ...diagnosticLines,
+ ]
+ .filter((line): line is string => Boolean(line))
+ .join("\n");
+
+ const titleTarget = cleanMediaTitle;
+ const resolvedIssueType =
+ issueCategory === "playback" && issueSymptoms.some((symptom) => symptom.toLowerCase().includes("transcode"))
+ ? "transcode"
+ : selectedIssueDefinition.issueType;
+ const priority =
+ mediaServerStatus?.status === "down" || issueCategory === "service_unavailable" ? "high" : "normal";
+ const response = await authFetch(`${getApiBase()}/portal/items`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ kind: "issue",
+ title: `${selectedIssueDefinition.titlePrefix}: ${titleTarget}`,
+ description,
+ issue_type: resolvedIssueType,
+ media_type: cleanMediaTitle ? issueMediaType : null,
+ external_ref: issueSelectedMedia?.requestId ? `/requests/${issueSelectedMedia.requestId}` : null,
+ priority,
+ }),
+ });
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ const text = await response.text();
+ throw new Error(text || "Could not submit the issue.");
+ }
+ const data = await response.json();
+ const item = data?.item as PortalItem | undefined;
+ let completionMessage = item?.id ? `Issue #${item.id} submitted.` : "Issue submitted.";
+ const requestId = issueSelectedMedia.requestId;
+ const actionBase = `/requests/${requestId}/actions`;
+ let actionMessage = "";
+ if (issueOptions.can_act === false) {
+ actionMessage = "Support has been given the selected title and affected content.";
+ } else if (issueCategory === "missing_content") {
+ actionMessage = await runIssueAction(`${actionBase}/search-missing`, {
+ issue_id: item?.id ?? null,
+ episode_ids: selectedMissingEpisodeIds,
+ season_numbers: selectedSeasonNumbers,
+ });
+ } else if (issueCategory === "subtitle") {
+ actionMessage = await runIssueAction(`${actionBase}/repair-subtitles`, {
+ issue_id: item?.id ?? null,
+ episode_ids: isMovie ? [] : selectedEpisodeIds,
+ forced: issueSymptoms.includes("Forced subtitles are missing"),
+ });
+ } else if (issueSupportsReplacement) {
+ actionMessage = await runIssueAction(`${actionBase}/replace`, {
+ issue_id: item?.id ?? null,
+ file_ids: actionFileIds,
+ confirmed: true,
+ });
+ }
+ if (actionMessage) completionMessage = `${completionMessage} ${actionMessage}`;
+ setStatus(completionMessage);
+ setError(null);
+ setIssueCategory(null);
+ setIssueMediaTitle("");
+ setIssueMediaType("movie");
+ setIssueSymptoms([]);
+ setIssueDevices([]);
+ setMediaServerStatus(null);
+ setIssueMediaQuery("");
+ setIssueMediaResults([]);
+ setIssueSelectedMedia(null);
+ setIssueOptions(null);
+ setIssueOptionsMessage(null);
+ setIssueStep("problem");
+ setActiveSeasonNumber(null);
+ setSelectedSeasonNumbers([]);
+ setSelectedEpisodeIds([]);
+ await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]);
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not submit the issue.");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Bootstrap loaders are deliberately coordinated once per route mount.
+ 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();
+ }, [router]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Filter values intentionally trigger the loader without recreating it.
+ useEffect(() => {
+ if (!getToken()) {
+ return;
+ }
+ void loadItems({ preferItemId: preselectedItemId });
+ }, [filterStatus, filterMine, filterSearch, workspace]);
+
+ useEffect(() => {
+ void workspace;
+ setFilterStatus("");
+ setCreateMediaType("");
+ setCreateYear("");
+ setSelectedItemId(null);
+ setSelectedItem(null);
+ setComments([]);
+ setActivity([]);
+ }, [workspace]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Selection is the lifecycle key for this request.
+ useEffect(() => {
+ if (selectedItemId == null) return;
+ void loadItem(selectedItemId);
+ }, [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");
+ setEditIssueType(selectedItem.issue?.issue_type ?? "general");
+ 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 || !canEditSelected) 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 (canModerateSelected) {
+ if (selectedItem.kind === "request") {
+ payload.request_status = editRequestStatus;
+ payload.media_status = editMediaStatus;
+ } else {
+ payload.status = editStatus;
+ payload.issue_type = editIssueType;
+ }
+ 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 : []);
+ setActivity(Array.isArray(data?.activity) ? data.activity : []);
+ 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 (commentInternal && !isAdmin) 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: isAdmin && 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);
+ }
+ };
+
+ const respondToResolution = async (resolved: boolean) => {
+ if (!selectedItem || !canConfirmResolution(selectedItem)) return;
+ setRespondingResolution(true);
+ setError(null);
+ setStatus(null);
+ try {
+ const response = await authFetch(`${getApiBase()}/portal/issues/${selectedItem.id}/resolution-response`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ resolved }),
+ });
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ const text = await response.text();
+ throw new Error(text || "Could not record your confirmation.");
+ }
+ const data = await response.json();
+ setSelectedItem((data?.item ?? null) as PortalItem | null);
+ setComments(Array.isArray(data?.comments) ? data.comments : []);
+ setActivity(Array.isArray(data?.activity) ? data.activity : []);
+ setStatus(
+ resolved ? "Thanks. This issue has been closed." : "Thanks. The issue is back in progress for another look.",
+ );
+ await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()]);
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not record your confirmation.");
+ } finally {
+ setRespondingResolution(false);
+ }
+ };
+
+ const deleteIssue = async () => {
+ if (selectedItem?.kind !== "issue" || !canDeleteSelected) return;
+ setDeleting(true);
+ setError(null);
+ setStatus(null);
+ const issueId = selectedItem.id;
+ try {
+ const response = await authFetch(`${getApiBase()}/portal/items/${issueId}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ const payload = await response.json().catch(() => null);
+ throw new Error(payload?.detail || `Could not delete issue (${response.status})`);
+ }
+ closeIssueModal();
+ setStatus(`Issue #${issueId} was deleted. The linked media request was not changed.`);
+ await Promise.all([loadItems(), loadOverview()]);
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not delete the issue.");
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const closeIssueModal = () => {
+ setSelectedItemId(null);
+ setSelectedItem(null);
+ setComments([]);
+ setActivity([]);
+ setCommentText("");
+ setDeleteConfirming(false);
+ };
+
+ useEffect(() => {
+ void selectedItemId;
+ setDeleteConfirming(false);
+ }, [selectedItemId]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Modal cleanup is keyed by workspace and selected item.
+ useEffect(() => {
+ if (workspace !== "issue" || selectedItemId == null) return;
+ const previousOverflow = document.body.style.overflow;
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") closeIssueModal();
+ };
+ document.body.style.overflow = "hidden";
+ window.addEventListener("keydown", closeOnEscape);
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [workspace, selectedItemId]);
+
+ if (loadingItems && !items.length) {
+ return Loading {workspace === "issue" ? "issues" : "requests"}... ;
+ }
+
+ return (
+
+
+ {visibleKindCount} reported {visibleKindCount === 1 ? "issue" : "issues"}
+
+ ) : undefined
+ }
+ />
+
+ {workspace === "issue" &&
+ items
+ .filter(
+ (item) =>
+ item.status === "awaiting_confirmation" &&
+ canConfirmResolution(item) &&
+ item.created_by_username === me?.username,
+ )
+ .map((item) => (
+
+ ))}
+
+ {workspace === "request" ? (
+
+
+ New requests
+
+ router.push("/portal/issues")}>
+ Issues
+
+
+ ) : null}
+
+ {error && {error}
}
+ {status && {status}
}
+
+ {workspace === "request" ? (
+
+
+
+
Search and request content
+
Search Seerr content directly, then submit a request in one click.
+
+
+
+ {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"}
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+ ) : (
+
+ )}
+
+ {workspace === "request" ? (
+ <>
+
+
+ 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" ? "Create request item" : "Create issue item"}
+
+ {workspace === "request"
+ ? "Create and track request-related notes in a dedicated request workflow."
+ : "Create and track operational issues in a dedicated issue workflow."}
+
+
+
+ >
+ ) : null}
+
+
+ {workspace === "issue" ? (
+
+
+ Issue history
+
Reported problems
+
+
{totalItems} total
+
+ ) : null}
+
+
+
+
+
+
+
+
{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 === "issue"
+ ? (ISSUE_TYPE_LABELS[item.issue?.issue_type ?? "general"] ?? "Issue")
+ : item.kind}
+
+ {item.priority}
+
+
{item.description}
+ {item.kind === "issue" ?
: null}
+
+ #{item.id}
+ {item.kind === "request" ? (
+ Status: {item.workflow?.stage_label ?? item.status}
+ ) : null}
+ {isAdmin ? By: {item.created_by_username} : null}
+ Updated: {formatDate(item.last_activity_at)}
+
+
+
+ ))}
+
+ )}
+
+
+ {workspace === "issue" && selectedItemId != null ? (
+
+ ) : null}
+
+
+ {workspace === "issue" && selectedItemId != null ? (
+
+
+ Reported problem
+
+ {selectedItem ? `Issue #${selectedItem.id}` : "Issue details"}
+
+
+
+ {canDeleteSelected ? (
+ setDeleteConfirming(true)}
+ >
+ Delete issue
+
+ ) : null}
+
+ Close
+
+
+
+ ) : null}
+ {!selectedItemId ? (
+ Select a {workspaceLabel} to view details.
+ ) : loadingItem ? (
+ Loading details…
+ ) : !selectedItem ? (
+ {workspace === "request" ? "Request" : "Issue"} not found.
+ ) : (
+ <>
+ {selectedItem.kind === "issue" &&
+ selectedItem.status === "awaiting_confirmation" &&
+ canConfirmResolution(selectedItem) && (
+ void respondToResolution(value)}
+ />
+ )}
+
+
+
+ {selectedItem.kind === "request" ? "Request" : "Issue"} #{selectedItem.id}
+
+
+ {isAdmin ? `Created by ${selectedItem.created_by_username} on ` : "Reported on "}
+ {formatDate(selectedItem.created_at)}
+
+ {selectedItem.kind === "issue" ? (
+
+ Category:{" "}
+ {ISSUE_TYPE_LABELS[selectedItem.issue?.issue_type ?? "general"] ?? "General"}
+ {selectedItem.issue?.is_resolved ? " · Resolved" : ""}
+
+ ) : null}
+ {selectedItem.kind === "request" && (
+
+ Pipeline:{" "}
+
+ {selectedItem.workflow?.request_status ?? "pending"} /{" "}
+ {selectedItem.workflow?.media_status ?? "pending"}
+ {" "}
+ ({selectedItem.workflow?.stage_label ?? "Pending"})
+
+ )}
+
+
+
+ {selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
+
+
+
Permanent deletion
+
Delete issue #{selectedItem.id}?
+
+ This removes the issue, its comments, and its activity history. The linked media request and
+ collected content will not be changed.
+
+
+
+ setDeleteConfirming(false)}
+ >
+ Cancel
+
+ void deleteIssue()}
+ >
+ {deleting ? "Deleting…" : "Delete permanently"}
+
+
+
+ ) : null}
+
+ {selectedItem.kind === "issue" ? : null}
+
+ {selectedItem.kind === "issue" && selectedItem.external_ref?.startsWith("/requests/") ? (
+
+
+ Linked collection record
+ {selectedItem.external_ref.replace("/requests/", "Request #")}
+
+
router.push(selectedItem.external_ref as string)}>
+ Open request
+
+
+ ) : null}
+
+
+
+ Title
+ setEditTitle(event.target.value)}
+ disabled={!canEditSelected}
+ />
+
+
+ Description
+ setEditDescription(event.target.value)}
+ disabled={!canEditSelected}
+ />
+
+ {selectedItem.kind === "request" ? (
+ <>
+
+ Media type
+ setEditMediaType(event.target.value)}
+ disabled={!canEditSelected}
+ >
+ {MEDIA_TYPE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Year
+ setEditYear(event.target.value)}
+ inputMode="numeric"
+ disabled={!canEditSelected}
+ />
+
+ >
+ ) : null}
+
+ External reference
+ setEditExternalRef(event.target.value)}
+ disabled={!canEditSelected}
+ />
+
+ {canModerateSelected && (
+ <>
+ {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}
+
+ ))}
+
+
+
+ Issue category
+ setEditIssueType(event.target.value)}>
+ {Object.entries(ISSUE_TYPE_LABELS).map(([value, label]) => (
+
+ {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"}
+
+
+
+
+ {selectedItem.kind === "issue" ? (
+
+
+
+ Recorded work
+
Issue activity
+
+
{visibleActivity.length} events
+
+ {visibleActivity.length === 0 ? (
+ No issue activity has been recorded yet.
+ ) : (
+
+ {visibleActivity.map((entry) => (
+
+
+
+ {entry.message}
+
+ {entry.actor_username} ({entry.actor_role})
+
+
+ {formatDate(entry.created_at)}
+
+ ))}
+
+ )}
+
+ ) : null}
+
+
+
Comments
+ {visibleComments.length === 0 ? (
+
No comments yet.
+ ) : (
+
+ {visibleComments.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/issue-flow.css b/frontend/app/portal/issue-flow.css
new file mode 100644
index 0000000..7762b54
--- /dev/null
+++ b/frontend/app/portal/issue-flow.css
@@ -0,0 +1,56 @@
+/* One expanded procedure at a time; completed steps become editable summaries. */
+.issue-flow-progressive .issue-guided-form { padding: 0; border: 0; }
+.issue-wizard-fields { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }
+.issue-procedure-step { min-width: 0; border-bottom: 1px solid var(--ops-line-soft); }
+.issue-procedure-step:last-child { border-bottom: 0; }
+.issue-procedure-step.is-current { padding: 16px 0 8px; }
+.issue-procedure-step .issue-flow-heading { align-items: center; margin-bottom: 18px; }
+.issue-procedure-step h2 { scroll-margin-top: 130px; }
+.issue-procedure-content { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; animation: issue-step-enter 150ms ease-out; }
+.issue-flow-progressive .issue-category-card { min-height: 122px; gap: 6px; padding: 14px; }
+.issue-flow-progressive .issue-media-finder { padding: 0; border: 0; background: transparent; }
+.page .issue-flow-progressive .issue-step-summary {
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
+ gap: 12px; width: 100%; padding: 10px 0; border: 0 !important;
+ background: transparent !important; text-align: left; color: var(--ops-text) !important;
+ box-shadow: none; text-transform: none;
+}
+.issue-step-summary .issue-step-number { width: 28px; height: 28px; color: var(--ops-primary-2); }
+.issue-step-summary-copy { display: grid; gap: 3px; min-width: 0; }
+.issue-step-summary-copy small { color: var(--ops-muted); font-size: 11px; font-weight: 500; }
+.issue-step-summary-copy strong { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.issue-step-change { color: var(--ops-primary-2); font-size: 12px; }
+.issue-step-summary:hover .issue-step-change { text-decoration: underline; }
+.issue-procedure-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 10px; }
+.issue-procedure-actions button { min-height: 44px; }
+.page .issue-procedure-actions > button:not(.ghost-button) {
+ background: #c7bdff !important; border-color: #c7bdff !important; color: #1c172c !important;
+}
+.issue-procedure-actions button:disabled { opacity: .4; }
+.issue-selection-count { margin-right: auto; color: var(--ops-muted); font-size: 12px; }
+.issue-flow-progressive .issue-choice-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.issue-flow-progressive .issue-choice-row button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; min-height: 48px; }
+.issue-device-check { display: grid; place-items: center; width: 22px; height: 22px; flex: 0 0 22px; border: 1px solid currentColor; border-radius: 6px; }
+/* Legacy global button colours are !important; scoped overrides keep toggles visible. */
+.page .issue-flow-progressive button[aria-pressed='true'] {
+ border-color: #c7bdff !important; background: #373147 !important; color: #f5f0ff !important;
+ box-shadow: inset 0 0 0 1px #c7bdff;
+}
+.issue-device-feedback { margin: 4px 0 0; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
+.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr) auto; padding: 0; border: 0; }
+.issue-flow-progressive .issue-resolution-card h3 { margin: 0; font-size: 19px; line-height: 1.4; }
+.issue-flow-progressive .issue-resolution-card p { margin-top: 8px; font-size: 13px; }
+.issue-flow-progressive .status-banner { display: grid; gap: 10px; }
+.issue-flow-progressive .status-banner button { justify-self: start; }
+@keyframes issue-step-enter { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
+@media (max-width: 680px) {
+ .issue-flow-progressive .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .issue-flow-progressive .issue-category-card { min-height: 112px; padding: 12px; }
+ .issue-flow-progressive .issue-category-card p { display: none; }
+ .issue-flow-progressive .issue-category-card strong { font-size: 13px; }
+ .issue-procedure-step .issue-flow-heading h2 { font-size: 19px; }
+ .issue-flow-progressive .issue-step-summary { gap: 8px; }
+ .issue-flow-progressive .issue-choice-row button { font-size: 12px; padding: 10px; }
+ .issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr); }
+}
+@media (prefers-reduced-motion: reduce) { .issue-procedure-content { animation: none; } }
diff --git a/frontend/app/portal/issues/page.tsx b/frontend/app/portal/issues/page.tsx
new file mode 100644
index 0000000..fdea91e
--- /dev/null
+++ b/frontend/app/portal/issues/page.tsx
@@ -0,0 +1,5 @@
+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..c0c88d7
--- /dev/null
+++ b/frontend/app/portal/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function PortalIndexPage() {
+ redirect("/new-requests");
+}
diff --git a/frontend/app/portal/requests/page.tsx b/frontend/app/portal/requests/page.tsx
new file mode 100644
index 0000000..6f5e700
--- /dev/null
+++ b/frontend/app/portal/requests/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function RequestPortalPage() {
+ redirect("/new-requests");
+}
diff --git a/frontend/app/profile/MonthlyRecapPreference.tsx b/frontend/app/profile/MonthlyRecapPreference.tsx
new file mode 100644
index 0000000..c945420
--- /dev/null
+++ b/frontend/app/profile/MonthlyRecapPreference.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { authFetch, getApiBase } from "../lib/auth";
+import "../email-recaps/recaps.css";
+
+type Preference = {
+ automatic_monthly: boolean;
+ state: "off" | "pending" | "expired" | "enabled";
+ email: string | null;
+ can_subscribe: boolean;
+ detail: string;
+ schedule_enabled: boolean;
+ next_send_at: number | null;
+ day: number;
+ hour: number;
+ resend_after: number | null;
+};
+const scheduled = (value: number) =>
+ `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
+
+export default function MonthlyRecapPreference() {
+ const router = useRouter();
+ const [data, setData] = useState(null);
+ const [automatic, setAutomatic] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [revision, setRevision] = useState(0);
+ const [now, setNow] = useState(Date.now());
+
+ useEffect(() => {
+ void revision;
+ const abort = new AbortController();
+ setError("");
+ void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
+ .then(async (response) => {
+ if (response.status === 401) {
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
+ const result = (await response.json()) as Preference;
+ if (!abort.signal.aborted) {
+ setData(result);
+ setAutomatic(result.automatic_monthly);
+ }
+ })
+ .catch((err: Error) => {
+ if (!abort.signal.aborted) setError(err.message);
+ });
+ return () => abort.abort();
+ }, [revision, router]);
+
+ useEffect(() => {
+ if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, [data?.resend_after, data?.state]);
+
+ const save = async (enabled: boolean, monthly = automatic) => {
+ if (busy) return;
+ setBusy(true);
+ setError("");
+ setNotice("");
+ try {
+ const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled, automatic_monthly: monthly }),
+ });
+ if (response.status === 401) {
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ const result = await response.json().catch(() => ({}));
+ if (!response.ok)
+ throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
+ setData(result);
+ setAutomatic(result.automatic_monthly);
+ setNow(Date.now());
+ setNotice(
+ result.message || (enabled ? "Personal report emails are enabled." : "Personal report emails are off."),
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not update your email preference.");
+ // A confirmation may be pending even if SMTP could not confirm delivery.
+ const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null);
+ if (response?.ok) {
+ const fresh = await response.json();
+ setData(fresh);
+ setAutomatic(fresh.automatic_monthly);
+ setNow(Date.now());
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
+ return (
+
+
+
+ A little look back
+
Your reports, your choice.
+
+ {data && (
+
+ {
+ { off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Email confirmed" }[
+ data.state
+ ]
+ }
+
+ )}
+
+
+ Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and
+ decide whether you also want automatic monthly emails.{" "}
+ Explore your latest report ↗
+
+ {!data && !error && Loading your email preference…
}
+ {data && (
+ <>
+ {data.state === "enabled" ? (
+
+ Recaps will go to {data.email} .{" "}
+ {!data.automatic_monthly
+ ? "On demand only: choose a month in Reports and email it whenever you want."
+ : data.schedule_enabled && data.next_send_at
+ ? `Next scheduled send: ${scheduled(data.next_send_at)}.`
+ : "The administrator has paused scheduled delivery."}
+
+ ) : (
+
+ {data.state === "pending"
+ ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.`
+ : data.state === "expired"
+ ? "Request a new confirmation link to turn on your recaps."
+ : "Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile."}
+
+ )}
+ {!data.can_subscribe && data.state !== "enabled" && {data.detail}
}
+ {data.state !== "enabled" && data.can_subscribe && automatic && !data.schedule_enabled && (
+
+ You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
+
+ )}
+
+ Delivery preference
+ {
+ const monthly = event.target.value === "monthly";
+ setAutomatic(monthly);
+ if (data.state === "enabled") void save(true, monthly);
+ }}
+ >
+ On demand only
+ On demand + automatic monthly emails
+
+
+
+ {data.state !== "enabled" && (
+ 0}
+ onClick={() => void save(true)}
+ >
+ {busy
+ ? "Sending confirmation…"
+ : data.state === "off"
+ ? "Confirm my email for reports"
+ : "Send a new confirmation"}
+
+ )}
+ {data.state !== "off" && (
+ void save(false)}>
+ {busy ? "Updating…" : data.state === "enabled" ? "Turn off report emails" : "Cancel subscription"}
+
+ )}
+ {
+ setNotice("");
+ setRevision((value) => value + 1);
+ }}
+ >
+ Refresh preference
+
+
+ {cooldown > 0 && data.state !== "enabled" && (
+
+ Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
+ {Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
+
+ )}
+ >
+ )}
+ {error && (
+
+ {error}
+ {!data && (
+ setRevision((value) => value + 1)}>
+ Try again
+
+ )}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/profile/NewsletterPreference.tsx b/frontend/app/profile/NewsletterPreference.tsx
new file mode 100644
index 0000000..bfb1d4f
--- /dev/null
+++ b/frontend/app/profile/NewsletterPreference.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { authFetch, getApiBase } from "../lib/auth";
+import "../email-recaps/recaps.css";
+
+type Preference = {
+ state: "off" | "pending" | "expired" | "enabled";
+ email: string | null;
+ can_subscribe: boolean;
+ detail: string;
+ schedule_enabled: boolean;
+ next_send_at: number | null;
+ weekday: number;
+ hour: number;
+ resend_after: number | null;
+};
+const scheduled = (value: number) =>
+ `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
+
+export default function NewsletterPreference() {
+ const router = useRouter();
+ const [data, setData] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [revision, setRevision] = useState(0);
+ const [now, setNow] = useState(Date.now());
+
+ useEffect(() => {
+ void revision;
+ const abort = new AbortController();
+ setError("");
+ void authFetch(`${getApiBase()}/profile/newsletters`, { signal: abort.signal })
+ .then(async (response) => {
+ if (response.status === 401) {
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
+ const result = (await response.json()) as Preference;
+ if (!abort.signal.aborted) setData(result);
+ })
+ .catch((err: Error) => {
+ if (!abort.signal.aborted) setError(err.message);
+ });
+ return () => abort.abort();
+ }, [revision, router]);
+
+ useEffect(() => {
+ if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, [data?.resend_after, data?.state]);
+
+ const save = async (enabled: boolean) => {
+ if (busy) return;
+ setBusy(true);
+ setError("");
+ setNotice("");
+ try {
+ const response = await authFetch(`${getApiBase()}/profile/newsletters`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ });
+ if (response.status === 401) {
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ const result = await response.json().catch(() => ({}));
+ if (!response.ok)
+ throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
+ setData(result);
+ setNow(Date.now());
+ setNotice(result.message || (enabled ? "Newsletters are on." : "Newsletters are off."));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not update your email preference.");
+ // A confirmation may be pending even if SMTP could not confirm delivery.
+ const response = await authFetch(`${getApiBase()}/profile/newsletters`).catch(() => null);
+ if (response?.ok) {
+ setData(await response.json());
+ setNow(Date.now());
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
+ return (
+
+
+
+ Your next watch
+
New in your library.
+
+ {data && (
+
+ {
+ { off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Subscribed" }[
+ data.state
+ ]
+ }
+
+ )}
+
+
+ Your minutes, movies, episodes, longest run and requests, in one personal monthly email.{" "}
+ Explore your latest report ↗
+
+ {!data && !error && Loading your email preference…
}
+ {data && (
+ <>
+ {data.state === "enabled" ? (
+
+ Newsletters will go to {data.email} .{" "}
+ {data.schedule_enabled && data.next_send_at
+ ? `Next scheduled send: ${scheduled(data.next_send_at)}.`
+ : "Weekly sending is paused. You may still receive editions scheduled by your administrator."}
+
+ ) : (
+
+ {data.state === "pending"
+ ? `Check ${data.email} for a confirmation link. It expires after 24 hours. Your newsletter subscription starts after you confirm.`
+ : data.state === "expired"
+ ? "Request a new confirmation link to turn on newsletters."
+ : "Subscribe with your profile email. If you already confirmed it for monthly recaps, you can turn this on straight away. Otherwise, we?ll send a confirmation link."}
+
+ )}
+ {!data.can_subscribe && data.state !== "enabled" && {data.detail}
}
+ {data.state !== "enabled" && data.can_subscribe && !data.schedule_enabled && (
+ You can subscribe now, ready for the next edition your administrator sends.
+ )}
+
+ {data.state !== "enabled" && (
+ 0}
+ onClick={() => void save(true)}
+ >
+ {busy
+ ? "Sending confirmation…"
+ : data.state === "off"
+ ? "Email me new arrivals"
+ : "Resend newsletter confirmation"}
+
+ )}
+ {data.state !== "off" && (
+ void save(false)}>
+ {busy
+ ? "Updating…"
+ : data.state === "enabled"
+ ? "Turn off newsletters"
+ : "Cancel newsletter subscription"}
+
+ )}
+ {
+ setNotice("");
+ setRevision((value) => value + 1);
+ }}
+ >
+ Refresh newsletter preference
+
+
+ {cooldown > 0 && data.state !== "enabled" && (
+
+ Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
+ {Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
+
+ )}
+ >
+ )}
+ {error && (
+
+ {error}
+ {!data && (
+ setRevision((value) => value + 1)}>
+ Try again
+
+ )}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/profile/invites/page.tsx b/frontend/app/profile/invites/page.tsx
new file mode 100644
index 0000000..66f2b21
--- /dev/null
+++ b/frontend/app/profile/invites/page.tsx
@@ -0,0 +1,629 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
+import { useEffectiveRole } from "../../lib/viewMode";
+import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
+import PageHeading from "../../ui/PageHeading";
+
+type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
+type OwnedInvite = {
+ id: number;
+ code: string;
+ label?: string | null;
+ description?: string | null;
+ code_available?: boolean;
+ recipient_email?: string | null;
+ max_uses?: number | null;
+ use_count: number;
+ remaining_uses?: number | null;
+ enabled: boolean;
+ expires_at?: string | null;
+ is_usable?: boolean;
+ created_at?: string | null;
+};
+type OwnedInvitesResponse = {
+ invites?: OwnedInvite[];
+ invite_access?: { enabled?: boolean; managed_by_master?: boolean };
+ master_invite?: {
+ id: number;
+ code: string;
+ label?: string | null;
+ max_uses?: number | null;
+ expires_at?: string | null;
+ } | null;
+};
+type InviteForm = {
+ code: string;
+ label: string;
+ description: string;
+ recipient_email: string;
+ enabled: boolean;
+ message: string;
+};
+type DeliveryMethod = "" | "manual" | "email";
+
+const defaultInviteForm = (): InviteForm => ({
+ code: "",
+ label: "",
+ description: "",
+ recipient_email: "",
+ enabled: true,
+ message: "",
+});
+const formatDate = (value?: string | null) => {
+ if (!value) return "Never";
+ const date = new Date(value);
+ return Number.isNaN(date.valueOf()) ? value : 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 [invites, setInvites] = useState([]);
+ const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false);
+ const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false);
+ const [masterInvite, setMasterInvite] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [status, setStatus] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [flowStep, setFlowStep] = useState(1);
+ const [useCustomCode, setUseCustomCode] = useState(false);
+ const [deliveryMethod, setDeliveryMethod] = useState("");
+ const [inviteForm, setInviteForm] = useState(defaultInviteForm());
+ const [createdInvite, setCreatedInvite] = useState(null);
+ const effectiveRole = useEffectiveRole(profile?.role);
+ const canManageInvites =
+ effectiveRole === "admin" ||
+ (profile?.role === "admin" ? Boolean(profile.invite_management_enabled) : inviteAccessEnabled);
+
+ const signupBaseUrl = useMemo(() => {
+ if (typeof window === "undefined") return "/signup";
+ return `${window.location.origin}/signup`;
+ }, []);
+
+ const loadInvites = useCallback(async () => {
+ const response = await authFetch(`${getApiBase()}/auth/profile/invites`);
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ throw new Error("Could not load your invite workspace.");
+ }
+ const data = (await response.json()) as OwnedInvitesResponse;
+ setInvites(Array.isArray(data.invites) ? data.invites : []);
+ setInviteAccessEnabled(Boolean(data.invite_access?.enabled));
+ setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master));
+ setMasterInvite(data.master_invite ?? null);
+ }, [router]);
+
+ useEffect(() => {
+ if (!getToken()) {
+ router.push("/login");
+ return;
+ }
+ const load = async () => {
+ try {
+ const profileResponse = await authFetch(`${getApiBase()}/auth/profile`);
+ if (!profileResponse.ok) {
+ if (profileResponse.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ throw new Error("Could not load your profile.");
+ }
+ const profileData = await profileResponse.json();
+ setProfile(profileData?.user ?? null);
+ await loadInvites();
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not load your invite workspace.");
+ } finally {
+ setLoading(false);
+ }
+ };
+ void load();
+ }, [loadInvites, router]);
+
+ const resetFlow = () => {
+ setEditingId(null);
+ setFlowStep(1);
+ setUseCustomCode(false);
+ setDeliveryMethod("");
+ setInviteForm(defaultInviteForm());
+ };
+
+ const editInvite = (invite: OwnedInvite) => {
+ setEditingId(invite.id);
+ setCreatedInvite(null);
+ setFlowStep(4);
+ setUseCustomCode(true);
+ setDeliveryMethod(invite.recipient_email ? "email" : "manual");
+ setInviteForm({
+ code: invite.code,
+ label: invite.label ?? "",
+ description: invite.description ?? "",
+ recipient_email: invite.recipient_email ?? "",
+ enabled: invite.enabled !== false,
+ message: "",
+ });
+ setError(null);
+ setStatus(null);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
+ const saveInvite = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!canManageInvites) return;
+ const inviteName = inviteForm.label.trim();
+ const recipientEmail = inviteForm.recipient_email.trim();
+ if (!inviteName) {
+ setError("Give this invite a name so you can recognise it later.");
+ return;
+ }
+ if (!deliveryMethod) {
+ setError("Choose how you want to deliver the invite.");
+ return;
+ }
+ if (deliveryMethod === "email" && !isValidEmail(recipientEmail)) {
+ setError("Enter a valid recipient email address.");
+ return;
+ }
+ setSaving(true);
+ setError(null);
+ setStatus(null);
+ try {
+ const response = await authFetch(
+ editingId == null
+ ? `${getApiBase()}/auth/profile/invites`
+ : `${getApiBase()}/auth/profile/invites/${editingId}`,
+ {
+ method: editingId == null ? "POST" : "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code: useCustomCode ? inviteForm.code || null : null,
+ label: inviteName,
+ description: inviteForm.description || null,
+ recipient_email: deliveryMethod === "email" ? recipientEmail : null,
+ enabled: inviteForm.enabled,
+ send_email: editingId == null && deliveryMethod === "email",
+ message: inviteForm.message || null,
+ }),
+ },
+ );
+ if (!response.ok) {
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ throw new Error((await response.text()) || "Could not save the invite.");
+ }
+ const data = await response.json();
+ const savedInvite = data?.invite as OwnedInvite | undefined;
+ setStatus(
+ data?.email?.status === "ok"
+ ? `Invite created and emailed to ${data.email.recipient_email}.`
+ : data?.email?.status === "error"
+ ? `Invite created, but the email could not be sent: ${data.email.detail}`
+ : editingId == null
+ ? "Invite link created and ready to share."
+ : "Invite updated.",
+ );
+ resetFlow();
+ if (editingId == null && savedInvite) setCreatedInvite(savedInvite);
+ await loadInvites();
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not save the invite.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const deleteInvite = async (invite: OwnedInvite) => {
+ if (!canManageInvites) return;
+ if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
+ setError(null);
+ try {
+ const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: "DELETE" });
+ if (!response.ok) throw new Error((await response.text()) || "Could not delete the invite.");
+ if (editingId === invite.id) resetFlow();
+ setStatus(`Deleted ${invite.label || invite.code}.`);
+ await loadInvites();
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not delete the invite.");
+ }
+ };
+
+ const copyInviteLink = async (invite: OwnedInvite) => {
+ if (!canManageInvites) return;
+ try {
+ let usableInvite = invite;
+ if (!invite.code_available) {
+ const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, {
+ method: "POST",
+ });
+ if (!response.ok) throw new Error((await response.text()) || "Could not generate a replacement link.");
+ const data = await response.json();
+ usableInvite = data.invite as OwnedInvite;
+ setInvites((current) => current.map((item) => (item.id === invite.id ? usableInvite : item)));
+ }
+ const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`;
+ await navigator.clipboard.writeText(url);
+ setStatus(
+ `Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`,
+ );
+ } catch {
+ setError("Could not generate or copy the invite link.");
+ }
+ };
+
+ const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
+ const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
+ const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
+
+ if (loading) return Loading invite workspace… ;
+
+ return (
+
+
+ {error && {error}
}
+ {status && {status}
}
+
+ {!canManageInvites ? (
+
+ Invites are not enabled for your account
+ Ask an administrator if you need permission to invite someone.
+
+ ) : (
+
+
+
+
Invite flow
+
{editingId == null ? "Create an invite" : `Edit ${inviteForm.label || "invite"}`}
+
Set up the invite one decision at a time.
+
+ {editingId != null && (
+
+ Cancel edit
+
+ )}
+
+
+ {createdInvite && editingId == null ? (
+
+
Invite ready
+
{createdInvite.label || "Your invite"}
+
+ {createdInvite.recipient_email
+ ? `The invite was emailed to ${createdInvite.recipient_email}.`
+ : "Copy this link and send it to the person you are inviting."}
+
+
+
+ void copyInviteLink(createdInvite)}>
+ Copy link
+
+
+
{
+ setCreatedInvite(null);
+ resetFlow();
+ }}
+ >
+ Create another invite
+
+
+ ) : (
+
+
+ {["Identity", "Description", "Access", "Delivery"].map((label, index) => {
+ const step = index + 1;
+ return (
+
+ {String(step).padStart(2, "0")}
+ {label}
+
+ );
+ })}
+
+
+ 1 ? "is-complete" : "is-active"}`}>
+
+
+
+
+ {flowStep >= 2 && (
+ 2 ? "is-complete" : "is-active"}`}>
+
+
+
+ Welcome note (optional)
+
+ setInviteForm((current) => ({ ...current, description: event.target.value }))
+ }
+ placeholder="Welcome! Use this link to create your account."
+ />
+
+ {flowStep === 2 && (
+
+ setFlowStep(1)}>
+ Back
+
+ {
+ setInviteForm((current) => ({ ...current, description: "" }));
+ setFlowStep(3);
+ }}
+ >
+ Skip
+
+ setFlowStep(3)}>
+ Continue
+
+
+ )}
+
+
+ )}
+
+ {flowStep >= 3 && (
+ 3 ? "is-complete" : "is-active"}`}>
+
+
+
+ Standard user access
+
+ {inviteManagedByMaster && masterInvite
+ ? `Using the “${masterInvite.label || masterInvite.code}” invite policy. Usage and expiry limits are automatic.`
+ : "This invite creates a standard user account using your configured defaults."}
+
+
+ {flowStep === 3 && (
+
+ setFlowStep(2)}>
+ Back
+
+ setFlowStep(4)}>
+ Continue to delivery
+
+
+ )}
+
+
+ )}
+
+ {flowStep >= 4 && (
+
+ )}
+
+ )}
+
+
+
+
+
Your invites
+
Created invites
+
Copy, edit, disable, or remove invitations you have made.
+
+
+ {invites.length === 0 ? (
+
You have not created any invites yet.
+ ) : (
+
+ {invites.map((invite) => (
+
+
+
+ {invite.label || "Unnamed invite"}
+ {invite.code}
+
+ {invite.is_usable ? "Ready" : "Unavailable"}
+
+
+ {invite.description && (
+
{invite.description}
+ )}
+
+ Delivery: {invite.recipient_email || "Manual link"}
+
+ Uses: {invite.use_count}
+ {typeof invite.max_uses === "number" ? ` / ${invite.max_uses}` : ""}
+
+ Expires: {formatDate(invite.expires_at)}
+ Created: {formatDate(invite.created_at)}
+
+
+
+ void copyInviteLink(invite)}>
+ {invite.code_available ? "Copy link" : "Generate replacement link"}
+
+ editInvite(invite)}>
+ Edit
+
+ void deleteInvite(invite)}>
+ Delete
+
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx
new file mode 100644
index 0000000..74b989b
--- /dev/null
+++ b/frontend/app/profile/page.tsx
@@ -0,0 +1,485 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
+import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
+import { canAccess, type FeatureAccess } from "../lib/features";
+import { useEffectiveRole } from "../lib/viewMode";
+import PageHeading from "../ui/PageHeading";
+import MonthlyRecapPreference from "./MonthlyRecapPreference";
+import NewsletterPreference from "./NewsletterPreference";
+
+type ProfileInfo = {
+ features?: FeatureAccess;
+ username: string;
+ email?: string | null;
+ role: string;
+ auth_provider: string;
+ password_change_supported?: boolean;
+ password_provider?: "local" | "jellyfin" | null;
+};
+type ActivityEntry = {
+ ip: string;
+ user_agent: string;
+ first_seen_at: string;
+ last_seen_at: string;
+};
+type ProfileResponse = {
+ user: ProfileInfo;
+ stats?: { total: number; ready: number; in_progress: number };
+ activity?: { recent: ActivityEntry[] };
+};
+type Notice = { tone: "status" | "error"; message: string } | null;
+type ProfileTab = "overview" | "security" | "activity";
+const TABS: { key: ProfileTab; label: string }[] = [
+ { key: "overview", label: "Account" },
+ { key: "security", label: "Security" },
+ { key: "activity", label: "Activity" },
+];
+const normalizeTab = (value: string | null): ProfileTab =>
+ value === "security" || value === "activity" ? value : "overview";
+const formatDate = (value?: string) => {
+ if (!value) return "Not recorded";
+ const date = new Date(value);
+ return Number.isNaN(date.valueOf())
+ ? "Not recorded"
+ : date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
+};
+const deviceName = (agent: string) => {
+ const value = (agent || "").toLowerCase();
+ const browser = value.includes("edg/")
+ ? "Edge"
+ : value.includes("firefox/") || value.includes("fxios/")
+ ? "Firefox"
+ : value.includes("chrome/") || value.includes("crios/")
+ ? "Chrome"
+ : value.includes("safari/")
+ ? "Safari"
+ : "Browser";
+ const device = /iphone|ipad/.test(value)
+ ? "iOS"
+ : value.includes("android")
+ ? "Android"
+ : value.includes("windows")
+ ? "Windows"
+ : value.includes("macintosh")
+ ? "Mac"
+ : value.includes("linux")
+ ? "Linux"
+ : "";
+ return device ? `${browser} on ${device}` : browser;
+};
+const responseMessage = async (response: Response, fallback: string) => {
+ const data = await response.json().catch(() => null);
+ return typeof data?.detail === "string" && data.detail.trim() ? data.detail : fallback;
+};
+
+export default function ProfilePage() {
+ const router = useRouter();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState("");
+ const [activeTab, setActiveTab] = useState("overview");
+ const [email, setEmail] = useState("");
+ const [emailSaving, setEmailSaving] = useState(false);
+ const [emailNotice, setEmailNotice] = useState(null);
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [passwordSaving, setPasswordSaving] = useState(false);
+ const [passwordNotice, setPasswordNotice] = useState(null);
+ const [showAllActivity, setShowAllActivity] = useState(false);
+
+ const loadProfile = useCallback(async () => {
+ if (!getToken()) {
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ setLoading(true);
+ setLoadError("");
+ try {
+ const response = await authFetch(`${getApiBase()}/auth/profile`);
+ if (response.status === 401) {
+ clearToken();
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ if (!response.ok) throw new Error("Could not load your profile. Please try again.");
+ const profile = (await response.json()) as ProfileResponse;
+ setData(profile);
+ setEmail(profile.user.email ?? "");
+ } catch {
+ setLoadError("Could not load your profile. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ }, [router]);
+
+ useEffect(() => {
+ void loadProfile();
+ }, [loadProfile]);
+ useEffect(() => {
+ const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get("tab")));
+ syncTab();
+ window.addEventListener("popstate", syncTab);
+ return () => window.removeEventListener("popstate", syncTab);
+ }, []);
+
+ const selectTab = (tab: ProfileTab) => {
+ setActiveTab(tab);
+ router.replace(tab === "overview" ? "/profile" : `/profile?tab=${tab}`, { scroll: false });
+ };
+ const tabKeyDown = (event: KeyboardEvent, index: number) => {
+ let next = index;
+ if (event.key === "ArrowRight") next = (index + 1) % TABS.length;
+ else if (event.key === "ArrowLeft") next = (index + TABS.length - 1) % TABS.length;
+ else if (event.key === "Home") next = 0;
+ else if (event.key === "End") next = TABS.length - 1;
+ else return;
+ event.preventDefault();
+ selectTab(TABS[next].key);
+ document.getElementById(`profile-tab-${TABS[next].key}`)?.focus();
+ };
+
+ const saveEmail = async (event: FormEvent) => {
+ event.preventDefault();
+ if (emailSaving) return;
+ setEmailSaving(true);
+ setEmailNotice(null);
+ try {
+ const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: email.trim() || null }),
+ });
+ if (response.status === 401) {
+ clearToken();
+ router.replace("/login?next=%2Fprofile");
+ return;
+ }
+ if (!response.ok)
+ throw new Error(await responseMessage(response, "Could not save your email. Please try again."));
+ const result = await response.json();
+ const saved = typeof result.email === "string" ? result.email : "";
+ setData((current) => (current ? { ...current, user: { ...current.user, email: saved || null } } : current));
+ setEmail(saved);
+ setEmailNotice({ tone: "status", message: saved ? "Email saved." : "Email removed." });
+ } catch (error) {
+ setEmailNotice({ tone: "error", message: error instanceof Error ? error.message : "Could not save your email." });
+ } finally {
+ setEmailSaving(false);
+ }
+ };
+
+ const savePassword = async (event: FormEvent) => {
+ event.preventDefault();
+ if (passwordSaving) return;
+ setPasswordNotice(null);
+ if (newPassword.trim().length < 8) {
+ setPasswordNotice({ tone: "error", message: "Use at least 8 characters for your new password." });
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ setPasswordNotice({ tone: "error", message: "The new passwords do not match." });
+ return;
+ }
+ setPasswordSaving(true);
+ try {
+ const response = await authFetch(`${getApiBase()}/auth/password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
+ });
+ if (!response.ok)
+ throw new Error(await responseMessage(response, "Could not change your password. Please try again."));
+ const result = await response.json();
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setPasswordNotice({
+ tone: "status",
+ message:
+ result.provider === "jellyfin"
+ ? "Password updated for Jellyfin and Magent. Seerr uses the same password."
+ : "Password updated.",
+ });
+ } catch (error) {
+ setPasswordNotice({
+ tone: "error",
+ message: error instanceof Error ? error.message : "Could not change your password.",
+ });
+ } finally {
+ setPasswordSaving(false);
+ }
+ };
+
+ const user = data?.user;
+ const effectiveRole = useEffectiveRole(user?.role);
+ const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
+ const canChangePassword =
+ user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
+ const emailChanged = email.trim() !== (user?.email ?? "");
+ const recent = data?.activity?.recent ?? [];
+ const notice = (value: Notice) =>
+ value && (
+
+ {value.message}
+
+ );
+
+ return (
+
+
+
+ {user.username.slice(0, 1).toUpperCase()}
+
+
+ {user.username}
+ {effectiveRole === "admin" ? "Administrator" : "Member"}
+
+
+ )
+ }
+ />
+
+ {loading ? (
+
+ Loading your profile…
+
+ ) : loadError ? (
+
+
{loadError}
+
void loadProfile()}>
+ Try again
+
+
+ ) : (
+ user && (
+ <>
+
+ {TABS.map((tab, index) => (
+ tabKeyDown(event, index)}
+ onClick={() => selectTab(tab.key)}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+
Contact email
+
For password recovery and updates on your reported issues.
+
+
+ Email address
+ {
+ setEmail(event.target.value);
+ setEmailNotice(null);
+ }}
+ />
+ {!user.email && (
+ Add an email so we can let you know when a fix is ready.
+ )}
+ {user.email && !email.trim() && (
+ Saving without an email stops account and issue emails.
+ )}
+ {notice(emailNotice)}
+
+
+ {emailSaving ? "Saving…" : "Save email"}
+
+ {emailChanged && (
+ {
+ setEmail(user.email ?? "");
+ setEmailNotice(null);
+ }}
+ >
+ Discard
+
+ )}
+
+
+
+
+
+ {user.auth_provider === "jellyfin"
+ ? "Connected with your Jellyfin account"
+ : user.auth_provider === "local"
+ ? "Signed in with a Magent account"
+ : "Signed in with your media account"}
+
+
+ {canAccess({ ...user, role: effectiveRole ?? undefined }, "stats") && (
+
+ )}
+
+
+
+
+
+
+
+
Your activity
+
Your requests and recent account access.
+
+ {data?.stats && (
+
+ )}
+ Recent account access
+ {recent.length ? (
+
+ ) : (
+ No recent activity yet.
+ )}
+ {recent.length > 5 && (
+ setShowAllActivity(!showAllActivity)}
+ >
+ {showAllActivity ? "Show less" : "Show all activity"}
+
+ )}
+
+ >
+ )
+ )}
+
+ );
+}
diff --git a/frontend/app/requests/[id]/LatestActivity.tsx b/frontend/app/requests/[id]/LatestActivity.tsx
new file mode 100644
index 0000000..39b7be0
--- /dev/null
+++ b/frontend/app/requests/[id]/LatestActivity.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import "./latest-activity.css";
+import { lockBodyScroll } from "../../lib/scrollLock";
+
+type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string };
+type Operation = {
+ summary?: { title: string; message: string; next: string; action?: string };
+ id: string;
+ label: string;
+ status: string;
+ events: Event[];
+};
+
+export default function LatestActivity({
+ operation,
+ besideDownload,
+ onDismiss,
+}: {
+ operation: Operation;
+ besideDownload: boolean;
+ onDismiss: () => void;
+}) {
+ const dialog = useRef(null);
+ const trigger = useRef(null);
+ const [open, setOpen] = useState(true);
+ useEffect(() => {
+ void operation.id;
+ setOpen(true);
+ }, [operation.id]);
+ // Events are appended in order. Client result events have no timestamp.
+ const latest = operation.events.at(-1);
+ const working = operation.status === "running" || operation.status === "searching";
+ const message = latest?.message ?? "";
+ const choosing = /[1-9]\d* releases? (found|shown)/i.test(message);
+ const sent =
+ operation.status === "complete" &&
+ /sent|accepted.*release/i.test(message) &&
+ /download|release|Sonarr|Radarr/i.test(message);
+ const interrupted = latest?.id === "connection-error";
+ const status =
+ operation.summary?.title ??
+ (working
+ ? "Working on it"
+ : choosing
+ ? "Choose a download"
+ : operation.status === "complete"
+ ? "Done"
+ : "Needs your attention");
+ const currentStep =
+ operation.summary?.message ??
+ (working
+ ? /send release/i.test(operation.label)
+ ? "Sending your download..."
+ : /search/i.test(operation.label)
+ ? "Looking for a download..."
+ : latest?.service === "Jellyfin"
+ ? "Checking if it is ready to watch..."
+ : latest?.service === "qBittorrent"
+ ? "Checking your download..."
+ : "Checking your request..."
+ : interrupted
+ ? "The connection was lost."
+ : choosing
+ ? "The search is finished. Choose a version to download."
+ : sent
+ ? "Your download has been sent."
+ : operation.status === "error"
+ ? "We could not finish this step."
+ : "This check is finished.");
+ const nextStep =
+ operation.summary?.next ??
+ (working
+ ? "Please wait. You can close this box while we work."
+ : interrupted
+ ? "Close this box and check the request before trying again."
+ : choosing
+ ? "Close this box to see the available downloads."
+ : sent
+ ? "You can close this box. The request will update when the download starts."
+ : operation.status === "error"
+ ? "Close this box to review the request and its available options."
+ : "Close this box to see the updated request status.");
+ const progress = (
+
+
+
+ );
+
+ useEffect(() => {
+ if (!open) return;
+ const element = dialog.current;
+ element?.showModal();
+ const unlock = lockBodyScroll();
+ return () => {
+ element?.close();
+ unlock();
+ trigger.current?.focus();
+ };
+ }, [open]);
+
+ return (
+
+
setOpen(true)}
+ aria-haspopup="dialog"
+ aria-expanded={open}
+ >
+
+ Latest activity
+ {status}
+
+
+ {working && }
+ {currentStep}
+
+ {progress}
+ View progress
+
+
setOpen(false)}
+ onClose={() => setOpen(false)}
+ >
+
+
+
+
+ {working && }
+ {currentStep}
+
+ {progress}
+
{nextStep}
+
+ {!working && (
+
+ {
+ setOpen(false);
+ onDismiss();
+ }}
+ >
+ {operation.summary?.action ?? "Dismiss activity"}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/requests/[id]/RequestLanguage.tsx b/frontend/app/requests/[id]/RequestLanguage.tsx
new file mode 100644
index 0000000..e59c7e5
--- /dev/null
+++ b/frontend/app/requests/[id]/RequestLanguage.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { authFetch, getApiBase } from "../../lib/auth";
+
+type AudioChoice = {
+ language: { code: string } | null;
+ originalEnabled?: boolean;
+ canChange?: boolean;
+ profileLanguage?: string;
+};
+
+export default function RequestLanguage({
+ requestId,
+ disabled,
+ onApply,
+}: {
+ requestId: string;
+ disabled: boolean;
+ onApply: (code: string) => Promise;
+}) {
+ const [choice, setChoice] = useState(null);
+ const [error, setError] = useState(null);
+ const [revision, setRevision] = useState(0);
+ useEffect(() => {
+ void revision;
+ const controller = new AbortController();
+ void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
+ .then(async (response) => {
+ if (!response.ok) throw new Error("Could not check the audio settings. Reload the request to try again.");
+ return response.json();
+ })
+ .then((data) => {
+ if (!controller.signal.aborted) setChoice(data);
+ })
+ .catch((e) => {
+ if (!controller.signal.aborted) setError(e.message);
+ });
+ return () => controller.abort();
+ }, [requestId, revision]);
+ if (error && !choice) return {error}
;
+ if (!choice?.language) return null;
+ const code = choice.language.code;
+ const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code) || code;
+ return (
+
+
+ {name} audio {choice.originalEnabled ? "enabled" : "may need your approval"}
+
+
+ This movie was originally made in {name}. An English dub may not exist.{" "}
+ {choice.originalEnabled
+ ? "Radarr is set to accept its original audio."
+ : `The current audio requirement is ${choice.profileLanguage || "set by the library"}. This can leave the request waiting even when an original-language release exists.`}
+
+
+ Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not
+ guaranteed by title metadata.
+
+ {choice.canChange && !choice.originalEnabled && (
+ {
+ setError(null);
+ try {
+ await onApply(code);
+ setRevision((v) => v + 1);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "The audio choice could not be saved.");
+ }
+ }}
+ >
+ Use {name} audio & search
+
+ )}
+ {error && {error}
}
+
+ );
+}
diff --git a/frontend/app/requests/[id]/latest-activity.css b/frontend/app/requests/[id]/latest-activity.css
new file mode 100644
index 0000000..f355963
--- /dev/null
+++ b/frontend/app/requests/[id]/latest-activity.css
@@ -0,0 +1,35 @@
+.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
+.latest-activity.full-row { grid-column: 1 / -1; }
+.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
+.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
+.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
+.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
+.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
+.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
+.latest-activity-badge.is-error { color: #ff9b9b; }
+.latest-activity-badge.is-complete { color: #55dec0; }
+.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
+.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
+.activity-dialog-content { padding: 1.25rem; }
+.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
+.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
+.activity-dialog small { color: var(--ops-muted, #bbb); }
+.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
+@media (max-width: 720px) {
+ .latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
+}
+
+.activity-current { padding: 1rem 0 .25rem; }
+.activity-current-step { font-size: 1.05rem; font-weight: 600; margin: 0 0 1rem; }
+.activity-next-step { color: var(--ops-muted, #bbb); font-size: .875rem; line-height: 1.6; margin: 1rem 0 0; }
+.activity-process { height: 6px; width: 100%; overflow: hidden; border-radius: 999px; background: #ffffff14; }
+.activity-process > span { display: block; height: 100%; width: 100%; border-radius: inherit; background: #55dec0; }
+.activity-process.is-working > span { width: 35%; background: var(--ops-accent, #83d7f7); animation: activity-process-slide 1.6s ease-in-out infinite alternate; }
+.activity-process.is-error > span { background: #e6b86c; }
+@keyframes activity-process-slide { from { transform: translateX(0); } to { transform: translateX(185%); } }
+@media (prefers-reduced-motion: reduce) { .activity-process.is-working > span { animation: none; width: 100%; opacity: .65; } }
+
+.activity-spinner { display: inline-block; width: 28px; height: 28px; flex: 0 0 28px; border: 3px solid #ffffff26; border-top-color: var(--ops-accent, #83d7f7); border-right-color: var(--ops-accent, #83d7f7); border-radius: 50%; animation: activity-spinner-turn .8s linear infinite; }
+.activity-current-step, .latest-activity .latest-activity-message { display: flex; align-items: center; gap: 12px; }
+@keyframes activity-spinner-turn { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .activity-spinner { animation: none; } }
diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx
new file mode 100644
index 0000000..b2af2be
--- /dev/null
+++ b/frontend/app/requests/[id]/page.tsx
@@ -0,0 +1,1601 @@
+"use client";
+
+import Image from "next/image";
+import { useParams, useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
+import { canAccess, type FeatureAccess } from "../../lib/features";
+import { lockBodyScroll } from "../../lib/scrollLock";
+import { useEffectiveRole } from "../../lib/viewMode";
+import PageHeading from "../../ui/PageHeading";
+import LatestActivity from "./LatestActivity";
+import RequestLanguage from "./RequestLanguage";
+
+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;
+ stateLabel?: string;
+ searchStatus?: "searching" | "queued" | "idle" | "unavailable";
+ summary: string;
+ available?: number;
+ missing?: number;
+ total?: number;
+ seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>;
+ unmonitoredSeasons?: Array<{ seasonNumber: number; episodeCount: number; available: number }>;
+ canAddSeasons?: boolean;
+ missingEpisodes?: Record;
+ actionIds?: string[];
+ visible?: boolean;
+ torrents?: Array>;
+ link?: string | null;
+};
+
+type RepairActivityStep = {
+ id: string;
+ label: string;
+ state: "complete" | "active" | "attention" | "waiting" | string;
+ detail: string;
+};
+
+type RepairActivity = {
+ visible?: boolean;
+ actionId?: string;
+ state?: "searching" | "downloading" | "importing" | "indexing" | "complete" | "attention" | string;
+ headline?: string;
+ message?: string;
+ service?: string;
+ updatedAt?: string | null;
+ steps?: RepairActivityStep[];
+};
+
+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?: {
+ repairCycle?: string | null;
+ 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[];
+ repairActivity?: RepairActivity;
+ };
+ raw?: { jellyfin?: { link?: string | null } };
+};
+
+type ReleaseOption = {
+ title?: string;
+ indexer?: string;
+ indexerId?: number;
+ guid?: string;
+ size?: number;
+ seeders?: number;
+ leechers?: number;
+ protocol?: string;
+ publishDate?: string;
+ infoUrl?: string;
+ downloadUrl?: string;
+ magnetUrl?: string;
+ fullSeason?: boolean;
+ seasonNumber?: number;
+ quality?: string;
+ customFormatScore?: number;
+ approved?: boolean;
+ selectionToken?: string;
+ requiresOverride?: boolean;
+ selectable?: boolean;
+ rejections?: string[];
+ bestPick?: boolean;
+};
+
+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;
+};
+
+type LiveDownloadProgress = {
+ repairCycle?: string | null;
+ visible?: boolean;
+ request_id: string;
+ state: string;
+ summary: string;
+ torrents: Array>;
+ updated_at: string;
+};
+
+type OperationEvent = {
+ id: string;
+ service: string;
+ state: "active" | "complete" | "error" | string;
+ message: string;
+ duration_ms?: number | null;
+ status_code?: number | null;
+};
+
+type OperationProgress = {
+ summary?: { title: string; message: string; next: string; action?: string };
+ id: string;
+ label: string;
+ status: "running" | "complete" | "error" | string;
+ duration_ms?: number | null;
+ events: OperationEvent[];
+};
+
+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 progress = Number(torrent.progress);
+ if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 1000) / 10;
+ const supplied = Number(torrent.progressPercent);
+ if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied * 10) / 10;
+ return null;
+};
+
+const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, "")}% complete`;
+
+const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
+ if (String(current.request_id) !== String(live.request_id)) return current;
+ if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current;
+ if (["COMPLETED", "AVAILABLE"].includes(current.state)) return current;
+ const downloadStage = current.presentation?.pipeline?.find((stage) => stage.id === "download");
+ if (downloadStage?.state === "complete" && downloadStage.visible === false) return current;
+ const visible = live.visible ?? live.state !== "not_started";
+ const stageState =
+ live.state === "completed"
+ ? "complete"
+ : ["missing", "error"].includes(live.state)
+ ? "attention"
+ : live.state === "not_started"
+ ? "waiting"
+ : "active";
+ const presentation = current.presentation ?? {};
+ const pipeline = (presentation.pipeline ?? fallbackPipeline(current)).map((stage) =>
+ stage.id === "download"
+ ? { ...stage, state: stageState, stateLabel: undefined, summary: live.summary, visible, torrents: live.torrents }
+ : stage,
+ );
+ return {
+ ...current,
+ presentation: {
+ ...presentation,
+ download: {
+ ...(presentation.download ?? {}),
+ visible,
+ state: live.state,
+ summary: live.summary,
+ torrents: live.torrents,
+ lastSeenAt: live.updated_at,
+ },
+ pipeline,
+ },
+ };
+};
+
+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);
+ const indexing = snapshot.state === "IMPORTING";
+ 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: complete ? "Available to watch" : indexing ? "Adding to Jellyfin" : "Media server",
+ state: complete ? "complete" : indexing ? "active" : "waiting",
+ stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting",
+ summary: complete
+ ? "This title is ready to watch in Jellyfin."
+ : indexing
+ ? "The download is complete. Jellyfin is indexing this title now."
+ : "This title has not reached Jellyfin 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 [releasePickerOpen, setReleasePickerOpen] = useState(false);
+ const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false);
+ const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false);
+ const [nextSearchOffset, setNextSearchOffset] = useState(null);
+ const [releaseCollector, setReleaseCollector] = useState(null);
+ const [releaseSearchMessage, setReleaseSearchMessage] = useState(null);
+ const [historySnapshots, setHistorySnapshots] = useState([]);
+ const [historyActions, setHistoryActions] = useState([]);
+ const [operationProgress, setOperationProgress] = useState(null);
+ const [viewer, setViewer] = useState<{ role?: string; features?: Partial } | null>(null);
+ const effectiveRole = useEffectiveRole(viewer?.role);
+ const isAdmin = effectiveRole === "admin";
+ const canReportIssues = canAccess(viewer ? { ...viewer, role: effectiveRole ?? undefined } : null, "issues");
+ const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState([]);
+ const awaitingMediaIndex = Boolean(
+ snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"),
+ );
+ const repairIsActive = Boolean(
+ snapshot?.presentation?.repairActivity?.visible &&
+ !["complete", "attention"].includes(snapshot.presentation.repairActivity.state ?? ""),
+ );
+ const searchIsActive = Boolean(
+ snapshot?.presentation?.pipeline?.some(
+ (stage) => stage.id === "library" && ["searching", "queued"].includes(stage.searchStatus ?? ""),
+ ),
+ );
+
+ const closeReleasePicker = useCallback(() => {
+ if (busyAction?.startsWith("grab:")) return;
+ setReleasePickerOpen(false);
+ setReleaseOptions([]);
+ setReleaseSearchMessage(null);
+ }, [busyAction]);
+
+ useEffect(() => {
+ if (!releasePickerOpen) return;
+ const unlock = lockBodyScroll();
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") closeReleasePicker();
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ unlock();
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [closeReleasePicker, releasePickerOpen]);
+
+ useEffect(() => {
+ if (!requestId) return;
+ const load = async () => {
+ setLoading(true);
+ setLoadError(null);
+ try {
+ if (!getToken()) {
+ router.push("/login");
+ return;
+ }
+ const baseUrl = getApiBase();
+ const [meResponse, snapshotResponse] = await Promise.all([
+ authFetch(`${baseUrl}/auth/me`),
+ authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
+ ]);
+ if ([meResponse, snapshotResponse].some((response) => response.status === 401)) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!meResponse.ok) {
+ throw new Error("Unable to verify your request access.");
+ }
+ const me = await meResponse.json();
+ setViewer(me);
+ 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);
+ } 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 (!isAdmin || !requestId) {
+ setShowDetails(false);
+ setHistorySnapshots([]);
+ setHistoryActions([]);
+ return;
+ }
+ const controller = new AbortController();
+ const loadHistory = async () => {
+ try {
+ const baseUrl = getApiBase();
+ const [historyResponse, actionsResponse] = await Promise.all([
+ authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`, { signal: controller.signal }),
+ authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`, { signal: controller.signal }),
+ ]);
+ if (historyResponse.ok) {
+ const data = await historyResponse.json();
+ if (!controller.signal.aborted && Array.isArray(data.snapshots)) setHistorySnapshots(data.snapshots);
+ }
+ if (actionsResponse.ok) {
+ const data = await actionsResponse.json();
+ if (!controller.signal.aborted && Array.isArray(data.actions)) setHistoryActions(data.actions);
+ }
+ } catch (error) {
+ if (!controller.signal.aborted) console.error(error);
+ }
+ };
+ void loadHistory();
+ return () => controller.abort();
+ }, [isAdmin, requestId]);
+
+ useEffect(() => {
+ if (!getToken() || !requestId) return;
+ let stopped = false;
+ let refreshing = false;
+ const refresh = async () => {
+ if (document.visibilityState === "hidden" || refreshing) return;
+ refreshing = true;
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`);
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!response.ok) return;
+ const payload = await response.json();
+ if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload);
+ } catch (error) {
+ if (!stopped) console.error(error);
+ } finally {
+ refreshing = false;
+ }
+ };
+ const timer = window.setInterval(
+ () => void refresh(),
+ awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000,
+ );
+ return () => {
+ stopped = true;
+ window.clearInterval(timer);
+ };
+ }, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router]);
+
+ const liveDownloadKey = useMemo(() => {
+ const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === "download");
+ if (!downloadStage || downloadStage.state === "complete") return "";
+ return "discover-and-track";
+ }, [snapshot]);
+
+ useEffect(() => {
+ if (!getToken() || !requestId || !liveDownloadKey) return;
+ let stopped = false;
+ let timer: number | undefined;
+ let controller: AbortController | null = null;
+ const schedule = () => {
+ if (!stopped) timer = window.setTimeout(() => void refresh(), 5_000);
+ };
+ const refresh = async () => {
+ if (document.visibilityState === "hidden") {
+ schedule();
+ return;
+ }
+ controller = new AbortController();
+ try {
+ const response = await authFetch(`${getApiBase()}/requests/${requestId}/download-progress`, {
+ signal: controller.signal,
+ cache: "no-store",
+ });
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (response.ok) {
+ const payload = (await response.json()) as LiveDownloadProgress;
+ if (!stopped && Array.isArray(payload.torrents)) {
+ setSnapshot((current) => (current ? mergeLiveDownload(current, payload) : current));
+ }
+ }
+ } catch (error) {
+ if (!stopped && !(error instanceof DOMException && error.name === "AbortError")) console.error(error);
+ } finally {
+ controller = null;
+ schedule();
+ }
+ };
+ timer = window.setTimeout(() => void refresh(), 750);
+ return () => {
+ stopped = true;
+ if (timer !== undefined) window.clearTimeout(timer);
+ controller?.abort();
+ };
+ }, [liveDownloadKey, requestId, router]);
+
+ 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 (
+
+
+
+ window.location.reload()}>
+ Retry
+
+ router.push("/")}>
+ Back to requests
+
+
+
+ );
+ }
+
+ const presentation = snapshot.presentation ?? {};
+ const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot);
+ const libraryStage = pipeline.find((stage) => stage.id === "library");
+ const unmonitoredSeasons = libraryStage?.unmonitoredSeasons ?? [];
+ const availableStage = pipeline.find((stage) => stage.id === "available");
+ const mediaServerLink = availableStage?.state === "complete" && availableStage.link ? availableStage.link : null;
+ const requestComplete = ["COMPLETED", "AVAILABLE"].includes(snapshot.state) || availableStage?.state === "complete";
+ const issueReportParams = new URLSearchParams({
+ reportRequest: snapshot.request_id,
+ title: snapshot.title,
+ type: snapshot.request_type === "tv" ? "tv" : "movie",
+ });
+ if (snapshot.year) issueReportParams.set("year", String(snapshot.year));
+ const issueReportLink = `/portal/issues?${issueReportParams.toString()}`;
+ 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 repairActivity = presentation.repairActivity;
+ 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 backdropUrl = snapshot.artwork?.backdrop_url?.replace(
+ "https://image.tmdb.org/t/p/w780/",
+ "https://image.tmdb.org/t/p/w1280/",
+ );
+ const resolvedBackdrop = backdropUrl?.startsWith("http")
+ ? backdropUrl
+ : backdropUrl
+ ? `${getApiBase()}${backdropUrl}`
+ : null;
+
+ const trackedPost = async (label: string, url: string, init: RequestInit = {}) => {
+ const operationId =
+ typeof crypto?.randomUUID === "function"
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const headers = new Headers(init.headers ?? {});
+ headers.set("X-Magent-Operation-ID", operationId);
+ headers.set("X-Magent-Operation-Label", label);
+ setOperationProgress({
+ id: operationId,
+ label,
+ status: "running",
+ duration_ms: null,
+ events: [
+ {
+ id: "sending",
+ service: "Magent",
+ state: "active",
+ message: "Sending the action to Magent…",
+ },
+ ],
+ });
+
+ let stopped = false;
+ const refreshProgress = async () => {
+ try {
+ const progressResponse = await authFetch(`${getApiBase()}/operations/${operationId}`, {
+ cache: "no-store",
+ });
+ if (!stopped && progressResponse.ok) {
+ const progress = await progressResponse.json();
+ if (Array.isArray(progress?.events)) {
+ setOperationProgress(progress);
+ return progress as OperationProgress;
+ }
+ }
+ } catch (error) {
+ if (!stopped) console.error(error);
+ }
+ };
+
+ const request = authFetch(url, { ...init, method: "POST", headers });
+ const timer = window.setInterval(() => void refreshProgress(), 650);
+ try {
+ const response = await request;
+ await refreshProgress();
+ let result: Record | null = null;
+ try {
+ result = await response.clone().json();
+ } catch {
+ /* Non-JSON error is handled below. */
+ }
+ const needsAttention = !response.ok || result?.status === "attention" || result?.outcome === "attention";
+ const finalState = needsAttention ? "error" : result?.status === "searching" ? "searching" : "complete";
+ const interactiveSearch = /\/actions\/search(?:\?|$)/.test(url);
+ const items = Array.isArray(result?.releases) ? result.releases : [];
+ const available = items.some((item: ReleaseOption) => item.selectable && !item.requiresOverride);
+ const outside = items.some((item: ReleaseOption) => item.selectable && item.requiresOverride);
+ const canChoose = available || (outside && result?.canIgnoreProfileLimits === true);
+ const summary = interactiveSearch
+ ? !response.ok
+ ? {
+ title: "Search could not finish",
+ message: "We could not complete the search.",
+ next: "Try again shortly. If it keeps happening, contact an admin.",
+ }
+ : canChoose
+ ? {
+ title: available ? "Downloads found" : "Other versions are available",
+ message: available
+ ? "Choose the version you want to download."
+ : "These versions are outside your usual download settings.",
+ next: available
+ ? "Your download starts after you choose a version."
+ : "You can review them and confirm a download outside your profile.",
+ action: "Choose a version",
+ }
+ : {
+ title: items.length ? "No suitable downloads" : "Nothing available yet",
+ message: items.length
+ ? "The versions found cannot be downloaded with your current settings."
+ : "No downloads were found in this search.",
+ next:
+ result?.nextOffset != null
+ ? "You can check the next group of missing episodes."
+ : "You can try again later.",
+ action: result?.nextOffset != null ? "View search results" : undefined,
+ }
+ : response.ok && result?.status === "pending"
+ ? {
+ title: "Waiting for download confirmation",
+ message: "The search was sent. The download queue may still be updating.",
+ next: "Close this window and recheck the request shortly. You do not need to start another search yet.",
+ }
+ : response.ok && result?.status === "downloading"
+ ? {
+ title: "Download queued",
+ message: "The download service has confirmed a download for this title.",
+ next: "Close this window to follow its progress.",
+ }
+ : response.ok && /\/actions\/grab$/.test(url)
+ ? {
+ title: "Waiting to start",
+ message: "Your download has been sent.",
+ next: "Close this box to follow its progress. It may take a moment to start.",
+ }
+ : undefined;
+ setOperationProgress((current) =>
+ current?.id === operationId
+ ? {
+ ...current,
+ status: finalState,
+ summary,
+ events: [
+ ...current.events.map((event) =>
+ event.state === "active" ? { ...event, state: "complete" as const } : event,
+ ),
+ {
+ id: "result",
+ service: "Magent",
+ state: needsAttention ? "error" : "complete",
+ message:
+ (typeof result?.message === "string" && result.message) ||
+ (typeof result?.detail === "string"
+ ? result.detail
+ : response.ok
+ ? "Action completed. The pipeline will update as the media services report progress."
+ : "The action failed. Recheck the request before trying again."),
+ },
+ ],
+ }
+ : current,
+ );
+ return response;
+ } catch (error) {
+ setOperationProgress((current) =>
+ current?.id === operationId
+ ? {
+ ...current,
+ status: "error",
+ events: [
+ ...current.events,
+ {
+ id: "connection-error",
+ service: "Magent",
+ state: "error",
+ message:
+ "The connection was interrupted. Recheck the request before trying the action again—it may already have started.",
+ },
+ ],
+ }
+ : current,
+ );
+ throw error;
+ } finally {
+ stopped = true;
+ window.clearInterval(timer);
+ }
+ };
+
+ const recheckRequest = async () => {
+ setBusyAction("recheck_pipeline");
+ setActionError(null);
+ setActionMessage(null);
+ setReleaseOptions([]);
+ setReleasePickerOpen(false);
+ setReleaseSearchMessage(null);
+ try {
+ const response = await trackedPost(
+ "Recheck request status",
+ `${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
+ );
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!response.ok) {
+ throw new Error(await readApiError(response, "The request could not be rechecked."));
+ }
+ const data = await response.json();
+ if (!isSnapshotPayload(data?.snapshot)) {
+ throw new Error("The request was checked, but Magent did not return a valid pipeline.");
+ }
+ setSnapshot(data.snapshot);
+ setActionMessage(data?.message ?? "Request status rebuilt from live service data.");
+ } catch (error) {
+ console.error(error);
+ setActionError(error instanceof Error ? error.message : "The request could not be rechecked.");
+ } finally {
+ setBusyAction(null);
+ }
+ };
+
+ const addSelectedSeasons = async () => {
+ if (!selectedAdditionalSeasons.length) return;
+ setBusyAction("add_seasons");
+ setActionError(null);
+ setActionMessage(null);
+ try {
+ const response = await trackedPost(
+ "Add seasons to this request",
+ `${getApiBase()}/requests/${snapshot.request_id}/actions/add-seasons`,
+ {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ season_numbers: selectedAdditionalSeasons }),
+ },
+ );
+ if (response.status === 401) {
+ clearToken();
+ router.push("/login");
+ return;
+ }
+ if (!response.ok) throw new Error(await readApiError(response, "The selected seasons could not be added."));
+ const data = await response.json();
+ if (!isSnapshotPayload(data?.snapshot)) {
+ throw new Error("The seasons were added, but Magent did not return an updated request.");
+ }
+ setSnapshot(data.snapshot);
+ setSelectedAdditionalSeasons([]);
+ setActionMessage(data?.message ?? "The selected seasons were added and will now be monitored.");
+ } catch (error) {
+ console.error(error);
+ setActionError(error instanceof Error ? error.message : "The selected seasons could not be added.");
+ } finally {
+ setBusyAction(null);
+ }
+ };
+
+ const runAction = async (action: RequestAction, searchOffset = 0) => {
+ 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;
+ }
+ if (action.id === "search_releases") {
+ setReleaseOptions([]);
+ setIgnoreProfileLimits(false);
+ setNextSearchOffset(null);
+ setReleaseCollector(snapshot.request_type === "tv" ? "Sonarr" : "Radarr");
+ setReleaseSearchMessage(null);
+ setReleasePickerOpen(false);
+ }
+ setBusyAction(action.id);
+ setActionError(null);
+ setActionMessage(null);
+ try {
+ const response = await trackedPost(
+ action.label,
+ `${getApiBase()}/requests/${snapshot.request_id}/${path}${action.id === "search_releases" ? `?offset=${searchOffset}` : ""}`,
+ );
+ 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);
+ setCanIgnoreProfileLimits(data.canIgnoreProfileLimits === true);
+ setNextSearchOffset(typeof data.nextOffset === "number" ? data.nextOffset : null);
+ setReleasePickerOpen(true);
+ setReleaseCollector(data?.collector ?? (snapshot.request_type === "tv" ? "Sonarr" : "Radarr"));
+ setReleaseSearchMessage(
+ data?.message ??
+ (releases.length
+ ? `Found ${releases.length} approved release${releases.length === 1 ? "" : "s"}.`
+ : "No releases currently meet the assigned quality profile."),
+ );
+ setActionMessage(null);
+ } 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 (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits)) return;
+ if (
+ release.requiresOverride &&
+ !window.confirm(
+ `Download this release outside the assigned profile?\n\n${release.title}\n${(release.rejections || []).join("\n")}\n\nThe assigned profile will stay unchanged.`,
+ )
+ )
+ return;
+ const collector = snapshot.request_type === "tv" ? "Sonarr" : "Radarr";
+ setBusyAction(`grab:${release.guid}`);
+ setActionError(null);
+ try {
+ const response = await trackedPost(
+ `Send release through ${collector}`,
+ `${getApiBase()}/requests/${snapshot.request_id}/actions/grab`,
+ {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...release,
+ ignoreProfileLimits: release.requiresOverride === true && ignoreProfileLimits,
+ }),
+ },
+ );
+ 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([]);
+ setReleasePickerOpen(false);
+ setReleaseSearchMessage(null);
+ } catch (error) {
+ console.error(error);
+ setActionError(error instanceof Error ? error.message : "The selected release could not be started.");
+ } finally {
+ setBusyAction(null);
+ }
+ };
+
+ return (
+
+
+ {resolvedBackdrop && (
+ {
+ event.currentTarget.style.opacity = "0";
+ }}
+ />
+ )}
+
+
+
+ {
+ setBusyAction("language");
+ try {
+ const response = await trackedPost(
+ "Use original audio and search",
+ `${getApiBase()}/requests/${snapshot.request_id}/actions/language`,
+ {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ acceptOriginalLanguage: true, languageCode: code }),
+ },
+ );
+ if (!response.ok) throw new Error(await readApiError(response, "The audio choice could not be saved."));
+ } finally {
+ setBusyAction(null);
+ }
+ }}
+ />
+
+
+
+
+ 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)}
+ )}
+
+ )}
+ {operationProgress && (
+ setOperationProgress(null)}
+ />
+ )}
+
+ {requestComplete ? (
+
+
+ Ready to watch
+ Watch this now!
+ Open {snapshot.title} directly in Jellyfin.
+ {mediaServerLink ? (
+
+ Watch on Jellyfin →
+
+ ) : (
+ The Jellyfin watch link is not configured.
+ )}
+
+
+ Need help?
+ Is there a problem with this?
+ Let us know what is wrong and we'll attach the title and request details automatically.
+ {canReportIssues ? (
+
+ Start issue report →
+
+ ) : (
+ Issue reporting is not enabled for your account.
+ )}
+
+
+ ) : (
+ <>
+
+
+
Next step
+
{nextStep.title}
+
{nextStep.description}
+
+
+
+ {recommendedActions.map((action) => (
+ void runAction(action)}
+ >
+ {busyAction === action.id ? "Working…" : action.label}
+
+ ))}
+ void recheckRequest()}
+ title="Recheck Seerr, the library collector, qBittorrent, and the media server"
+ >
+ {busyAction === "recheck_pipeline" ? "Rechecking…" : "Recheck request"}
+
+
+ >
+ )}
+
+ {(actionMessage || actionError) && (
+
+ {actionError ?? actionMessage}
+
+ )}
+
+
+ {snapshot.request_type === "tv" && unmonitoredSeasons.length > 0 && (
+
+
+
+
Collection expansion
+
Add more seasons
+
+ Sonarr knows about {unmonitoredSeasons.length} additional season
+ {unmonitoredSeasons.length === 1 ? "" : "s"} that {unmonitoredSeasons.length === 1 ? "is" : "are"} not
+ currently part of this request.
+
+
+
+
+ Available to add
+
+
+
+ Choose seasons to monitor and search
+
+ setSelectedAdditionalSeasons(unmonitoredSeasons.map((season) => season.seasonNumber))}
+ >
+ Select all
+
+ setSelectedAdditionalSeasons([])}
+ >
+ Clear
+
+
+
+ {unmonitoredSeasons.map((season) => {
+ const selected = selectedAdditionalSeasons.includes(season.seasonNumber);
+ const episodeLabel =
+ season.episodeCount > 0
+ ? `${season.episodeCount} known episode${season.episodeCount === 1 ? "" : "s"}`
+ : "Episodes not announced yet";
+ return (
+
+
+ setSelectedAdditionalSeasons((current) =>
+ selected
+ ? current.filter((number) => number !== season.seasonNumber)
+ : [...current, season.seasonNumber].sort((left, right) => left - right),
+ )
+ }
+ />
+
+ Season {season.seasonNumber}
+
+ {episodeLabel}
+ {season.available > 0 ? ` · ${season.available} already collected` : ""}
+
+
+
+ );
+ })}
+
+
+
+
+
+ {selectedAdditionalSeasons.length
+ ? `${selectedAdditionalSeasons.length} season${selectedAdditionalSeasons.length === 1 ? "" : "s"} selected`
+ : "Choose one or more seasons"}
+
+
+ Selected seasons will be monitored in Sonarr and released missing episodes will be searched immediately.
+
+
+ {libraryStage?.canAddSeasons === false ? (
+
+ Automatic collection searches are not enabled for your account.
+
+ ) : (
+
void addSelectedSeasons()}
+ >
+ {busyAction === "add_seasons" ? "Adding seasons…" : "Add selected seasons"}
+
+ )}
+
+
+ )}
+
+ {repairActivity?.visible && (
+
+
+
+
Repair activity
+
{repairActivity.headline ?? "Repair in progress"}
+
{repairActivity.message ?? "Magent is checking the repair with the connected services."}
+
+
+
+ {repairActivity.state === "complete"
+ ? "Complete"
+ : repairActivity.state === "attention"
+ ? "Attention"
+ : "Live"}
+
+ {repairActivity.updatedAt && Updated {formatWhen(repairActivity.updatedAt)} }
+
+
+
+ {(repairActivity.steps ?? []).map((step) => (
+
+
+
+ {step.label}
+ {step.detail}
+
+
+ ))}
+
+
+ )}
+
+ {!requestComplete && (
+
+
+
+ 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.stateLabel ?? 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);
+ const torrentName = typeof torrent.name === "string" ? torrent.name : "Download";
+ const torrentHash = typeof torrent.hash === "string" ? torrent.hash : torrentName;
+ const episodeLabel = typeof torrent.episodeLabel === "string" ? torrent.episodeLabel : null;
+ return (
+
+ {episodeLabel &&
{episodeLabel} }
+
+ {torrentName}
+ {progress === null ? "Progress unavailable" : formatProgress(progress)}
+
+ {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}
+
+ );
+ })}
+
+
+ )}
+
+ {releasePickerOpen && (
+
+
+
+
+
+
+ {busyAction === "search_releases" && (
+
+
+
+ Checking available releases
+
+ {releaseCollector ?? "The collector"} is applying its quality profile and ranking the results.
+
+
+
+ )}
+
+ {busyAction !== "search_releases" && releaseSearchMessage && (
+
+ Search results
+ {releaseSearchMessage}
+
+ )}
+
+ {busyAction !== "search_releases" && actionError && (
+
+ The release search could not be completed
+ {actionError}
+
+ )}
+
+ {busyAction !== "search_releases" && !actionError && releaseOptions.length === 0 && (
+
+ No suitable downloads are available right now
+ No releases were returned for this search. Check the indexers or try again later.
+
+ )}
+
+ {canIgnoreProfileLimits && (
+
+
+ Ignore profile limits
+ Allow releases outside this title's profile. You'll confirm each download.
+
+ setIgnoreProfileLimits(event.target.checked)}
+ />
+
+ )}
+ {nextSearchOffset !== null && (
+
+ void runAction(
+ {
+ id: "search_releases",
+ label: "Search next missing episodes",
+ risk: "low",
+ requires_confirmation: false,
+ },
+ nextSearchOffset,
+ )
+ }
+ >
+ Search next missing episodes
+
+ )}
+ {releaseOptions.length > 0 && (
+
+ {releaseOptions.map((release) => {
+ const isBestPick = release.bestPick === true;
+ return (
+
+
+
+ {isBestPick && Best pick }
+ {release.requiresOverride && Outside profile }
+ {release.selectable === false && Unavailable for selection }
+ {release.quality && {release.quality} }
+ {release.fullSeason && Season {release.seasonNumber ?? ""} pack }
+
+
{release.title ?? "Unknown release"}
+
+ {release.indexer ?? "Unknown indexer"} · {release.seeders ?? 0} seeders ·{" "}
+ {formatBytes(release.size)}
+ {typeof release.customFormatScore === "number"
+ ? ` · Score ${release.customFormatScore}`
+ : ""}
+
+ {!!release.rejections?.length && (
+
{release.rejections.join("; ")}
+ )}
+ {isBestPick && (
+
+ This is the highest-ranked release approved by {releaseCollector ?? "the collector"}.
+
+ )}
+
+ void downloadRelease(release)}
+ >
+ {busyAction === `grab:${release.guid}`
+ ? "Sending…"
+ : release.requiresOverride
+ ? "Download outside profile"
+ : isBestPick
+ ? "Download best pick"
+ : "Download this release"}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ )}
+
+ {isAdmin && (
+
+ 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..943fcb2
--- /dev/null
+++ b/frontend/app/reset-password/page.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { Suspense, useEffect, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import AuthLayout from "../ui/AuthLayout";
+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 (
+
+
+ {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 (
+
+ Checking your reset link…
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx
new file mode 100644
index 0000000..cba20fb
--- /dev/null
+++ b/frontend/app/setup/page.tsx
@@ -0,0 +1,623 @@
+"use client";
+
+import { useEffect, useState, type FormEvent } from "react";
+import { apiUrl, requestJson } from "../lib/api-client";
+import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
+import MagentMark from "../ui/MagentMark";
+import { serviceStatusLabel } from "../admin/configNavigation";
+import {
+ ALL_FIELDS,
+ APPS,
+ PREFERENCES,
+ bootstrapApplicationUrl,
+ configuredApp,
+ settingsPayload,
+ settingsValues,
+ type AppDefinition,
+ type Field,
+ type Setting,
+ type SetupState,
+ type SetupStatus,
+ type SetupStep,
+ type Values,
+} from "./setup-model";
+import styles from "./setup.module.css";
+
+type Check = { status: string; message?: string };
+type CollectorOptions = { rootFolders: { path: string }[]; qualityProfiles: { id: number; name: string }[] };
+const steps: { id: SetupStep; label: string }[] = [
+ { id: "administrator", label: "Administrator" },
+ { id: "apps", label: "Apps" },
+ { id: "preferences", label: "Preferences" },
+ { id: "review", label: "Review" },
+];
+const json = (body: unknown, method = "POST"): RequestInit => ({
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+});
+const message = (error: unknown) =>
+ error instanceof Error ? error.message : "Something went wrong. Please try again.";
+
+export default function SetupPage() {
+ const [status, setStatus] = useState(null);
+ const [state, setState] = useState(null);
+ const [step, setStep] = useState("administrator");
+ const [settings, setSettings] = useState([]);
+ const [draft, setDraft] = useState({});
+ const [ready, setReady] = useState(false);
+ const [admin, setAdmin] = useState(false);
+ const [forbidden, setForbidden] = useState(false);
+ const [busy, setBusy] = useState("");
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [username, setUsername] = useState("admin");
+ const [password, setPassword] = useState("");
+ const [confirmation, setConfirmation] = useState("");
+ const [setupToken, setSetupToken] = useState("");
+ const [applicationUrl, setApplicationUrl] = useState("");
+ const [checks, setChecks] = useState>({});
+ const [options, setOptions] = useState>({});
+ const [accepted, setAccepted] = useState(false);
+ const values = { ...settingsValues(settings), ...draft };
+
+ useEffect(() => {
+ setApplicationUrl(window.location.origin);
+ const controller = new AbortController();
+ const load = async () => {
+ try {
+ const current = await requestJson(
+ "/setup/status",
+ { signal: controller.signal, cache: "no-store" },
+ authFetch,
+ );
+ setStatus(current);
+ if (current.needs_admin) return;
+ const response = await authFetch(apiUrl("/auth/me"), { signal: controller.signal });
+ if (!response.ok) return;
+ const user = await response.json();
+ if (user.role !== "admin") {
+ setForbidden(true);
+ return;
+ }
+ const [progress, config] = await Promise.all([
+ requestJson("/setup/state", { signal: controller.signal }),
+ requestJson<{ settings: Setting[] }>("/admin/settings", { signal: controller.signal }),
+ ]);
+ setAdmin(true);
+ setState(progress);
+ setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
+ setSettings(config.settings);
+ } catch (failure) {
+ if (!controller.signal.aborted) setError(message(failure));
+ } finally {
+ if (!controller.signal.aborted) setReady(true);
+ }
+ };
+ void load();
+ return () => controller.abort();
+ }, []);
+
+ useEffect(() => {
+ if (!Object.keys(draft).length) return;
+ const warn = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ event.returnValue = "";
+ };
+ window.addEventListener("beforeunload", warn);
+ return () => window.removeEventListener("beforeunload", warn);
+ }, [draft]);
+
+ const run = async (name: string, action: () => Promise) => {
+ if (busy) return;
+ setBusy(name);
+ setError("");
+ setNotice("");
+ try {
+ await action();
+ } catch (failure) {
+ if (failure instanceof UnauthorizedError) {
+ setAdmin(false);
+ setForbidden(false);
+ setAccepted(false);
+ setPassword("");
+ setError("Your session expired. Sign in to continue; your unsaved changes are still here.");
+ } else if (failure instanceof ForbiddenError) {
+ setAdmin(false);
+ setForbidden(true);
+ setAccepted(false);
+ } else setError(message(failure));
+ } finally {
+ setBusy("");
+ }
+ };
+
+ const signIn = async (loginPassword = password) => {
+ const result = await requestJson<{ authenticated: boolean; user?: { role: string } }>(
+ "/auth/login",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({ username: username.trim(), password: loginPassword }),
+ },
+ authFetch,
+ );
+ if (!result.authenticated) throw new Error("Could not sign in. Try your administrator credentials again.");
+ setToken("cookie");
+ setPassword("");
+ setConfirmation("");
+ const user = await requestJson<{ role: string }>("/auth/me");
+ if (user.role !== "admin") {
+ setForbidden(true);
+ return;
+ }
+ const [progress, config] = await Promise.all([
+ requestJson("/setup/state"),
+ requestJson<{ settings: Setting[] }>("/admin/settings"),
+ ]);
+ setAdmin(true);
+ setForbidden(false);
+ setNotice("");
+ setState(progress);
+ setSettings(config.settings);
+ setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
+ };
+
+ const authenticate = (event: FormEvent) => {
+ event.preventDefault();
+ void run("account", async () => {
+ let loginPassword = password;
+ if (status?.needs_admin) {
+ const confirmedApplicationUrl = bootstrapApplicationUrl(applicationUrl, window.location.origin);
+ if (password !== confirmation) throw new Error("The passwords do not match.");
+ loginPassword = password.trim();
+ if (loginPassword.length < 12)
+ throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces.");
+ await requestJson(
+ "/setup/bootstrap",
+ json({
+ setup_token: setupToken,
+ username: username.trim(),
+ password,
+ application_url: confirmedApplicationUrl,
+ }),
+ authFetch,
+ );
+ setPassword(loginPassword);
+ setSetupToken("");
+ setStatus({ setup_required: true, needs_admin: false });
+ setNotice("Administrator created. Signing in...");
+ }
+ await signIn(loginPassword);
+ });
+ };
+
+ const switchAccount = () =>
+ void run("switch-account", async () => {
+ await logout();
+ setAdmin(false);
+ setForbidden(false);
+ setAccepted(false);
+ setUsername("");
+ setPassword("");
+ setConfirmation("");
+ setDraft({});
+ setSettings([]);
+ setChecks({});
+ setOptions({});
+ setNotice("Sign in with a Magent administrator account to continue setup.");
+ });
+
+ const save = async (fields: Field[] = ALL_FIELDS) => {
+ const payload = settingsPayload(draft, fields);
+ if (!Object.keys(payload).length) return;
+ if (values.site_login_show_local_login === false && values.site_login_show_jellyfin_login === false) {
+ throw new Error("Keep at least one sign-in method enabled.");
+ }
+ if (values.magent_notify_email_use_tls === true && values.magent_notify_email_use_ssl === true) {
+ throw new Error("Choose STARTTLS or implicit TLS, not both.");
+ }
+ await requestJson("/admin/settings", json(payload, "PUT"));
+ const config = await requestJson<{ settings: Setting[] }>("/admin/settings");
+ setSettings(config.settings);
+ setDraft((previous) =>
+ Object.fromEntries(Object.entries(previous).filter(([key]) => !fields.some((field) => field.key === key))),
+ );
+ };
+
+ const go = (next: SetupStep) =>
+ void run("save", async () => {
+ await save();
+ if (!state?.completed) setState(await requestJson("/setup/state", json({ step: next }, "PUT")));
+ setStep(next);
+ setNotice("Settings saved. You can return to finish setup later.");
+ });
+
+ const test = (app: AppDefinition) =>
+ void run(app.id, async () => {
+ await save(app.fields);
+ const check = await requestJson(`/status/services/${app.id}/test`, { method: "POST" });
+ setChecks((previous) => ({ ...previous, [app.id]: check }));
+ setNotice(`${app.name}: ${serviceStatusLabel(check.status)}${check.message ? ` — ${check.message}` : ""}`);
+ if ((app.id === "sonarr" || app.id === "radarr") && check.status === "up") {
+ const choices = await requestJson(`/admin/${app.id}/options`);
+ setOptions((previous) => ({ ...previous, [app.id]: choices }));
+ }
+ });
+
+ const update = (field: Field, value: string | boolean) => {
+ setDraft((previous) => ({ ...previous, [field.key]: value }));
+ setAccepted(false);
+ setNotice("");
+ const app = APPS.find((candidate) => candidate.fields.some((item) => item.key === field.key));
+ if (app) setChecks((previous) => ({ ...previous, [app.id]: { status: "unchecked" } }));
+ };
+
+ const fieldControl = (field: Field) => {
+ const saved = settings.some((setting) => setting.key === field.key && setting.isSet);
+ const collectorId = field.key.startsWith("sonarr_") ? "sonarr" : "radarr";
+ const choices = options[collectorId];
+ const profile = field.key.endsWith("_quality_profile_id") && choices?.qualityProfiles.length;
+ const folders = field.key.endsWith("_root_folder") && choices?.rootFolders.length;
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+
+ Magent / Installation
+
+ Set up Magent
+ Connect your media apps, choose your settings and make yourself at home.
+
+ {!ready ? (
+ Checking installation...
+ ) : (
+ <>
+ {error && (
+
+ {error}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+ {!status ? (
+ window.location.reload()}>
+ Retry
+
+ ) : forbidden ? (
+
+ Administrator access required
+ Ask an administrator to finish installation.
+
+ {busy === "switch-account" ? "Signing out..." : "Sign in with an administrator account"}
+
+
+ ) : !admin ? (
+
+ ) : (
+ <>
+ {state?.completed ? (
+
+ This installation is already set up. You can use this guide to update its connections.{" "}
+ Back to settings
+
+ ) : (
+
+ Your administrator is ready. Background imports and automation are paused until you finish. Already
+ have a backup? Restore it here .
+
+ )}
+
+ {steps.map((item, index) => (
+ go(item.id)}
+ >
+ {index + 1}
+ {item.id === "administrator" ? "Administrator ready" : item.label}
+
+ ))}
+
+ {
+ event.preventDefault();
+ go(step === "apps" ? "preferences" : "review");
+ }}
+ >
+ {step === "apps" && (
+
+ Connect your apps
+
+ Each app is optional. Expand the apps you use, save and test their connections, then continue. In
+ Docker, localhost means the Magent container itself.
+
+
+ {APPS.map((app) => (
+
+
+
+ {app.name}
+ {app.description}
+
+
+ {checks[app.id]
+ ? serviceStatusLabel(checks[app.id].status)
+ : configuredApp(app, settings)
+ ? "Configured"
+ : "Optional / not set up"}
+
+
+ {app.fields.map(fieldControl)}
+ test(app)}>
+ {busy === app.id ? "Testing..." : `Save & test ${app.name}`}
+
+ {checks[app.id]?.message && {checks[app.id].message}
}
+
+ ))}
+
+
+ )}
+ {step === "preferences" && (
+
+ Choose your preferences
+
+ Defaults are loaded from your installation. Advanced notification channels, branding and invite
+ policies are available in Settings afterwards.
+
+ {PREFERENCES.map((group) => (
+
+ {group.title}
+ {group.fields.map(fieldControl)}
+
+ ))}
+
+ )}
+ {step === "review" && (
+
+ Ready to finish?
+ Unconfigured apps remain disconnected. You can change every connection later in Settings.
+
+ {APPS.map((app) => (
+
+ {app.name}
+
+ {checks[app.id]
+ ? serviceStatusLabel(checks[app.id].status)
+ : configuredApp(app, settings)
+ ? "Configured (not tested this session)"
+ : "Not configured"}
+
+
+ ))}
+
+
+ Finishing starts the configured background imports and automation, unless disabled in your
+ deployment. Save an encrypted backup once you have checked the installation.
+
+
+ setAccepted(event.target.checked)}
+ disabled={!!busy}
+ />
+ I have reviewed the connections and want to finish setup.
+
+
+ You may remove a manually configured SETUP_TOKEN from your environment after completion. Managed
+ installs keep their generated keys in the data volume; do not remove that file or volume. Existing
+ users and invites are preserved.
+
+
+ )}
+
+ {step !== "apps" && (
+ go(step === "review" ? "preferences" : "apps")}
+ >
+ Back
+
+ )}
+ {Object.keys(draft).length ? "Unsaved changes" : "Progress is saved"}
+ {step !== "review" ? (
+
+ {busy === "save" ? "Saving..." : "Save & continue"}
+
+ ) : (
+
+ void run("finish", async () => {
+ await save();
+ await requestJson("/setup/complete", { method: "POST" });
+ window.location.assign("/admin");
+ })
+ }
+ >
+ {busy === "finish" ? "Finishing..." : "Finish setup"}
+
+ )}
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/app/setup/setup-model.test.ts b/frontend/app/setup/setup-model.test.ts
new file mode 100644
index 0000000..3491793
--- /dev/null
+++ b/frontend/app/setup/setup-model.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+import { APPS, bootstrapApplicationUrl, configuredApp, settingsPayload, settingsValues } from "./setup-model";
+
+describe("first administrator application URL confirmation", () => {
+ it.each([
+ ["https://magent.example.com", "https://magent.example.com"],
+ [" https://MAGENT.example.com:443/ ", "https://magent.example.com"],
+ ["http://192.0.2.10:3000", "http://192.0.2.10:3000"],
+ ["http://[fd00::10]:3000/", "http://[fd00::10]:3000"],
+ ])("confirms a canonical same-origin address %s", (value, browserOrigin) => {
+ expect(bootstrapApplicationUrl(value, browserOrigin)).toBe(browserOrigin);
+ });
+
+ it.each([
+ "",
+ "magent.example.com",
+ "//magent.example.com",
+ "https:/magent.example.com",
+ "ftp://magent.example.com",
+ "javascript:alert(1)",
+ "https://user:password@magent.example.com",
+ "https://magent.example.com/setup",
+ "https://magent.example.com/../",
+ "https://magent.example.com?query=1",
+ "https://magent.example.com?",
+ "https://magent.example.com#fragment",
+ "https://magent.example.com#",
+ "https://magent.example.com\\path",
+ "https://magent.\texample.com",
+ ])("rejects a non-origin or unsafe URL %j", (value) => {
+ expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow("Public Magent URL must");
+ });
+
+ it.each(["https://other.example.com", "http://magent.example.com", "https://magent.example.com:8443"])(
+ "requires the intended browser origin before claiming %s",
+ (value) => {
+ expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow(
+ "Open Magent at your intended address",
+ );
+ },
+ );
+});
+
+describe("installation settings", () => {
+ it("offers every supported media integration", () => {
+ expect(APPS.map((app) => app.id).sort()).toEqual([
+ "bazarr",
+ "jellyfin",
+ "jellystat",
+ "prowlarr",
+ "qbittorrent",
+ "radarr",
+ "seerr",
+ "sonarr",
+ ]);
+ });
+ it("never copies saved secrets into the form or overwrites them with a blank", () => {
+ expect(settingsValues([{ key: "sonarr_api_key", value: "secret", sensitive: true, isSet: true }])).toEqual({
+ sonarr_api_key: "",
+ });
+ expect(settingsPayload({ sonarr_api_key: "", sonarr_base_url: "http://sonarr:8989" })).toEqual({
+ sonarr_base_url: "http://sonarr:8989",
+ });
+ });
+ it("sends only editable fields and validates numeric settings", () => {
+ expect(
+ settingsPayload({ jwt_secret: "no", requests_cleanup_days: "90", site_login_show_signup_link: false }),
+ ).toEqual({ requests_cleanup_days: 90, site_login_show_signup_link: false });
+ expect(() => settingsPayload({ requests_cleanup_days: "-1" })).toThrow("whole number");
+ expect(() => settingsPayload({ sonarr_quality_profile_id: "1.5" })).toThrow("whole number");
+ });
+ it("can save just one app without accidentally saving another draft", () => {
+ expect(
+ settingsPayload(
+ { sonarr_base_url: "http://sonarr:8989", radarr_api_key: "draft-secret" },
+ APPS.find((app) => app.id === "sonarr")?.fields,
+ ),
+ ).toEqual({ sonarr_base_url: "http://sonarr:8989" });
+ });
+ it("validates URL drafts even when app testing bypasses browser form validation", () => {
+ for (const value of [
+ "sonarr:8989",
+ "/sonarr",
+ "ftp://sonarr:8989",
+ "javascript:alert(1)",
+ "http://sonarr/my library",
+ ]) {
+ expect(() => settingsPayload({ sonarr_base_url: value })).toThrow("HTTP or HTTPS URL");
+ }
+ expect(() => settingsPayload({ sonarr_base_url: "https://user:secret@sonarr.test" })).toThrow("credential fields");
+ expect(
+ settingsPayload({
+ sonarr_base_url: " http://sonarr:8989 ",
+ magent_application_url: "https://magent.example.test",
+ }),
+ ).toEqual({ sonarr_base_url: "http://sonarr:8989", magent_application_url: "https://magent.example.test" });
+ expect(settingsPayload({ sonarr_base_url: "" })).toEqual({ sonarr_base_url: "" });
+ });
+ it("validates sender email and sync time before step navigation saves", () => {
+ for (const value of ["not-an-email", "two@@example.test", "name@example test", "Name "]) {
+ expect(() => settingsPayload({ magent_notify_email_from_address: value })).toThrow("valid email address");
+ }
+ for (const value of ["24:00", "12:60", "2:30", "02:30:00"]) {
+ expect(() => settingsPayload({ requests_full_sync_time: value })).toThrow("HH:MM");
+ }
+ expect(
+ settingsPayload({
+ magent_notify_email_from_address: " alerts+admin@example.test ",
+ requests_full_sync_time: "23:59",
+ }),
+ ).toEqual({ magent_notify_email_from_address: "alerts+admin@example.test", requests_full_sync_time: "23:59" });
+ expect(settingsPayload({ magent_notify_email_from_address: "", requests_full_sync_time: "" })).toEqual({
+ magent_notify_email_from_address: "",
+ requests_full_sync_time: "",
+ });
+ });
+ it("does not call a URL-only app configured", () => {
+ const app = APPS[0];
+ const url = { key: "jellyfin_base_url", value: "http://jellyfin:8096", sensitive: false, isSet: true };
+ expect(configuredApp(app, [url])).toBe(false);
+ expect(configuredApp(app, [url, { key: "jellyfin_api_key", value: null, sensitive: true, isSet: true }])).toBe(
+ true,
+ );
+ });
+});
diff --git a/frontend/app/setup/setup-model.ts b/frontend/app/setup/setup-model.ts
new file mode 100644
index 0000000..19a4088
--- /dev/null
+++ b/frontend/app/setup/setup-model.ts
@@ -0,0 +1,294 @@
+export type SetupStep = "administrator" | "apps" | "preferences" | "review";
+export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null };
+export type SetupStatus = { setup_required: boolean; needs_admin: boolean };
+export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean };
+export type Values = Record;
+export type Field = {
+ key: string;
+ label: string;
+ type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea";
+ hint?: string;
+ placeholder?: string;
+ min?: number;
+ max?: number;
+};
+export type AppDefinition = { id: string; name: string; description: string; fields: Field[] };
+
+const connection = (prefix: string, placeholder: string): Field[] => [
+ {
+ key: `${prefix}_base_url`,
+ label: "Server URL",
+ type: "url",
+ placeholder,
+ hint: "Use an address reachable from the Magent server, not your browser.",
+ },
+ { key: `${prefix}_api_key`, label: "API key", type: "password" },
+];
+const collector = (prefix: string): Field[] => [
+ {
+ key: `${prefix}_quality_profile_id`,
+ label: "Quality profile ID",
+ type: "number",
+ min: 1,
+ hint: "Save and test the connection to load available profiles.",
+ },
+ {
+ key: `${prefix}_root_folder`,
+ label: "Root folder",
+ hint: "The library path as seen by this app, for example /tv or /movies.",
+ },
+ {
+ key: `${prefix}_qbittorrent_category`,
+ label: "Download category",
+ hint: "Match the category configured in the app's download client.",
+ },
+];
+
+export const APPS: AppDefinition[] = [
+ {
+ id: "jellyfin",
+ name: "Jellyfin",
+ description: "Playback, library availability and Jellyfin sign-in.",
+ fields: [
+ ...connection("jellyfin", "http://jellyfin:8096"),
+ {
+ key: "jellyfin_public_url",
+ label: "Public playback URL",
+ type: "url",
+ hint: "The address your users open to watch media.",
+ },
+ {
+ key: "jellyfin_sync_to_arr",
+ label: "Sync Jellyfin library into Sonarr / Radarr",
+ type: "checkbox",
+ hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.",
+ },
+ ],
+ },
+ {
+ id: "seerr",
+ name: "Seerr",
+ description: "Requests, approvals and request history (including Jellyseerr).",
+ fields: connection("jellyseerr", "http://seerr:5055"),
+ },
+ {
+ id: "sonarr",
+ name: "Sonarr",
+ description: "TV requests, seasons and collection progress.",
+ fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")],
+ },
+ {
+ id: "radarr",
+ name: "Radarr",
+ description: "Movie requests and collection progress.",
+ fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")],
+ },
+ {
+ id: "prowlarr",
+ name: "Prowlarr",
+ description: "Indexer searches and release discovery.",
+ fields: connection("prowlarr", "http://prowlarr:9696"),
+ },
+ {
+ id: "qbittorrent",
+ name: "qBittorrent",
+ description: "Download progress and recovery actions.",
+ fields: [
+ { key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" },
+ { key: "qbittorrent_username", label: "Username" },
+ { key: "qbittorrent_password", label: "Password", type: "password" },
+ ],
+ },
+ {
+ id: "bazarr",
+ name: "Bazarr",
+ description: "Optional subtitle searches and repairs.",
+ fields: [
+ ...connection("bazarr", "http://bazarr:6767"),
+ { key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" },
+ ],
+ },
+ {
+ id: "jellystat",
+ name: "Jellystat",
+ description: "Optional personal viewing statistics.",
+ fields: connection("jellystat", "http://jellystat:3000"),
+ },
+];
+
+export const PREFERENCES: { title: string; fields: Field[] }[] = [
+ {
+ title: "Site & access",
+ fields: [
+ {
+ key: "magent_application_url",
+ label: "Public Magent URL",
+ type: "url",
+ hint: "Used in invite and notification links. Managed installs also use this address for CORS and sign-in; changing it changes the allowed browser origin. Manual installs keep their environment-configured CORS policy.",
+ },
+ { key: "site_login_message", label: "Login page message", type: "textarea" },
+ {
+ key: "site_login_show_local_login",
+ label: "Show Magent account sign-in",
+ type: "checkbox",
+ hint: "Keep this enabled for local administrator access.",
+ },
+ { key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" },
+ {
+ key: "site_login_show_signup_link",
+ label: "Show invite signup link",
+ type: "checkbox",
+ hint: "Account creation still requires a valid invite. This does not open public registration.",
+ },
+ ],
+ },
+ {
+ title: "Request updates",
+ fields: [
+ { key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 },
+ {
+ key: "requests_delta_sync_interval_minutes",
+ label: "Incremental sync interval (minutes)",
+ type: "number",
+ min: 1,
+ },
+ { key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" },
+ { key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 },
+ ],
+ },
+ {
+ title: "Email (optional)",
+ fields: [
+ { key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" },
+ {
+ key: "magent_notify_email_enabled",
+ label: "Enable email delivery",
+ type: "checkbox",
+ hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.",
+ },
+ { key: "magent_notify_email_smtp_host", label: "SMTP hostname" },
+ { key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 },
+ { key: "magent_notify_email_smtp_username", label: "SMTP username" },
+ { key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" },
+ { key: "magent_notify_email_from_address", label: "Sender email", type: "email" },
+ { key: "magent_notify_email_from_name", label: "Sender name" },
+ { key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" },
+ {
+ key: "magent_notify_email_use_ssl",
+ label: "Use implicit TLS (usually port 465)",
+ type: "checkbox",
+ hint: "Choose either STARTTLS or implicit TLS, not both.",
+ },
+ ],
+ },
+];
+
+export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
+
+export function bootstrapApplicationUrl(value: string, browserOrigin: string): string {
+ const configured = value.trim();
+ let url: URL;
+ try {
+ url = new URL(configured);
+ } catch {
+ throw new Error("Public Magent URL must be a full HTTP or HTTPS origin, for example https://magent.example.com.");
+ }
+ if (
+ !/^https?:\/\/[^/?#]+\/?$/i.test(configured) ||
+ /[\s\\]/.test(configured) ||
+ !["http:", "https:"].includes(url.protocol) ||
+ !url.hostname ||
+ url.username ||
+ url.password ||
+ url.pathname !== "/" ||
+ url.search ||
+ url.hash
+ ) {
+ throw new Error(
+ "Public Magent URL must be an HTTP or HTTPS origin without credentials, a path, query or fragment.",
+ );
+ }
+ if (url.origin !== browserOrigin) {
+ throw new Error(
+ "Public Magent URL must match the address open in this browser. Open Magent at your intended address, then confirm it and create the administrator there.",
+ );
+ }
+ return url.origin;
+}
+
+export function settingsValues(settings: Setting[]): Values {
+ const values: Values = {};
+ for (const field of ALL_FIELDS) {
+ const setting = settings.find((candidate) => candidate.key === field.key);
+ if (!setting) continue;
+ values[field.key] =
+ field.type === "password" || setting.sensitive
+ ? ""
+ : field.type === "checkbox"
+ ? setting.value === true || setting.value === "true" || setting.value === "1"
+ : String(setting.value ?? "");
+ }
+ return values;
+}
+
+// Only explicitly edited fields are sent. A blank password never clears a saved
+// secret (masked values from the settings endpoint are not actual credentials).
+export function settingsPayload(
+ draft: Values,
+ fields: Field[] = ALL_FIELDS,
+): Record {
+ const payload: Record = {};
+ for (const field of fields) {
+ const value = draft[field.key];
+ if (value === undefined || (field.type === "password" && !String(value).trim())) continue;
+ if (field.type === "url" || field.type === "email" || field.type === "time") {
+ const text = String(value).trim();
+ if (text && field.type === "url") {
+ let url: URL;
+ try {
+ url = new URL(text);
+ } catch {
+ throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
+ }
+ if (
+ !/^https?:\/\//i.test(text) ||
+ !["http:", "https:"].includes(url.protocol) ||
+ !url.hostname ||
+ /\s/.test(text)
+ ) {
+ throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
+ }
+ if (url.username || url.password)
+ throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`);
+ }
+ if (
+ text &&
+ field.type === "email" &&
+ !/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
+ text,
+ )
+ ) {
+ throw new Error(`${field.label} must be a valid email address.`);
+ }
+ if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
+ throw new Error(`${field.label} must be a valid time in HH:MM format.`);
+ }
+ payload[field.key] = text;
+ } else if (field.type === "number" && value !== "") {
+ const number = Number(value);
+ if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) {
+ throw new Error(
+ `${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`,
+ );
+ }
+ payload[field.key] = number;
+ } else payload[field.key] = value;
+ }
+ return payload;
+}
+
+export function configuredApp(app: AppDefinition, settings: Setting[]): boolean {
+ return app.fields
+ .filter((field) => field.key.endsWith("_base_url") || field.type === "password")
+ .every((field) => settings.some((setting) => setting.key === field.key && setting.isSet));
+}
diff --git a/frontend/app/setup/setup.module.css b/frontend/app/setup/setup.module.css
new file mode 100644
index 0000000..479c5c1
--- /dev/null
+++ b/frontend/app/setup/setup.module.css
@@ -0,0 +1,49 @@
+.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
+.heading { margin-bottom: 30px; }
+.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
+.setup p { color: var(--ops-muted); line-height: 1.6; }
+.brand { display: flex; align-items: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
+.brand svg { width: 38px; height: 38px; }
+.panel { padding: 24px; margin: 16px 0; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); min-width: 0; }
+.panel h2, .panel h3 { margin-top: 0; }
+.panel summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; list-style: none; }
+.panel summary::after { content: "+"; color: var(--ops-primary-2); }
+.panel[open] summary::after { content: "−"; }
+.panel summary > span:first-child { flex: 1; }
+.panel summary strong { display: block; font-size: 17px; }
+.panel summary small { display: block; margin-top: 6px; color: var(--ops-muted); line-height: 1.5; }
+.panel[open] summary { margin-bottom: 24px; }
+.badge { font-size: 12px; color: var(--ops-primary-2); }
+.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
+.field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
+.field label { color: var(--ops-text); font-size: 13px; }
+.field label small { margin-left: 8px; color: var(--ops-green); }
+.field p, .hint { font-size: 12px; margin: 0; }
+.field input:not([type=checkbox]), .field textarea, .field select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); border-radius: 8px; font: inherit; font-size: 14px; }
+.field textarea { resize: vertical; }
+.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
+.toggle p { grid-column: 1 / -1; }
+.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
+.account { display: grid; gap: 20px; max-width: 440px; margin: 24px 0; }
+.steps { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 30px; }
+.steps button { flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px; background: var(--ops-panel); color: var(--ops-muted); border: 1px solid var(--ops-line); box-shadow: none; }
+.steps button[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
+.steps button span { font-size: 12px; }
+.actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--ops-line); }
+.actions > span { flex: 1; color: var(--ops-muted); font-size: 12px; }
+.error, .notice { padding: 16px 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-bg-2); overflow-wrap: anywhere; }
+.setup .error { border-color: var(--ops-red); color: var(--ops-red); }
+.review { list-style: none; padding: 0; margin: 24px 0; }
+.review li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--ops-line); }
+.review li span:last-child { font-size: 13px; color: var(--ops-muted); text-align: right; }
+.confirm { display: flex; align-items: center; gap: 12px; margin: 24px 0; }
+.setup :is(button, input, textarea, select, a, summary):focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 3px; }
+.setup button:disabled { opacity: .6; cursor: not-allowed; }
+@media (max-width: 640px) {
+ .setup { margin-top: 20px; padding: 0 4px; }
+ .fields { grid-template-columns: 1fr; gap: 20px; }
+ .panel { padding: 18px; }
+ .steps button { flex-basis: 42%; font-size: 12px; }
+ .badge { max-width: 100px; text-align: right; }
+ .panel summary { gap: 10px; }
+}
diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx
new file mode 100644
index 0000000..d3f8b63
--- /dev/null
+++ b/frontend/app/signup/page.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import AuthLayout from "../ui/AuthLayout";
+import { clearToken, getApiBase, setToken } from "../lib/auth";
+
+type InviteInfo = {
+ code: string;
+ email_bound?: boolean;
+ 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 [email, setEmail] = 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 &&
+ (invite.email_bound || email.trim()) &&
+ username.trim() &&
+ password &&
+ !loading &&
+ !inviteLoading,
+ );
+ }, [invite, email, username, password, loading, inviteLoading]);
+
+ const lookupInvite = useCallback(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);
+ }
+ }, [lookupInvite, 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(),
+ ...(!invite.email_bound ? { email: email.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 = "/welcome";
+ 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 (
+
+
+
+ Invite code
+
+ {
+ setInviteCode(e.target.value);
+ setInvite(null);
+ setEmail("");
+ }}
+ placeholder="Paste your invite code"
+ autoCapitalize="characters"
+ />
+ void lookupInvite(inviteCode)}
+ >
+ {inviteLoading ? "Checking…" : "Check invite"}
+
+
+
+ {invite && (
+
+
+ {invite.label || invite.code}
+
+ {invite.is_usable ? "Ready" : "Unavailable"}
+
+
+ {invite.description &&
{invite.description}
}
+
+ Invite details
+
+ Code: {invite.code}
+ Expires: {formatDate(invite.expires_at)}
+ Remaining uses: {invite.remaining_uses ?? "Unlimited"}
+ Profile: {invite.profile?.name || "None"}
+
+
+
+ )}
+ {invite?.email_bound ? (
+
+ Your account will use the email address this invitation was sent to. This invitation can be used once.
+
+ ) : (
+
+ Email address
+ setEmail(e.target.value)}
+ autoComplete="email"
+ placeholder="you@example.com"
+ />
+
+ )}
+
+ 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"}
+
+
+ router.push("/login")}>
+ Back to sign in
+
+
+
+ );
+}
+
+export default function SignupPage() {
+ return (
+
+ Loading sign-up…
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/app/styles/tokens.css b/frontend/app/styles/tokens.css
new file mode 100644
index 0000000..1ccf34d
--- /dev/null
+++ b/frontend/app/styles/tokens.css
@@ -0,0 +1,48 @@
+:root,
+[data-theme='dark'],
+[data-theme='light'] {
+ color-scheme: dark;
+ --ops-bg: #131315;
+ --ops-bg-2: #0e0e10;
+ --ops-panel: #1c1b1d;
+ --ops-panel-2: #201f21;
+ --ops-panel-3: #2a2a2c;
+ --ops-line: #46464d;
+ --ops-line-soft: rgba(145, 144, 152, 0.24);
+ --ops-text: #e5e1e4;
+ --ops-muted: #c7c5ce;
+ --ops-faint: #919098;
+ --ops-primary: #090d25;
+ --ops-primary-2: #c2c4e5;
+ --ops-cyan: #22d3ee;
+ --ops-cyan-2: #3b82f6;
+ --ops-coral: #ffb5a0;
+ --ops-green: #14b8a6;
+ --ops-red: #ef4444;
+ --ops-warn: #f59e0b;
+ --ops-radius-sm: 4px;
+ --ops-radius: 8px;
+ --ops-radius-lg: 12px;
+ --workspace-width: 1440px;
+ --workspace-gutter: 32px;
+ --workspace-gap: 24px;
+ --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: #0e0e10;
+ --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;
+}
diff --git a/frontend/app/ui/AdminDiagnosticsPanel.tsx b/frontend/app/ui/AdminDiagnosticsPanel.tsx
new file mode 100644
index 0000000..f0f6787
--- /dev/null
+++ b/frontend/app/ui/AdminDiagnosticsPanel.tsx
@@ -0,0 +1,530 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, 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 = useMemo(() => checks.filter((check) => check.live_safe).map((check) => check.key), [checks]);
+
+ const runDiagnostics = useCallback(
+ async (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)));
+ }
+ },
+ [checks, emailRecipient, router],
+ );
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Authorization bootstrap runs once for each router instance.
+ 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, runDiagnostics]);
+
+ 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"}
+
+ Check Magent and your connected services. Automatic refresh runs health checks only. Test messages are
+ managed in Notifications below.
+
+
+
+ setAutoRefresh((current) => !current)}
+ >
+ {autoRefresh ? "Disable auto refresh" : "Enable auto refresh"}
+
+ {
+ void runDiagnostics(liveSafeKeys, "safe");
+ }}
+ disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
+ >
+ Run live checks
+
+
+
+ {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
+
+
+ {category === "Notifications" && (
+
+
+ Test email recipient
+ setEmailRecipient(event.target.value)}
+ />
+
+
Choose where the test email goes. Other channels use their configured destinations.
+
0 || categoryChecks.length === 0}
+ onClick={() =>
+ void runDiagnostics(
+ categoryChecks.map((check) => check.key),
+ "all",
+ )
+ }
+ >
+ Test all notification channels
+
+
+ )}
+
+
+ {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 (
+
+ Database storage, tables and timings
+ {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..9214fb1
--- /dev/null
+++ b/frontend/app/ui/AdminShell.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import type { ReactNode } from "react";
+import SettingsNavigation from "./SettingsNavigation";
+import PageHeading from "./PageHeading";
+
+type AdminShellProps = {
+ title: string;
+ subtitle?: string;
+ actions?: ReactNode;
+ rail?: ReactNode;
+ children: ReactNode;
+};
+
+export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
+ return (
+
+
+
+
+ {children}
+ {rail && (
+
+ Additional information
+ {rail}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/ui/AdminViewGate.tsx b/frontend/app/ui/AdminViewGate.tsx
new file mode 100644
index 0000000..1850afc
--- /dev/null
+++ b/frontend/app/ui/AdminViewGate.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import type { ReactNode } from "react";
+import { isAdminPage } from "../lib/user-view-policy";
+import { setUserViewPreview, useUserViewState } from "../lib/viewMode";
+
+export default function AdminViewGate({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ const { enabled, ready } = useUserViewState();
+ if (!isAdminPage(pathname)) return children;
+ if (!ready)
+ return (
+
+ Checking view mode...
+
+ );
+ if (!enabled) return children;
+
+ return (
+
+ Administrator tools are hidden
+ Configuration, user management and other admin tools are unavailable while previewing user view.
+ Your account is unchanged. Exit the preview to return to this page.
+
+
+ );
+}
diff --git a/frontend/app/ui/ApplicationChrome.tsx b/frontend/app/ui/ApplicationChrome.tsx
new file mode 100644
index 0000000..8444940
--- /dev/null
+++ b/frontend/app/ui/ApplicationChrome.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import BrandingLogo from "./BrandingLogo";
+import HeaderActions from "./HeaderActions";
+import HeaderIdentity from "./HeaderIdentity";
+import GlobalSearch from "./GlobalSearch";
+import SiteStatus from "./SiteStatus";
+import UserViewBanner from "./UserViewBanner";
+import WorkspaceNavigation from "./WorkspaceNavigation";
+
+export default function ApplicationChrome() {
+ const pathname = usePathname();
+ if (
+ [
+ "/welcome",
+ "/coming-soon",
+ "/login",
+ "/setup",
+ "/forgot-password",
+ "/reset-password",
+ "/signup",
+ "/email-recaps",
+ "/newsletter-subscription",
+ ].includes(pathname)
+ )
+ return null;
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/app/ui/AuthLayout.tsx b/frontend/app/ui/AuthLayout.tsx
new file mode 100644
index 0000000..5bacd81
--- /dev/null
+++ b/frontend/app/ui/AuthLayout.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+import MagentMark from "./MagentMark";
+
+export default function AuthLayout({
+ title,
+ description,
+ children,
+ footer,
+}: {
+ title: string;
+ description: string;
+ children: ReactNode;
+ footer?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+ {description}
+
+ {children}
+ {footer && }
+
+ Magent · Request. Watch. Enjoy.
+
+ );
+}
diff --git a/frontend/app/ui/BrandingFavicon.tsx b/frontend/app/ui/BrandingFavicon.tsx
new file mode 100644
index 0000000..cfb6463
--- /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..7860a52
--- /dev/null
+++ b/frontend/app/ui/BrandingLogo.tsx
@@ -0,0 +1,41 @@
+"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/FeatureGate.tsx b/frontend/app/ui/FeatureGate.tsx
new file mode 100644
index 0000000..a522476
--- /dev/null
+++ b/frontend/app/ui/FeatureGate.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { useEffect, useState, type ReactNode } from "react";
+import { authFetch, getApiBase, getToken } from "../lib/auth";
+import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
+import { useEffectiveRole } from "../lib/viewMode";
+import { isAdminPage } from "../lib/user-view-policy";
+
+export function useFeatureUser() {
+ const pathname = usePathname();
+ const [state, setState] = useState<{
+ path: string;
+ user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
+ }>({ path: "", user: null });
+ const role = useEffectiveRole(state.user?.role);
+ useEffect(() => {
+ let active = true;
+ const load = async () => {
+ if (!getToken()) {
+ if (active) setState({ path: pathname, user: null });
+ return;
+ }
+ try {
+ const response = await authFetch(`${getApiBase()}/auth/me`);
+ const user = response.ok ? await response.json() : null;
+ if (active) setState({ path: pathname, user });
+ } catch {
+ if (active) setState({ path: pathname, user: null });
+ }
+ };
+ void load();
+ window.addEventListener("focus", load);
+ return () => {
+ active = false;
+ window.removeEventListener("focus", load);
+ };
+ }, [pathname]);
+ return { user: state.user ? { ...state.user, role: role ?? undefined } : null, ready: state.path === pathname };
+}
+
+export default function FeatureGate({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ const { user, ready } = useFeatureUser();
+ const feature = featureForPath(pathname);
+ if (isAdminPage(pathname, false)) {
+ if (!ready) return Checking administrator access... ;
+ if (user?.role !== "admin") {
+ return (
+
+ Administrator access required
+ Sign in with an administrator account to use configuration and administration tools.
+ Sign in
+
+ );
+ }
+ return children;
+ }
+ if (!feature) return children;
+ if (!ready) return Loading account access... ;
+ if (!getToken()) return children;
+ if (!canAccess(user, feature))
+ return (
+
+ Feature unavailable
+ Your account does not have access to this feature. Ask an administrator if you need it enabled.
+ Go to my profile
+
+ );
+ return children;
+}
diff --git a/frontend/app/ui/GlobalSearch.tsx b/frontend/app/ui/GlobalSearch.tsx
new file mode 100644
index 0000000..33cc208
--- /dev/null
+++ b/frontend/app/ui/GlobalSearch.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useRef, useState } from "react";
+import { authFetch, getApiBase } from "../lib/auth";
+import { canAccess } from "../lib/features";
+import { useFeatureUser } from "./FeatureGate";
+
+type SearchResult = {
+ title: string;
+ year?: number | null;
+ type: "movie" | "tv";
+ tmdbId: number;
+ requestId?: number | null;
+ statusLabel?: string | null;
+};
+
+export default function GlobalSearch() {
+ const router = useRouter();
+ const { user, ready } = useFeatureUser();
+ const root = useRef(null);
+ const requestVersion = useRef(0);
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [searching, setSearching] = useState(false);
+ const [message, setMessage] = useState(null);
+ const canSearch = canAccess(user, "new_requests") || canAccess(user, "issues");
+ const canOpenRequests = canAccess(user, "requests");
+ const canCreateRequests = canAccess(user, "new_requests");
+
+ useEffect(() => {
+ const close = (event: PointerEvent) => {
+ if (!root.current?.contains(event.target as Node)) setOpen(false);
+ };
+ document.addEventListener("pointerdown", close);
+ return () => document.removeEventListener("pointerdown", close);
+ }, []);
+
+ useEffect(() => {
+ const term = query.trim();
+ requestVersion.current += 1;
+ const version = requestVersion.current;
+ if (term.length < 2 || !canSearch) {
+ setResults([]);
+ setSearching(false);
+ setMessage(null);
+ return;
+ }
+ setSearching(true);
+ setMessage(null);
+ const controller = new AbortController();
+ const timer = window.setTimeout(async () => {
+ try {
+ const params = new URLSearchParams({ query: term });
+ const response = await authFetch(`${getApiBase()}/requests/search?${params.toString()}`, {
+ signal: controller.signal,
+ cache: "no-store",
+ });
+ if (!response.ok) throw new Error("Search is unavailable right now.");
+ const payload = await response.json();
+ if (version !== requestVersion.current) return;
+ const mapped = (Array.isArray(payload?.results) ? payload.results : [])
+ .filter(
+ (item: Record) =>
+ typeof item.type === "string" && ["movie", "tv"].includes(item.type) && Number(item.tmdbId) > 0,
+ )
+ .slice(0, 7)
+ .map(
+ (item: Record): SearchResult => ({
+ title: String(item?.title || "Untitled"),
+ year: typeof item?.year === "number" ? item.year : null,
+ type: item.type === "tv" ? "tv" : "movie",
+ tmdbId: Number(item.tmdbId),
+ requestId: typeof item?.requestId === "number" ? item.requestId : null,
+ statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
+ }),
+ );
+ setResults(mapped);
+ setMessage(mapped.length ? null : "No matching titles found.");
+ } catch (error) {
+ if (controller.signal.aborted || version !== requestVersion.current) return;
+ console.error(error);
+ setResults([]);
+ setMessage("Search is unavailable right now.");
+ } finally {
+ if (version === requestVersion.current) setSearching(false);
+ }
+ }, 280);
+ return () => {
+ window.clearTimeout(timer);
+ controller.abort();
+ };
+ }, [query, canSearch]);
+
+ if (!ready || !user || !canSearch) return null;
+
+ const openResult = (result: SearchResult) => {
+ setOpen(false);
+ setQuery("");
+ if (result.requestId && canOpenRequests) {
+ router.push(`/requests/${result.requestId}`);
+ return;
+ }
+ if (canCreateRequests) {
+ const params = new URLSearchParams({ type: result.type, query: result.title });
+ router.push(`/new-requests?${params.toString()}`);
+ return;
+ }
+ router.push("/portal/issues");
+ };
+
+ const showResults = open && query.trim().length >= 2;
+ return (
+
+
+ {
+ event.preventDefault();
+ if (results[0]) openResult(results[0]);
+ }}
+ >
+
+
+
+
+ setOpen(true)}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") setOpen(false);
+ }}
+ onChange={(event) => {
+ setQuery(event.target.value);
+ setOpen(true);
+ }}
+ />
+ {searching && }
+
+
+ {showResults && (
+
+ {results.map((result) => (
+
openResult(result)}
+ >
+
+ {result.title}
+
+ {result.type === "tv" ? "TV show" : "Movie"}
+ {result.year ? ` · ${result.year}` : ""}
+
+
+
+ {result.requestId && canOpenRequests
+ ? result.statusLabel || "View request"
+ : canCreateRequests
+ ? "New request"
+ : "Report issue"}
+
+
+ ))}
+ {!searching && message &&
{message}
}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx
new file mode 100644
index 0000000..81ccfc8
--- /dev/null
+++ b/frontend/app/ui/HeaderActions.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { canAccess, featureForPath } from "../lib/features";
+import { useFeatureUser } from "./FeatureGate";
+
+export default function HeaderActions() {
+ const pathname = usePathname();
+ const { user, ready } = useFeatureUser();
+ const role = user?.role ?? null;
+ const showRequestsNav = canAccess(user, "new_requests");
+ if (!ready || !user) return null;
+
+ const roleItems =
+ role === null
+ ? []
+ : role === "admin"
+ ? [
+ {
+ href: "/profile/invites",
+ label: "Invites",
+ match: (path: string) => path.startsWith("/profile/invites"),
+ },
+ {
+ href: "/admin",
+ label: "Config",
+ match: (path: string) => path.startsWith("/admin") || path.startsWith("/users"),
+ },
+ ]
+ : [
+ {
+ href: "/profile/invites",
+ label: "Invites",
+ match: (path: string) => path.startsWith("/profile/invites"),
+ },
+ ];
+
+ const commonItems = [
+ ...(showRequestsNav
+ ? [
+ {
+ href: "/new-requests",
+ label: "New Requests",
+ match: (path: string) => path === "/new-requests",
+ },
+ ]
+ : []),
+ {
+ href: "/",
+ label: "My Requests",
+ match: (path: string) => path === "/" || path.startsWith("/requests/"),
+ },
+ {
+ href: "/insights",
+ label: "My Stats",
+ match: (path: string) => path === "/insights" || path.startsWith("/insights/"),
+ },
+ {
+ href: "/portal/issues",
+ label: "Issues",
+ match: (path: string) => path === "/portal/issues" || path === "/admin/issues",
+ },
+ ];
+
+ const items = [...commonItems, ...roleItems].filter((item) => canAccess(user, featureForPath(item.href)));
+
+ return (
+
+ {items.map((item) => {
+ const active = item.match(pathname);
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/app/ui/HeaderIdentity.tsx b/frontend/app/ui/HeaderIdentity.tsx
new file mode 100644
index 0000000..4605256
--- /dev/null
+++ b/frontend/app/ui/HeaderIdentity.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
+import { setUserViewPreview, useEffectiveRole, useUserViewPreview } from "../lib/viewMode";
+
+export default function HeaderIdentity() {
+ const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
+ const [buildNumber, setBuildNumber] = useState(null);
+ const [open, setOpen] = useState(false);
+ const viewAsUser = useUserViewPreview();
+ const visibleRole = useEffectiveRole(identity?.role);
+
+ 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 });
+ if (data.role !== "admin") {
+ setUserViewPreview(false);
+ }
+ }
+ 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 () => {
+ setUserViewPreview(false);
+ await logout().catch(() => undefined);
+ clearToken();
+ if (typeof window !== "undefined") {
+ window.location.href = "/login";
+ }
+ };
+
+ return (
+
+ {identity.role === "admin" ? (
+
setUserViewPreview(!viewAsUser)}
+ >
+ {viewAsUser ? "Exit user view" : "View as user"}
+
+ ) : null}
+
+
setOpen((prev) => !prev)}
+ aria-haspopup="true"
+ aria-expanded={open}
+ title={label}
+ >
+ {initial}
+
+ {open && (
+
+
+ Signed in as {label}
+ {viewAsUser ? Previewing user view : null}
+
+
+ {buildNumber ?
Build {buildNumber}
: null}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/ui/InviteDeliveryChoice.tsx b/frontend/app/ui/InviteDeliveryChoice.tsx
new file mode 100644
index 0000000..90a3d3b
--- /dev/null
+++ b/frontend/app/ui/InviteDeliveryChoice.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import "./invite-delivery.css";
+
+export default function InviteDeliveryChoice({
+ value,
+ onChange,
+}: {
+ value: "manual" | "email" | "" | null;
+ onChange: (method: "manual" | "email") => void;
+}) {
+ return (
+
+ Invite delivery method
+ {(["manual", "email"] as const).map((method) => (
+ onChange(method)}>
+
+
+ {method === "manual" ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ {method === "manual" ? "Copy a link" : "Send an email"}
+
+ {method === "manual"
+ ? "They add their email when signing up."
+ : "One use, tied to the recipient’s email. You get the link too."}
+
+
+
+ {value === method ? "✓" : ""}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/ui/MagentMark.tsx b/frontend/app/ui/MagentMark.tsx
new file mode 100644
index 0000000..de1cedc
--- /dev/null
+++ b/frontend/app/ui/MagentMark.tsx
@@ -0,0 +1,8 @@
+export default function MagentMark() {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/app/ui/PageHeading.tsx b/frontend/app/ui/PageHeading.tsx
new file mode 100644
index 0000000..7d90d61
--- /dev/null
+++ b/frontend/app/ui/PageHeading.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+
+type PageHeadingProps = {
+ title: string;
+ description?: string;
+ eyebrow?: string;
+ leading?: ReactNode;
+ actions?: ReactNode;
+};
+
+/** A flat, shared page title. Panels belong to the content below it. */
+export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
+ return (
+
+
+ {leading &&
{leading}
}
+
+ {eyebrow &&
{eyebrow} }
+
{title}
+ {description &&
{description}
}
+
+
+ {actions && {actions}
}
+
+ );
+}
diff --git a/frontend/app/ui/RequestStageFilter.tsx b/frontend/app/ui/RequestStageFilter.tsx
new file mode 100644
index 0000000..c86a2e8
--- /dev/null
+++ b/frontend/app/ui/RequestStageFilter.tsx
@@ -0,0 +1,33 @@
+export type RequestStage = "all" | "pending" | "in_progress" | "working" | "ready";
+
+const REQUEST_STAGE_OPTIONS: ReadonlyArray<{ value: RequestStage; label: string }> = [
+ { value: "all", label: "All" },
+ { value: "pending", label: "Waiting" },
+ { value: "in_progress", label: "In progress" },
+ { value: "working", label: "Working" },
+ { value: "ready", label: "Ready" },
+];
+
+export default function RequestStageFilter({
+ value,
+ onChange,
+}: {
+ value: RequestStage;
+ onChange: (stage: RequestStage) => void;
+}) {
+ return (
+
+ {REQUEST_STAGE_OPTIONS.map((option) => (
+ onChange(option.value)}
+ >
+ {option.value === "working" ? : null}
+ {option.label}
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/ui/ResolutionChoice.tsx b/frontend/app/ui/ResolutionChoice.tsx
new file mode 100644
index 0000000..35ce072
--- /dev/null
+++ b/frontend/app/ui/ResolutionChoice.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import "./resolution-choice.css";
+
+export default function ResolutionChoice({
+ title,
+ busy,
+ onAnswer,
+}: {
+ title: string;
+ busy: boolean;
+ onAnswer: (resolved: boolean) => void;
+}) {
+ return (
+
+ Your answer is needed
+ Is it fixed?
+ {title}
+ Try the affected content in Jellyfin, then choose:
+
+ onAnswer(true)}>
+ YES
+ It works — close this issue
+
+ onAnswer(false)}>
+ NO
+ Still broken — keep it open
+
+
+ {busy && Saving your answer…
}
+
+ );
+}
diff --git a/frontend/app/ui/SettingsNavigation.tsx b/frontend/app/ui/SettingsNavigation.tsx
new file mode 100644
index 0000000..df7f255
--- /dev/null
+++ b/frontend/app/ui/SettingsNavigation.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { usePathname, useRouter } from "next/navigation";
+import { CONFIG_GROUPS } from "../admin/configNavigation";
+
+export default function SettingsNavigation() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname);
+ if (pathname === "/admin") return null;
+ return (
+
+ ← All settings
+
+ Jump to
+ router.push(event.target.value)}
+ >
+ Settings overview
+ {CONFIG_GROUPS.map((group) => (
+
+ {group.items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/app/ui/SetupGate.tsx b/frontend/app/ui/SetupGate.tsx
new file mode 100644
index 0000000..ab27b2a
--- /dev/null
+++ b/frontend/app/ui/SetupGate.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { usePathname, useRouter } from "next/navigation";
+import { useEffect, useState, type ReactNode } from "react";
+import { requestJson } from "../lib/api-client";
+import { authFetch } from "../lib/auth";
+
+// Backup access stays available so a fresh installation can be restored before
+// connecting any apps. This is navigation only; the API enforces admin access.
+export default function SetupGate({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const bypass = pathname === "/setup" || pathname === "/admin/backups";
+ const [checked, setChecked] = useState(false);
+
+ useEffect(() => {
+ if (bypass) return;
+ const controller = new AbortController();
+ void requestJson<{ setup_required: boolean }>(
+ "/setup/status",
+ { signal: controller.signal, cache: "no-store" },
+ authFetch,
+ )
+ .then((status) => {
+ if (controller.signal.aborted) return;
+ if (status.setup_required) router.replace("/setup");
+ else setChecked(true);
+ })
+ .catch(() => {
+ // Never hide an existing installation during an API outage or rollout.
+ if (!controller.signal.aborted) setChecked(true);
+ });
+ return () => controller.abort();
+ }, [bypass, router]);
+
+ if (bypass || checked) return children;
+ return (
+
+ Checking installation...
+
+ );
+}
diff --git a/frontend/app/ui/SiteStatus.tsx b/frontend/app/ui/SiteStatus.tsx
new file mode 100644
index 0000000..dc481e3
--- /dev/null
+++ b/frontend/app/ui/SiteStatus.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useEffect, useState, type CSSProperties } from "react";
+import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
+
+type BannerInfo = {
+ enabled: boolean;
+ message: string;
+ tone?: string;
+ backgroundColor?: string | null;
+ borderColor?: string | null;
+};
+
+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";
+ const bannerStyle = {
+ "--site-banner-background-color": banner?.backgroundColor || undefined,
+ "--site-banner-border-color": banner?.borderColor || undefined,
+ } as CSSProperties;
+ return (
+ <>
+ {banner?.enabled && banner.message ? (
+
+ {banner.message}
+
+ ) : null}
+ >
+ );
+}
diff --git a/frontend/app/ui/UserViewBanner.tsx b/frontend/app/ui/UserViewBanner.tsx
new file mode 100644
index 0000000..4569c93
--- /dev/null
+++ b/frontend/app/ui/UserViewBanner.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
+
+export default function UserViewBanner() {
+ const enabled = useUserViewPreview();
+
+ if (!enabled) return null;
+
+ return (
+
+
+ User view
+
+ Admin controls are hidden. You are still using your own account and data; backend permissions are unchanged.
+
+
+
setUserViewPreview(false)}>
+ Exit user view
+
+
+ );
+}
diff --git a/frontend/app/ui/WorkspaceNavigation.tsx b/frontend/app/ui/WorkspaceNavigation.tsx
new file mode 100644
index 0000000..c5becd2
--- /dev/null
+++ b/frontend/app/ui/WorkspaceNavigation.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { getToken } from "../lib/auth";
+import { canAccess, featureForPath } from "../lib/features";
+import { useFeatureUser } from "./FeatureGate";
+
+type NavigationItem = {
+ href: string;
+ label: string;
+ shortLabel: string;
+ icon: "dashboard" | "media" | "issues" | "invites" | "settings" | "stats";
+ adminOnly?: boolean;
+ match: (path: string) => boolean;
+};
+
+const NAVIGATION: NavigationItem[] = [
+ {
+ href: "/new-requests",
+ label: "New Request",
+ shortLabel: "New",
+ icon: "media",
+ match: (path) => path === "/new-requests",
+ },
+ {
+ href: "/",
+ label: "My Requests",
+ shortLabel: "Requests",
+ icon: "dashboard",
+ match: (path) => path === "/" || path.startsWith("/requests/"),
+ },
+ {
+ href: "/insights",
+ label: "My Stats",
+ shortLabel: "Stats",
+ icon: "stats",
+ match: (path) => path === "/insights" || path.startsWith("/insights/"),
+ },
+ {
+ href: "/portal/issues",
+ label: "Issues",
+ shortLabel: "Issues",
+ icon: "issues",
+ match: (path) => path.startsWith("/portal/issues"),
+ },
+ {
+ href: "/profile/invites",
+ label: "Invites",
+ shortLabel: "Invites",
+ icon: "invites",
+ match: (path) => path.startsWith("/profile/invites"),
+ },
+ {
+ href: "/admin",
+ label: "Configuration",
+ shortLabel: "Config",
+ icon: "settings",
+ adminOnly: true,
+ match: (path) => path.startsWith("/admin") || path.startsWith("/users"),
+ },
+];
+
+const HIDDEN_ROUTES = ["/login", "/signup", "/forgot-password", "/reset-password", "/how-it-works"];
+
+function NavigationIcon({ name }: { name: NavigationItem["icon"] }) {
+ const paths: Record = {
+ stats: (
+ <>
+
+ >
+ ),
+ dashboard: (
+ <>
+
+
+
+
+ >
+ ),
+ media: (
+ <>
+
+
+
+ >
+ ),
+ issues: (
+ <>
+
+
+ >
+ ),
+ invites: (
+ <>
+
+
+ >
+ ),
+ settings: (
+ <>
+
+
+ >
+ ),
+ };
+ return (
+
+ {paths[name]}
+
+ );
+}
+
+export default function WorkspaceNavigation() {
+ const pathname = usePathname();
+ const { user, ready } = useFeatureUser();
+ const role = user?.role;
+
+ if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
+ return null;
+ }
+
+ const items = NAVIGATION.filter(
+ (item) => (!item.adminOnly || role === "admin") && canAccess(user, featureForPath(item.href)),
+ );
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.shortLabel}
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/ui/branding.test.tsx b/frontend/app/ui/branding.test.tsx
new file mode 100644
index 0000000..bcd89a2
--- /dev/null
+++ b/frontend/app/ui/branding.test.tsx
@@ -0,0 +1,37 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import ComingSoonPage from "../coming-soon/page";
+import HowItWorksPage from "../how-it-works/page";
+import AuthLayout from "./AuthLayout";
+
+describe("portable default branding", () => {
+ it("uses Magent branding without replacing the supplied sign-in content", () => {
+ const html = renderToStaticMarkup(
+
+ A custom message
+ ,
+ );
+
+ expect(html).toContain("Magent · Request. Watch. Enjoy.");
+ expect(html).toContain("Welcome to our library");
+ expect(html).toContain("A custom message");
+ expect(html).toContain("Local help");
+ expect(html).not.toMatch(/grizzlyflix/i);
+ expect(html).not.toContain(">Beta<");
+ });
+
+ it("shows a generic coming-soon page", () => {
+ const html = renderToStaticMarkup( );
+
+ expect(html).toContain("MAGENT");
+ expect(html).toContain("Your media member portal");
+ expect(html).not.toMatch(/grizzlyflix/i);
+ });
+
+ it("explains the Jellyfin integration without a deployment-specific service name", () => {
+ const html = renderToStaticMarkup( );
+
+ expect(html).toContain("Jellyfin is where you watch them.");
+ expect(html).not.toMatch(/grizzlyflix/i);
+ });
+});
diff --git a/frontend/app/ui/invite-delivery.css b/frontend/app/ui/invite-delivery.css
new file mode 100644
index 0000000..8c234c3
--- /dev/null
+++ b/frontend/app/ui/invite-delivery.css
@@ -0,0 +1,15 @@
+.invite-delivery-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.page .invite-delivery-options > button { display: flex; align-items: center; justify-content: flex-start; gap: 14px; min-height: 96px; padding: 18px; text-align: left; border-radius: 12px; background: var(--ops-panel-2, #202023) !important; border: 1px solid var(--ops-line, #444) !important; color: var(--ops-text, #eee) !important; text-transform: none; }
+.page .invite-delivery-options > button[aria-pressed='true'] { background: #302d3e !important; border-color: #c7bdff !important; box-shadow: inset 0 0 0 1px #c7bdff; }
+.invite-delivery-options > button:focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
+.invite-delivery-options .delivery-choice-icon { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 42px; border-radius: 10px; background: #ffffff09; color: #c7bdff; }
+.delivery-choice-icon svg { width: 24px; height: 24px; }
+.invite-delivery-options .delivery-choice-copy { display: grid; gap: 6px; min-width: 0; flex: 1; }
+.delivery-choice-copy strong { font-size: .95rem; color: var(--ops-text, #eee); }
+.delivery-choice-copy small { font-size: .8rem; font-weight: 400; line-height: 1.45; color: var(--ops-muted, #bbb); }
+.invite-delivery-options .delivery-choice-check { flex: 0 0 20px; width: 20px; height: 20px; border: 1px solid var(--ops-line, #666); border-radius: 50%; display: grid; place-items: center; color: #c7bdff; font-size: .8rem; }
+.invite-delivery-fields { align-items: start; }
+.invite-delivery-fields > label { display: grid; align-content: start; gap: 8px; }
+.invite-delivery-fields input { min-height: 48px; }
+.invite-delivery-fields textarea { min-height: 88px; resize: vertical; }
+@media (max-width: 640px) { .invite-delivery-options { grid-template-columns: 1fr; } }
diff --git a/frontend/app/ui/resolution-choice.css b/frontend/app/ui/resolution-choice.css
new file mode 100644
index 0000000..7bb1976
--- /dev/null
+++ b/frontend/app/ui/resolution-choice.css
@@ -0,0 +1,13 @@
+.resolution-choice { padding: clamp(20px, 4vw, 36px); border: 1px solid var(--ops-border, #555); border-radius: 18px; background: var(--ops-surface, #202023); margin-bottom: 20px; }
+.resolution-choice h2 { margin: 10px 0; font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.1; }
+.resolution-choice p { line-height: 1.5; overflow-wrap: anywhere; }
+.resolution-choice-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
+.resolution-choice-buttons button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 130px; padding: 20px; border-radius: 14px; border: 2px solid transparent; text-transform: none; }
+.resolution-choice-buttons button strong { font-size: 2.75rem; line-height: 1; color: inherit; }
+.resolution-choice-buttons button span { color: inherit; opacity: 1; font-size: .9rem; }
+/* Scoped overrides for the legacy global !important button palette. */
+.page .resolution-choice-buttons button.resolution-yes { background: #b4f4d2 !important; color: #10261b !important; border-color: #b4f4d2 !important; }
+.page .resolution-choice-buttons button.resolution-no { background: #ffc1c5 !important; color: #391318 !important; border-color: #ffc1c5 !important; }
+.resolution-choice-buttons button:focus-visible { outline: 3px solid var(--ops-accent, #c7baff); outline-offset: 4px; }
+.resolution-response-page { width: min(760px, 100%); margin: 20px auto; }
+@media (max-width: 520px) { .resolution-choice-buttons { grid-template-columns: 1fr; } .resolution-choice-buttons button { min-height: 104px; } }
diff --git a/frontend/app/users/FeatureControls.tsx b/frontend/app/users/FeatureControls.tsx
new file mode 100644
index 0000000..270d7d5
--- /dev/null
+++ b/frontend/app/users/FeatureControls.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { authFetch, getApiBase } from "../lib/auth";
+import { FEATURES, type Feature, type FeatureAccess } from "../lib/features";
+
+type Account = { username: string; role: string; features: FeatureAccess };
+
+export default function FeatureControls({ username, onSaved }: { username?: string; onSaved: () => void }) {
+ const [accounts, setAccounts] = useState(null);
+ const [changes, setChanges] = useState>({});
+ const [busy, setBusy] = useState(false);
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
+ const load = useCallback(async () => {
+ const response = await authFetch(
+ `${getApiBase()}/admin/users/${username ? encodeURIComponent(username) : "summary"}`,
+ );
+ if (!response.ok) throw new Error("Could not load feature permissions.");
+ const data = await response.json();
+ setAccounts(username ? [data.user] : data.users.filter((user: Account) => user.role !== "admin"));
+ }, [username]);
+ useEffect(() => {
+ void load().catch((err) => setError(err.message));
+ }, [load]);
+ const save = async () => {
+ setBusy(true);
+ setError("");
+ setMessage("");
+ try {
+ const response = await authFetch(
+ `${getApiBase()}/admin/users/${username ? `${encodeURIComponent(username)}/features` : "features/bulk"}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(changes),
+ },
+ );
+ if (!response.ok) throw new Error((await response.json()).detail || "Could not save permissions.");
+ const result = await response.json();
+ setChanges({});
+ setMessage(username ? "Feature access saved." : `Feature access saved for ${result.updated} non-admin accounts.`);
+ await load();
+ onSaved();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not save permissions.");
+ } finally {
+ setBusy(false);
+ }
+ };
+ const admin = accounts?.some((account) => account.role === "admin");
+ return (
+
+ Feature access
+
+ {username
+ ? "Choose which features this person can use in Magent."
+ : "Apply feature access to every existing non-admin account, including users outside the current search. Only the checkboxes you change will be applied."}
+
+
+ {admin
+ ? "Administrators always have access to all features."
+ : "Changes take effect on the next page or API request. These permissions control Magent access; linked services keep their own permissions."}
+
+ {!accounts && !error && Loading permissions...
}
+ {FEATURES.map(({ key, label, description }) => {
+ const enabled = accounts?.filter((account) => account.features?.[key]).length ?? 0;
+ const mixed = !!accounts?.length && enabled > 0 && enabled < accounts.length;
+ const changed = Object.hasOwn(changes, key);
+ return (
+
+ {
+ if (input) input.indeterminate = !changed && mixed;
+ }}
+ checked={changes[key] ?? (!!accounts?.length && enabled === accounts.length)}
+ disabled={busy || !accounts?.length || admin}
+ onChange={(event) => setChanges((previous) => ({ ...previous, [key as Feature]: event.target.checked }))}
+ />
+
+ {label}
+ {description}
+ {!username && (
+
+ {enabled} of {accounts?.length ?? 0} enabled{mixed && !changed ? " · Mixed access" : ""}
+ {changed ? ` · Will ${changes[key] ? "enable" : "disable"} for everyone` : ""}
+
+ )}
+
+
+ );
+ })}
+ {error && (
+
+ {error}
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+
+ void save()}>
+ {busy ? "Saving..." : username ? "Save feature access" : "Apply changed features to all users"}
+
+ setChanges({})}
+ >
+ Reset changes
+
+
+
+ );
+}
diff --git a/frontend/app/users/[id]/page.tsx b/frontend/app/users/[id]/page.tsx
new file mode 100644
index 0000000..28b1b62
--- /dev/null
+++ b/frontend/app/users/[id]/page.tsx
@@ -0,0 +1,809 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
+import FeatureControls from "../FeatureControls";
+import "../users.css";
+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: Record | null | undefined): 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: typeof stats?.last_request_at === "string" ? stats.last_request_at : null,
+});
+
+export default function UserDetailPage() {
+ const [manageOpen, setManageOpen] = useState(false);
+ const managementDialog = useRef(null);
+ const manageTrigger = useRef(null);
+ useEffect(() => {
+ if (!manageOpen) return;
+ const dialog = managementDialog.current;
+ if (!dialog) return;
+ const overflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ dialog.showModal();
+ return () => {
+ dialog.close();
+ document.body.style.overflow = overflow;
+ manageTrigger.current?.focus();
+ };
+ }, [manageOpen]);
+ 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 [emailInput, setEmailInput] = useState("");
+ const [savingProfile, setSavingProfile] = useState(false);
+ const [savingExpiry, setSavingExpiry] = useState(false);
+ const [savingEmail, setSavingEmail] = useState(false);
+ const [systemActionBusy, setSystemActionBusy] = useState(false);
+ const [actionStatus, setActionStatus] = useState(null);
+ const [lineage, setLineage] = useState(null);
+
+ const loadProfiles = useCallback(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: Record) => ({
+ 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 = useCallback(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));
+ setEmailInput(nextUser?.email ?? "");
+ setError(null);
+ } catch (err) {
+ console.error(err);
+ setError("Could not load user.");
+ } finally {
+ setLoading(false);
+ }
+ }, [idParam, router]);
+
+ 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 saveUserEmail = async (clear = false) => {
+ if (!user) return;
+ const email = clear ? "" : emailInput.trim();
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ setError("Enter a valid email address.");
+ setActionStatus(null);
+ return;
+ }
+ setSavingEmail(true);
+ setError(null);
+ setActionStatus(null);
+ try {
+ const response = await authFetch(`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: email || null }),
+ });
+ const text = await response.text();
+ let data: { detail?: string; user?: { email?: string | null } } | null = null;
+ try {
+ data = text ? JSON.parse(text) : null;
+ } catch {
+ data = null;
+ }
+ if (!response.ok) {
+ throw new Error(data?.detail || text || "Email update failed");
+ }
+ setEmailInput(data?.user?.email ?? "");
+ await loadUser();
+ setActionStatus(email ? "Contact email saved." : "Contact email removed.");
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Could not update the contact email.");
+ } finally {
+ setSavingEmail(false);
+ }
+ };
+
+ 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 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(
+ `Permanently delete ${user.username} from Magent, the same-name Jellyfin account and linked Seerr account, disable their invitations and attempt a notification email? This cannot be undone. Media files and Jellystat history are kept.`,
+ );
+ if (!confirmed) return;
+ }
+ if (action === "ban") {
+ const confirmed = window.confirm(
+ `Block ${user.username} in Magent, disable their same-name Jellyfin account and issued invitations, and attempt a notification email? Seerr relies on Jellyfin sign-in and is not directly banned.`,
+ );
+ 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: { detail?: string; status?: string } | null = 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();
+ }, [loadProfiles, loadUser, router]);
+
+ if (loading) {
+ return Loading user... ;
+ }
+
+ return (
+
+ router.push("/users")}>
+ Back to users
+
+ setManageOpen(true)}
+ >
+ Manage this user
+
+ >
+ }
+ >
+
+ {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 ?? "Not linked"}
+
+
+ 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)}
+
+
+
+
+
+
setManageOpen(false)}
+ onClose={() => setManageOpen(false)}
+ >
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {actionStatus && (
+
+ {actionStatus}
+
+ )}
+
+
+ Review service links & duplicate accounts
+
+
+ {manageOpen && (
+
void loadUser()} />
+ )}
+
+
+
+
Contact email
+
Used by Magent for account recovery and issue updates.
+
+
{
+ event.preventDefault();
+ void saveUserEmail();
+ }}
+ >
+
+ Email address
+ setEmailInput(event.target.value)}
+ placeholder="person@example.com"
+ autoComplete="off"
+ disabled={savingEmail}
+ />
+
+
+ This updates Magent only. It does not change the user's Jellyfin or Seerr account.
+
+
+
+ {savingEmail ? "Saving..." : user.email ? "Save email" : "Add email"}
+
+ {user.email && (
+ void saveUserEmail(true)}
+ disabled={savingEmail}
+ >
+ Remove email
+
+ )}
+
+
+
+
+
+
+
Access controls
+
Role, login access, and auto-download behavior.
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+ Restrict access or delete accounts
+
+ Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.
+
+ toggleUserBlock(!user.is_blocked)}
+ disabled={systemActionBusy || user.role === "admin"}
+ >
+ {user.is_blocked ? "Restore Magent access" : "Block Magent access"}
+
+
+ Disable access also disables invitations this user created and attempts an account notification
+ email. Jellyfin is matched by username. Seerr relies on Jellyfin sign-in; its account is not
+ directly banned. Restoring access does not reactivate invitations.
+
+
+ void runSystemAction(user.is_blocked ? "unban" : "ban")}
+ disabled={systemActionBusy || user.role === "admin"}
+ >
+ {systemActionBusy
+ ? "Working..."
+ : user.is_blocked
+ ? "Restore Magent and Jellyfin access"
+ : "Disable Magent and Jellyfin access"}
+
+ void runSystemAction("remove")}
+ disabled={systemActionBusy || user.role === "admin"}
+ >
+ Delete Magent, Jellyfin and Seerr accounts
+
+
+
+ Deletion removes the Magent account and local login activity, attempts to delete the same-name
+ Jellyfin account and linked Seerr account, and disables issued invitations. It cannot be undone
+ here. Media files and Jellystat history are not deleted. External actions can partially fail.
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx
new file mode 100644
index 0000000..86d766a
--- /dev/null
+++ b/frontend/app/users/page.tsx
@@ -0,0 +1,604 @@
+"use client";
+
+import { useCallback, useEffect, useRef, 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";
+import "./users.css";
+import FeatureControls from "./FeatureControls";
+import IdentityReviewPanel from "../admin/identities/IdentityReviewPanel";
+
+type AdminUser = {
+ id: number;
+ username: string;
+ email?: string | null;
+ role: string;
+ authProvider?: string | null;
+ lastLoginAt?: string | null;
+ isBlocked?: boolean;
+ autoSearchEnabled?: boolean;
+ inviteManagementEnabled?: 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 normalizeStats = (stats: Record | null | undefined): 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: typeof stats?.last_request_at === "string" ? stats.last_request_at : null,
+});
+
+export default function UsersPage() {
+ const router = useRouter();
+ const [view, setView] = useState("directory");
+ useEffect(() => {
+ const update = () =>
+ setView(new URLSearchParams(window.location.search).get("view") === "identities" ? "identities" : "directory");
+ update();
+ window.addEventListener("popstate", update);
+ return () => window.removeEventListener("popstate", update);
+ }, []);
+ const changeView = (next: string) => {
+ setView(next);
+ window.history.pushState(null, "", next === "identities" ? "/users?view=identities" : "/users");
+ };
+ const [controlsOpen, setControlsOpen] = useState(false);
+ const controlsDialog = useRef(null);
+ const controlsTrigger = useRef(null);
+ const controlsClose = useRef(null);
+ const [refreshing, setRefreshing] = useState(false);
+ 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 = useCallback(async () => {
+ setRefreshing(true);
+ 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: Record) => ({
+ username: typeof user.username === "string" ? user.username : "Unknown",
+ email: typeof user.email === "string" ? user.email : null,
+ role: typeof user.role === "string" ? user.role : "user",
+ authProvider: typeof user.auth_provider === "string" ? user.auth_provider : "local",
+ lastLoginAt: typeof user.last_login_at === "string" ? user.last_login_at : null,
+ isBlocked: Boolean(user.is_blocked),
+ autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
+ inviteManagementEnabled: Boolean(user.invite_management_enabled),
+ profileId:
+ user.profile_id == null || Number.isNaN(Number(user.profile_id)) ? null : Number(user.profile_id),
+ expiresAt: typeof user.expires_at === "string" ? user.expires_at : null,
+ isExpired: Boolean(user.is_expired),
+ id: Number(user.id ?? 0),
+ stats: normalizeStats(
+ user.stats && typeof user.stats === "object" ? (user.stats as Record) : null,
+ ),
+ })),
+ );
+ } else {
+ setUsers([]);
+ }
+ setError(null);
+ } catch (err) {
+ console.error(err);
+ setError("Could not load user list.");
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, [router]);
+
+ 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(
+ `Checked ${data?.total ?? 0} Seerr records against Jellyfin IDs. Added ${data?.imported ?? 0} users; existing settings retained.`,
+ );
+ await loadUsers();
+ } catch (err) {
+ console.error(err);
+ setJellyseerrSyncStatus("Could not sync Seerr users.");
+ } finally {
+ setJellyseerrSyncBusy(false);
+ }
+ };
+
+ const resyncJellyseerrUsers = async () => {
+ 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(
+ `Reconciled service identities. Added ${data?.imported ?? 0} new users; existing accounts and settings were retained.`,
+ );
+ 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();
+ }, [loadUsers, router]);
+
+ useEffect(() => {
+ if (!controlsOpen) return;
+ const dialog = controlsDialog.current;
+ dialog?.showModal();
+ controlsClose.current?.focus();
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ dialog?.close();
+ document.body.style.overflow = previous;
+ controlsTrigger.current?.focus();
+ };
+ }, [controlsOpen]);
+
+ const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy;
+
+ 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 (
+ setControlsOpen(true)}
+ >
+ Manage users
+
+ }
+ >
+ setControlsOpen(false)}
+ onClose={() => setControlsOpen(false)}
+ >
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {jellyseerrSyncStatus && (
+
+ {jellyseerrSyncStatus}
+
+ )}
+
+
+ Directory actions
+ Review linked accounts, manage invitations or refresh the list.
+
+
+ Review account links ↗
+
+
Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.
+
+
+
+ Manage invitations ↗
+
+
Create invitations, review issued links and set invitation defaults.
+
+
+
void loadUsers()}
+ disabled={controlsBusy}
+ aria-describedby="reload-help"
+ >
+ {refreshing ? "Refreshing…" : "Refresh user list"}
+
+
+ Reload account status and request totals from Magent. Your search stays in place.
+
+
+
+
+ Seerr sync
+ Connect existing Magent accounts to their Seerr request accounts.
+
+
void syncJellyseerrUsers()}
+ disabled={controlsBusy}
+ aria-describedby="sync-help"
+ >
+ {jellyseerrSyncBusy ? "Matching accounts…" : "Match unlinked Seerr accounts"}
+
+
+ Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and
+ available email into Magent. Already-linked users are skipped.
+
+
+
+ Reconcile service identities
+
+ Refreshes Jellyfin and Seerr accounts using their shared Jellyfin ID. Preserves account settings and
+ history. Duplicate or conflicting links stay available for reviewed repair.
+
+ void resyncJellyseerrUsers()}
+ disabled={controlsBusy}
+ aria-describedby="resync-help"
+ >
+ {jellyseerrResyncBusy ? "Reconciling identities…" : "Reconcile Jellyfin and Seerr"}
+
+
+
+
+ Automatic search & download
+ Allow users to trigger automatic searches and downloads for their requests.
+
+ {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
+
+
+ Applies to every existing non-admin account, including accounts outside your search results. Use an
+ individual user's page to change just their access.
+
+
+ void bulkUpdateAutoSearch(true)}
+ disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length}
+ aria-describedby="auto-search-help"
+ >
+ Enable for all non-admin users
+
+ void bulkUpdateAutoSearch(false)}
+ disabled={controlsBusy || !autoSearchEnabledCount}
+ aria-describedby="auto-search-help"
+ >
+ Disable for all non-admin users
+
+
+
+
void loadUsers()} />
+
+
+ Directory totals
+ {usersRail}
+
+
+
+
+ changeView("directory")}
+ >
+ User directory
+
+ changeView("identities")}
+ >
+ Account links & repairs
+
+
+ {view === "identities" ? (
+
+ ) : (
+
+ {!controlsOpen && error && (
+
+ {error}
+
+ )}
+ {!controlsOpen && jellyseerrSyncStatus && (
+
+ {jellyseerrSyncStatus}
+
+ )}
+
+
+
+
Directory search
+
+ Find an account by username, email, role, login provider or profile ID. Select a user to manage their
+ access.
+
+
+
{filteredCountLabel}
+
+
+
+
+ Search users
+ setQuery(event.target.value)}
+ placeholder="Search username, email, role, login provider or profile ID…"
+ />
+
+
+
+
+ {filteredUsers.length === 0 ? (
+
+ {normalizedQuery
+ ? "No users match your search. Try another name or email."
+ : "No users found yet. Open Manage users to review the directory tools."}
+
+ ) : (
+
+
+ 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/app/users/users.css b/frontend/app/users/users.css
new file mode 100644
index 0000000..1e7a851
--- /dev/null
+++ b/frontend/app/users/users.css
@@ -0,0 +1,52 @@
+.users-directory-centered { width: 100%; max-width: 1280px; margin: 0 auto; min-width: 0; }
+.user-management-dialog { position: fixed; inset: 0; width: min(980px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); overflow: auto; padding: 0; margin: auto; border: 1px solid var(--ops-line); border-radius: 16px; background: var(--ops-panel, #1c1b1d); color: var(--ops-text, #eee8f2); box-shadow: 0 24px 90px #0009; }
+.user-management-dialog::backdrop { background: #000a; backdrop-filter: blur(4px); }
+.user-management-content { padding: 28px; }
+.user-management-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; padding-bottom: 12px; background: var(--ops-panel, #1c1b1d); box-shadow: 0 -28px 0 var(--ops-panel, #1c1b1d); }
+.user-management-heading h2 { margin: 8px 0; font-size: 26px; }
+.user-management-heading > button { flex-shrink: 0; }
+.user-management-dialog p { color: var(--ops-muted, #bdb6c3); font-size: 13px; line-height: 1.7; margin: 8px 0 16px; }
+.user-management-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
+.user-management-panel { min-width: 0; padding: 22px; border: 1px solid var(--ops-line); border-radius: 12px; background: #ffffff02; }
+.user-management-panel h3 { margin: 0 0 10px; font-size: 17px; }
+.user-management-action + .user-management-action { border-top: 1px solid var(--ops-line); padding-top: 16px; }
+.user-management-action p { margin-top: 10px; font-size: 12px; }
+.user-management-action a { display: inline-flex; text-decoration: none; }
+.user-management-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
+.user-management-dialog button, .user-management-dialog a.ghost-button { max-width: 100%; white-space: normal; line-height: 1.5; }
+.user-management-count { display: block; font-size: 12px; color: #c7bdff; margin: 14px 0; }
+.user-management-advanced { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--ops-line); }
+.user-management-advanced summary, .user-management-summary > summary { cursor: pointer; font-size: 13px; color: var(--ops-text); padding: 8px 0; }
+.user-management-advanced button { border-color: #d4a38f70; color: #edc7b8; }
+.user-management-summary { border-top: 1px solid var(--ops-line); margin-top: 24px; padding-top: 12px; }
+.user-management-summary .admin-rail-stack { margin-top: 14px; }
+@media (min-width: 1000px) {
+ .users-directory-centered .user-directory-header, .users-directory-centered .user-directory-row { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, .9fr) minmax(0, 1.1fr) 64px; }
+ .users-directory-centered .user-directory-row-chevron { justify-self: end; }
+}
+@media (max-width: 700px) {
+ .user-management-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 24px); }
+ .user-management-content { padding: 20px 16px; }
+ .user-management-grid { grid-template-columns: 1fr; }
+ .user-management-panel { padding: 18px; }
+ .user-management-heading h2 { font-size: 22px; }
+}
+
+/* Individual profiles use the directory's modal management pattern. */
+.user-detail-page-grid.user-detail-centered { display: block; width: min(100%, 1100px); margin-inline: auto; }
+.user-detail-centered .user-detail-main-column { display: flex; flex-direction: column; gap: 24px; }
+.user-detail-centered .user-detail-main-column > :nth-child(2) { order: -1; }
+.user-detail-centered .user-detail-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.feature-controls { margin-bottom: 20px; }
+.feature-access-row { display: flex; align-items: flex-start; gap: 14px; padding: 15px 0; border-bottom: 1px solid var(--border, #34343c); cursor: pointer; }
+.feature-access-row input { flex: 0 0 auto; margin-top: 4px; width: 18px; height: 18px; accent-color: #c4b5fd; }
+.feature-access-row span { display: grid; gap: 5px; }
+.feature-access-row small { color: var(--text-muted, #a9a9ba); line-height: 1.5; }
+.feature-controls .admin-inline-actions { margin-top: 20px; }
+.user-management-panel.user-management-danger { margin-top: 24px; border: 1px solid #a84049; background: #321b2080; }
+.user-management-danger h3 { color: #ff9ca6; }
+.user-management-danger button { border-color: #a84049; color: #ffb6bd; background: #441e27; }
+.user-management-danger p { margin-block: 16px; line-height: 1.6; }
+@media (max-width: 640px) {
+ .user-detail-centered .user-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
diff --git a/frontend/app/welcome.css b/frontend/app/welcome.css
new file mode 100644
index 0000000..6d62da8
--- /dev/null
+++ b/frontend/app/welcome.css
@@ -0,0 +1,21 @@
+.welcome-page, .friendly-guide { width: min(100%, 1040px); margin: 40px auto; color: var(--ops-text); }
+.welcome-page > header { text-align: center; margin-bottom: 32px; }
+.welcome-kicker { color: var(--ops-cyan); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }
+.welcome-page h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.15; margin: 16px 0; }
+.welcome-page p, .friendly-guide p { color: var(--ops-muted); line-height: 1.65; }
+.welcome-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
+.welcome-choice { display: flex; flex-direction: column; align-items: flex-start; padding: 32px; gap: 16px; background: var(--ops-panel); border: 1px solid var(--ops-line); border-radius: 16px; text-decoration: none; color: inherit; }
+.welcome-choice h2, .welcome-choice p { margin: 0; }
+.welcome-choice strong { color: var(--ops-primary-2); margin-top: auto; padding-top: 16px; }
+a.welcome-choice:hover { border-color: var(--ops-cyan); background: var(--ops-panel-2); }
+.welcome-icon { color: var(--ops-cyan); font-size: 36px; line-height: 1; }
+.welcome-page footer { text-align: center; margin-top: 28px; color: var(--ops-muted); }
+.welcome-page a:focus-visible, .friendly-guide a:focus-visible, .friendly-guide summary:focus-visible { outline: 3px solid var(--ops-cyan); outline-offset: 5px; }
+.friendly-guide > nav { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 24px; }
+.friendly-guide details { border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); margin: 12px 0; padding: 20px 24px; }
+.friendly-guide summary { font-weight: 700; font-size: 19px; cursor: pointer; }
+.friendly-guide ol { padding-left: 24px; }
+.friendly-guide li { padding: 8px 0 8px 8px; line-height: 1.6; }
+.friendly-guide li p { margin: 4px 0; }
+.friendly-guide > footer { padding: 20px 0; }
+@media (max-width: 640px) { .welcome-choices { grid-template-columns: 1fr; } .welcome-page, .friendly-guide { margin: 24px auto; } .welcome-choice { padding: 24px; } }
diff --git a/frontend/app/welcome/page.tsx b/frontend/app/welcome/page.tsx
new file mode 100644
index 0000000..5f84b46
--- /dev/null
+++ b/frontend/app/welcome/page.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { authFetch, clearToken, getApiBase } from "../lib/auth";
+import "../welcome.css";
+
+export default function WelcomePage() {
+ const [ready, setReady] = useState(false);
+ const [url, setUrl] = useState(null);
+ const [error, setError] = useState("");
+ useEffect(() => {
+ const controller = new AbortController();
+ void (async () => {
+ try {
+ const response = await authFetch(`${getApiBase()}/site/info`, { signal: controller.signal });
+ if (response.status === 401) {
+ clearToken();
+ window.location.replace("/login");
+ return;
+ }
+ if (!response.ok) throw new Error("Unavailable");
+ const data = await response.json();
+ const candidate = data.mediaServerUrl ? new URL(data.mediaServerUrl) : null;
+ if (candidate && ["https:", "http:"].includes(candidate.protocol) && !candidate.username && !candidate.password)
+ setUrl(candidate.href);
+ setReady(true);
+ } catch {
+ if (!controller.signal.aborted) setError("We couldn’t load your welcome page. Please try again.");
+ }
+ })();
+ return () => controller.abort();
+ }, []);
+ return (
+
+
+ {error ? (
+
+
{error}
+
window.location.reload()}>
+ Try again
+ {" "}
+
Back to sign in
+
+ ) : !ready ? (
+ Getting things ready…
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/workspace.css b/frontend/app/workspace.css
new file mode 100644
index 0000000..124c4b4
--- /dev/null
+++ b/frontend/app/workspace.css
@@ -0,0 +1,168 @@
+/* Shared page rhythm. Feature styles own their content, not the outer shell. */
+.page > main:not(.login-page),
+.admin-shell.admin-shell--top-nav {
+ width: calc(100% - var(--workspace-gutter) * 2);
+ max-width: var(--workspace-width) !important;
+ margin: 32px auto 0;
+ padding: 0;
+ border: 0 !important;
+ border-radius: 0 !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ animation: none;
+}
+.page > main:not(.login-page) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--workspace-gap);
+ align-content: start;
+}
+.admin-shell.admin-shell--top-nav { display: block; }
+.admin-shell--top-nav > .admin-card { grid-template-columns: minmax(0, 1fr); gap: var(--workspace-gap); border-radius: 0 !important; animation: none; }
+.admin-card > * { min-width: 0; }
+.admin-card .admin-section, .invite-admin-stack { grid-template-columns: minmax(0, 1fr); min-width: 0; }
+.admin-section > *, .invite-admin-stack > * { min-width: 0; }
+.page > .site-banner, .page > .user-view-banner {
+ width: calc(100% - var(--workspace-gutter) * 2);
+ max-width: var(--workspace-width);
+ margin: 16px auto 0;
+ border-radius: 8px;
+}
+.header { border-radius: 0 !important; }
+
+/* A single, calm heading treatment across every workspace page. */
+.page-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ min-width: 0;
+ margin: 0;
+ padding: 0 0 24px;
+ border: 0;
+ border-bottom: 1px solid var(--ops-line-soft);
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+}
+.page-heading-main { display: flex; align-items: center; gap: 18px; min-width: 0; }
+.page-heading-copy { display: grid; gap: 8px; min-width: 0; }
+.page-heading h1 {
+ margin: 0;
+ color: var(--ops-text);
+ font: 700 clamp(26px, 2.5vw, 32px)/1.2 "DM Sans", "Segoe UI", sans-serif;
+ text-transform: none;
+ overflow-wrap: anywhere;
+}
+.page-heading-copy > p { margin: 0; max-width: 64ch; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
+.page-heading-eyebrow { color: var(--ops-faint); font: 11px "JetBrains Mono", monospace; }
+.page-heading-leading { flex-shrink: 0; }
+.page-heading-leading .request-poster { display: block; width: 60px; height: 90px; margin: 0; border-radius: 8px; object-fit: cover; }
+.page-heading-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 10px; min-width: 0; flex-shrink: 0; max-width: 50%; }
+.page-heading-actions .lede { margin: 0; }
+.page-heading { grid-column: 1 / -1; }
+.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr) minmax(310px, 360px); }
+.issue-reports-column { top: 80px; height: calc(100dvh - 104px); }
+.page-heading-meta { color: var(--ops-faint); font-size: 13px; }
+.page-heading .home-search { width: min(440px, 100%); }
+.page-heading .home-search > label { font: 500 12px "DM Sans", sans-serif; text-transform: none; }
+.page-heading .home-search-row { grid-template-columns: minmax(0, 1fr) auto; }
+
+/* Keep small forms readable without moving their page title off the shared grid. */
+.account-page > .account-tabs, .account-page > .account-panel { width: 100%; max-width: 920px; margin: 0; }
+.feedback-form { width: 100%; max-width: 760px; }
+.feedback-form label:not(:first-child) { margin-top: 12px; }
+.feedback-form :is(select, textarea) { width: 100%; min-width: 0; padding: 12px; border-radius: 8px; font-size: 14px; }
+.feedback-form button[type=submit] { justify-self: start; margin-top: 12px; }
+.how-page > .how-flow, .changelog-page > .changelog-groups { width: 100%; max-width: 1120px; }
+.how-flow > h2 { margin: 0 0 16px; font-size: 20px; }
+.how-card { background: var(--ops-panel); border-color: var(--ops-line); border-radius: 12px; box-shadow: none; }
+.how-card p { margin: 0; color: var(--ops-muted); line-height: 1.7; }
+.changelog-group { padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
+.changelog-group:first-child { padding-top: 24px; border-top: 1px solid var(--ops-line); }
+.changelog-group h2 { font-size: 18px; }
+.changelog-list { margin-bottom: 0; color: var(--ops-muted); line-height: 1.7; }
+
+/* Shared hierarchy: readable form labels, restrained section titles and corners. */
+:is(.request-flow-heading, .issue-flow-heading, .invite-flow-heading, .home-section-heading, .request-journey-heading) h2 { font-size: 21px; line-height: 1.3; }
+:is(.request-flow-stage, .issue-flow, .issue-reports-column, .invite-flow-step, .profile-invites-list, .account-panel) { border-radius: 12px; }
+.invites-page > .profile-invites-section { margin: 0; padding: 0; border: 0; background: transparent !important; }
+.invites-page .invite-flow-heading > div > .eyebrow { display: none; }
+.invites-page .profile-invites-list { margin-top: 12px; padding: 20px; border: 1px solid var(--ops-line); }
+.invite-flow-fields label > span:first-child, .invite-flow-field-grid label > span:first-child { font-size: 13px; text-transform: none; }
+.invite-admin-tabbar .admin-segmented { flex-wrap: wrap; width: auto; max-width: 100%; }
+.invite-admin-tabbar .admin-segmented { padding: 0; gap: 4px 18px; border: 0; border-bottom: 1px solid var(--ops-line-soft); border-radius: 0; background: transparent; }
+.invite-admin-tabbar .admin-segmented button { min-height: 44px; padding: 10px 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0 !important; background: transparent !important; color: var(--ops-muted); }
+.invite-admin-tabbar .admin-segmented button[aria-selected=true] { border-bottom-color: #c7bdff; color: #dedaff; }
+.invite-operations-strip > button { justify-content: stretch; justify-items: start; border-color: var(--ops-line) !important; background: var(--ops-panel) !important; }
+.invite-operations-strip > button:hover { border-color: var(--ops-primary-2) !important; }
+.request-stage-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.request-stage-grid > .request-stage { grid-column: auto; }
+:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label:not(.setting-switch) {
+ font-family: "DM Sans", "Segoe UI", sans-serif;
+ font-size: 13px;
+ text-transform: none;
+}
+:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label > span { text-transform: none; }
+:is(.request-flow-stage, .issue-flow, .profile-invites-section) :is(input:not([type=checkbox]):not([type=radio]), select, textarea) { border-radius: 8px; font: 14px/1.5 "DM Sans", sans-serif; }
+:is(.request-flow-stage, .issue-flow, .profile-invites-section) input:not([type=checkbox]):not([type=radio]),
+:is(.request-flow-stage, .issue-flow, .profile-invites-section) select { min-height: 44px; }
+.auth-flow-form > label { display: grid; gap: 8px; }
+.auth-flow-form > label:not(:first-child) { margin-top: 10px; }
+.auth-flow-form .auth-actions { display: grid; margin-top: 12px; }
+.auth-flow-form .invite-lookup-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
+.auth-flow-form .invite-lookup-row button { font-size: 12px; padding: 10px; }
+.auth-flow-form .invite-summary { background: var(--ops-panel-2); padding: 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; overflow-wrap: anywhere; }
+.auth-invite-details { font-size: 12px; color: var(--ops-muted); margin-top: 10px; }
+.auth-invite-details summary { cursor: pointer; }
+.auth-invite-details .admin-meta-row { margin-top: 10px; }
+.page main button[type=submit]:not(.ghost-button),
+.page .request-submit-bar > button,
+.page .issue-resolution-card > button {
+ min-height: 42px;
+ padding: 11px 18px;
+ border-color: #c7bdff !important;
+ border-radius: 8px;
+ background: #c7bdff !important;
+ color: #1c172c !important;
+ font: 700 13px "DM Sans", sans-serif;
+ text-transform: none;
+ box-shadow: none;
+}
+.page main button[type=submit]:disabled { opacity: .4; }
+.page button.danger-button { border-color: #86464f !important; background: #361f25 !important; color: #ffc0c5 !important; }
+.page :is(button, input, select, textarea) { font-family: "DM Sans", "Segoe UI", sans-serif; }
+.page :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
+.login-card { border-radius: 12px; }
+.login-card header p { line-height: 1.6; }
+.login-card .status-banner { font-size: 13px; line-height: 1.6; }
+
+@media (max-width: 1100px) {
+ .page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr); }
+ .issue-reports-column { height: auto; }
+ .request-stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 980px) {
+ .page-heading { flex-wrap: wrap; align-items: flex-start; gap: 18px; }
+ .page-heading-actions { justify-content: flex-start; max-width: 100%; }
+ .home-page .page-heading-actions { width: 100%; }
+ .page-heading .home-search { width: 100%; }
+}
+@media (max-width: 680px) {
+ .workspace-mobile-nav { padding-inline: 4px; gap: 0; }
+ .workspace-mobile-nav a { min-width: 0; flex: 1; padding-inline: 3px; }
+ :root { --workspace-gutter: 16px; --workspace-gap: 20px; }
+ .page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
+ .page-heading { padding-bottom: 20px; }
+ .page-heading-copy > p { font-size: 13px; }
+ .page-heading-main { gap: 14px; }
+ .page-heading-leading .request-poster { width: 48px; height: 72px; }
+ .page-heading-actions > .admin-inline-actions { flex-wrap: wrap; }
+ .changelog-group { padding: 20px; }
+ .request-stage-grid { grid-template-columns: minmax(0, 1fr); }
+ .invite-operations-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .page *, .page *::before, .page *::after { animation: none !important; scroll-behavior: auto !important; }
+}
diff --git a/frontend/biome.json b/frontend/biome.json
new file mode 100644
index 0000000..1b7d97b
--- /dev/null
+++ b/frontend/biome.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
+ "files": {
+ "includes": ["app/**/*.{ts,tsx}", "next.config.js", "!node_modules", "!.next"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 120
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "correctness": {
+ "useExhaustiveDependencies": "error"
+ },
+ "performance": {
+ "noImgElement": "off"
+ },
+ "suspicious": {
+ "noArrayIndexKey": "off",
+ "noDocumentCookie": "off",
+ "noExplicitAny": "error"
+ }
+ }
+ }
+}
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..ce4e94a
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,7 @@
+///
+///
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.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..da1ce3f
--- /dev/null
+++ b/frontend/next.config.js
@@ -0,0 +1,43 @@
+const backendUrl = process.env.BACKEND_INTERNAL_URL || "http://backend:8000";
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: "standalone",
+ poweredByHeader: false,
+ compress: true,
+ // API rewrites clone bodies even when excluded from proxy.ts's matcher.
+ // Match the backend restore envelope cap (32 MiB backup + multipart margin).
+ experimental: { proxyTimeout: 180000, proxyClientMaxBodySize: 34 * 1024 * 1024 },
+ async headers() {
+ return [
+ {
+ source: "/:path*",
+ headers: [
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "X-Frame-Options", value: "DENY" },
+ { key: "Referrer-Policy", value: "no-referrer" },
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
+ ],
+ },
+ {
+ source: "/login",
+ headers: [{ key: "Cache-Control", value: "private, no-store, max-age=0" }],
+ },
+ ];
+ },
+ async rewrites() {
+ return [
+ {
+ source: "/favicon.ico",
+ destination: `${backendUrl}/branding/favicon.ico`,
+ },
+ {
+ 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..cec6d4b
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,2326 @@
+{
+ "name": "magent-frontend",
+ "version": "0803262237",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "magent-frontend",
+ "version": "0803262237",
+ "dependencies": {
+ "next": "16.3.5",
+ "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",
+ "vitest": "5.0.1"
+ }
+ },
+ "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.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
+ "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
+ "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
+ "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
+ "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
+ "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
+ "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
+ "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
+ "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
+ "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
+ "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
+ "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
+ "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
+ "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
+ "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
+ "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
+ "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
+ "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
+ "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
+ "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
+ "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
+ "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
+ "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.3"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
+ "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
+ "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
+ "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
+ "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
+ "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
+ "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/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz",
+ "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz",
+ "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz",
+ "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz",
+ "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz",
+ "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz",
+ "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz",
+ "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz",
+ "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.3.5",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz",
+ "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.150.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
+ "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
+ "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
+ "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
+ "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
+ "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
+ "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
+ "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
+ "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
+ "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
+ "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
+ "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
+ "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
+ "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
+ "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
+ "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
+ "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/@vitest/mocker": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
+ "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.1",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
+ "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.24",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
+ "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==",
+ "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/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "peer": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+ "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "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.3.5",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz",
+ "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.3.5",
+ "@swc/helpers": "0.5.23",
+ "baseline-browser-mapping": "^2.9.19",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.5.23",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.3.5",
+ "@next/swc-darwin-x64": "16.3.5",
+ "@next/swc-linux-arm64-gnu": "16.3.5",
+ "@next/swc-linux-arm64-musl": "16.3.5",
+ "@next/swc-linux-x64-gnu": "16.3.5",
+ "@next/swc-linux-x64-musl": "16.3.5",
+ "@next/swc-win32-arm64-msvc": "16.3.5",
+ "@next/swc-win32-x64-msvc": "16.3.5",
+ "sharp": "^0.35.4"
+ },
+ "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/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "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/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "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.18",
+ "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/rolldown": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
+ "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@oxc-project/types": "=0.150.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.9",
+ "@rolldown/binding-android-arm64": "1.2.9",
+ "@rolldown/binding-darwin-arm64": "1.2.9",
+ "@rolldown/binding-darwin-x64": "1.2.9",
+ "@rolldown/binding-freebsd-x64": "1.2.9",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.9",
+ "@rolldown/binding-linux-arm64-musl": "1.2.9",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.9",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.9",
+ "@rolldown/binding-linux-x64-gnu": "1.2.9",
+ "@rolldown/binding-linux-x64-musl": "1.2.9",
+ "@rolldown/binding-openharmony-arm64": "1.2.9",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.9",
+ "@rolldown/binding-win32-x64-msvc": "1.2.9"
+ }
+ },
+ "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/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/sharp": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
+ "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
+ "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.4",
+ "@img/sharp-darwin-x64": "0.35.4",
+ "@img/sharp-freebsd-wasm32": "0.35.4",
+ "@img/sharp-libvips-darwin-arm64": "1.3.3",
+ "@img/sharp-libvips-darwin-x64": "1.3.3",
+ "@img/sharp-libvips-linux-arm": "1.3.3",
+ "@img/sharp-libvips-linux-arm64": "1.3.3",
+ "@img/sharp-libvips-linux-ppc64": "1.3.3",
+ "@img/sharp-libvips-linux-riscv64": "1.3.3",
+ "@img/sharp-libvips-linux-s390x": "1.3.3",
+ "@img/sharp-libvips-linux-x64": "1.3.3",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3",
+ "@img/sharp-linux-arm": "0.35.4",
+ "@img/sharp-linux-arm64": "0.35.4",
+ "@img/sharp-linux-ppc64": "0.35.4",
+ "@img/sharp-linux-riscv64": "0.35.4",
+ "@img/sharp-linux-s390x": "0.35.4",
+ "@img/sharp-linux-x64": "0.35.4",
+ "@img/sharp-linuxmusl-arm64": "0.35.4",
+ "@img/sharp-linuxmusl-x64": "0.35.4",
+ "@img/sharp-webcontainers-wasm32": "0.35.4",
+ "@img/sharp-win32-arm64": "0.35.4",
+ "@img/sharp-win32-ia32": "0.35.4",
+ "@img/sharp-win32-x64": "0.35.4"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "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/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/tinybench": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+ "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "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"
+ },
+ "node_modules/vite": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
+ "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.6",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
+ "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.1",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "6.1.4",
+ "tinyexec": "1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.1",
+ "@vitest/browser-preview": "5.0.1",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.1",
+ "@vitest/coverage-v8": "5.0.1",
+ "@vitest/ui": "5.0.1",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..76c55fa
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "magent-frontend",
+ "private": true,
+ "version": "0803262237",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "biome lint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "format": "biome format --write .",
+ "format:check": "biome format ."
+ },
+ "dependencies": {
+ "next": "16.3.5",
+ "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",
+ "vitest": "5.0.1"
+ },
+ "overrides": {
+ "nanoid": "3.3.18",
+ "postcss": "8.5.28",
+ "sharp": "0.35.4"
+ }
+}
diff --git a/frontend/proxy.test.ts b/frontend/proxy.test.ts
new file mode 100644
index 0000000..1685ef2
--- /dev/null
+++ b/frontend/proxy.test.ts
@@ -0,0 +1,101 @@
+import { NextRequest } from "next/server";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { proxy } from "./proxy";
+
+function response(headers: Record = {}) {
+ return proxy(new NextRequest("http://localhost:3000/setup", { headers }));
+}
+
+describe("deployment-aware content security policy", () => {
+ beforeEach(() => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("MAGENT_APPLICATION_URL", undefined);
+ vi.stubEnv("MAGENT_RUNTIME_MANAGED", undefined);
+ });
+
+ afterEach(() => vi.unstubAllEnvs());
+
+ it("keeps HTTPS upgrades enabled by default", () => {
+ expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
+ });
+
+ it("keeps HTTPS upgrades for an explicitly configured HTTPS site", () => {
+ vi.stubEnv("MAGENT_APPLICATION_URL", "https://magent.example.com");
+ expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
+ });
+
+ it("allows an unclaimed managed install to load its wizard on HTTP", () => {
+ vi.stubEnv("MAGENT_RUNTIME_MANAGED", "1");
+ expect(response().headers.get("Content-Security-Policy")).not.toContain("upgrade-insecure-requests");
+ });
+
+ it.each(["0", "true", "false", ""])('does not activate managed setup for flag "%s"', (flag) => {
+ vi.stubEnv("MAGENT_RUNTIME_MANAGED", flag);
+ expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
+ });
+
+ it.each(["https://magent.example.com", "not-a-url", "http://magent.lan/path"])(
+ "does not let managed mode bypass configured HTTPS or invalid origins: %s",
+ (origin) => {
+ vi.stubEnv("MAGENT_RUNTIME_MANAGED", "1");
+ vi.stubEnv("MAGENT_APPLICATION_URL", origin);
+ expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
+ },
+ );
+
+ it("does not accept a caller-provided managed-mode header", () => {
+ expect(response({ MAGENT_RUNTIME_MANAGED: "1" }).headers.get("Content-Security-Policy")).toContain(
+ "upgrade-insecure-requests",
+ );
+ });
+
+ it.each(["http://192.0.2.10:3000", "http://magent.lan:3000/", "http://[fd00::10]:3000"])(
+ "supports the operator's explicit HTTP origin %s without upgrading its assets",
+ (origin) => {
+ vi.stubEnv("MAGENT_APPLICATION_URL", origin);
+ expect(response().headers.get("Content-Security-Policy")).not.toContain("upgrade-insecure-requests");
+ },
+ );
+
+ it.each([
+ "",
+ "not-a-url",
+ "http:/magent.lan",
+ "//magent.lan",
+ "ftp://magent.lan",
+ "http://user:password@magent.lan",
+ "http://magent.lan/path",
+ "http://magent.lan?query=1",
+ "http://magent.lan#fragment",
+ "http://magent.lan\\path",
+ "http://magent.\tlan",
+ ])("does not relax HTTPS upgrades for invalid or non-origin configuration %j", (origin) => {
+ vi.stubEnv("MAGENT_APPLICATION_URL", origin);
+ expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
+ });
+
+ it("does not trust caller-controlled host or forwarding headers to disable upgrades", () => {
+ const policy = response({
+ Host: "magent.lan:3000",
+ "X-Forwarded-Host": "magent.lan:3000",
+ "X-Forwarded-Proto": "http",
+ Forwarded: "host=magent.lan:3000;proto=http",
+ }).headers.get("Content-Security-Policy");
+ expect(policy).toContain("upgrade-insecure-requests");
+ });
+
+ it("preserves nonce propagation and strict production script rules on HTTP", () => {
+ vi.stubEnv("MAGENT_APPLICATION_URL", "http://magent.lan:3000");
+ const first = response();
+ const policy = first.headers.get("Content-Security-Policy");
+ const nonce = first.headers.get("x-middleware-request-x-nonce");
+ expect(nonce).toBeTruthy();
+ expect(policy).toContain(`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`);
+ expect(policy).not.toContain("'unsafe-eval'");
+ expect(policy).toContain("frame-ancestors 'none'");
+ expect(policy).toContain("form-action 'self'");
+ expect(policy).toContain("connect-src 'self'");
+ expect(first.headers.get("x-middleware-request-content-security-policy")).toBe(policy);
+ expect(response().headers.get("x-middleware-request-x-nonce")).not.toBe(nonce);
+ });
+});
diff --git a/frontend/proxy.ts b/frontend/proxy.ts
new file mode 100644
index 0000000..7e0f48e
--- /dev/null
+++ b/frontend/proxy.ts
@@ -0,0 +1,57 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+function hasExplicitHttpOrigin(): boolean {
+ // Allow initial managed setup on a private LAN before the operator claims its
+ // origin. The entrypoint owns this flag; it is never derived from the request.
+ // Otherwise only an operator-provided origin may opt into HTTP for a private LAN.
+ // Never derive this decision from caller-controlled Host/forwarded headers.
+ const configured = (process.env.MAGENT_APPLICATION_URL || '').trim()
+ if (!configured && process.env.MAGENT_RUNTIME_MANAGED === '1') return true
+ if (!/^http:\/\//i.test(configured) || /[\s\\]/.test(configured)) return false
+ try {
+ const url = new URL(configured)
+ return url.protocol === 'http:' && !!url.hostname && !url.username && !url.password
+ && url.pathname === '/' && !url.search && !url.hash
+ } catch {
+ return false
+ }
+}
+
+export function proxy(request: NextRequest) {
+ const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
+ const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''
+ const csp = [
+ "default-src 'self'",
+ "base-uri 'self'",
+ "object-src 'none'",
+ "frame-ancestors 'none'",
+ "form-action 'self'",
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${developmentEval}`,
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: https:",
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ "worker-src 'self' blob:",
+ "manifest-src 'self'",
+ ...(hasExplicitHttpOrigin() ? [] : ['upgrade-insecure-requests']),
+ ].join('; ')
+
+ const requestHeaders = new Headers(request.headers)
+ requestHeaders.set('x-nonce', nonce)
+ requestHeaders.set('Content-Security-Policy', csp)
+ const response = NextResponse.next({ request: { headers: requestHeaders } })
+ response.headers.set('Content-Security-Policy', csp)
+ return response
+}
+
+export const config = {
+ matcher: [
+ {
+ source: '/((?!api|_next/static|_next/image|favicon.ico|branding/).*)',
+ missing: [
+ { type: 'header', key: 'next-router-prefetch' },
+ { type: 'header', key: 'purpose', value: 'prefetch' },
+ ],
+ },
+ ],
+}
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/public/service-icons/radarr.svg b/frontend/public/service-icons/radarr.svg
new file mode 100644
index 0000000..1ee8b33
--- /dev/null
+++ b/frontend/public/service-icons/radarr.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/frontend/public/service-icons/sonarr.svg b/frontend/public/service-icons/sonarr.svg
new file mode 100644
index 0000000..563b44f
--- /dev/null
+++ b/frontend/public/service-icons/sonarr.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
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/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8c5aa5e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,15 @@
+[tool.ruff]
+target-version = "py314"
+line-length = 120
+
+[tool.ruff.lint]
+select = ["E4", "E7", "E9", "F"]
+
+[tool.coverage.run]
+branch = true
+source = ["backend/app"]
+
+[tool.coverage.report]
+show_missing = true
+skip_covered = true
+fail_under = 45
diff --git a/scripts/check_environment_docs.py b/scripts/check_environment_docs.py
new file mode 100644
index 0000000..4ee53e2
--- /dev/null
+++ b/scripts/check_environment_docs.py
@@ -0,0 +1,132 @@
+"""Check the environment reference against source without importing application settings.
+
+Only tracked-source locations are inspected. Deployment .env files, process
+environment values and runtime data are never opened or evaluated.
+"""
+
+import ast
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import re
+import sys
+
+
+ROOT = Path(__file__).resolve().parents[1]
+ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z")
+
+
+@dataclass(frozen=True)
+class Setting:
+ names: tuple[str, ...]
+ default: str
+
+
+def settings_inventory(source: str) -> list[Setting]:
+ tree = ast.parse(source.lstrip("\ufeff"))
+ settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings")
+ result = []
+ for node in settings.body:
+ if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
+ continue
+ name = node.target.id
+ names = (name.upper(),)
+ default = node.value
+ if isinstance(default, ast.Call):
+ arguments = {keyword.arg: keyword.value for keyword in default.keywords}
+ alias = arguments.get("validation_alias")
+ if isinstance(alias, ast.Constant):
+ names = (alias.value,)
+ elif isinstance(alias, ast.Call):
+ names = tuple(ast.literal_eval(argument) for argument in alias.args)
+ default = arguments.get("default")
+ if isinstance(default, ast.Name):
+ value = "@" + default.id
+ else:
+ value = json.dumps(ast.literal_eval(default), ensure_ascii=True)
+ result.append(Setting(names, value))
+ return result
+
+
+def python_environment_names(source: str) -> set[str]:
+ names = set()
+ for node in ast.walk(ast.parse(source.lstrip("\ufeff"))):
+ argument = None
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args:
+ receiver = ast.unparse(node.func.value)
+ if (node.func.attr == "getenv" and receiver == "os") or (
+ node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"}
+ ):
+ argument = node.args[0]
+ elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in {
+ "os.environ", "environ", "environment", "prepared"
+ }:
+ argument = node.slice
+ if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value):
+ names.add(argument.value)
+ return names
+
+
+def runtime_environment_names(root: Path) -> set[str]:
+ names = set()
+ sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")]
+ for path in sources:
+ names.update(python_environment_names(path.read_text(encoding="utf-8")))
+
+ javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"),
+ *root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"]
+ for path in javascript:
+ if ".test." not in path.name:
+ names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
+
+ deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"),
+ *root.glob(".gitea/workflows/*.yml")]
+ for path in deployment:
+ source = path.read_text(encoding="utf-8")
+ names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source))
+ names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source))
+ names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source))
+ # These are shell syntax/builtins, not Magent configuration options.
+ names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"})
+
+ dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
+ for line in dockerfile.splitlines():
+ if line.startswith(("ENV ", "ARG ")):
+ names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
+ supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8")
+ for line in supervisor.splitlines():
+ if line.startswith("environment="):
+ names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
+ return names
+
+
+def check_documentation(root: Path = ROOT) -> tuple[list[str], int]:
+ document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8")
+ documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document))
+ settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8"))
+ required = runtime_environment_names(root) | {name for setting in settings for name in setting.names}
+ errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)]
+ defaults = {}
+ for line in document.splitlines():
+ cells = line.split("|")
+ if len(cells) >= 4 and cells[1].strip().startswith("`"):
+ for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]):
+ defaults[name] = cells[2].strip().strip("`")
+ for setting in settings:
+ for name in setting.names:
+ if name in documented and defaults.get(name) != setting.default:
+ errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}")
+ return errors, len(required)
+
+
+def main() -> int:
+ errors, count = check_documentation()
+ if errors:
+ print("\n".join(errors), file=sys.stderr)
+ return 1
+ print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/ci_backend_quality_gate.sh b/scripts/ci_backend_quality_gate.sh
new file mode 100644
index 0000000..f070906
--- /dev/null
+++ b/scripts/ci_backend_quality_gate.sh
@@ -0,0 +1,27 @@
+#!/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 and quality tools"
+"$python_bin" -m pip install -r backend/requirements-dev.txt
+
+echo "Running Python dependency integrity check"
+"$python_bin" -m pip check
+
+echo "Auditing Python production dependencies"
+"$python_bin" -m pip_audit -r backend/requirements.txt --progress-spinner off
+"$python_bin" -m pip_audit -r docker/requirements-runtime.txt --progress-spinner off
+
+echo "Linting backend application code"
+"$python_bin" -m ruff check backend/app scripts/container_smoke.py scripts/check_environment_docs.py backend/tests/test_container_packaging.py backend/tests/test_container_bootstrap.py backend/tests/test_managed_setup_origin.py backend/tests/test_environment_docs.py
+
+echo "Running backend unit tests with coverage"
+"$python_bin" -m coverage erase
+"$python_bin" -m coverage run -m unittest discover -s backend/tests -p "test_*.py" -v
+"$python_bin" -m coverage report
+
+echo "Backend quality gate passed"
diff --git a/scripts/ci_container_smoke.sh b/scripts/ci_container_smoke.sh
new file mode 100644
index 0000000..08509a5
--- /dev/null
+++ b/scripts/ci_container_smoke.sh
@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+# No argument preserves the CI build-and-test entry point. Pass an image tag to
+# test an already-built release without building, pulling, or publishing it.
+set -euo pipefail
+
+if [ "$#" -gt 1 ]; then
+ echo "Usage: $0 [existing-image]" >&2
+ exit 2
+fi
+script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repository_directory="$(cd -- "$script_directory/.." && pwd)"
+image="${1:-magent:ci}"
+size_limit_mb="${MAGENT_IMAGE_MAX_MB:-350}"
+managed_mode="${MAGENT_SMOKE_MANAGED:-false}"
+if [[ "$managed_mode" != true && "$managed_mode" != false ]]; then
+ echo "MAGENT_SMOKE_MANAGED must be true or false." >&2
+ exit 2
+fi
+if ! [[ "$size_limit_mb" =~ ^[1-9][0-9]*$ ]]; then
+ echo "MAGENT_IMAGE_MAX_MB must be a positive integer (MiB)." >&2
+ exit 2
+fi
+
+container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$-${RANDOM}"
+volume_name="${container_name}-data"
+network_name="${container_name}-isolated"
+container_created=false
+volume_created=false
+network_created=false
+cleanup() {
+ result=$?
+ trap - EXIT
+ if [ "$result" -ne 0 ] && [ "$container_created" = true ]; then
+ # Only synthetic credentials/data enter this test container.
+ docker logs --tail 100 "$container_name" >&2 || true
+ fi
+ if [ "$container_created" = true ]; then
+ docker rm -f "$container_name" >/dev/null 2>&1 || true
+ fi
+ if [ "$volume_created" = true ]; then
+ docker volume rm "$volume_name" >/dev/null 2>&1 || true
+ fi
+ if [ "$network_created" = true ]; then
+ docker network rm "$network_name" >/dev/null 2>&1 || true
+ fi
+ exit "$result"
+}
+trap cleanup EXIT
+
+if [ "$#" -eq 0 ]; then
+ docker build --tag "$image" "$repository_directory"
+fi
+image_size="$(docker image inspect --format '{{.Size}}' "$image")"
+image_id="$(docker image inspect --format '{{.Id}}' "$image")"
+if [ "$image_size" -gt "$((size_limit_mb * 1024 * 1024))" ]; then
+ echo "Image exceeds ${size_limit_mb} MiB unpacked budget: ${image_size} bytes" >&2
+ exit 1
+fi
+echo "Image size: ${image_size} bytes (budget ${size_limit_mb} MiB unpacked)"
+
+# Inspect the image's original filesystem before tmpfs or volume mounts could
+# hide accidentally shipped build caches or private files.
+docker run --rm --pull never --network none --read-only \
+ --cap-drop ALL --security-opt no-new-privileges:true \
+ --entrypoint python -i "$image_id" - packaging < "$script_directory/container_smoke.py"
+
+# An internal network prevents accidental external integration calls. No host
+# files, existing volumes, host credentials, or host ports are used.
+docker network create --internal "$network_name" >/dev/null
+network_created=true
+docker volume create "$volume_name" >/dev/null
+volume_created=true
+
+start_container() {
+ local -a secret_environment
+ if [ "$managed_mode" = true ]; then
+ # Exercise the image defaults: no keys, origin or managed-mode variables.
+ secret_environment=()
+ else
+ secret_environment=(
+ --env JWT_SECRET=ci-only-secret-with-at-least-32-characters
+ --env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
+ --env SETUP_TOKEN=ci-only-setup-token-with-at-least-32-characters
+ --env AUTH_COOKIE_SECURE=true
+ --env MAGENT_APPLICATION_URL=https://magent-ci.example.test
+ )
+ fi
+ docker run --detach --name "$container_name" --pull never \
+ --network "$network_name" \
+ --read-only --cap-drop ALL --security-opt no-new-privileges:true \
+ --pids-limit 256 --memory 1g --cpus 2 \
+ --tmpfs /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 \
+ --tmpfs /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \
+ --volume "$volume_name:/app/data" \
+ "${secret_environment[@]}" \
+ --env ADMIN_PASSWORD= \
+ --env AUTH_COOKIE_SAMESITE=strict \
+ --env BACKGROUND_TASKS_ENABLED=false \
+ --env MAGENT_METRICS_ENABLED=false \
+ "$image_id" >/dev/null
+ container_created=true
+}
+
+wait_for_health() {
+ local deadline=$((SECONDS + 150))
+ local status
+ while true; do
+ status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_name")"
+ if [ "$status" = healthy ]; then
+ return
+ fi
+ if [ "$status" = missing ] || [ "$SECONDS" -ge "$deadline" ]; then
+ echo "Container did not become healthy within 150 seconds (status: $status)" >&2
+ return 1
+ fi
+ if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != true ]; then
+ echo "Container exited before becoming healthy" >&2
+ return 1
+ fi
+ sleep 2
+ done
+}
+
+# Deliberately no root/chown helper: the image must initialize a fresh named
+# volume with correct ownership for its normal non-root runtime user.
+start_container
+wait_for_health
+docker exec -i "$container_name" python - fresh < "$script_directory/container_smoke.py"
+
+docker restart --time 15 "$container_name" >/dev/null
+wait_for_health
+docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
+
+# Recreation proves database/configuration are in the volume, not merely in the
+# container's writable layer. Both test instances use the same immutable image.
+docker rm -f "$container_name" >/dev/null
+container_created=false
+start_container
+wait_for_health
+docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
+echo "Container smoke passed: fresh install, security headers, assets, login, backup/restore, restart, recreation."
diff --git a/scripts/container_smoke.py b/scripts/container_smoke.py
new file mode 100644
index 0000000..b90c8fa
--- /dev/null
+++ b/scripts/container_smoke.py
@@ -0,0 +1,340 @@
+"""Disposable-image checks, streamed into the container by ci_container_smoke.sh.
+
+Uses only Python's standard library. All credentials and configuration below
+are synthetic and the caller disables network egress and background workers.
+This checks script/asset delivery and CSP compatibility, not browser execution.
+"""
+
+from html.parser import HTMLParser
+import hashlib
+from http.cookies import SimpleCookie
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import shutil
+import sqlite3
+import subprocess
+import sys
+from urllib import error, parse, request
+
+
+ORIGIN = "https://magent-ci.example.test"
+FRONTEND = "http://127.0.0.1:3000"
+SETUP_TOKEN = "ci-only-setup-token-with-at-least-32-characters"
+ADMIN_USERNAME = "container-smoke-admin"
+ADMIN_PASSWORD = "Container-smoke-owner-password-123456789!"
+INTEGRATION_SECRET = "synthetic-container-smoke-integration-key"
+LOGIN_MESSAGE = "Welcome to an independent Magent installation"
+BACKUP_PASSPHRASE = "Synthetic container backup passphrase only"
+CACHE_FIXTURE = Path("/app/data/artwork/tmdb/w342/container-smoke.jpg")
+CACHE_CONTENT = b"synthetic artwork cache fixture"
+
+
+def managed_installation() -> bool:
+ return os.environ.get("MAGENT_MANAGED_SECRETS") in {"true", "auto"} and not os.environ.get("JWT_SECRET")
+
+
+def check(condition: bool, message: str) -> None:
+ if not condition:
+ raise AssertionError(message)
+
+
+def http(
+ path: str,
+ *,
+ expected: int = 200,
+ method: str = "GET",
+ payload: dict | None = None,
+ form: dict | None = None,
+ raw: bytes | None = None,
+ headers: dict | None = None,
+ base: str = FRONTEND,
+) -> tuple[bytes, object]:
+ outgoing_headers = {"Origin": ORIGIN, **(headers or {})}
+ check(sum(value is not None for value in (payload, form, raw)) <= 1,
+ "HTTP body must use only one encoding")
+ data = raw
+ if payload is not None:
+ data = json.dumps(payload).encode()
+ outgoing_headers["Content-Type"] = "application/json"
+ elif form is not None:
+ data = parse.urlencode(form).encode()
+ outgoing_headers["Content-Type"] = "application/x-www-form-urlencoded"
+ probe = request.Request(base + path, data=data, headers=outgoing_headers, method=method)
+ try:
+ response = request.urlopen(probe, timeout=30)
+ except error.HTTPError as exc:
+ response = exc
+ with response:
+ check(response.status == expected, f"{method} {path}: expected {expected}, got {response.status}")
+ return response.read(), response.headers
+
+
+def api(path: str, **kwargs) -> dict:
+ body, _ = http("/api" + path, **kwargs)
+ return json.loads(body)
+
+
+class PageAssets(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.scripts: list[dict] = []
+ self.assets: set[str] = set()
+
+ def handle_starttag(self, tag: str, attributes: list) -> None:
+ values = dict(attributes)
+ if tag == "script":
+ self.scripts.append(values)
+ if values.get("src"):
+ self.assets.add(values["src"])
+ if tag == "link" and values.get("href", "").startswith("/_next/static/"):
+ self.assets.add(values["href"])
+
+
+def check_page(path: str, asset_cache: set[str]) -> str:
+ body, headers = http(path)
+ check("text/html" in headers.get("Content-Type", ""), f"{path} is not HTML")
+ policy = headers.get("Content-Security-Policy", "")
+ match = re.search(r"script-src [^;]*'nonce-([^']+)'", policy)
+ check(match is not None, f"{path} missing script nonce policy")
+ nonce = match.group(1)
+ check("'strict-dynamic'" in policy, f"{path} lost strict-dynamic")
+ check("'unsafe-eval'" not in policy, f"{path} enables development eval")
+ check(headers.get("X-Content-Type-Options") == "nosniff", "Missing nosniff header")
+ check(headers.get("X-Frame-Options") == "DENY", "Missing anti-framing header")
+ check(headers.get("X-Powered-By") is None, "Frontend exposes its framework")
+ parsed = PageAssets()
+ parsed.feed(body.decode())
+ executable_scripts = [
+ script for script in parsed.scripts
+ if script.get("type", "").lower() in ("", "module", "text/javascript", "application/javascript")
+ ]
+ check(bool(executable_scripts), f"{path} contains no frontend bootstrap scripts")
+ for script in executable_scripts:
+ check(script.get("nonce") == nonce, f"{path} contains a script blocked by its CSP nonce")
+ check(any(asset.startswith("/_next/static/") and ".js" in asset for asset in parsed.assets),
+ f"{path} contains no static JavaScript assets")
+ for asset in sorted(parsed.assets - asset_cache):
+ check(asset.startswith("/_next/static/"), f"Unexpected external executable asset on {path}")
+ content, asset_headers = http(asset)
+ check(bool(content), f"Empty static asset: {asset}")
+ check("text/html" not in asset_headers.get("Content-Type", ""), f"Asset returned HTML: {asset}")
+ asset_cache.add(asset)
+ return nonce
+
+
+def check_packaging() -> None:
+ check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
+ check(Path("/app/frontend/server.js").is_file(), "Missing standalone frontend server")
+ check(shutil.which("node") == "/usr/local/bin/node", "Node is not the standalone runtime binary")
+ check(shutil.which("supervisord") == "/usr/local/bin/supervisord", "Missing Python supervisor")
+ check(shutil.which("curl") is not None, "curl compatibility for existing healthchecks was removed")
+ for executable in ("npm", "npx", "yarn", "pnpm", "pip", "pip3", "gcc", "g++", "make", "git", "gpg"):
+ check(shutil.which(executable) is None, f"Unnecessary runtime development tool: {executable}")
+ for forbidden in (
+ "/app/.git", "/app/tests", "/app/app/tests", "/app/backend/tests",
+ "/app/frontend/app", "/app/frontend/tsconfig.json", "/app/frontend/proxy.ts",
+ "/app/frontend/node_modules/typescript", "/app/frontend/node_modules/eslint",
+ "/app/frontend/node_modules/vitest", "/app/frontend/node_modules/@playwright",
+ "/app/frontend/node_modules/@biomejs",
+ "/app/frontend/node_modules/@next/swc-linux-x64-gnu",
+ "/app/frontend/node_modules/@next/swc-linux-arm64-gnu",
+ "/app/frontend/node_modules/@next/swc-linux-x64-musl",
+ "/app/frontend/node_modules/@next/swc-linux-arm64-musl",
+ "/root/.npm", "/root/.cache/pip", "/usr/local/lib/node_modules/npm",
+ ):
+ check(not Path(forbidden).exists(), f"Unnecessary build/private artifact: {forbidden}")
+ for directory in (Path("/app"), Path("/app/frontend")):
+ check(not any(directory.glob(".env*")), f"Private environment file in {directory}")
+ check(not Path("/app/data/bootstrap-secrets.json").exists(), "Managed secrets baked into image")
+ check(not any(Path("/app/frontend/.next/cache").iterdir()), "Frontend build cache shipped in runtime")
+ check(Path("/usr/share/licenses/magent/LICENSE").is_file(), "Magent license is missing")
+ check(Path("/usr/local/share/doc/nodejs/LICENSE").is_file(), "Node distribution license is missing")
+ check(Path("/usr/share/licenses/magent/frontend/dependencies.json").is_file(),
+ "Frontend dependency inventory is missing")
+ print("Standalone packaging, non-root runtime and absent development tools/private files: PASS")
+
+
+def check_runtime() -> None:
+ check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
+ check(Path("/app/data").stat().st_uid == os.getuid(), "Fresh data volume is not owned by runtime user")
+ check(os.access("/app/data", os.W_OK), "Data volume is not writable")
+ for path in ("/api/health", "/api/setup/status"):
+ http(path)
+ http("/health", base="http://127.0.0.1:8000")
+ asset_cache: set[str] = set()
+ first_nonce = check_page("/login", asset_cache)
+ second_nonce = check_page("/login", asset_cache)
+ check(first_nonce != second_nonce, "CSP nonce is reused between requests")
+ check_page("/setup", asset_cache)
+ # Check the retained curl command because some deployed stacks override the
+ # image HEALTHCHECK with this exact runtime dependency.
+ subprocess.run(["curl", "--fail", "--silent", "--show-error", FRONTEND + "/api/health"],
+ check=True, stdout=subprocess.DEVNULL)
+ print(f"Runtime, API rewrite, CSP nonce consistency and {len(asset_cache)} static assets: PASS")
+
+
+def check_origin_guards() -> None:
+ # MAGENT_APPLICATION_URL must permit the public origin even while CORS uses
+ # its localhost default. Test both direct backend and Next's API rewrite.
+ for base, prefix in ((FRONTEND, "/api"), ("http://127.0.0.1:8000", "")):
+ for endpoint in ("/auth/login", "/auth/jellyfin/login"):
+ for origin, expected in ((ORIGIN, 422), ("https://untrusted.example.test", 403)):
+ http(prefix + endpoint, base=base, method="POST", form={}, expected=expected,
+ headers={"Origin": origin})
+ print("Both login Origin guards, directly and via frontend: PASS")
+
+
+def sign_in() -> dict:
+ _, headers = http("/api/auth/login", method="POST", form={
+ "username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
+ })
+ cookies = SimpleCookie()
+ for raw_cookie in headers.get_all("Set-Cookie", []):
+ cookies.load(raw_cookie)
+ check("magent_auth" in cookies, "Local login did not issue an authentication cookie")
+ auth_cookie = cookies["magent_auth"]
+ check(bool(auth_cookie["httponly"]), "Authentication cookie missing HttpOnly")
+ check(bool(auth_cookie["secure"]), "Authentication cookie missing Secure")
+ check(auth_cookie["samesite"].lower() == "strict", "Authentication cookie missing SameSite=strict")
+ # These requests traverse HTTP loopback behind the simulated HTTPS public
+ # origin. Forward only our synthetic cookie explicitly; never print tokens.
+ authenticated_headers = {"Cookie": "magent_auth=" + auth_cookie.value}
+ identity = api("/auth/me", headers=authenticated_headers)
+ check(identity["username"] == ADMIN_USERNAME and identity["role"] == "admin",
+ "Local administrator identity did not survive login")
+ return authenticated_headers
+
+
+def check_persisted_settings(headers: dict) -> None:
+ values = {item["key"]: item for item in api("/admin/settings", headers=headers)["settings"]}
+ check(values["site_login_message"]["value"] == LOGIN_MESSAGE, "Public configuration did not persist")
+ check(values["jellyfin_api_key"]["value"] is None and values["jellyfin_api_key"]["isSet"],
+ "Integration secret is missing or exposed by settings API")
+ with sqlite3.connect("file:/app/data/magent.db?mode=ro", uri=True) as connection:
+ check(connection.execute("PRAGMA quick_check").fetchone()[0] == "ok", "SQLite integrity failure")
+ check(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1,
+ "Fresh smoke instance has unexpected users")
+ stored = connection.execute("SELECT value FROM settings WHERE key = 'jellyfin_api_key'").fetchone()
+ check(stored is not None and INTEGRATION_SECRET not in str(stored[0]),
+ "Integration secret was stored without encryption")
+
+
+def backup_restore_upload(content: bytes, passphrase: str) -> tuple[bytes, str]:
+ boundary = "magent-smoke-" + secrets.token_hex(24)
+ parts = []
+ for name, value in (("passphrase", passphrase), ("confirmation", "RESTORE")):
+ parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n'
+ f'\r\n{value}\r\n').encode())
+ parts.extend([
+ (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="smoke.magent-backup"\r\n'
+ 'Content-Type: application/octet-stream\r\n\r\n').encode(),
+ content,
+ f"\r\n--{boundary}--\r\n".encode(),
+ ])
+ return b"".join(parts), f"multipart/form-data; boundary={boundary}"
+
+
+def stage_backup_roundtrip(headers: dict) -> None:
+ # All paths and values belong to this disposable CI volume, never real data.
+ CACHE_FIXTURE.parent.mkdir(parents=True, exist_ok=True)
+ CACHE_FIXTURE.write_bytes(CACHE_CONTENT)
+ content, response_headers = http("/api/admin/backups/export", method="POST", headers=headers,
+ payload={"passphrase": BACKUP_PASSPHRASE, "include_cache": True})
+ check(content.startswith(b"MAGENT-BACKUP\x00\x01"), "Backup is not the encrypted portable format")
+ check(INTEGRATION_SECRET.encode() not in content, "Backup exposed plaintext integration credentials")
+ check(response_headers.get("Cache-Control") == "no-store", "Backup download is cacheable")
+ api("/admin/settings", method="PUT", headers=headers,
+ payload={"site_login_message": "Changed after backup"})
+ CACHE_FIXTURE.write_bytes(b"changed after backup")
+ body, content_type = backup_restore_upload(content, BACKUP_PASSPHRASE)
+ restored = api("/admin/backups/restore", method="POST", expected=202, raw=body,
+ headers={**headers, "Content-Type": content_type})
+ check(restored["restart_required"], "Restore did not require a restart")
+ status = api("/admin/backups", headers=headers)
+ check(status["pending_restore"] is not None, "Backup was not staged")
+ check(CACHE_FIXTURE.read_bytes() == b"changed after backup", "Restore applied before restart")
+ print("Encrypted config/database/cache backup and authenticated restore staging: PASS")
+
+
+def fresh_install() -> None:
+ check(api("/setup/status") == {"setup_required": True, "needs_admin": True},
+ "Fresh volume did not open authorized first-install setup")
+ api("/setup/state", expected=401)
+ api("/admin/settings", expected=401)
+ token = SETUP_TOKEN
+ if managed_installation():
+ # docker exec does not inherit the entrypoint's generated environment:
+ # the console command must read persistent state, not rely on getenv.
+ token = subprocess.check_output(
+ [sys.executable, "-m", "app.container_bootstrap", "setup-token"], text=True,
+ ).strip()
+ check(len(token) == 64 and token != SETUP_TOKEN, "Managed setup token was not generated")
+ state_file = Path("/app/data/bootstrap-secrets.json")
+ check(state_file.stat().st_mode & 0o777 == 0o600, "Managed secrets file is not private")
+ Path("/app/data/.smoke-managed-digest").write_text(hashlib.sha256(state_file.read_bytes()).hexdigest())
+ bootstrap = {"setup_token": token, "username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
+ "application_url": ORIGIN}
+ api("/setup/bootstrap", method="POST", payload=bootstrap, expected=403,
+ headers={"Origin": "https://untrusted.example.test"})
+ api("/setup/bootstrap", method="POST", payload={**bootstrap, "setup_token": "wrong-token"}, expected=403)
+ api("/setup/bootstrap", method="POST", payload=bootstrap, expected=201)
+ api("/setup/bootstrap", method="POST", payload=bootstrap, expected=409)
+ headers = sign_in()
+ check(api("/setup/state", headers=headers)["step"] == "apps", "Setup did not advance to apps")
+ updated = api("/admin/settings", method="PUT", headers=headers, payload={
+ "site_login_message": LOGIN_MESSAGE,
+ "jellyfin_api_key": INTEGRATION_SECRET,
+ })
+ check(updated["updated"] == 2, "Setup configuration was not saved")
+ api("/setup/state", method="PUT", payload={"step": "review"}, headers=headers)
+ check(api("/setup/complete", method="POST", headers=headers)["completed"], "Setup did not complete")
+ check(api("/setup/status") == {"setup_required": False, "needs_admin": False}, "Setup remained public")
+ check_persisted_settings(headers)
+ print("Token-authorized setup, local admin login, secure cookies and encrypted settings: PASS")
+ stage_backup_roundtrip(headers)
+
+
+def persisted_install() -> None:
+ check(api("/setup/status") == {"setup_required": False, "needs_admin": False},
+ "Setup reopened after restart/recreation")
+ api("/setup/bootstrap", method="POST", payload={
+ "setup_token": SETUP_TOKEN, "username": "must-not-exist", "password": ADMIN_PASSWORD,
+ }, expected=409)
+ headers = sign_in()
+ check_persisted_settings(headers)
+ status = api("/admin/backups", headers=headers)
+ check(status["pending_restore"] is None, "Restore remained pending after restart")
+ check(status["last_restore"] and status["last_restore"]["status"] == "restored",
+ "Backup restore did not complete")
+ check(CACHE_FIXTURE.read_bytes() == CACHE_CONTENT, "Artwork cache was not restored")
+ if managed_installation():
+ state_file = Path("/app/data/bootstrap-secrets.json")
+ check(hashlib.sha256(state_file.read_bytes()).hexdigest()
+ == Path("/app/data/.smoke-managed-digest").read_text(), "Managed keys changed on restart/restore")
+ result = subprocess.run([sys.executable, "-m", "app.container_bootstrap", "setup-token"],
+ capture_output=True, text=True, check=False)
+ check(result.returncode != 0 and not result.stdout, "Setup token remains available after admin creation")
+ print("Generated keys persisted unchanged; initial setup token is no longer available: PASS")
+ print("Persistent setup state, administrator login, encrypted settings and database integrity: PASS")
+ print("Restored configuration, database and artwork cache: PASS")
+
+
+def main() -> None:
+ check(len(sys.argv) == 2 and sys.argv[1] in ("packaging", "fresh", "persisted"),
+ "Expected packaging, fresh or persisted mode")
+ if sys.argv[1] == "packaging":
+ check_packaging()
+ return
+ check_runtime()
+ if sys.argv[1] == "fresh":
+ fresh_install()
+ else:
+ persisted_install()
+ check_origin_guards()
+
+
+if __name__ == "__main__":
+ main()