diff --git a/.build_number b/.build_number
new file mode 100644
index 0000000..2862b8b
--- /dev/null
+++ b/.build_number
@@ -0,0 +1 @@
+0803262237
diff --git a/.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/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ad288e0
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,53 @@
+FROM node:24-slim AS frontend-builder
+
+WORKDIR /frontend
+
+ENV NODE_ENV=production \
+ BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
+ NEXT_PUBLIC_API_BASE=/api
+
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm ci --include=dev
+
+COPY frontend/app ./app
+COPY frontend/public ./public
+COPY frontend/next-env.d.ts ./next-env.d.ts
+COPY frontend/next.config.js ./next.config.js
+COPY frontend/tsconfig.json ./tsconfig.json
+
+RUN npm run build
+
+FROM python:3.14-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ NODE_ENV=production
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends curl gnupg supervisor \
+ && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY backend/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY backend/app ./app
+COPY data/branding /app/data/branding
+
+COPY --from=frontend-builder /frontend/.next /app/frontend/.next
+COPY --from=frontend-builder /frontend/public /app/frontend/public
+COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
+COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
+COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
+COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
+COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
+
+COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
+
+EXPOSE 3000 8000
+
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
diff --git a/README.md b/README.md
index d1e4aa5..3a72055 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
# Magent
-Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
+Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
## How it works
-1) Requests are pulled from Jellyseerr and stored locally.
+1) Requests are pulled from Seerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services.
- Request search by title/year or request ID.
- Recent requests list with posters and status.
-- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
+- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders.
@@ -160,7 +160,7 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
### No recent requests
-- Confirm Jellyseerr credentials in Settings.
+- Confirm Seerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
diff --git a/backend/app/ai/triage.py b/backend/app/ai/triage.py
index b01dc0c..ae4499c 100644
--- a/backend/app/ai/triage.py
+++ b/backend/app/ai/triage.py
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
if snapshot.state == NormalizedState.requested:
root_cause = "approval"
- summary = "The request is waiting for approval in Jellyseerr."
+ 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="Jellyseerr has not marked this request as approved.",
+ reason="Seerr has not marked this request as approved.",
risk="low",
)
)
@@ -26,7 +26,7 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
recommendations.append(
TriageRecommendation(
action_id="readd_to_arr",
- title="Add it to the library queue",
+ title="Push to Sonarr/Radarr",
reason="Sonarr/Radarr has not created the entry for this request.",
risk="medium",
)
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
index c54c1f3..945bc84 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -1,19 +1,152 @@
-from typing import Dict, Any
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
-from .db import get_user_by_username
-from .security import safe_decode_token, TokenError
+from .config import settings
+from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
+from .network_security import request_trusts_forwarded_headers
+from .security import TokenError, safe_decode_token, verify_password
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
-def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
+def _is_expired(expires_at: str | None) -> bool:
+ if not isinstance(expires_at, str) or not expires_at.strip():
+ return False
+ candidate = expires_at.strip()
+ if candidate.endswith("Z"):
+ candidate = candidate[:-1] + "+00:00"
+ try:
+ parsed = datetime.fromisoformat(candidate)
+ except ValueError:
+ return False
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed <= datetime.now(timezone.utc)
+
+
+def _extract_client_ip(request: Request) -> str:
+ direct_host = request.client.host if request.client else None
+ if request_trusts_forwarded_headers(direct_host):
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ parts = [part.strip() for part in forwarded.split(",") if part.strip()]
+ if parts:
+ return parts[0]
+ real_ip = request.headers.get("x-real-ip")
+ if real_ip:
+ return real_ip.strip()
+ if direct_host:
+ return direct_host
+ return "unknown"
+
+
+def _cookie_settings() -> dict[str, Any]:
+ samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
+ if samesite not in {"lax", "strict", "none"}:
+ samesite = "lax"
+ return {
+ "secure": bool(settings.auth_cookie_secure),
+ "httponly": True,
+ "samesite": samesite,
+ "domain": settings.auth_cookie_domain or None,
+ "path": "/",
+ }
+
+
+def _state_cookie_settings() -> dict[str, Any]:
+ cookie = _cookie_settings()
+ cookie["httponly"] = False
+ return cookie
+
+
+def set_auth_cookies(response: Response, token: str) -> None:
+ max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
+ response.set_cookie(
+ settings.auth_cookie_name,
+ token,
+ max_age=max_age,
+ **_cookie_settings(),
+ )
+ response.set_cookie(
+ settings.auth_state_cookie_name,
+ "1",
+ max_age=max_age,
+ **_state_cookie_settings(),
+ )
+
+
+def clear_auth_cookies(response: Response) -> None:
+ response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
+ response.delete_cookie(
+ settings.auth_state_cookie_name,
+ path="/",
+ domain=settings.auth_cookie_domain or None,
+ )
+
+
+def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.lower().startswith("bearer "):
+ return auth_header.split(" ", 1)[1].strip()
+ if oauth_token:
+ return oauth_token
+ cookie_token = request.cookies.get(settings.auth_cookie_name)
+ if isinstance(cookie_token, str) and cookie_token.strip():
+ return cookie_token.strip()
+ return None
+
+
+def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
+ if not isinstance(user, dict):
+ return "local"
+ provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
+ if provider != "local":
+ return provider
+ password_hash = user.get("password_hash")
+ if isinstance(password_hash, str) and password_hash:
+ if verify_password("jellyfin-user", password_hash):
+ return "jellyfin"
+ if verify_password("jellyseerr-user", password_hash):
+ return "jellyseerr"
+ return provider
+
+
+def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ if not isinstance(user, dict):
+ return {}
+ resolved_provider = resolve_user_auth_provider(user)
+ stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
+ if resolved_provider != stored_provider:
+ username = str(user.get("username") or "").strip()
+ if username:
+ set_user_auth_provider(username, resolved_provider)
+ refreshed_user = get_user_by_username(username)
+ if refreshed_user:
+ user = refreshed_user
+ normalized = dict(user)
+ normalized["auth_provider"] = resolved_provider
+ normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
+ normalized["password_provider"] = (
+ resolved_provider if resolved_provider in {"local", "jellyfin"} else None
+ )
+ return normalized
+
+
+def _load_current_user_from_token(
+ token: str,
+ request: Optional[Request] = None,
+ allowed_token_types: Optional[set[str]] = None,
+) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
+ token_type = str(payload.get("typ") or "access").strip().lower()
+ if allowed_token_types and token_type not in allowed_token_types:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
username = payload.get("sub")
if not username:
@@ -24,15 +157,70 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
+ if _is_expired(user.get("expires_at")):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
+
+ user = normalize_user_auth_provider(user)
+
+ if request is not None:
+ ip = _extract_client_ip(request)
+ user_agent = request.headers.get("user-agent", "unknown")
+ upsert_user_activity(user["username"], ip, user_agent)
return {
"username": user["username"],
+ "email": user.get("email"),
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
+ "jellyseerr_user_id": user.get("jellyseerr_user_id"),
+ "auto_search_enabled": bool(user.get("auto_search_enabled", True)),
+ "invite_management_enabled": bool(user.get("invite_management_enabled", False)),
+ "profile_id": user.get("profile_id"),
+ "expires_at": user.get("expires_at"),
+ "is_expired": bool(user.get("is_expired", False)),
+ "password_change_supported": bool(user.get("password_change_supported", False)),
+ "password_provider": user.get("password_provider"),
}
+def get_current_user(
+ request: Request,
+ token: Optional[str] = Depends(oauth2_scheme),
+) -> Dict[str, Any]:
+ resolved_token = _extract_access_token(request, token)
+ if not resolved_token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
+ return _load_current_user_from_token(resolved_token, request)
+
+
+def get_current_user_event_stream(
+ request: Request,
+ token: Optional[str] = Depends(oauth2_scheme),
+) -> Dict[str, Any]:
+ """EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
+ resolved_token = _extract_access_token(request, token)
+ stream_query_token = request.query_params.get("stream_token")
+ if resolved_token:
+ # Allow standard bearer tokens for non-browser EventSource clients.
+ return _load_current_user_from_token(resolved_token, None)
+ if not stream_query_token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
+ return _load_current_user_from_token(
+ str(stream_query_token),
+ None,
+ allowed_token_types={"sse"},
+ )
+
+
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
+
+
+def require_admin_event_stream(
+ user: Dict[str, Any] = Depends(get_current_user_event_stream),
+) -> Dict[str, Any]:
+ if user.get("role") != "admin":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
+ return user
diff --git a/backend/app/build_info.py b/backend/app/build_info.py
new file mode 100644
index 0000000..5fc2598
--- /dev/null
+++ b/backend/app/build_info.py
@@ -0,0 +1,2 @@
+BUILD_NUMBER = "0803262237"
+CHANGELOG = '2026-03-08|Process 1 build 0803262229\n2026-03-08|Process 1 build 0803262216\n2026-03-08|Process 1 build 0803262038\n2026-03-07|Process 1 build 0703261729\n2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit'
diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py
index 303ca23..718b65b 100644
--- a/backend/app/clients/base.py
+++ b/backend/app/clients/base.py
@@ -1,11 +1,16 @@
from typing import Any, Dict, Optional
+import logging
+import time
import httpx
+from ..logging_config import sanitize_headers, sanitize_value
+
class ApiClient:
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
self.base_url = base_url.rstrip("/") if base_url else None
self.api_key = api_key
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
def configured(self) -> bool:
return bool(self.base_url)
@@ -13,20 +18,91 @@ class ApiClient:
def headers(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key} if self.api_key else {}
- async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
+ if response is None:
+ return None
+ try:
+ payload = sanitize_value(response.json())
+ except ValueError:
+ payload = sanitize_value(response.text)
+ if isinstance(payload, str) and len(payload) > 500:
+ return f"{payload[:500]}..."
+ return payload
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: Optional[Dict[str, Any]] = None,
+ payload: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Any]:
if not self.base_url:
+ self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
return None
url = f"{self.base_url}{path}"
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.get(url, headers=self.headers(), params=params)
- response.raise_for_status()
+ started_at = time.perf_counter()
+ self.logger.debug(
+ "outbound request started method=%s url=%s params=%s payload=%s headers=%s",
+ method,
+ url,
+ sanitize_value(params),
+ sanitize_value(payload),
+ sanitize_headers(self.headers()),
+ )
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.request(
+ method,
+ url,
+ headers=self.headers(),
+ params=params,
+ json=payload,
+ )
+ response.raise_for_status()
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ self.logger.debug(
+ "outbound request completed method=%s url=%s status=%s duration_ms=%s",
+ method,
+ url,
+ response.status_code,
+ duration_ms,
+ )
+ if not response.content:
+ return None
return response.json()
+ except httpx.HTTPStatusError as exc:
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ response = exc.response
+ status = response.status_code if response is not None else "unknown"
+ log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
+ log_fn(
+ "outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
+ method,
+ url,
+ status,
+ duration_ms,
+ self._response_summary(response),
+ )
+ raise
+ except Exception:
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ self.logger.exception(
+ "outbound request failed method=%s url=%s duration_ms=%s",
+ method,
+ url,
+ duration_ms,
+ )
+ raise
+
+ async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ return await self._request("GET", path, params=params)
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
- if not self.base_url:
- return None
- url = f"{self.base_url}{path}"
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.post(url, headers=self.headers(), json=payload)
- response.raise_for_status()
- return response.json()
+ return await self._request("POST", path, payload=payload)
+
+ async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+ return await self._request("PUT", path, payload=payload)
+
+ async def delete(self, path: str) -> Optional[Any]:
+ return await self._request("DELETE", path)
diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py
index 16ad8a4..d735b41 100644
--- a/backend/app/clients/jellyfin.py
+++ b/backend/app/clients/jellyfin.py
@@ -10,27 +10,158 @@ class JellyfinClient(ApiClient):
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 = {"X-Emby-Token": self.api_key} if self.api_key else {}
+ 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 = {"X-Emby-Token": self.api_key} if self.api_key else {}
+ 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]]:
@@ -43,7 +174,7 @@ class JellyfinClient(ApiClient):
"Recursive": "true",
"Limit": limit,
}
- headers = {"X-Emby-Token": self.api_key}
+ headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
@@ -53,8 +184,18 @@ class JellyfinClient(ApiClient):
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/System/Info"
- headers = {"X-Emby-Token": self.api_key}
+ headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
+
+ async def refresh_library(self, recursive: bool = True) -> None:
+ if not self.base_url or not self.api_key:
+ return None
+ url = f"{self.base_url}/Library/Refresh"
+ headers = self._emby_headers()
+ params = {"Recursive": "true" if recursive else "false"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers=headers, params=params)
+ response.raise_for_status()
diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py
index fdb32fa..7201283 100644
--- a/backend/app/clients/jellyseerr.py
+++ b/backend/app/clients/jellyseerr.py
@@ -1,4 +1,5 @@
from typing import Any, Dict, Optional
+import httpx
from .base import ApiClient
@@ -18,9 +19,6 @@ class JellyseerrClient(ApiClient):
},
)
- async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
- return await self.get(f"/api/v1/media/{media_id}")
-
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/movie/{tmdb_id}")
@@ -35,3 +33,47 @@ class JellyseerrClient(ApiClient):
"page": page,
},
)
+
+ async def create_request(
+ self,
+ *,
+ media_type: str,
+ media_id: int,
+ seasons: Optional[list[int]] = None,
+ is_4k: Optional[bool] = None,
+ ) -> Optional[Dict[str, Any]]:
+ payload: Dict[str, Any] = {
+ "mediaType": media_type,
+ "mediaId": media_id,
+ }
+ if isinstance(seasons, list) and seasons:
+ payload["seasons"] = seasons
+ if isinstance(is_4k, bool):
+ payload["is4k"] = is_4k
+ return await self.post("/api/v1/request", payload=payload)
+
+ async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
+ return await self.get(
+ "/api/v1/user",
+ params={
+ "take": take,
+ "skip": skip,
+ },
+ )
+
+ async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v1/user/{user_id}")
+
+ async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
+ return await self.delete(f"/api/v1/user/{user_id}")
+
+ async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
+ payload = {"email": email, "password": password}
+ try:
+ return await self.post("/api/v1/auth/local", payload=payload)
+ except httpx.HTTPStatusError as exc:
+ # Backward compatibility for older Seerr/Overseerr deployments
+ # that still expose /auth/login instead of /auth/local.
+ if exc.response is not None and exc.response.status_code in {404, 405}:
+ return await self.post("/api/v1/auth/login", payload=payload)
+ raise
diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py
index 932c297..fc70d1f 100644
--- a/backend/app/clients/qbittorrent.py
+++ b/backend/app/clients/qbittorrent.py
@@ -1,5 +1,6 @@
from typing import Any, Dict, Optional
import httpx
+import logging
from .base import ApiClient
@@ -8,6 +9,7 @@ class QBittorrentClient(ApiClient):
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)
@@ -72,6 +74,14 @@ class QBittorrentClient(ApiClient):
raise
async def add_torrent_url(self, url: str, category: 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
diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py
index 569ca00..83da911 100644
--- a/backend/app/clients/radarr.py
+++ b/backend/app/clients/radarr.py
@@ -9,6 +9,9 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
+ async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v3/movie/{movie_id}")
+
async def get_movies(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie")
@@ -21,6 +24,9 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
+ async def get_indexers(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/indexer")
+
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
@@ -41,5 +47,17 @@ class RadarrClient(ApiClient):
}
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
index 65f1d96..6de81be 100644
--- a/backend/app/clients/sonarr.py
+++ b/backend/app/clients/sonarr.py
@@ -9,6 +9,9 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
+ async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
+ return await self.get(f"/api/v3/series/{series_id}")
+
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder")
@@ -18,6 +21,9 @@ class SonarrClient(ApiClient):
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"seriesId": series_id})
+ async def get_indexers(self) -> Optional[Dict[str, Any]]:
+ return await self.get("/api/v3/indexer")
+
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
@@ -48,5 +54,17 @@ class SonarrClient(ApiClient):
payload["title"] = title
return await self.post("/api/v3/series", payload=payload)
+ async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return await self.put("/api/v3/series", payload=payload)
+
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
+
+ async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ return await self.post("/api/v3/release/push", payload=payload)
+
+ async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
+ return await self.post(
+ "/api/v3/command",
+ payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
+ )
diff --git a/backend/app/config.py b/backend/app/config.py
index 902d658..ddea219 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -2,18 +2,68 @@ from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
+from .build_info import BUILD_NUMBER, CHANGELOG
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000"
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
- jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
+ sqlite_journal_mode: str = Field(
+ default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
+ )
+ jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
+ api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
+ auth_rate_limit_window_seconds: int = Field(
+ default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
+ )
+ auth_rate_limit_max_attempts_ip: int = Field(
+ default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
+ )
+ auth_rate_limit_max_attempts_user: int = Field(
+ default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
+ )
+ password_reset_rate_limit_window_seconds: int = Field(
+ default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
+ )
+ password_reset_rate_limit_max_attempts_ip: int = Field(
+ default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
+ )
+ password_reset_rate_limit_max_attempts_identifier: int = Field(
+ default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
+ )
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
- admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
+ admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
+ auth_cookie_name: str = Field(
+ default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
+ )
+ auth_cookie_secure: bool = Field(
+ default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
+ )
+ auth_cookie_samesite: str = Field(
+ default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
+ )
+ auth_cookie_domain: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
+ )
+ auth_state_cookie_name: str = Field(
+ default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
+ )
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
+ log_file_max_bytes: int = Field(
+ default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
+ )
+ log_file_backup_count: int = Field(
+ default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
+ )
+ log_http_client_level: str = Field(
+ default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
+ )
+ log_background_sync_level: str = Field(
+ default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
+ )
requests_sync_ttl_minutes: int = Field(
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
)
@@ -38,6 +88,157 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
+ site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
+ site_banner_enabled: bool = Field(
+ default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
+ )
+ site_banner_message: Optional[str] = Field(
+ default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
+ )
+ site_banner_tone: str = Field(
+ default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
+ )
+ site_login_show_jellyfin_login: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
+ )
+ site_login_show_local_login: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
+ )
+ site_login_show_forgot_password: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
+ )
+ site_login_show_signup_link: bool = Field(
+ default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
+ )
+ site_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")
@@ -70,6 +271,10 @@ class Settings(BaseSettings):
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")
@@ -83,6 +288,10 @@ class Settings(BaseSettings):
radarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
)
+ radarr_qbittorrent_category: Optional[str] = Field(
+ default="radarr",
+ validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
+ )
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
@@ -102,7 +311,7 @@ class Settings(BaseSettings):
)
discord_webhook_url: Optional[str] = Field(
- default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
+ default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
diff --git a/backend/app/db.py b/backend/app/db.py
index 72cac98..9d2c156 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -2,7 +2,9 @@ import json
import os
import sqlite3
import logging
+from hashlib import sha256
from datetime import datetime, timezone, timedelta
+from time import perf_counter
from typing import Any, Dict, Optional
from .config import settings
@@ -11,17 +13,176 @@ from .security import hash_password, verify_password
logger = logging.getLogger(__name__)
+SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS = 6
+SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS = 24
+SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS = 30
+SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
+SQLITE_BUSY_TIMEOUT_MS = 5_000
+SQLITE_CACHE_SIZE_KIB = 32_768
+SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
+_DB_UNSET = object()
+_DEFAULT_JWT_SECRET = "change-me"
+_DEFAULT_ADMIN_PASSWORD = "adminadmin"
+
def _db_path() -> str:
path = settings.sqlite_path or "data/magent.db"
if not os.path.isabs(path):
- path = os.path.join(os.getcwd(), path)
+ app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+ path = os.path.join(app_root, path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
+def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
+ journal_mode = str(getattr(settings, "sqlite_journal_mode", "DELETE") or "DELETE").strip().upper()
+ if journal_mode not in {"DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"}:
+ journal_mode = "DELETE"
+ pragmas = (
+ ("journal_mode", journal_mode),
+ ("synchronous", "NORMAL"),
+ ("temp_store", "MEMORY"),
+ ("cache_size", -SQLITE_CACHE_SIZE_KIB),
+ ("mmap_size", SQLITE_MMAP_SIZE_BYTES),
+ ("busy_timeout", SQLITE_BUSY_TIMEOUT_MS),
+ )
+ for pragma, value in pragmas:
+ try:
+ conn.execute(f"PRAGMA {pragma} = {value}")
+ except sqlite3.DatabaseError:
+ logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True)
+
+
def _connect() -> sqlite3.Connection:
- return sqlite3.connect(_db_path())
+ conn = sqlite3.connect(
+ _db_path(),
+ timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
+ cached_statements=512,
+ )
+ _apply_connection_pragmas(conn)
+ return conn
+
+
+def _parse_datetime_value(value: Optional[str]) -> Optional[datetime]:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ candidate = value.strip()
+ if candidate.endswith("Z"):
+ candidate = candidate[:-1] + "+00:00"
+ try:
+ parsed = datetime.fromisoformat(candidate)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _is_datetime_in_past(value: Optional[str]) -> bool:
+ parsed = _parse_datetime_value(value)
+ if parsed is None:
+ return False
+ return parsed <= datetime.now(timezone.utc)
+
+
+def _normalize_title_value(title: Optional[str]) -> Optional[str]:
+ if not isinstance(title, str):
+ return None
+ trimmed = title.strip()
+ return trimmed if trimmed else None
+
+
+def _normalize_year_value(year: Optional[Any]) -> Optional[int]:
+ if isinstance(year, int):
+ return year
+ if isinstance(year, str):
+ trimmed = year.strip()
+ if trimmed.isdigit():
+ return int(trimmed)
+ return None
+
+
+def _is_placeholder_title(title: Optional[str], request_id: Optional[int]) -> bool:
+ if not isinstance(title, str):
+ return True
+ normalized = title.strip().lower()
+ if not normalized:
+ return True
+ if normalized == "untitled":
+ return True
+ if request_id and normalized == f"request {request_id}":
+ return True
+ return False
+
+
+def _extract_title_year_from_payload(payload_json: Optional[str]) -> tuple[Optional[str], Optional[int]]:
+ if not payload_json:
+ return None, None
+ try:
+ payload = json.loads(payload_json)
+ except json.JSONDecodeError:
+ return None, None
+ if not isinstance(payload, dict):
+ return None, None
+ media = payload.get("media") or {}
+ title = None
+ year = None
+ if isinstance(media, dict):
+ title = media.get("title") or media.get("name")
+ year = media.get("year")
+ if not title:
+ title = payload.get("title") or payload.get("name")
+ if year is None:
+ year = payload.get("year")
+ return _normalize_title_value(title), _normalize_year_value(year)
+
+
+def _extract_tmdb_from_payload(payload_json: Optional[str]) -> tuple[Optional[int], Optional[str]]:
+ if not payload_json:
+ return None, None
+ try:
+ payload = json.loads(payload_json)
+ except (TypeError, json.JSONDecodeError):
+ return None, None
+ if not isinstance(payload, dict):
+ return None, None
+ media = payload.get("media") or {}
+ if not isinstance(media, dict):
+ media = {}
+ tmdb_id = (
+ media.get("tmdbId")
+ or payload.get("tmdbId")
+ or payload.get("tmdb_id")
+ or media.get("externalServiceId")
+ or payload.get("externalServiceId")
+ )
+ media_type = (
+ media.get("mediaType")
+ or payload.get("mediaType")
+ or payload.get("media_type")
+ or payload.get("type")
+ )
+ try:
+ tmdb_id = int(tmdb_id) if tmdb_id is not None else None
+ except (TypeError, ValueError):
+ tmdb_id = None
+ if isinstance(media_type, str):
+ media_type = media_type.strip().lower() or None
+ return tmdb_id, media_type
+
+
+def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ candidate = value.strip()
+ if not candidate or "@" not in candidate:
+ return None
+ return candidate
+
+
+def _has_secure_bootstrap_admin_credentials() -> bool:
+ password = str(settings.admin_password or "")
+ return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
def init_db() -> None:
@@ -56,15 +217,72 @@ def init_db() -> None:
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
+ is_blocked INTEGER NOT NULL DEFAULT 0,
+ auto_search_enabled INTEGER NOT NULL DEFAULT 1,
+ invite_management_enabled INTEGER NOT NULL DEFAULT 0,
+ profile_id INTEGER,
+ expires_at TEXT,
+ invited_by_code TEXT,
+ invited_at TEXT,
+ jellyfin_password_hash TEXT,
+ last_jellyfin_auth_at TEXT
)
"""
)
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_profiles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ role TEXT NOT NULL DEFAULT 'user',
+ auto_search_enabled INTEGER NOT NULL DEFAULT 1,
+ account_expires_days INTEGER,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS signup_invites (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT NOT NULL UNIQUE,
+ label TEXT,
+ description TEXT,
+ profile_id INTEGER,
+ role TEXT,
+ max_uses INTEGER,
+ use_count INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ expires_at TEXT,
+ recipient_email TEXT,
+ created_by TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_signup_invites_enabled
+ ON signup_invites (enabled)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_signup_invites_expires_at
+ ON signup_invites (expires_at)
+ """
+ )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -85,12 +303,103 @@ def init_db() -> None:
year INTEGER,
requested_by TEXT,
requested_by_norm TEXT,
+ requested_by_id INTEGER,
created_at TEXT,
updated_at TEXT,
payload_json TEXT NOT NULL
)
"""
)
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS artwork_cache_status (
+ request_id INTEGER PRIMARY KEY,
+ tmdb_id INTEGER,
+ media_type TEXT,
+ poster_path TEXT,
+ backdrop_path TEXT,
+ has_tmdb INTEGER NOT NULL DEFAULT 0,
+ poster_cached INTEGER NOT NULL DEFAULT 0,
+ backdrop_cached INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS seerr_media_failures (
+ media_type TEXT NOT NULL,
+ tmdb_id INTEGER NOT NULL,
+ status_code INTEGER,
+ error_message TEXT,
+ failure_count INTEGER NOT NULL DEFAULT 1,
+ first_failed_at TEXT NOT NULL,
+ last_failed_at TEXT NOT NULL,
+ suppress_until TEXT NOT NULL,
+ is_persistent INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (media_type, tmdb_id)
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ token_hash TEXT NOT NULL UNIQUE,
+ username TEXT NOT NULL,
+ recipient_email TEXT NOT NULL,
+ auth_provider TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ used_at TEXT,
+ requested_by_ip TEXT,
+ requested_user_agent TEXT
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS portal_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ media_type TEXT,
+ year INTEGER,
+ external_ref TEXT,
+ source_system TEXT,
+ source_request_id INTEGER,
+ related_item_id INTEGER,
+ status TEXT NOT NULL,
+ workflow_request_status TEXT,
+ workflow_media_status TEXT,
+ issue_type TEXT,
+ issue_resolved_at TEXT,
+ metadata_json TEXT,
+ priority TEXT NOT NULL,
+ created_by_username TEXT NOT NULL,
+ created_by_id INTEGER,
+ assignee_username TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ last_activity_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS portal_comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ item_id INTEGER NOT NULL,
+ author_username TEXT NOT NULL,
+ author_role TEXT NOT NULL,
+ message TEXT NOT NULL,
+ is_internal INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
+ )
+ """
+ )
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -103,6 +412,126 @@ def init_db() -> None:
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)
+ """
+ )
+ 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)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
+ ON requests_cache (requested_by_norm, created_at DESC, request_id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_status_created_at
+ ON requests_cache (status, created_at DESC, request_id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_artwork_cache_status_updated_at
+ ON artwork_cache_status (updated_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_seerr_media_failures_suppress_until
+ ON seerr_media_failures (suppress_until)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_username
+ ON password_reset_tokens (username)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
+ ON password_reset_tokens (expires_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_kind_status
+ ON portal_items (kind, status, updated_at DESC, id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_creator
+ ON portal_items (created_by_username, updated_at DESC, id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_status
+ ON portal_items (status, updated_at DESC, id DESC)
+ """
+ )
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
+ ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
+ ON portal_items (related_item_id, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
+ ON portal_comments (item_id, created_at DESC, id DESC)
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_activity (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL,
+ ip TEXT NOT NULL,
+ user_agent TEXT NOT NULL,
+ first_seen_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL,
+ hit_count INTEGER NOT NULL DEFAULT 1,
+ UNIQUE(username, ip, user_agent)
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_user_activity_username
+ ON user_activity (username)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen
+ ON user_activity (last_seen_at)
+ """
+ )
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
+ except sqlite3.OperationalError:
+ pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError:
@@ -115,6 +544,141 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError:
pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
+ ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
+ ON portal_items (related_item_id, updated_at DESC, id DESC)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_profile_id
+ ON users (profile_id)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_expires_at
+ ON users (expires_at)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_username_nocase
+ ON users (username COLLATE NOCASE)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_users_email_nocase
+ ON users (email COLLATE NOCASE)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
+ ON requests_cache (requested_by_id)
+ """
+ )
+ except sqlite3.OperationalError:
+ pass
+ try:
+ conn.execute("PRAGMA optimize")
+ except sqlite3.OperationalError:
+ pass
_backfill_auth_providers()
ensure_admin_user()
@@ -210,7 +774,7 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
def ensure_admin_user() -> None:
- if not settings.admin_username or not settings.admin_password:
+ if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
return
existing = get_user_by_username(settings.admin_username)
if existing:
@@ -218,42 +782,146 @@ def ensure_admin_user() -> None:
create_user(settings.admin_username, settings.admin_password, role="admin")
-def create_user(username: str, password: str, role: str = "user", auth_provider: str = "local") -> None:
+def has_admin_user() -> bool:
+ with _connect() as conn:
+ row = conn.execute(
+ "SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
+ ).fetchone()
+ return bool(row)
+
+
+def create_user(
+ username: str,
+ password: str,
+ role: str = "user",
+ email: Optional[str] = None,
+ auth_provider: str = "local",
+ jellyseerr_user_id: Optional[int] = None,
+ auto_search_enabled: bool = True,
+ invite_management_enabled: bool = False,
+ profile_id: Optional[int] = None,
+ expires_at: Optional[str] = None,
+ invited_by_code: Optional[str] = None,
+) -> None:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
+ normalized_email = _normalize_stored_email(email)
with _connect() as conn:
conn.execute(
"""
- INSERT INTO users (username, password_hash, role, auth_provider, created_at)
- VALUES (?, ?, ?, ?, ?)
+ 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, password_hash, role, auth_provider, created_at),
+ (
+ 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", auth_provider: str = "local"
+ username: str,
+ password: str,
+ role: str = "user",
+ email: Optional[str] = None,
+ auth_provider: str = "local",
+ jellyseerr_user_id: Optional[int] = None,
+ auto_search_enabled: bool = True,
+ invite_management_enabled: bool = False,
+ profile_id: Optional[int] = None,
+ expires_at: Optional[str] = None,
+ invited_by_code: Optional[str] = None,
) -> bool:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
+ normalized_email = _normalize_stored_email(email)
with _connect() as conn:
cursor = conn.execute(
"""
- INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, created_at)
- VALUES (?, ?, ?, ?, ?)
+ 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, password_hash, role, auth_provider, created_at),
+ (
+ 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,
+ ),
)
- return cursor.rowcount > 0
+ 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, password_hash, role, auth_provider, created_at, last_login_at, is_blocked
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
FROM users
- WHERE username = ?
+ WHERE username = ? COLLATE NOCASE
""",
(username,),
).fetchone()
@@ -262,46 +930,225 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
return {
"id": row[0],
"username": row[1],
- "password_hash": row[2],
- "role": row[3],
- "auth_provider": row[4],
- "created_at": row[5],
- "last_login_at": row[6],
- "is_blocked": bool(row[7]),
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
}
+def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE jellyseerr_user_id = ?
+ ORDER BY id ASC
+ LIMIT 1
+ """,
+ (jellyseerr_user_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+
+
+def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE id = ?
+ """,
+ (user_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
- SELECT id, username, role, auth_provider, created_at, last_login_at, is_blocked
+ SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at,
+ last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
+ profile_id, expires_at, invited_by_code, invited_at
FROM users
ORDER BY username COLLATE NOCASE
"""
).fetchall()
- results: list[Dict[str, Any]] = []
+ all_rows: list[Dict[str, Any]] = []
for row in rows:
- results.append(
+ all_rows.append(
{
"id": row[0],
"username": row[1],
- "role": row[2],
- "auth_provider": row[3],
- "created_at": row[4],
- "last_login_at": row[5],
- "is_blocked": bool(row[6]),
+ "email": row[2],
+ "role": row[3],
+ "auth_provider": row[4],
+ "jellyseerr_user_id": row[5],
+ "created_at": row[6],
+ "last_login_at": row[7],
+ "is_blocked": bool(row[8]),
+ "auto_search_enabled": bool(row[9]),
+ "invite_management_enabled": bool(row[10]),
+ "profile_id": row[11],
+ "expires_at": row[12],
+ "invited_by_code": row[13],
+ "invited_at": row[14],
+ "is_expired": _is_datetime_in_past(row[12]),
}
)
+ # Admin user management uses Jellyfin as the source of truth for non-admin
+ # user objects. Seerr rows are treated as enrichment-only and hidden
+ # from admin/user-management views to avoid duplicate accounts in the UI.
+ def _provider_rank(user: Dict[str, Any]) -> int:
+ provider = str(user.get("auth_provider") or "local").strip().lower()
+ if provider == "jellyfin":
+ return 0
+ if provider == "local":
+ return 1
+ if provider == "jellyseerr":
+ return 2
+ return 2
+
+ visible_candidates = [
+ user
+ for user in all_rows
+ if not (
+ str(user.get("auth_provider") or "local").strip().lower() == "jellyseerr"
+ and str(user.get("role") or "user").strip().lower() != "admin"
+ )
+ ]
+
+ visible_candidates.sort(
+ key=lambda user: (
+ 0 if str(user.get("role") or "user").strip().lower() == "admin" else 1,
+ 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
+ _provider_rank(user),
+ 0 if user.get("last_login_at") else 1,
+ int(user.get("id") or 0),
+ )
+ )
+ seen_usernames: set[str] = set()
+ seen_jellyseerr_ids: set[int] = set()
+ results: list[Dict[str, Any]] = []
+ for user in visible_candidates:
+ username = str(user.get("username") or "").strip()
+ if not username:
+ continue
+ username_key = username.lower()
+ jellyseerr_user_id = user.get("jellyseerr_user_id")
+ if isinstance(jellyseerr_user_id, int) and jellyseerr_user_id in seen_jellyseerr_ids:
+ continue
+ if username_key in seen_usernames:
+ continue
+ results.append(user)
+ seen_usernames.add(username_key)
+ if isinstance(jellyseerr_user_id, int):
+ seen_jellyseerr_ids.add(jellyseerr_user_id)
+ results.sort(key=lambda user: str(user.get("username") or "").lower())
return results
+def delete_non_admin_users() -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM users WHERE role != 'admin'
+ """
+ )
+ return cursor.rowcount
+
+
+def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (jellyseerr_user_id, username),
+ )
+
+
+def set_user_auth_provider(username: str, auth_provider: str) -> None:
+ provider = (auth_provider or "local").strip().lower() or "local"
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET auth_provider = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (provider, username),
+ )
+
+
def set_last_login(username: str) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
- UPDATE users SET last_login_at = ? WHERE username = ?
+ UPDATE users SET last_login_at = ? WHERE username = ? COLLATE NOCASE
""",
(timestamp, username),
)
@@ -315,25 +1162,562 @@ def set_user_blocked(username: str, blocked: bool) -> None:
""",
(1 if blocked else 0, username),
)
+ logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
+
+
+def delete_user_by_username(username: str) -> bool:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM users WHERE username = ? COLLATE NOCASE
+ """,
+ (username,),
+ )
+ deleted = cursor.rowcount > 0
+ logger.warning("user delete username=%s deleted=%s", username, deleted)
+ return deleted
+
+
+def delete_user_activity_by_username(username: str) -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM user_activity WHERE username = ? COLLATE NOCASE
+ """,
+ (username,),
+ )
+ return cursor.rowcount
+
+
+def disable_signup_invites_by_creator(username: str) -> int:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET enabled = 0, updated_at = ?
+ WHERE created_by = ? COLLATE NOCASE AND enabled != 0
+ """,
+ (timestamp, username),
+ )
+ return cursor.rowcount
def set_user_role(username: str, role: str) -> None:
with _connect() as conn:
conn.execute(
"""
- UPDATE users SET role = ? WHERE username = ?
+ UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE
""",
(role, username),
)
+ logger.info("user role updated username=%s role=%s", username, role)
+
+
+def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET auto_search_enabled = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (1 if enabled else 0, username),
+ )
+ logger.info("user auto-search updated username=%s enabled=%s", username, enabled)
+
+
+def set_user_invite_management_enabled(username: str, enabled: bool) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET invite_management_enabled = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (1 if enabled else 0, username),
+ )
+ logger.info("user invite-management updated username=%s enabled=%s", username, enabled)
+
+
+def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE users SET auto_search_enabled = ? WHERE role != 'admin'
+ """,
+ (1 if enabled else 0,),
+ )
+ return cursor.rowcount
+
+
+def set_invite_management_enabled_for_non_admin_users(enabled: bool) -> int:
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE users SET invite_management_enabled = ? WHERE role != 'admin'
+ """,
+ (1 if enabled else 0,),
+ )
+ logger.info(
+ "bulk invite-management updated non_admin_users=%s enabled=%s",
+ cursor.rowcount,
+ enabled,
+ )
+ return cursor.rowcount
+
+
+def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET profile_id = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (profile_id, username),
+ )
+ logger.info("user profile assignment updated username=%s profile_id=%s", username, profile_id)
+
+
+def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users SET expires_at = ? WHERE username = ? COLLATE NOCASE
+ """,
+ (expires_at, username),
+ )
+ logger.info("user expiry updated username=%s expires_at=%s", username, expires_at)
+
+
+def _row_to_user_profile(row: Any) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "name": row[1],
+ "description": row[2],
+ "role": row[3],
+ "auto_search_enabled": bool(row[4]),
+ "account_expires_days": row[5],
+ "is_active": bool(row[6]),
+ "created_at": row[7],
+ "updated_at": row[8],
+ }
+
+
+def list_user_profiles() -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
+ FROM user_profiles
+ ORDER BY name COLLATE NOCASE
+ """
+ ).fetchall()
+ return [_row_to_user_profile(row) for row in rows]
+
+
+def get_user_profile(profile_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
+ FROM user_profiles
+ WHERE id = ?
+ """,
+ (profile_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_user_profile(row)
+
+
+def create_user_profile(
+ name: str,
+ description: Optional[str] = None,
+ role: str = "user",
+ auto_search_enabled: bool = True,
+ account_expires_days: Optional[int] = None,
+ is_active: bool = True,
+) -> Dict[str, Any]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO user_profiles (
+ name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ name,
+ description,
+ role,
+ 1 if auto_search_enabled else 0,
+ account_expires_days,
+ 1 if is_active else 0,
+ timestamp,
+ timestamp,
+ ),
+ )
+ profile_id = int(cursor.lastrowid)
+ profile = get_user_profile(profile_id)
+ if not profile:
+ raise RuntimeError("Profile creation failed")
+ return profile
+
+
+def update_user_profile(
+ profile_id: int,
+ *,
+ name: str,
+ description: Optional[str],
+ role: str,
+ auto_search_enabled: bool,
+ account_expires_days: Optional[int],
+ is_active: bool,
+) -> Optional[Dict[str, Any]]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE user_profiles
+ SET name = ?, description = ?, role = ?, auto_search_enabled = ?,
+ account_expires_days = ?, is_active = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ name,
+ description,
+ role,
+ 1 if auto_search_enabled else 0,
+ account_expires_days,
+ 1 if is_active else 0,
+ timestamp,
+ profile_id,
+ ),
+ )
+ if cursor.rowcount <= 0:
+ return None
+ return get_user_profile(profile_id)
+
+
+def delete_user_profile(profile_id: int) -> bool:
+ with _connect() as conn:
+ users_count = conn.execute(
+ "SELECT COUNT(*) FROM users WHERE profile_id = ?",
+ (profile_id,),
+ ).fetchone()
+ invites_count = conn.execute(
+ "SELECT COUNT(*) FROM signup_invites WHERE profile_id = ?",
+ (profile_id,),
+ ).fetchone()
+ if int((users_count or [0])[0] or 0) > 0:
+ raise ValueError("Profile is assigned to existing users.")
+ if int((invites_count or [0])[0] or 0) > 0:
+ raise ValueError("Profile is assigned to existing invites.")
+ cursor = conn.execute(
+ "DELETE FROM user_profiles WHERE id = ?",
+ (profile_id,),
+ )
+ return cursor.rowcount > 0
+
+
+def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
+ max_uses = row[6]
+ use_count = int(row[7] or 0)
+ expires_at = row[9]
+ is_expired = _is_datetime_in_past(expires_at)
+ remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
+ return {
+ "id": row[0],
+ "code": row[1],
+ "label": row[2],
+ "description": row[3],
+ "profile_id": row[4],
+ "role": row[5],
+ "max_uses": max_uses,
+ "use_count": use_count,
+ "enabled": bool(row[8]),
+ "expires_at": expires_at,
+ "recipient_email": row[10],
+ "created_by": row[11],
+ "created_at": row[12],
+ "updated_at": row[13],
+ "is_expired": is_expired,
+ "remaining_uses": remaining_uses,
+ "is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
+ }
+
+
+def list_signup_invites() -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ FROM signup_invites
+ ORDER BY created_at DESC, id DESC
+ """
+ ).fetchall()
+ return [_row_to_signup_invite(row) for row in rows]
+
+
+def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ FROM signup_invites
+ WHERE id = ?
+ """,
+ (invite_id,),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_signup_invite(row)
+
+
+def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ FROM signup_invites
+ WHERE code = ? COLLATE NOCASE
+ """,
+ (code,),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_signup_invite(row)
+
+
+def create_signup_invite(
+ *,
+ code: str,
+ label: Optional[str] = None,
+ description: Optional[str] = None,
+ profile_id: Optional[int] = None,
+ role: Optional[str] = None,
+ max_uses: Optional[int] = None,
+ enabled: bool = True,
+ expires_at: Optional[str] = None,
+ recipient_email: Optional[str] = None,
+ created_by: Optional[str] = None,
+) -> Dict[str, Any]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO signup_invites (
+ code, label, description, profile_id, role, max_uses, use_count, enabled,
+ expires_at, recipient_email, created_by, created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ code,
+ label,
+ description,
+ profile_id,
+ role,
+ max_uses,
+ 1 if enabled else 0,
+ expires_at,
+ recipient_email,
+ created_by,
+ timestamp,
+ timestamp,
+ ),
+ )
+ invite_id = int(cursor.lastrowid)
+ logger.info(
+ "signup invite created invite_id=%s code=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s recipient_email=%s created_by=%s",
+ invite_id,
+ code,
+ role,
+ profile_id,
+ max_uses,
+ enabled,
+ expires_at,
+ recipient_email,
+ created_by,
+ )
+ invite = get_signup_invite_by_id(invite_id)
+ if not invite:
+ raise RuntimeError("Invite creation failed")
+ return invite
+
+
+def update_signup_invite(
+ invite_id: int,
+ *,
+ code: str,
+ label: Optional[str],
+ description: Optional[str],
+ profile_id: Optional[int],
+ role: Optional[str],
+ max_uses: Optional[int],
+ enabled: bool,
+ expires_at: Optional[str],
+ recipient_email: Optional[str],
+) -> Optional[Dict[str, Any]]:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE signup_invites
+ SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
+ enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ code,
+ label,
+ description,
+ profile_id,
+ role,
+ max_uses,
+ 1 if enabled else 0,
+ expires_at,
+ recipient_email,
+ timestamp,
+ invite_id,
+ ),
+ )
+ if cursor.rowcount <= 0:
+ return None
+ return get_signup_invite_by_id(invite_id)
+
+
+def delete_signup_invite(invite_id: int) -> bool:
+ with _connect() as conn:
+ cursor = conn.execute(
+ "DELETE FROM signup_invites WHERE id = ?",
+ (invite_id,),
+ )
+ return cursor.rowcount > 0
+
+
+def increment_signup_invite_use(invite_id: int) -> None:
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE signup_invites
+ SET use_count = use_count + 1, updated_at = ?
+ WHERE id = ?
+ """,
+ (timestamp, invite_id),
+ )
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
- user = get_user_by_username(username)
- if not user:
+ # Resolve case-insensitive duplicates safely by only considering local-provider rows.
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ ORDER BY
+ CASE WHEN username = ? THEN 0 ELSE 1 END,
+ id ASC
+ """,
+ (username, username),
+ ).fetchall()
+ if not rows:
return None
- if not verify_password(password, user["password_hash"]):
- return None
- return user
+ for row in rows:
+ provider = str(row[4] or "local").lower()
+ if provider != "local":
+ continue
+ if not verify_password(password, row[2]):
+ continue
+ return {
+ "id": row[0],
+ "username": row[1],
+ "password_hash": row[2],
+ "role": row[3],
+ "auth_provider": row[4],
+ "jellyseerr_user_id": row[5],
+ "created_at": row[6],
+ "last_login_at": row[7],
+ "is_blocked": bool(row[8]),
+ "auto_search_enabled": bool(row[9]),
+ "invite_management_enabled": bool(row[10]),
+ "profile_id": row[11],
+ "expires_at": row[12],
+ "invited_by_code": row[13],
+ "invited_at": row[14],
+ "is_expired": _is_datetime_in_past(row[12]),
+ "jellyfin_password_hash": row[15],
+ "last_jellyfin_auth_at": row[16],
+ }
+ return None
+
+
+def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
+ with _connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
+ created_at, last_login_at, is_blocked, auto_search_enabled,
+ invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
+ jellyfin_password_hash, last_jellyfin_auth_at
+ FROM users
+ WHERE username = ? COLLATE NOCASE
+ ORDER BY
+ CASE WHEN username = ? THEN 0 ELSE 1 END,
+ id ASC
+ """,
+ (username, username),
+ ).fetchall()
+ results: list[Dict[str, Any]] = []
+ for row in rows:
+ results.append(
+ {
+ "id": row[0],
+ "username": row[1],
+ "email": row[2],
+ "password_hash": row[3],
+ "role": row[4],
+ "auth_provider": row[5],
+ "jellyseerr_user_id": row[6],
+ "created_at": row[7],
+ "last_login_at": row[8],
+ "is_blocked": bool(row[9]),
+ "auto_search_enabled": bool(row[10]),
+ "invite_management_enabled": bool(row[11]),
+ "profile_id": row[12],
+ "expires_at": row[13],
+ "invited_by_code": row[14],
+ "invited_at": row[15],
+ "is_expired": _is_datetime_in_past(row[13]),
+ "jellyfin_password_hash": row[16],
+ "last_jellyfin_auth_at": row[17],
+ }
+ )
+ return results
+
+
+def set_user_email(username: str, email: Optional[str]) -> bool:
+ normalized_email = _normalize_stored_email(email)
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ UPDATE users
+ SET email = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (normalized_email, username),
+ )
+ updated = cursor.rowcount > 0
+ if updated:
+ logger.info("user email updated username=%s email=%s", username, normalized_email)
+ else:
+ logger.debug("user email update skipped username=%s", username)
+ return updated
def set_user_password(username: str, password: str) -> None:
@@ -341,12 +1725,46 @@ def set_user_password(username: str, password: str) -> None:
with _connect() as conn:
conn.execute(
"""
- UPDATE users SET password_hash = ? WHERE username = ?
+ UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE
""",
(password_hash, username),
)
+def sync_jellyfin_password_state(username: str, password: str) -> None:
+ if not username or not password:
+ return
+ password_hash = hash_password(password)
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET password_hash = ?,
+ jellyfin_password_hash = ?,
+ last_jellyfin_auth_at = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (password_hash, password_hash, timestamp, username),
+ )
+
+
+def set_jellyfin_auth_cache(username: str, password: str) -> None:
+ if not username or not password:
+ return
+ password_hash = hash_password(password)
+ timestamp = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET jellyfin_password_hash = ?, last_jellyfin_auth_at = ?
+ WHERE username = ? COLLATE NOCASE
+ """,
+ (password_hash, timestamp, username),
+ )
+
+
def _backfill_auth_providers() -> None:
with _connect() as conn:
rows = conn.execute(
@@ -377,6 +1795,304 @@ def _backfill_auth_providers() -> None:
)
+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],
@@ -386,53 +2102,33 @@ def upsert_request_cache(
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:
- conn.execute(
- """
- INSERT INTO requests_cache (
- request_id,
- media_id,
- media_type,
- status,
- title,
- year,
- requested_by,
- requested_by_norm,
- 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,
- created_at = excluded.created_at,
- updated_at = excluded.updated_at,
- payload_json = excluded.payload_json
- """,
- (
- request_id,
- media_id,
- media_type,
- status,
- title,
- year,
- requested_by,
- requested_by_norm,
- created_at,
- updated_at,
- payload_json,
- ),
+ 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,
@@ -442,6 +2138,17 @@ def upsert_request_cache(
)
+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(
@@ -497,20 +2204,30 @@ 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, created_at, payload_json
+ 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_norm:
+ 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 ?"
@@ -518,32 +2235,24 @@ def get_cached_requests(
with _connect() as conn:
rows = conn.execute(query, tuple(params)).fetchall()
logger.debug(
- "requests_cache list: count=%s requested_by_norm=%s since_iso=%s",
+ "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]
- if (not title or not year) and row[8]:
- try:
- payload = json.loads(row[8])
- if isinstance(payload, dict):
- media = payload.get("media") or {}
- if not title:
- title = (
- (media.get("title") if isinstance(media, dict) else None)
- or (media.get("name") if isinstance(media, dict) else None)
- or payload.get("title")
- or payload.get("name")
- )
- if not year:
- year = media.get("year") if isinstance(media, dict) else None
- year = year or payload.get("year")
- except json.JSONDecodeError:
- pass
+ 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],
@@ -553,18 +2262,52 @@ def get_cached_requests(
"title": title,
"year": year,
"requested_by": row[6],
- "created_at": row[7],
+ "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, created_at, updated_at, payload_json
+ 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 ?
@@ -574,19 +2317,9 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
results: list[Dict[str, Any]] = []
for row in rows:
title = row[4]
- if not title and row[9]:
- try:
- payload = json.loads(row[9])
- if isinstance(payload, dict):
- media = payload.get("media") or {}
- title = (
- (media.get("title") if isinstance(media, dict) else None)
- or (media.get("name") if isinstance(media, dict) else None)
- or payload.get("title")
- or payload.get("name")
- )
- except json.JSONDecodeError:
- 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],
@@ -596,8 +2329,38 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
"title": title,
"year": row[5],
"requested_by": row[6],
- "created_at": row[7],
- "updated_at": row[8],
+ "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
@@ -609,6 +2372,193 @@ def get_request_cache_count() -> int:
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(
@@ -651,11 +2601,45 @@ def get_request_cache_payloads(limit: int = 200, offset: int = 0) -> list[Dict[s
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, created_at
+ SELECT request_id, media_id, media_type, status, title, year, requested_by,
+ requested_by_norm, requested_by_id, created_at
FROM requests_cache
WHERE created_at >= ?
ORDER BY created_at DESC, request_id DESC
@@ -674,14 +2658,17 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
"year": row[5],
"requested_by": row[6],
"requested_by_norm": row[7],
- "created_at": row[8],
+ "requested_by_id": row[8],
+ "created_at": row[9],
}
)
return results
def get_cached_request_by_media_id(
- media_id: int, requested_by_norm: Optional[str] = None
+ media_id: int,
+ requested_by_norm: Optional[str] = None,
+ requested_by_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
query = """
SELECT request_id, status
@@ -689,7 +2676,10 @@ def get_cached_request_by_media_id(
WHERE media_id = ?
"""
params: list[Any] = [media_id]
- if requested_by_norm:
+ 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"
@@ -752,6 +2742,815 @@ def get_settings_overrides() -> Dict[str, str]:
return overrides
+def _hash_password_reset_token(token_value: str) -> str:
+ return sha256(str(token_value).encode("utf-8")).hexdigest()
+
+
+def _password_reset_token_row_to_dict(row: Any) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "token_hash": row[1],
+ "username": row[2],
+ "recipient_email": row[3],
+ "auth_provider": row[4],
+ "created_at": row[5],
+ "expires_at": row[6],
+ "used_at": row[7],
+ "requested_by_ip": row[8],
+ "requested_user_agent": row[9],
+ "is_expired": _is_datetime_in_past(row[6]),
+ "is_used": bool(row[7]),
+ }
+
+
+def delete_expired_password_reset_tokens() -> int:
+ now_iso = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM password_reset_tokens
+ WHERE expires_at <= ? OR used_at IS NOT NULL
+ """,
+ (now_iso,),
+ )
+ return int(cursor.rowcount or 0)
+
+
+def create_password_reset_token(
+ token_value: str,
+ username: str,
+ recipient_email: str,
+ auth_provider: str,
+ expires_at: str,
+ *,
+ requested_by_ip: Optional[str] = None,
+ requested_user_agent: Optional[str] = None,
+) -> Dict[str, Any]:
+ created_at = datetime.now(timezone.utc).isoformat()
+ token_hash = _hash_password_reset_token(token_value)
+ delete_expired_password_reset_tokens()
+ with _connect() as conn:
+ conn.execute(
+ """
+ DELETE FROM password_reset_tokens
+ WHERE username = ? AND used_at IS NULL
+ """,
+ (username,),
+ )
+ conn.execute(
+ """
+ INSERT INTO password_reset_tokens (
+ token_hash,
+ username,
+ recipient_email,
+ auth_provider,
+ created_at,
+ expires_at,
+ used_at,
+ requested_by_ip,
+ requested_user_agent
+ )
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
+ """,
+ (
+ token_hash,
+ username,
+ recipient_email,
+ auth_provider,
+ created_at,
+ expires_at,
+ requested_by_ip,
+ requested_user_agent,
+ ),
+ )
+ logger.info(
+ "password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s",
+ username,
+ auth_provider,
+ recipient_email,
+ expires_at,
+ requested_by_ip,
+ )
+ return {
+ "username": username,
+ "recipient_email": recipient_email,
+ "auth_provider": auth_provider,
+ "created_at": created_at,
+ "expires_at": expires_at,
+ "requested_by_ip": requested_by_ip,
+ "requested_user_agent": requested_user_agent,
+ }
+
+
+def get_password_reset_token(token_value: str) -> Optional[Dict[str, Any]]:
+ token_hash = _hash_password_reset_token(token_value)
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT id, token_hash, username, recipient_email, auth_provider, created_at,
+ expires_at, used_at, requested_by_ip, requested_user_agent
+ FROM password_reset_tokens
+ WHERE token_hash = ?
+ """,
+ (token_hash,),
+ ).fetchone()
+ if not row:
+ return None
+ return _password_reset_token_row_to_dict(row)
+
+
+def mark_password_reset_token_used(token_value: str) -> None:
+ token_hash = _hash_password_reset_token(token_value)
+ used_at = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ conn.execute(
+ """
+ UPDATE password_reset_tokens
+ SET used_at = ?
+ WHERE token_hash = ? AND used_at IS NULL
+ """,
+ (used_at, token_hash),
+ )
+ logger.info("password reset token marked used token_hash=%s", token_hash[:12])
+
+
+def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
+ if not media_type or not tmdb_id:
+ return None
+ normalized_media_type = str(media_type).strip().lower()
+ try:
+ normalized_tmdb_id = int(tmdb_id)
+ except (TypeError, ValueError):
+ return None
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT media_type, tmdb_id, status_code, error_message, failure_count,
+ first_failed_at, last_failed_at, suppress_until, is_persistent
+ FROM seerr_media_failures
+ WHERE media_type = ? AND tmdb_id = ?
+ """,
+ (normalized_media_type, normalized_tmdb_id),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "media_type": row[0],
+ "tmdb_id": row[1],
+ "status_code": row[2],
+ "error_message": row[3],
+ "failure_count": row[4],
+ "first_failed_at": row[5],
+ "last_failed_at": row[6],
+ "suppress_until": row[7],
+ "is_persistent": bool(row[8]),
+ }
+
+
+def is_seerr_media_failure_suppressed(media_type: Optional[str], tmdb_id: Optional[int]) -> bool:
+ record = get_seerr_media_failure(media_type, tmdb_id)
+ if not record:
+ return False
+ suppress_until = _parse_datetime_value(record.get("suppress_until"))
+ if suppress_until and suppress_until > datetime.now(timezone.utc):
+ return True
+ clear_seerr_media_failure(media_type, tmdb_id)
+ return False
+
+
+def record_seerr_media_failure(
+ media_type: Optional[str],
+ tmdb_id: Optional[int],
+ *,
+ status_code: Optional[int] = None,
+ error_message: Optional[str] = None,
+) -> Dict[str, Any]:
+ if not media_type or not tmdb_id:
+ return {}
+ normalized_media_type = str(media_type).strip().lower()
+ normalized_tmdb_id = int(tmdb_id)
+ now = datetime.now(timezone.utc)
+ existing = get_seerr_media_failure(normalized_media_type, normalized_tmdb_id)
+ failure_count = int(existing.get("failure_count", 0)) + 1 if existing else 1
+ is_persistent = failure_count >= SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD
+ if is_persistent:
+ suppress_until = now + timedelta(days=SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS)
+ elif failure_count >= 2:
+ suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS)
+ else:
+ suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS)
+ payload = {
+ "media_type": normalized_media_type,
+ "tmdb_id": normalized_tmdb_id,
+ "status_code": status_code,
+ "error_message": error_message,
+ "failure_count": failure_count,
+ "first_failed_at": existing.get("first_failed_at") if existing else now.isoformat(),
+ "last_failed_at": now.isoformat(),
+ "suppress_until": suppress_until.isoformat(),
+ "is_persistent": is_persistent,
+ }
+ with _connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO seerr_media_failures (
+ media_type,
+ tmdb_id,
+ status_code,
+ error_message,
+ failure_count,
+ first_failed_at,
+ last_failed_at,
+ suppress_until,
+ is_persistent
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(media_type, tmdb_id) DO UPDATE SET
+ status_code = excluded.status_code,
+ error_message = excluded.error_message,
+ failure_count = excluded.failure_count,
+ first_failed_at = excluded.first_failed_at,
+ last_failed_at = excluded.last_failed_at,
+ suppress_until = excluded.suppress_until,
+ is_persistent = excluded.is_persistent
+ """,
+ (
+ payload["media_type"],
+ payload["tmdb_id"],
+ payload["status_code"],
+ payload["error_message"],
+ payload["failure_count"],
+ payload["first_failed_at"],
+ payload["last_failed_at"],
+ payload["suppress_until"],
+ 1 if payload["is_persistent"] else 0,
+ ),
+ )
+ logger.warning(
+ "seerr_media_failure upsert: media_type=%s tmdb_id=%s status=%s failure_count=%s suppress_until=%s persistent=%s",
+ payload["media_type"],
+ payload["tmdb_id"],
+ payload["status_code"],
+ payload["failure_count"],
+ payload["suppress_until"],
+ payload["is_persistent"],
+ )
+ return payload
+
+
+def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> None:
+ if not media_type or not tmdb_id:
+ return
+ normalized_media_type = str(media_type).strip().lower()
+ try:
+ normalized_tmdb_id = int(tmdb_id)
+ except (TypeError, ValueError):
+ return
+ with _connect() as conn:
+ deleted = conn.execute(
+ """
+ DELETE FROM seerr_media_failures
+ WHERE media_type = ? AND tmdb_id = ?
+ """,
+ (normalized_media_type, normalized_tmdb_id),
+ ).rowcount
+ if deleted:
+ logger.info(
+ "seerr_media_failure cleared: media_type=%s tmdb_id=%s",
+ normalized_media_type,
+ normalized_tmdb_id,
+ )
+
+
+def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "kind": row[1],
+ "title": row[2],
+ "description": row[3],
+ "media_type": row[4],
+ "year": row[5],
+ "external_ref": row[6],
+ "source_system": row[7],
+ "source_request_id": row[8],
+ "related_item_id": row[9],
+ "status": row[10],
+ "workflow_request_status": row[11],
+ "workflow_media_status": row[12],
+ "issue_type": row[13],
+ "issue_resolved_at": row[14],
+ "metadata_json": row[15],
+ "priority": row[16],
+ "created_by_username": row[17],
+ "created_by_id": row[18],
+ "assignee_username": row[19],
+ "created_at": row[20],
+ "updated_at": row[21],
+ "last_activity_at": row[22],
+ }
+
+
+def _portal_comment_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
+ return {
+ "id": row[0],
+ "item_id": row[1],
+ "author_username": row[2],
+ "author_role": row[3],
+ "message": row[4],
+ "is_internal": bool(row[5]),
+ "created_at": row[6],
+ }
+
+
+def create_portal_item(
+ *,
+ kind: str,
+ title: str,
+ description: str,
+ created_by_username: str,
+ created_by_id: Optional[int],
+ media_type: Optional[str] = None,
+ year: Optional[int] = None,
+ external_ref: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ status: str = "new",
+ workflow_request_status: Optional[str] = None,
+ workflow_media_status: Optional[str] = None,
+ issue_type: Optional[str] = None,
+ issue_resolved_at: Optional[str] = None,
+ metadata_json: Optional[str] = None,
+ priority: str = "normal",
+ assignee_username: Optional[str] = None,
+) -> Dict[str, Any]:
+ now = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO portal_items (
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ created_at,
+ updated_at,
+ last_activity_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ now,
+ now,
+ now,
+ ),
+ )
+ item_id = cursor.lastrowid
+ created = get_portal_item(item_id)
+ if not created:
+ raise RuntimeError("Portal item could not be loaded after insert.")
+ logger.info(
+ "portal item created id=%s kind=%s status=%s priority=%s created_by=%s",
+ created["id"],
+ created["kind"],
+ created["status"],
+ created["priority"],
+ created["created_by_username"],
+ )
+ return created
+
+
+def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
+ with _connect() as conn:
+ row = conn.execute(
+ """
+ SELECT
+ id,
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ created_at,
+ updated_at,
+ last_activity_at
+ FROM portal_items
+ WHERE id = ?
+ """,
+ (item_id,),
+ ).fetchone()
+ return _portal_item_from_row(row) if row else None
+
+
+def list_portal_items(
+ *,
+ kind: Optional[str] = None,
+ status: Optional[str] = None,
+ workflow_request_status: Optional[str] = None,
+ workflow_media_status: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ mine_username: Optional[str] = None,
+ search: Optional[str] = None,
+ limit: int = 100,
+ offset: int = 0,
+) -> list[Dict[str, Any]]:
+ clauses: list[str] = []
+ params: list[Any] = []
+ if isinstance(kind, str) and kind.strip():
+ clauses.append("kind = ?")
+ params.append(kind.strip().lower())
+ if isinstance(status, str) and status.strip():
+ clauses.append("status = ?")
+ params.append(status.strip().lower())
+ if isinstance(workflow_request_status, str) and workflow_request_status.strip():
+ clauses.append("workflow_request_status = ?")
+ params.append(workflow_request_status.strip().lower())
+ if isinstance(workflow_media_status, str) and workflow_media_status.strip():
+ clauses.append("workflow_media_status = ?")
+ params.append(workflow_media_status.strip().lower())
+ if isinstance(source_system, str) and source_system.strip():
+ clauses.append("source_system = ?")
+ params.append(source_system.strip().lower())
+ if isinstance(source_request_id, int):
+ clauses.append("source_request_id = ?")
+ params.append(source_request_id)
+ if isinstance(related_item_id, int):
+ clauses.append("related_item_id = ?")
+ params.append(related_item_id)
+ if isinstance(mine_username, str) and mine_username.strip():
+ clauses.append("created_by_username = ?")
+ params.append(mine_username.strip())
+ if isinstance(search, str) and search.strip():
+ token = f"%{search.strip().lower()}%"
+ clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
+ params.extend([token, token, search.strip()])
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ safe_limit = max(1, min(int(limit), 500))
+ safe_offset = max(0, int(offset))
+ params.extend([safe_limit, safe_offset])
+ with _connect() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT
+ id,
+ kind,
+ title,
+ description,
+ media_type,
+ year,
+ external_ref,
+ source_system,
+ source_request_id,
+ related_item_id,
+ status,
+ workflow_request_status,
+ workflow_media_status,
+ issue_type,
+ issue_resolved_at,
+ metadata_json,
+ priority,
+ created_by_username,
+ created_by_id,
+ assignee_username,
+ created_at,
+ updated_at,
+ last_activity_at
+ FROM portal_items
+ {where_sql}
+ ORDER BY last_activity_at DESC, id DESC
+ LIMIT ? OFFSET ?
+ """,
+ tuple(params),
+ ).fetchall()
+ return [_portal_item_from_row(row) for row in rows]
+
+
+def count_portal_items(
+ *,
+ kind: Optional[str] = None,
+ status: Optional[str] = None,
+ workflow_request_status: Optional[str] = None,
+ workflow_media_status: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ mine_username: Optional[str] = None,
+ search: Optional[str] = None,
+) -> int:
+ clauses: list[str] = []
+ params: list[Any] = []
+ if isinstance(kind, str) and kind.strip():
+ clauses.append("kind = ?")
+ params.append(kind.strip().lower())
+ if isinstance(status, str) and status.strip():
+ clauses.append("status = ?")
+ params.append(status.strip().lower())
+ if isinstance(workflow_request_status, str) and workflow_request_status.strip():
+ clauses.append("workflow_request_status = ?")
+ params.append(workflow_request_status.strip().lower())
+ if isinstance(workflow_media_status, str) and workflow_media_status.strip():
+ clauses.append("workflow_media_status = ?")
+ params.append(workflow_media_status.strip().lower())
+ if isinstance(source_system, str) and source_system.strip():
+ clauses.append("source_system = ?")
+ params.append(source_system.strip().lower())
+ if isinstance(source_request_id, int):
+ clauses.append("source_request_id = ?")
+ params.append(source_request_id)
+ if isinstance(related_item_id, int):
+ clauses.append("related_item_id = ?")
+ params.append(related_item_id)
+ if isinstance(mine_username, str) and mine_username.strip():
+ clauses.append("created_by_username = ?")
+ params.append(mine_username.strip())
+ if isinstance(search, str) and search.strip():
+ token = f"%{search.strip().lower()}%"
+ clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
+ params.extend([token, token, search.strip()])
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ with _connect() as conn:
+ row = conn.execute(
+ f"SELECT COUNT(*) FROM portal_items {where_sql}",
+ tuple(params),
+ ).fetchone()
+ return int(row[0] or 0) if row else 0
+
+
+def update_portal_item(
+ item_id: int,
+ *,
+ title: Any = _DB_UNSET,
+ description: Any = _DB_UNSET,
+ status: Any = _DB_UNSET,
+ priority: Any = _DB_UNSET,
+ assignee_username: Any = _DB_UNSET,
+ media_type: Any = _DB_UNSET,
+ year: Any = _DB_UNSET,
+ external_ref: Any = _DB_UNSET,
+ source_system: Any = _DB_UNSET,
+ source_request_id: Any = _DB_UNSET,
+ related_item_id: Any = _DB_UNSET,
+ workflow_request_status: Any = _DB_UNSET,
+ workflow_media_status: Any = _DB_UNSET,
+ issue_type: Any = _DB_UNSET,
+ issue_resolved_at: Any = _DB_UNSET,
+ metadata_json: Any = _DB_UNSET,
+) -> Optional[Dict[str, Any]]:
+ updates: list[str] = []
+ params: list[Any] = []
+ if title is not _DB_UNSET:
+ updates.append("title = ?")
+ params.append(title)
+ if description is not _DB_UNSET:
+ updates.append("description = ?")
+ params.append(description)
+ if status is not _DB_UNSET:
+ updates.append("status = ?")
+ params.append(status)
+ if priority is not _DB_UNSET:
+ updates.append("priority = ?")
+ params.append(priority)
+ if assignee_username is not _DB_UNSET:
+ updates.append("assignee_username = ?")
+ params.append(assignee_username)
+ if media_type is not _DB_UNSET:
+ updates.append("media_type = ?")
+ params.append(media_type)
+ if year is not _DB_UNSET:
+ updates.append("year = ?")
+ params.append(year)
+ if external_ref is not _DB_UNSET:
+ updates.append("external_ref = ?")
+ params.append(external_ref)
+ if source_system is not _DB_UNSET:
+ updates.append("source_system = ?")
+ params.append(source_system)
+ if source_request_id is not _DB_UNSET:
+ updates.append("source_request_id = ?")
+ params.append(source_request_id)
+ if related_item_id is not _DB_UNSET:
+ updates.append("related_item_id = ?")
+ params.append(related_item_id)
+ if workflow_request_status is not _DB_UNSET:
+ updates.append("workflow_request_status = ?")
+ params.append(workflow_request_status)
+ if workflow_media_status is not _DB_UNSET:
+ updates.append("workflow_media_status = ?")
+ params.append(workflow_media_status)
+ if issue_type is not _DB_UNSET:
+ updates.append("issue_type = ?")
+ params.append(issue_type)
+ if issue_resolved_at is not _DB_UNSET:
+ updates.append("issue_resolved_at = ?")
+ params.append(issue_resolved_at)
+ if metadata_json is not _DB_UNSET:
+ updates.append("metadata_json = ?")
+ params.append(metadata_json)
+ if not updates:
+ return get_portal_item(item_id)
+ now = datetime.now(timezone.utc).isoformat()
+ updates.append("updated_at = ?")
+ updates.append("last_activity_at = ?")
+ params.extend([now, now, item_id])
+ with _connect() as conn:
+ changed = conn.execute(
+ f"""
+ UPDATE portal_items
+ SET {', '.join(updates)}
+ WHERE id = ?
+ """,
+ tuple(params),
+ ).rowcount
+ if not changed:
+ return None
+ updated = get_portal_item(item_id)
+ if updated:
+ logger.info(
+ "portal item updated id=%s status=%s priority=%s assignee=%s",
+ updated["id"],
+ updated["status"],
+ updated["priority"],
+ updated["assignee_username"],
+ )
+ return updated
+
+
+def add_portal_comment(
+ item_id: int,
+ *,
+ author_username: str,
+ author_role: str,
+ message: str,
+ is_internal: bool = False,
+) -> Dict[str, Any]:
+ now = datetime.now(timezone.utc).isoformat()
+ with _connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO portal_comments (
+ item_id,
+ author_username,
+ author_role,
+ message,
+ is_internal,
+ created_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ item_id,
+ author_username,
+ author_role,
+ message,
+ 1 if is_internal else 0,
+ now,
+ ),
+ )
+ conn.execute(
+ """
+ UPDATE portal_items
+ SET last_activity_at = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (now, now, item_id),
+ )
+ comment_id = cursor.lastrowid
+ row = conn.execute(
+ """
+ SELECT id, item_id, author_username, author_role, message, is_internal, created_at
+ FROM portal_comments
+ WHERE id = ?
+ """,
+ (comment_id,),
+ ).fetchone()
+ if not row:
+ raise RuntimeError("Portal comment could not be loaded after insert.")
+ comment = _portal_comment_from_row(row)
+ logger.info(
+ "portal comment created id=%s item_id=%s author=%s internal=%s",
+ comment["id"],
+ comment["item_id"],
+ comment["author_username"],
+ comment["is_internal"],
+ )
+ return comment
+
+
+def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: int = 200) -> list[Dict[str, Any]]:
+ clauses = ["item_id = ?"]
+ params: list[Any] = [item_id]
+ if not include_internal:
+ clauses.append("is_internal = 0")
+ safe_limit = max(1, min(int(limit), 500))
+ params.append(safe_limit)
+ with _connect() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT id, item_id, author_username, author_role, message, is_internal, created_at
+ FROM portal_comments
+ WHERE {' AND '.join(clauses)}
+ ORDER BY created_at ASC, id ASC
+ LIMIT ?
+ """,
+ tuple(params),
+ ).fetchall()
+ return [_portal_comment_from_row(row) for row in rows]
+
+
+def get_portal_overview() -> Dict[str, Any]:
+ with _connect() as conn:
+ kind_rows = conn.execute(
+ """
+ SELECT kind, COUNT(*)
+ FROM portal_items
+ GROUP BY kind
+ """
+ ).fetchall()
+ status_rows = conn.execute(
+ """
+ SELECT status, COUNT(*)
+ FROM portal_items
+ GROUP BY status
+ """
+ ).fetchall()
+ request_workflow_rows = conn.execute(
+ """
+ SELECT
+ COALESCE(workflow_request_status, ''),
+ COALESCE(workflow_media_status, ''),
+ COUNT(*)
+ FROM portal_items
+ WHERE kind = 'request'
+ GROUP BY workflow_request_status, workflow_media_status
+ """
+ ).fetchall()
+ total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
+ total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
+ request_workflow: Dict[str, Dict[str, int]] = {}
+ for row in request_workflow_rows:
+ request_status = str(row[0] or "")
+ media_status = str(row[1] or "")
+ request_workflow.setdefault(request_status, {})
+ request_workflow[request_status][media_status] = int(row[2] or 0)
+ return {
+ "total_items": int(total_items_row[0] or 0) if total_items_row else 0,
+ "total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0,
+ "by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows},
+ "by_status": {str(row[0]): int(row[1] or 0) for row in status_rows},
+ "request_workflow": request_workflow,
+ }
+
+
def run_integrity_check() -> str:
with _connect() as conn:
row = conn.execute("PRAGMA integrity_check").fetchone()
@@ -760,6 +3559,75 @@ def run_integrity_check() -> str:
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")
@@ -778,6 +3646,29 @@ def clear_history() -> Dict[str, int]:
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}
diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py
index c928c48..89cef15 100644
--- a/backend/app/logging_config.py
+++ b/backend/app/logging_config.py
@@ -1,10 +1,148 @@
+import contextvars
+import json
import logging
import os
from logging.handlers import RotatingFileHandler
-from typing import Optional
+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
-def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
+class RequestContextFilter(logging.Filter):
+ def filter(self, record: logging.LogRecord) -> bool:
+ record.request_id = REQUEST_ID_CONTEXT.get("-")
+ return True
+
+
+def bind_request_id(request_id: str) -> contextvars.Token[str]:
+ return REQUEST_ID_CONTEXT.set(request_id or "-")
+
+
+def reset_request_id(token: contextvars.Token[str]) -> None:
+ REQUEST_ID_CONTEXT.reset(token)
+
+
+def current_request_id() -> str:
+ return REQUEST_ID_CONTEXT.get("-")
+
+
+def _is_sensitive_key(key: str) -> bool:
+ lowered = key.strip().lower()
+ return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
+
+
+def _redact_scalar(value: Any) -> Any:
+ if value is None or isinstance(value, (int, float, bool)):
+ return value
+ text = str(value)
+ if len(text) <= 4:
+ return "***"
+ return f"{text[:2]}***{text[-2:]}"
+
+
+def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
+ if key_hint and _is_sensitive_key(key_hint):
+ return _redact_scalar(value)
+ if value is None or isinstance(value, (bool, int, float)):
+ return value
+ if isinstance(value, bytes):
+ return f""
+ if isinstance(value, str):
+ return value if len(value) <= 512 else f"{value[:509]}..."
+ if depth >= 3:
+ return f"<{type(value).__name__}>"
+ if isinstance(value, Mapping):
+ return {
+ str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
+ for key, item in value.items()
+ }
+ if isinstance(value, (list, tuple, set)):
+ return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
+ if hasattr(value, "model_dump"):
+ try:
+ return sanitize_value(value.model_dump(), depth=depth + 1)
+ except Exception:
+ return f"<{type(value).__name__}>"
+ return str(value)
+
+
+def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ str(key).lower(): sanitize_value(value, key_hint=str(key))
+ for key, value in headers.items()
+ }
+
+
+def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
+ if not body:
+ return None
+ normalized = (content_type or "").split(";")[0].strip().lower()
+ if normalized == "application/json":
+ preview = body[:_MAX_BODY_BYTES]
+ try:
+ payload = json.loads(preview.decode("utf-8"))
+ summary = sanitize_value(payload)
+ if len(body) > _MAX_BODY_BYTES:
+ return {"truncated": True, "bytes": len(body), "payload": summary}
+ return summary
+ except Exception:
+ pass
+ if normalized == "application/x-www-form-urlencoded":
+ try:
+ parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
+ compact = {
+ key: value[0] if len(value) == 1 else value
+ for key, value in parsed.items()
+ }
+ return sanitize_value(compact)
+ except Exception:
+ pass
+ if normalized.startswith("multipart/"):
+ return {"content_type": normalized, "bytes": len(body)}
+ preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
+ return {
+ "content_type": normalized or "unknown",
+ "bytes": len(body),
+ "preview": preview if len(body) <= 256 else f"{preview}...",
+ }
+
+
+def _coerce_level(level_name: Optional[str], fallback: int) -> int:
+ if not level_name:
+ return fallback
+ return getattr(logging, str(level_name).upper(), fallback)
+
+
+def configure_logging(
+ log_level: Optional[str],
+ log_file: Optional[str],
+ *,
+ log_file_max_bytes: int = 20_000_000,
+ log_file_backup_count: int = 10,
+ log_http_client_level: Optional[str] = "INFO",
+ log_background_sync_level: Optional[str] = "INFO",
+) -> None:
level_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
@@ -18,15 +156,20 @@ def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None
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=2_000_000, backupCount=3, encoding="utf-8"
+ log_path,
+ maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
+ backupCount=max(1, int(log_file_backup_count or 10)),
+ encoding="utf-8",
)
handlers.append(file_handler)
+ context_filter = RequestContextFilter()
formatter = logging.Formatter(
- fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ 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()
@@ -38,4 +181,10 @@ def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None
logging.getLogger("uvicorn").setLevel(level)
logging.getLogger("uvicorn.error").setLevel(level)
- logging.getLogger("uvicorn.access").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
index 4d9a876..5db75d8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,10 +1,14 @@
import asyncio
+import logging
+import time
+import uuid
+from typing import Awaitable, Callable
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
-from .db import init_db
+from .db import has_admin_user, init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
@@ -13,16 +17,34 @@ from .routers.requests import (
run_daily_db_cleanup,
)
from .routers.auth import router as auth_router
-from .routers.admin import router as admin_router
+from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.images import router as images_router
from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
+from .routers.site import router as site_router
+from .routers.events import router as events_router
+from .routers.portal import router as portal_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
-from .logging_config import configure_logging
+from .logging_config import (
+ bind_request_id,
+ configure_logging,
+ reset_request_id,
+ sanitize_headers,
+ sanitize_value,
+ summarize_http_body,
+)
from .runtime import get_runtime_settings
-app = FastAPI(title=settings.app_name)
+logger = logging.getLogger(__name__)
+_background_tasks: list[asyncio.Task[None]] = []
+
+app = FastAPI(
+ title=settings.app_name,
+ docs_url="/docs" if settings.api_docs_enabled else None,
+ redoc_url=None,
+ openapi_url="/openapi.json" if settings.api_docs_enabled else None,
+)
app.add_middleware(
CORSMiddleware,
@@ -33,26 +55,192 @@ app.add_middleware(
)
+@app.middleware("http")
+async def log_requests_and_add_security_headers(request: Request, call_next):
+ request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
+ token = bind_request_id(request_id)
+ request.state.request_id = request_id
+ started_at = time.perf_counter()
+ body = await request.body()
+ body_summary = summarize_http_body(body, request.headers.get("content-type"))
+
+ async def receive() -> dict:
+ return {"type": "http.request", "body": body, "more_body": False}
+
+ request._receive = receive
+ logger.info(
+ "request started method=%s path=%s query=%s client=%s headers=%s body=%s",
+ request.method,
+ request.url.path,
+ sanitize_value(dict(request.query_params)),
+ request.client.host if request.client else "-",
+ sanitize_headers(
+ {
+ key: value
+ for key, value in request.headers.items()
+ if key.lower()
+ in {
+ "content-type",
+ "content-length",
+ "user-agent",
+ "x-forwarded-for",
+ "x-forwarded-proto",
+ "x-request-id",
+ }
+ }
+ ),
+ body_summary,
+ )
+ try:
+ response = await call_next(request)
+ except Exception:
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ logger.exception(
+ "request failed method=%s path=%s duration_ms=%s",
+ request.method,
+ request.url.path,
+ duration_ms,
+ )
+ reset_request_id(token)
+ raise
+
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ response.headers.setdefault("X-Request-ID", request_id)
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
+ response.headers.setdefault("X-Frame-Options", "DENY")
+ response.headers.setdefault("Referrer-Policy", "no-referrer")
+ response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+ # Keep API responses non-executable and non-embeddable by default.
+ if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
+ response.headers.setdefault(
+ "Content-Security-Policy",
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
+ )
+ logger.info(
+ "request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
+ request.method,
+ request.url.path,
+ response.status_code,
+ duration_ms,
+ sanitize_headers(
+ {
+ key: value
+ for key, value in response.headers.items()
+ if key.lower() in {"content-type", "content-length", "x-request-id"}
+ }
+ ),
+ )
+ reset_request_id(token)
+ return response
+
+
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
+
+async def _run_background_task(
+ name: str, coroutine_factory: Callable[[], Awaitable[None]]
+) -> None:
+ token = bind_request_id(f"task-{name}")
+ logger.info("background task started task=%s", name)
+ try:
+ await coroutine_factory()
+ logger.warning("background task exited task=%s", name)
+ except asyncio.CancelledError:
+ logger.info("background task cancelled task=%s", name)
+ raise
+ except Exception:
+ logger.exception("background task crashed task=%s", name)
+ raise
+ finally:
+ reset_request_id(token)
+
+
+def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
+ task = asyncio.create_task(
+ _run_background_task(name, coroutine_factory), name=f"magent:{name}"
+ )
+ _background_tasks.append(task)
+
+
+def _log_security_configuration_warnings() -> None:
+ jwt_secret = str(settings.jwt_secret or "").strip()
+ if not jwt_secret or jwt_secret == "change-me":
+ logger.warning(
+ "security configuration warning: JWT_SECRET is unset or still set to the default value"
+ )
+ admin_password = str(settings.admin_password or "")
+ if not admin_password or admin_password == "adminadmin":
+ logger.warning(
+ "security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
+ )
+ if bool(settings.api_docs_enabled):
+ logger.warning(
+ "security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
+ )
+
+
+def _enforce_secure_startup_configuration() -> None:
+ jwt_secret = str(settings.jwt_secret or "").strip()
+ if not jwt_secret or jwt_secret == "change-me":
+ raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
+ admin_password = str(settings.admin_password or "")
+ if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
+ raise RuntimeError(
+ "A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
+ )
+
+
@app.on_event("startup")
async def startup() -> None:
+ configure_logging(
+ settings.log_level,
+ settings.log_file,
+ log_file_max_bytes=settings.log_file_max_bytes,
+ log_file_backup_count=settings.log_file_backup_count,
+ log_http_client_level=settings.log_http_client_level,
+ log_background_sync_level=settings.log_background_sync_level,
+ )
+ logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
+ _log_security_configuration_warnings()
init_db()
+ _enforce_secure_startup_configuration()
runtime = get_runtime_settings()
- configure_logging(runtime.log_level, runtime.log_file)
- asyncio.create_task(run_daily_jellyfin_sync())
- asyncio.create_task(startup_warmup_requests_cache())
- asyncio.create_task(run_requests_delta_loop())
- asyncio.create_task(run_daily_requests_full_sync())
- asyncio.create_task(run_daily_db_cleanup())
+ configure_logging(
+ runtime.log_level,
+ runtime.log_file,
+ log_file_max_bytes=runtime.log_file_max_bytes,
+ log_file_backup_count=runtime.log_file_backup_count,
+ log_http_client_level=runtime.log_http_client_level,
+ log_background_sync_level=runtime.log_background_sync_level,
+ )
+ logger.info(
+ "runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
+ runtime.log_level,
+ runtime.log_file,
+ runtime.log_file_max_bytes,
+ runtime.log_file_backup_count,
+ runtime.log_http_client_level,
+ runtime.log_background_sync_level,
+ runtime.requests_data_source,
+ )
+ _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
+ _launch_background_task("requests-warmup", startup_warmup_requests_cache)
+ _launch_background_task("requests-delta-loop", run_requests_delta_loop)
+ _launch_background_task("requests-full-sync", run_daily_requests_full_sync)
+ _launch_background_task("db-cleanup", run_daily_db_cleanup)
+ logger.info("startup complete")
app.include_router(requests_router)
app.include_router(auth_router)
app.include_router(admin_router)
+app.include_router(admin_events_router)
app.include_router(images_router)
app.include_router(branding_router)
app.include_router(status_router)
app.include_router(feedback_router)
+app.include_router(site_router)
+app.include_router(events_router)
+app.include_router(portal_router)
diff --git a/backend/app/network_security.py b/backend/app/network_security.py
new file mode 100644
index 0000000..68219ed
--- /dev/null
+++ b/backend/app/network_security.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import ipaddress
+import socket
+from functools import lru_cache
+from typing import Iterable
+from urllib.parse import urlparse
+
+from .config import settings
+
+_METADATA_HOSTS = {
+ "169.254.169.254",
+ "metadata.google.internal",
+ "metadata.azure.internal",
+}
+
+
+def _normalize_text(value: object) -> str:
+ if value is None:
+ return ""
+ return str(value).strip()
+
+
+def _split_csv(value: object) -> list[str]:
+ raw = _normalize_text(value)
+ if not raw:
+ return []
+ return [part.strip() for part in raw.split(",") if part.strip()]
+
+
+def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
+ return bool(
+ ip_obj.is_loopback
+ or ip_obj.is_link_local
+ or ip_obj.is_multicast
+ or ip_obj.is_unspecified
+ or ip_obj.is_reserved
+ or ip_obj.is_private
+ )
+
+
+@lru_cache(maxsize=256)
+def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
+ resolved: list[ipaddress._BaseAddress] = []
+ for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
+ if family == socket.AF_INET:
+ resolved.append(ipaddress.ip_address(sockaddr[0]))
+ elif family == socket.AF_INET6:
+ resolved.append(ipaddress.ip_address(sockaddr[0]))
+ return tuple(resolved)
+
+
+def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
+ candidate = _normalize_text(host)
+ if not candidate:
+ return False
+ try:
+ host_ip = ipaddress.ip_address(candidate)
+ except ValueError:
+ return candidate.lower() in {entry.lower() for entry in trusted_proxies}
+
+ for entry in trusted_proxies:
+ raw = _normalize_text(entry)
+ if not raw:
+ continue
+ try:
+ if "/" in raw:
+ if host_ip in ipaddress.ip_network(raw, strict=False):
+ return True
+ elif host_ip == ipaddress.ip_address(raw):
+ return True
+ except ValueError:
+ continue
+ return False
+
+
+def request_trusts_forwarded_headers(client_host: str | None) -> bool:
+ if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
+ return False
+ trusted = _split_csv(settings.magent_proxy_trusted_proxies)
+ if not trusted:
+ return False
+ return _is_trusted_proxy_host(client_host or "", trusted)
+
+
+def validate_notification_target_url(
+ url: str,
+ *,
+ allow_private: bool | None = None,
+) -> str:
+ raw = _normalize_text(url)
+ if not raw:
+ raise ValueError("URL cannot be empty.")
+
+ parsed = urlparse(raw)
+ if parsed.scheme not in {"http", "https"}:
+ raise ValueError("URL must use http:// or https://.")
+ if parsed.username or parsed.password:
+ raise ValueError("URL must not embed credentials.")
+ hostname = _normalize_text(parsed.hostname).lower()
+ if not hostname:
+ raise ValueError("URL must include a valid host.")
+
+ allow_private_targets = (
+ settings.magent_allow_private_notification_targets
+ if allow_private is None
+ else bool(allow_private)
+ )
+ if hostname in _METADATA_HOSTS:
+ raise ValueError("Metadata service targets are not allowed.")
+ if hostname == "localhost" and not allow_private_targets:
+ raise ValueError("Local notification targets are not allowed.")
+
+ try:
+ host_ip = ipaddress.ip_address(hostname)
+ except ValueError:
+ host_ip = None
+
+ if host_ip is not None:
+ if _ip_is_sensitive(host_ip) and not allow_private_targets:
+ raise ValueError("Private or local notification targets are not allowed.")
+ return raw
+
+ try:
+ resolved_ips = _resolve_host_ips(hostname)
+ except socket.gaierror as exc:
+ raise ValueError("Host could not be resolved.") from exc
+ if not resolved_ips:
+ raise ValueError("Host could not be resolved.")
+ if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
+ raise ValueError("Private or local notification targets are not allowed.")
+ return raw
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index a5603f3..232351a 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -1,25 +1,76 @@
-from typing import Any, Dict, List
+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
+from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
+from fastapi.responses import StreamingResponse
-from ..auth import require_admin
+from ..auth import (
+ require_admin,
+ get_current_user,
+ require_admin_event_stream,
+ normalize_user_auth_provider,
+ resolve_user_auth_provider,
+)
from ..config import settings as env_settings
+from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
get_all_users,
+ get_cached_requests,
+ get_cached_requests_count,
+ get_setting,
get_request_cache_overview,
+ get_request_cache_missing_titles,
+ get_request_cache_stats,
get_settings_overrides,
+ get_user_by_id,
get_user_by_username,
+ get_user_request_stats,
+ create_user_if_missing,
+ set_user_jellyseerr_id,
set_setting,
set_user_blocked,
+ delete_user_by_username,
+ delete_user_activity_by_username,
+ set_user_auto_search_enabled,
+ set_auto_search_enabled_for_non_admin_users,
+ set_user_email,
+ set_user_invite_management_enabled,
+ set_invite_management_enabled_for_non_admin_users,
+ set_user_profile_id,
+ set_user_expires_at,
set_user_password,
+ sync_jellyfin_password_state,
set_user_role,
run_integrity_check,
vacuum_db,
clear_requests_cache,
clear_history,
+ clear_user_objects_nuclear,
cleanup_history,
+ update_request_cache_title,
+ repair_request_cache_titles,
+ delete_non_admin_users,
+ list_user_profiles,
+ get_user_profile,
+ create_user_profile,
+ update_user_profile,
+ delete_user_profile,
+ list_signup_invites,
+ get_signup_invite_by_id,
+ create_signup_invite,
+ update_signup_invite,
+ delete_signup_invite,
+ get_signup_invite_by_code,
+ disable_signup_invites_by_creator,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -27,15 +78,59 @@ from ..clients.radarr import RadarrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
+from ..services.user_cache import (
+ build_jellyseerr_candidate_map,
+ extract_jellyseerr_user_email,
+ find_matching_jellyseerr_user,
+ get_cached_jellyfin_users,
+ get_cached_jellyseerr_users,
+ match_jellyseerr_user_id,
+ save_jellyfin_users_cache,
+ save_jellyseerr_users_cache,
+ clear_user_import_caches,
+)
+from ..security import validate_password_policy
+from ..services.invite_email import (
+ TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
+ get_invite_email_templates,
+ normalize_delivery_email,
+ reset_invite_email_template,
+ save_invite_email_template,
+ send_test_email,
+ smtp_email_delivery_warning,
+ send_templated_email,
+ smtp_email_config_ready,
+)
+from ..services.diagnostics import get_diagnostics_catalog, run_diagnostics
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
+events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
+SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
+
+
+def _require_recipient_email(value: object) -> str:
+ normalized = normalize_delivery_email(value)
+ if normalized:
+ return normalized
+ raise HTTPException(
+ status_code=400,
+ detail="recipient_email is required and must be a valid email address",
+ )
SENSITIVE_KEYS = {
+ "magent_ssl_certificate_pem",
+ "magent_ssl_private_key_pem",
+ "magent_notify_email_smtp_password",
+ "magent_notify_discord_webhook_url",
+ "magent_notify_telegram_bot_token",
+ "magent_notify_push_token",
+ "magent_notify_push_user_key",
+ "magent_notify_webhook_url",
"jellyseerr_api_key",
"jellyfin_api_key",
"sonarr_api_key",
@@ -44,7 +139,66 @@ SENSITIVE_KEYS = {
"qbittorrent_password",
}
+URL_SETTING_KEYS = {
+ "magent_application_url",
+ "magent_api_url",
+ "magent_proxy_base_url",
+ "magent_notify_discord_webhook_url",
+ "magent_notify_push_base_url",
+ "jellyseerr_base_url",
+ "jellyfin_base_url",
+ "jellyfin_public_url",
+ "sonarr_base_url",
+ "radarr_base_url",
+ "prowlarr_base_url",
+ "qbittorrent_base_url",
+}
+
+NOTIFICATION_URL_SETTING_KEYS = {
+ "magent_notify_discord_webhook_url",
+ "magent_notify_push_base_url",
+ "magent_notify_webhook_url",
+}
+
SETTING_KEYS: List[str] = [
+ "magent_application_url",
+ "magent_application_port",
+ "magent_api_url",
+ "magent_api_port",
+ "magent_bind_host",
+ "magent_proxy_enabled",
+ "magent_proxy_base_url",
+ "magent_proxy_trust_forwarded_headers",
+ "magent_proxy_forwarded_prefix",
+ "magent_ssl_bind_enabled",
+ "magent_ssl_certificate_path",
+ "magent_ssl_private_key_path",
+ "magent_ssl_certificate_pem",
+ "magent_ssl_private_key_pem",
+ "magent_notify_enabled",
+ "magent_notify_email_enabled",
+ "magent_notify_email_smtp_host",
+ "magent_notify_email_smtp_port",
+ "magent_notify_email_smtp_username",
+ "magent_notify_email_smtp_password",
+ "magent_notify_email_from_address",
+ "magent_notify_email_from_name",
+ "magent_notify_email_use_tls",
+ "magent_notify_email_use_ssl",
+ "magent_notify_discord_enabled",
+ "magent_notify_discord_webhook_url",
+ "magent_notify_telegram_enabled",
+ "magent_notify_telegram_bot_token",
+ "magent_notify_telegram_chat_id",
+ "magent_notify_push_enabled",
+ "magent_notify_push_provider",
+ "magent_notify_push_base_url",
+ "magent_notify_push_topic",
+ "magent_notify_push_token",
+ "magent_notify_push_user_key",
+ "magent_notify_push_device",
+ "magent_notify_webhook_enabled",
+ "magent_notify_webhook_url",
"jellyseerr_base_url",
"jellyseerr_api_key",
"jellyfin_base_url",
@@ -56,10 +210,12 @@ SETTING_KEYS: List[str] = [
"sonarr_api_key",
"sonarr_quality_profile_id",
"sonarr_root_folder",
+ "sonarr_qbittorrent_category",
"radarr_base_url",
"radarr_api_key",
"radarr_quality_profile_id",
"radarr_root_folder",
+ "radarr_qbittorrent_category",
"prowlarr_base_url",
"prowlarr_api_key",
"qbittorrent_base_url",
@@ -67,6 +223,10 @@ SETTING_KEYS: List[str] = [
"qbittorrent_password",
"log_level",
"log_file",
+ "log_file_max_bytes",
+ "log_file_backup_count",
+ "log_http_client_level",
+ "log_background_sync_level",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
@@ -74,8 +234,234 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
+ "site_banner_enabled",
+ "site_banner_message",
+ "site_banner_tone",
+ "site_login_show_jellyfin_login",
+ "site_login_show_local_login",
+ "site_login_show_forgot_password",
+ "site_login_show_signup_link",
]
+
+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 []
@@ -91,6 +477,38 @@ def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
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 []
@@ -106,6 +524,105 @@ def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
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()
@@ -131,6 +648,7 @@ async def list_settings() -> Dict[str, Any]:
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
updates = 0
touched_logging = False
+ changed_keys: List[str] = []
for key, value in payload.items():
if key not in SETTING_KEYS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
@@ -139,17 +657,96 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(value, str) and value.strip() == "":
delete_setting(key)
updates += 1
+ changed_keys.append(key)
continue
- set_setting(key, str(value))
+ value_to_store = str(value).strip() if isinstance(value, str) else str(value)
+ if key in URL_SETTING_KEYS and value_to_store:
+ try:
+ value_to_store = _normalize_service_url(value_to_store)
+ except ValueError as exc:
+ friendly_key = key.replace("_", " ")
+ raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
+ if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
+ try:
+ value_to_store = validate_notification_target_url(value_to_store)
+ except ValueError as exc:
+ friendly_key = key.replace("_", " ")
+ raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
+ set_setting(key, value_to_store)
updates += 1
- if key in {"log_level", "log_file"}:
+ changed_keys.append(key)
+ if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
- configure_logging(runtime.log_level, runtime.log_file)
+ configure_logging(
+ runtime.log_level,
+ runtime.log_file,
+ log_file_max_bytes=runtime.log_file_max_bytes,
+ log_file_backup_count=runtime.log_file_backup_count,
+ log_http_client_level=runtime.log_http_client_level,
+ log_background_sync_level=runtime.log_background_sync_level,
+ )
+ logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates}
+@router.post("/settings/test/email")
+async def test_email_settings(request: Request) -> Dict[str, Any]:
+ recipient_email = None
+ content_type = (request.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
+ try:
+ if content_type == "application/json":
+ payload = await request.json()
+ if isinstance(payload, dict) and isinstance(payload.get("recipient_email"), str):
+ recipient_email = payload["recipient_email"]
+ elif content_type in {
+ "application/x-www-form-urlencoded",
+ "multipart/form-data",
+ }:
+ form = await request.form()
+ candidate = form.get("recipient_email")
+ if isinstance(candidate, str):
+ recipient_email = candidate
+ except Exception:
+ recipient_email = None
+ try:
+ result = await send_test_email(recipient_email=recipient_email)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
+ return {"status": "ok", **result}
+
+
+@router.get("/diagnostics")
+async def diagnostics_catalog() -> Dict[str, Any]:
+ return {"status": "ok", **get_diagnostics_catalog()}
+
+
+@router.post("/diagnostics/run")
+async def diagnostics_run(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ keys: Optional[List[str]] = None
+ recipient_email: Optional[str] = None
+ if payload is not None:
+ raw_keys = payload.get("keys")
+ if raw_keys is not None:
+ if not isinstance(raw_keys, list):
+ raise HTTPException(status_code=400, detail="keys must be an array of diagnostic keys")
+ keys = []
+ for raw_key in raw_keys:
+ if not isinstance(raw_key, str):
+ raise HTTPException(status_code=400, detail="Each diagnostic key must be a string")
+ normalized = raw_key.strip()
+ if normalized:
+ keys.append(normalized)
+ raw_recipient_email = payload.get("recipient_email")
+ if raw_recipient_email is not None:
+ if not isinstance(raw_recipient_email, str):
+ raise HTTPException(status_code=400, detail="recipient_email must be a string")
+ recipient_email = raw_recipient_email.strip() or None
+ return {"status": "ok", **(await run_diagnostics(keys, recipient_email=recipient_email))}
+
+
@router.get("/sonarr/options")
async def sonarr_options() -> Dict[str, Any]:
runtime = get_runtime_settings()
@@ -180,6 +777,9 @@ async def radarr_options() -> Dict[str, Any]:
@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():
@@ -187,18 +787,7 @@ async def jellyfin_users() -> Dict[str, Any]:
users = await client.get_users()
if not isinstance(users, list):
return {"users": []}
- results = []
- for user in users:
- if not isinstance(user, dict):
- continue
- results.append(
- {
- "id": user.get("Id"),
- "name": user.get("Name"),
- "hasPassword": user.get("HasPassword"),
- "lastLoginDate": user.get("LastLoginDate"),
- }
- )
+ results = save_jellyfin_users_cache(users)
return {"users": results}
@@ -207,13 +796,123 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported}
+async def _fetch_all_jellyseerr_users(
+ client: JellyseerrClient, use_cache: bool = True
+) -> List[Dict[str, Any]]:
+ if use_cache:
+ cached = get_cached_jellyseerr_users()
+ if cached is not None:
+ return cached
+ users: List[Dict[str, Any]] = []
+ take = 100
+ skip = 0
+ while True:
+ payload = await client.get_users(take=take, skip=skip)
+ if not payload:
+ break
+ if isinstance(payload, list):
+ batch = payload
+ elif isinstance(payload, dict):
+ batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
+ else:
+ batch = None
+ if not isinstance(batch, list) or not batch:
+ break
+ users.extend([user for user in batch if isinstance(user, dict)])
+ if len(batch) < take:
+ break
+ skip += take
+ if users:
+ return save_jellyseerr_users_cache(users)
+ return users
+
+@router.post("/seerr/users/sync")
+@router.post("/jellyseerr/users/sync")
+async def jellyseerr_users_sync() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
+ if not jellyseerr_users:
+ return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
+
+ candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
+
+ updated = 0
+ skipped = 0
+ users = get_all_users()
+ for user in users:
+ if user.get("jellyseerr_user_id") is not None:
+ skipped += 1
+ continue
+ username = user.get("username") or ""
+ matched_id = match_jellyseerr_user_id(username, candidate_to_id)
+ matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
+ matched_email = extract_jellyseerr_user_email(matched_seerr_user)
+ if matched_id is not None:
+ set_user_jellyseerr_id(username, matched_id)
+ if matched_email:
+ set_user_email(username, matched_email)
+ updated += 1
+ else:
+ skipped += 1
+
+ return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
+
+def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
+ for key in ("email", "username", "displayName", "name"):
+ value = user.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+@router.post("/seerr/users/resync")
+@router.post("/jellyseerr/users/resync")
+async def jellyseerr_users_resync() -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
+ if not jellyseerr_users:
+ return {"status": "ok", "imported": 0, "cleared": 0}
+
+ cleared = delete_non_admin_users()
+ imported = 0
+ for user in jellyseerr_users:
+ user_id = user.get("id") or user.get("userId") or user.get("Id")
+ try:
+ user_id = int(user_id)
+ except (TypeError, ValueError):
+ continue
+ username = _pick_jellyseerr_username(user)
+ if not username:
+ continue
+ email = extract_jellyseerr_user_email(user)
+ created = create_user_if_missing(
+ username,
+ "jellyseerr-user",
+ role="user",
+ email=email,
+ auth_provider="jellyseerr",
+ jellyseerr_user_id=user_id,
+ )
+ if created:
+ imported += 1
+ else:
+ set_user_jellyseerr_id(username, user_id)
+ if email:
+ set_user_email(username, email)
+ return {"status": "ok", "imported": imported, "cleared": cleared}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
- raise HTTPException(status_code=400, detail="Jellyseerr not 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
)
@@ -226,7 +925,7 @@ 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="Jellyseerr not 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
)
@@ -235,10 +934,12 @@ async def requests_sync_delta() -> Dict[str, Any]:
@router.post("/requests/artwork/prefetch")
-async def requests_artwork_prefetch() -> Dict[str, Any]:
+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
+ 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}
@@ -248,33 +949,138 @@ async def requests_artwork_prefetch() -> Dict[str, Any]:
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]:
- 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 {"lines": list(tail)}
+ return {"lines": _read_log_tail_lines(lines)}
@router.get("/requests/cache")
async def requests_cache(limit: int = 50) -> Dict[str, Any]:
- return {"rows": get_request_cache_overview(limit)}
+ 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")
@@ -294,9 +1100,23 @@ async def repair_database() -> Dict[str, Any]:
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", cleared, history)
- return {"status": "ok", "requestsCleared": cleared, "historyCleared": history}
+ 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")
@@ -329,19 +1149,168 @@ async def list_users() -> Dict[str, Any]:
users = get_all_users()
return {"users": users}
+@router.get("/users/summary")
+async def list_users_summary() -> Dict[str, Any]:
+ users = get_all_users()
+ results: list[Dict[str, Any]] = []
+ for user in users:
+ username = user.get("username") or ""
+ username_norm = _normalize_username(username) if username else ""
+ stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
+ results.append({**user, "stats": stats})
+ return {"users": results}
+
+@router.get("/users/{username}")
+async def get_user_summary(username: str) -> Dict[str, Any]:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ username_norm = _normalize_username(user.get("username") or "")
+ stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
+ return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
+
+
+@router.get("/users/id/{user_id}")
+async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
+ user = get_user_by_id(user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ username_norm = _normalize_username(user.get("username") or "")
+ stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
+ return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
+
@router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, True)
+ logger.warning("Admin blocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": True}
@router.post("/users/{username}/unblock")
async def unblock_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, False)
+ logger.info("Admin unblocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": False}
+@router.post("/users/{username}/system-action")
+async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ action = str(payload.get("action") or "").strip().lower()
+ if action not in {"ban", "unban", "remove"}:
+ raise HTTPException(status_code=400, detail="action must be ban, unban, or remove")
+
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.get("role") == "admin":
+ raise HTTPException(status_code=400, detail="Cross-system actions are not allowed for admin users")
+
+ runtime = get_runtime_settings()
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ result: Dict[str, Any] = {
+ "status": "ok",
+ "action": action,
+ "username": user.get("username"),
+ "local": {"status": "pending"},
+ "jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"},
+ "jellyseerr": {"status": "skipped", "detail": "Seerr not configured or no linked user ID"},
+ "invites": {"status": "pending", "disabled": 0},
+ "email": {"status": "skipped", "detail": "No email action required"},
+ }
+
+ if action == "ban":
+ set_user_blocked(username, True)
+ result["local"] = {"status": "ok", "blocked": True}
+ elif action == "unban":
+ set_user_blocked(username, False)
+ result["local"] = {"status": "ok", "blocked": False}
+ else:
+ result["local"] = {"status": "pending-delete"}
+
+ if action in {"ban", "remove"}:
+ result["invites"] = {"status": "ok", "disabled": disable_signup_invites_by_creator(username)}
+ else:
+ result["invites"] = {"status": "ok", "disabled": 0}
+
+ if action in {"ban", "remove"}:
+ try:
+ invite = _resolve_user_invite(user)
+ email_result = await send_templated_email(
+ "banned",
+ invite=invite,
+ user=user,
+ reason="Account banned" if action == "ban" else "Account removed",
+ )
+ result["email"] = {"status": "ok", **email_result}
+ except Exception as exc:
+ result["email"] = {"status": "error", "detail": str(exc)}
+
+ if jellyfin.configured():
+ try:
+ jellyfin_user = await jellyfin.find_user_by_name(username)
+ if not jellyfin_user:
+ result["jellyfin"] = {"status": "not_found"}
+ else:
+ jellyfin_user_id = jellyfin._extract_user_id(jellyfin_user) # type: ignore[attr-defined]
+ if not jellyfin_user_id:
+ raise RuntimeError("Could not determine Jellyfin user ID")
+ if action == "ban":
+ await jellyfin.set_user_disabled(jellyfin_user_id, True)
+ result["jellyfin"] = {"status": "ok", "action": "disabled", "user_id": jellyfin_user_id}
+ elif action == "unban":
+ await jellyfin.set_user_disabled(jellyfin_user_id, False)
+ result["jellyfin"] = {"status": "ok", "action": "enabled", "user_id": jellyfin_user_id}
+ else:
+ await jellyfin.delete_user(jellyfin_user_id)
+ result["jellyfin"] = {"status": "ok", "action": "deleted", "user_id": jellyfin_user_id}
+ except Exception as exc:
+ result["jellyfin"] = {"status": "error", "detail": _http_error_detail(exc)}
+
+ jellyseerr_user_id = user.get("jellyseerr_user_id")
+ if jellyseerr.configured() and jellyseerr_user_id is not None:
+ try:
+ if action == "remove":
+ await jellyseerr.delete_user(int(jellyseerr_user_id))
+ result["jellyseerr"] = {"status": "ok", "action": "deleted", "user_id": int(jellyseerr_user_id)}
+ elif action == "ban":
+ result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-disable", "user_id": int(jellyseerr_user_id)}
+ else:
+ result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-enable", "user_id": int(jellyseerr_user_id)}
+ except Exception as exc:
+ result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
+
+ if action == "remove":
+ deleted = delete_user_by_username(username)
+ activity_deleted = delete_user_activity_by_username(username)
+ result["local"] = {
+ "status": "ok" if deleted else "not_found",
+ "deleted": bool(deleted),
+ "activity_deleted": activity_deleted,
+ }
+
+ if any(
+ isinstance(system, dict) and system.get("status") == "error"
+ for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email"))
+ ):
+ result["status"] = "partial"
+ logger.info(
+ "Admin system action completed: username=%s action=%s overall=%s local=%s jellyfin=%s jellyseerr=%s invites=%s email=%s",
+ username,
+ action,
+ result.get("status"),
+ result.get("local", {}).get("status"),
+ result.get("jellyfin", {}).get("status"),
+ result.get("jellyseerr", {}).get("status"),
+ result.get("invites", {}).get("status"),
+ result.get("email", {}).get("status"),
+ )
+ return result
+
+
@router.post("/users/{username}/role")
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
role = payload.get("role")
@@ -351,17 +1320,682 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
return {"status": "ok", "username": username, "role": role}
-@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) or len(new_password.strip()) < 8:
- raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
+@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")
- if user.get("auth_provider") != "local":
- raise HTTPException(
- status_code=400, detail="Password changes are only available for local users."
+ set_user_auto_search_enabled(username, enabled)
+ return {"status": "ok", "username": username, "auto_search_enabled": enabled}
+
+
+@router.post("/users/{username}/invite-access")
+async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ set_user_invite_management_enabled(username, enabled)
+ refreshed = get_user_by_username(username)
+ return {
+ "status": "ok",
+ "username": username,
+ "invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled,
+ "user": refreshed,
+ }
+
+
+@router.post("/users/{username}/profile")
+async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ profile_id = payload.get("profile_id")
+ if profile_id in (None, ""):
+ set_user_profile_id(username, None)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+ try:
+ parsed_profile_id = int(profile_id)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
+ profile = get_user_profile(parsed_profile_id)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if not profile.get("is_active", True):
+ raise HTTPException(status_code=400, detail="Profile is disabled")
+ refreshed = _apply_profile_defaults_to_user(username, profile)
+ return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id}
+
+
+@router.post("/users/{username}/expiry")
+async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ clear = payload.get("clear")
+ if clear is True:
+ set_user_expires_at(username, None)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+ if "days" in payload and payload.get("days") not in (None, ""):
+ days = _parse_optional_positive_int(payload.get("days"), "days")
+ expires_at = None
+ if days is not None:
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
+ set_user_expires_at(username, expires_at)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ set_user_expires_at(username, expires_at)
+ refreshed = get_user_by_username(username)
+ return {"status": "ok", "user": refreshed}
+
+
+@router.post("/users/auto-search/bulk")
+async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ updated = set_auto_search_enabled_for_non_admin_users(enabled)
+ return {
+ "status": "ok",
+ "enabled": enabled,
+ "updated": updated,
+ "scope": "non-admin-users",
+ }
+
+
+@router.post("/users/invite-access/bulk")
+async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ enabled = payload.get("enabled") if isinstance(payload, dict) else None
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ updated = set_invite_management_enabled_for_non_admin_users(enabled)
+ return {
+ "status": "ok",
+ "enabled": enabled,
+ "updated": updated,
+ "scope": "non-admin-users",
+ }
+
+
+@router.post("/users/profile/bulk")
+async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ scope = str(payload.get("scope") or "non-admin-users").strip().lower()
+ if scope not in {"non-admin-users", "all-users"}:
+ raise HTTPException(status_code=400, detail="Invalid scope")
+ profile_id_value = payload.get("profile_id")
+ if profile_id_value in (None, ""):
+ users = get_all_users()
+ updated = 0
+ for user in users:
+ if scope == "non-admin-users" and user.get("role") == "admin":
+ continue
+ set_user_profile_id(user["username"], None)
+ updated += 1
+ return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None}
+ try:
+ profile_id = int(profile_id_value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
+ profile = get_user_profile(profile_id)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if not profile.get("is_active", True):
+ raise HTTPException(status_code=400, detail="Profile is disabled")
+ users = get_all_users()
+ updated = 0
+ for user in users:
+ if scope == "non-admin-users" and user.get("role") == "admin":
+ continue
+ _apply_profile_defaults_to_user(user["username"], profile)
+ updated += 1
+ return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id}
+
+
+@router.post("/users/expiry/bulk")
+async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ scope = str(payload.get("scope") or "non-admin-users").strip().lower()
+ if scope not in {"non-admin-users", "all-users"}:
+ raise HTTPException(status_code=400, detail="Invalid scope")
+ clear = payload.get("clear")
+ expires_at: Optional[str] = None
+ if clear is True:
+ expires_at = None
+ elif "days" in payload and payload.get("days") not in (None, ""):
+ days = _parse_optional_positive_int(payload.get("days"), "days")
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None
+ else:
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ users = get_all_users()
+ updated = 0
+ for user in users:
+ if scope == "non-admin-users" and user.get("role") == "admin":
+ continue
+ set_user_expires_at(user["username"], expires_at)
+ updated += 1
+ return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at}
+
+
+@router.post("/users/{username}/password")
+async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ new_password = payload.get("password") if isinstance(payload, dict) else None
+ if not isinstance(new_password, str):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ try:
+ new_password_clean = validate_password_policy(new_password)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ user = normalize_user_auth_provider(user)
+ auth_provider = resolve_user_auth_provider(user)
+ if auth_provider == "local":
+ set_user_password(username, new_password_clean)
+ return {"status": "ok", "username": username, "provider": "local"}
+ if auth_provider == "jellyfin":
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.")
+ try:
+ jf_user = await client.find_user_by_name(username)
+ user_id = client._extract_user_id(jf_user)
+ if not user_id:
+ raise RuntimeError("Jellyfin user ID not found")
+ await client.set_user_password(user_id, new_password_clean)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
+ sync_jellyfin_password_state(username, new_password_clean)
+ return {"status": "ok", "username": username, "provider": "jellyfin"}
+ raise HTTPException(
+ status_code=400,
+ detail="Password changes are not available for this sign-in provider.",
+ )
+
+
+@router.get("/profiles")
+async def get_profiles() -> Dict[str, Any]:
+ profiles = list_user_profiles()
+ users = get_all_users()
+ invites = list_signup_invites()
+ user_counts: Dict[int, int] = {}
+ invite_counts: Dict[int, int] = {}
+ for user in users:
+ profile_id = user.get("profile_id")
+ if isinstance(profile_id, int):
+ user_counts[profile_id] = user_counts.get(profile_id, 0) + 1
+ for invite in invites:
+ profile_id = invite.get("profile_id")
+ if isinstance(profile_id, int):
+ invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1
+ enriched = []
+ for profile in profiles:
+ pid = int(profile["id"])
+ enriched.append(
+ {
+ **profile,
+ "assigned_users": user_counts.get(pid, 0),
+ "assigned_invites": invite_counts.get(pid, 0),
+ }
)
- set_user_password(username, new_password.strip())
- return {"status": "ok", "username": username}
+ return {"profiles": enriched}
+
+
+@router.post("/profiles")
+async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ name = _normalize_optional_text(payload.get("name"))
+ if not name:
+ raise HTTPException(status_code=400, detail="Profile name is required")
+ role = _normalize_role_or_none(payload.get("role")) or "user"
+ auto_search_enabled = payload.get("auto_search_enabled")
+ if auto_search_enabled is None:
+ auto_search_enabled = True
+ if not isinstance(auto_search_enabled, bool):
+ raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
+ is_active = payload.get("is_active")
+ if is_active is None:
+ is_active = True
+ if not isinstance(is_active, bool):
+ raise HTTPException(status_code=400, detail="is_active must be true or false")
+ account_expires_days = _parse_optional_positive_int(
+ payload.get("account_expires_days"), "account_expires_days"
+ )
+ try:
+ profile = create_user_profile(
+ name=name,
+ description=_normalize_optional_text(payload.get("description")),
+ role=role,
+ auto_search_enabled=auto_search_enabled,
+ account_expires_days=account_expires_days,
+ is_active=is_active,
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
+ logger.info(
+ "Admin created profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
+ profile.get("id"),
+ profile.get("name"),
+ profile.get("role"),
+ profile.get("is_active"),
+ profile.get("auto_search_enabled"),
+ profile.get("account_expires_days"),
+ )
+ return {"status": "ok", "profile": profile}
+
+
+@router.put("/profiles/{profile_id}")
+async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ existing = get_user_profile(profile_id)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ name = _normalize_optional_text(payload.get("name"))
+ if not name:
+ raise HTTPException(status_code=400, detail="Profile name is required")
+ role = _normalize_role_or_none(payload.get("role")) or "user"
+ auto_search_enabled = payload.get("auto_search_enabled")
+ if not isinstance(auto_search_enabled, bool):
+ raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
+ is_active = payload.get("is_active")
+ if not isinstance(is_active, bool):
+ raise HTTPException(status_code=400, detail="is_active must be true or false")
+ account_expires_days = _parse_optional_positive_int(
+ payload.get("account_expires_days"), "account_expires_days"
+ )
+ try:
+ profile = update_user_profile(
+ profile_id,
+ name=name,
+ description=_normalize_optional_text(payload.get("description")),
+ role=role,
+ auto_search_enabled=auto_search_enabled,
+ account_expires_days=account_expires_days,
+ is_active=is_active,
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ logger.info(
+ "Admin updated profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
+ profile.get("id"),
+ profile.get("name"),
+ profile.get("role"),
+ profile.get("is_active"),
+ profile.get("auto_search_enabled"),
+ profile.get("account_expires_days"),
+ )
+ return {"status": "ok", "profile": profile}
+
+
+@router.delete("/profiles/{profile_id}")
+async def remove_profile(profile_id: int) -> Dict[str, Any]:
+ try:
+ deleted = delete_user_profile(profile_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ logger.warning("Admin deleted profile: profile_id=%s", profile_id)
+ return {"status": "ok", "deleted": True, "profile_id": profile_id}
+
+
+@router.get("/invites")
+async def get_invites() -> Dict[str, Any]:
+ invites = list_signup_invites()
+ profiles = {profile["id"]: profile for profile in list_user_profiles()}
+ results = []
+ for invite in invites:
+ profile = profiles.get(invite.get("profile_id"))
+ results.append(
+ {
+ **invite,
+ "profile": (
+ {
+ "id": profile.get("id"),
+ "name": profile.get("name"),
+ }
+ if profile
+ else None
+ ),
+ }
+ )
+ return {"invites": results}
+
+
+@router.get("/invites/policy")
+async def get_invite_policy() -> Dict[str, Any]:
+ users = get_all_users()
+ non_admin_users = [user for user in users if user.get("role") != "admin"]
+ invite_access_enabled_count = sum(
+ 1 for user in non_admin_users if bool(user.get("invite_management_enabled", False))
+ )
+ raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
+ master_invite_id: Optional[int] = None
+ master_invite: Optional[Dict[str, Any]] = None
+ if raw_master_invite_id not in (None, ""):
+ try:
+ candidate = int(str(raw_master_invite_id).strip())
+ if candidate > 0:
+ master_invite_id = candidate
+ master_invite = get_signup_invite_by_id(candidate)
+ except (TypeError, ValueError):
+ master_invite_id = None
+ master_invite = None
+ return {
+ "status": "ok",
+ "policy": {
+ "master_invite_id": master_invite_id if master_invite is not None else None,
+ "master_invite": master_invite,
+ "non_admin_users": len(non_admin_users),
+ "invite_access_enabled_users": invite_access_enabled_count,
+ },
+ }
+
+
+@router.post("/invites/policy")
+async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ master_invite_value = payload.get("master_invite_id")
+ if master_invite_value in (None, "", 0, "0"):
+ set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
+ logger.info("Admin cleared invite policy master invite")
+ return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
+ try:
+ master_invite_id = int(master_invite_value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc
+ if master_invite_id <= 0:
+ raise HTTPException(status_code=400, detail="master_invite_id must be a positive number")
+ invite = get_signup_invite_by_id(master_invite_id)
+ if not invite:
+ raise HTTPException(status_code=404, detail="Master invite not found")
+ set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
+ logger.info("Admin updated invite policy: master_invite_id=%s", master_invite_id)
+ return {
+ "status": "ok",
+ "policy": {
+ "master_invite_id": master_invite_id,
+ "master_invite": invite,
+ },
+ }
+
+
+@router.get("/invites/email/templates")
+async def get_invite_email_template_settings() -> Dict[str, Any]:
+ ready, detail = smtp_email_config_ready()
+ warning = smtp_email_delivery_warning()
+ return {
+ "status": "ok",
+ "email": {
+ "configured": ready,
+ "detail": warning or detail,
+ },
+ "templates": list(get_invite_email_templates().values()),
+ }
+
+
+@router.put("/invites/email/templates/{template_key}")
+async def update_invite_email_template_settings(template_key: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
+ raise HTTPException(status_code=404, detail="Email template not found")
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ subject = _normalize_optional_text(payload.get("subject"))
+ body_text = _normalize_optional_text(payload.get("body_text"))
+ body_html = _normalize_optional_text(payload.get("body_html"))
+ if not subject:
+ raise HTTPException(status_code=400, detail="subject is required")
+ if not body_text and not body_html:
+ raise HTTPException(status_code=400, detail="At least one email body is required")
+ template = save_invite_email_template(
+ template_key,
+ subject=subject,
+ body_text=body_text or "",
+ body_html=body_html or "",
+ )
+ logger.info("Admin updated invite email template: template=%s", template_key)
+ return {"status": "ok", "template": template}
+
+
+@router.delete("/invites/email/templates/{template_key}")
+async def reset_invite_email_template_settings(template_key: str) -> Dict[str, Any]:
+ if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
+ raise HTTPException(status_code=404, detail="Email template not found")
+ template = reset_invite_email_template(template_key)
+ logger.info("Admin reset invite email template: template=%s", template_key)
+ return {"status": "ok", "template": template}
+
+
+@router.post("/invites/email/send")
+async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ template_key = str(payload.get("template_key") or "").strip().lower()
+ if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
+ raise HTTPException(status_code=400, detail="template_key is invalid")
+
+ invite: Optional[Dict[str, Any]] = None
+ invite_id = payload.get("invite_id")
+ if invite_id not in (None, ""):
+ try:
+ invite = get_signup_invite_by_id(int(invite_id))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="invite_id must be a number") from exc
+ if not invite:
+ raise HTTPException(status_code=404, detail="Invite not found")
+
+ user: Optional[Dict[str, Any]] = None
+ username = _normalize_optional_text(payload.get("username"))
+ if username:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if invite is None:
+ invite = _resolve_user_invite(user)
+
+ recipient_email = _require_recipient_email(payload.get("recipient_email"))
+ message = _normalize_optional_text(payload.get("message"))
+ reason = _normalize_optional_text(payload.get("reason"))
+
+ try:
+ result = await send_templated_email(
+ template_key,
+ invite=invite,
+ user=user,
+ recipient_email=recipient_email,
+ message=message,
+ reason=reason,
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ logger.info(
+ "Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
+ template_key,
+ result.get("recipient_email"),
+ invite.get("id") if invite else None,
+ user.get("username") if user else None,
+ )
+
+ return {
+ "status": "ok",
+ "template_key": template_key,
+ **result,
+ }
+
+
+@router.get("/invites/trace")
+async def get_invite_trace() -> Dict[str, Any]:
+ return {"status": "ok", "trace": _build_invite_trace_payload()}
+
+
+@router.post("/invites")
+async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ raw_code = _normalize_optional_text(payload.get("code"))
+ code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code()
+ profile_id = _parse_optional_profile_id(payload.get("profile_id"))
+ enabled = payload.get("enabled")
+ if enabled is None:
+ enabled = True
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ role = _normalize_role_or_none(payload.get("role"))
+ max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ recipient_email = _require_recipient_email(payload.get("recipient_email"))
+ send_email = bool(payload.get("send_email"))
+ delivery_message = _normalize_optional_text(payload.get("message"))
+ try:
+ invite = create_signup_invite(
+ code=code,
+ label=_normalize_optional_text(payload.get("label")),
+ description=_normalize_optional_text(payload.get("description")),
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ created_by=current_user.get("username"),
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ user=current_user,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ logger.info(
+ "Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
+ invite.get("id"),
+ invite.get("code"),
+ invite.get("label"),
+ invite.get("profile_id"),
+ invite.get("role"),
+ invite.get("max_uses"),
+ invite.get("enabled"),
+ invite.get("recipient_email"),
+ send_email,
+ )
+ return {
+ "status": "partial" if email_error else "ok",
+ "invite": invite,
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.put("/invites/{invite_id}")
+async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ existing = get_signup_invite_by_id(invite_id)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
+ profile_id = _parse_optional_profile_id(payload.get("profile_id"))
+ enabled = payload.get("enabled")
+ if not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="enabled must be true or false")
+ role = _normalize_role_or_none(payload.get("role"))
+ max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ recipient_email = _normalize_optional_text(payload.get("recipient_email"))
+ send_email = bool(payload.get("send_email"))
+ delivery_message = _normalize_optional_text(payload.get("message"))
+ try:
+ invite = update_signup_invite(
+ invite_id,
+ code=code,
+ label=_normalize_optional_text(payload.get("label")),
+ description=_normalize_optional_text(payload.get("description")),
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ )
+ except sqlite3.IntegrityError as exc:
+ raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
+ if not invite:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ logger.info(
+ "Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
+ invite.get("id"),
+ invite.get("code"),
+ invite.get("label"),
+ invite.get("profile_id"),
+ invite.get("role"),
+ invite.get("max_uses"),
+ invite.get("enabled"),
+ invite.get("recipient_email"),
+ send_email,
+ )
+ return {
+ "status": "partial" if email_error else "ok",
+ "invite": invite,
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.delete("/invites/{invite_id}")
+async def remove_invite(invite_id: int) -> Dict[str, Any]:
+ deleted = delete_signup_invite(invite_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ logger.warning("Admin deleted invite: invite_id=%s", invite_id)
+ return {"status": "ok", "deleted": True, "invite_id": invite_id}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 97f0d67..018636e 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -1,90 +1,831 @@
-from fastapi import APIRouter, HTTPException, status, Depends
+from datetime import datetime, timedelta, timezone
+from collections import defaultdict, deque
+import logging
+import secrets
+import string
+import time
+from threading import Lock
+
+import httpx
+from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
verify_user_password,
+ create_user,
create_user_if_missing,
set_last_login,
get_user_by_username,
+ get_users_by_username_ci,
set_user_password,
+ set_user_jellyseerr_id,
+ set_user_email,
+ set_user_auth_provider,
+ get_signup_invite_by_code,
+ get_signup_invite_by_id,
+ list_signup_invites,
+ create_signup_invite,
+ update_signup_invite,
+ delete_signup_invite,
+ increment_signup_invite_use,
+ get_user_profile,
+ get_user_activity,
+ get_user_activity_summary,
+ get_user_request_stats,
+ get_global_request_leader,
+ get_global_request_total,
+ get_setting,
+ sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
-from ..security import create_access_token
-from ..auth import get_current_user
+from ..security import (
+ PASSWORD_POLICY_MESSAGE,
+ create_access_token,
+ validate_password_policy,
+ verify_password,
+)
+from ..security import create_stream_token
+from ..auth import (
+ clear_auth_cookies,
+ get_current_user,
+ normalize_user_auth_provider,
+ resolve_user_auth_provider,
+ set_auth_cookies,
+)
+from ..config import settings
+from ..network_security import request_trusts_forwarded_headers
+from ..services.user_cache import (
+ build_jellyseerr_candidate_map,
+ extract_jellyseerr_user_email,
+ find_matching_jellyseerr_user,
+ get_cached_jellyseerr_users,
+ match_jellyseerr_user_id,
+ save_jellyfin_users_cache,
+)
+from ..services.invite_email import (
+ normalize_delivery_email,
+ send_templated_email,
+ smtp_email_config_ready,
+)
+from ..services.password_reset import (
+ PasswordResetUnavailableError,
+ apply_password_reset,
+ request_password_reset,
+ verify_password_reset_token,
+)
router = APIRouter(prefix="/auth", tags=["auth"])
+logger = logging.getLogger(__name__)
+SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
+STREAM_TOKEN_TTL_SECONDS = 120
+PASSWORD_RESET_GENERIC_MESSAGE = (
+ "If an account exists for that username or email, a password reset link has been sent."
+)
+
+_LOGIN_RATE_LOCK = Lock()
+_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
+_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
+_RESET_RATE_LOCK = Lock()
+_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
+_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
-@router.post("/login")
-async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
- user = verify_user_password(form_data.username, form_data.password)
+def _require_recipient_email(value: object) -> str:
+ normalized = normalize_delivery_email(value)
+ if normalized:
+ return normalized
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="recipient_email is required and must be a valid email address.",
+ )
+
+
+def _auth_client_ip(request: Request) -> str:
+ direct_host = request.client.host if request.client else None
+ if request_trusts_forwarded_headers(direct_host):
+ forwarded = request.headers.get("x-forwarded-for")
+ if isinstance(forwarded, str) and forwarded.strip():
+ return forwarded.split(",", 1)[0].strip()
+ real = request.headers.get("x-real-ip")
+ if isinstance(real, str) and real.strip():
+ return real.strip()
+ if request.client and request.client.host:
+ return str(request.client.host)
+ return "unknown"
+
+
+def _login_rate_key_user(username: str) -> str:
+ return (username or "").strip().lower()[:256] or ""
+
+
+def _password_reset_rate_key_identifier(identifier: str) -> str:
+ return (identifier or "").strip().lower()[:256] or ""
+
+
+def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
+ cutoff = now - window_seconds
+ while bucket and bucket[0] < cutoff:
+ bucket.popleft()
+
+
+def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
+ if not users:
+ return None
+ requested = (requested_username or "").strip()
+ requested_lower = requested.lower()
+
+ def _rank(user: dict) -> tuple[int, int, int, int]:
+ provider = str(user.get("auth_provider") or "local").strip().lower()
+ role = str(user.get("role") or "user").strip().lower()
+ username = str(user.get("username") or "")
+ return (
+ 0 if role == "admin" else 1,
+ 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
+ 0 if provider == "jellyfin" else (1 if provider == "local" else (2 if provider == "jellyseerr" else 3)),
+ 0 if username.lower() == requested_lower else 1,
+ )
+
+ return sorted(users, key=_rank)[0]
+
+
+def _record_login_failure(request: Request, username: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ with _LOGIN_RATE_LOCK:
+ ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
+ user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(user_bucket, now, window)
+ ip_bucket.append(now)
+ user_bucket.append(now)
+ logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
+
+
+def _clear_login_failures(request: Request, username: str) -> None:
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ with _LOGIN_RATE_LOCK:
+ _LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
+ _LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
+
+
+def _enforce_login_rate_limit(request: Request, username: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
+ max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
+ max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
+ ip_key = _auth_client_ip(request)
+ user_key = _login_rate_key_user(username)
+ with _LOGIN_RATE_LOCK:
+ ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
+ user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(user_bucket, now, window)
+ exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
+ retry_after = 1
+ if exceeded:
+ retry_candidates = []
+ if ip_bucket:
+ retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
+ if user_bucket:
+ retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
+ if retry_candidates:
+ retry_after = max(retry_candidates)
+ if exceeded:
+ logger.warning(
+ "login rate limit exceeded username=%s client=%s retry_after=%s",
+ user_key,
+ ip_key,
+ retry_after,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail="Too many login attempts. Try again shortly.",
+ headers={"Retry-After": str(retry_after)},
+ )
+
+
+def _record_password_reset_attempt(request: Request, identifier: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
+ ip_key = _auth_client_ip(request)
+ identifier_key = _password_reset_rate_key_identifier(identifier)
+ with _RESET_RATE_LOCK:
+ ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
+ identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(identifier_bucket, now, window)
+ ip_bucket.append(now)
+ identifier_bucket.append(now)
+ logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
+
+
+def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
+ now = time.monotonic()
+ window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
+ max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
+ max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
+ ip_key = _auth_client_ip(request)
+ identifier_key = _password_reset_rate_key_identifier(identifier)
+ with _RESET_RATE_LOCK:
+ ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
+ identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
+ _prune_attempts(ip_bucket, now, window)
+ _prune_attempts(identifier_bucket, now, window)
+ exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
+ retry_after = 1
+ if exceeded:
+ retry_candidates = []
+ if ip_bucket:
+ retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
+ if identifier_bucket:
+ retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
+ if retry_candidates:
+ retry_after = max(retry_candidates)
+ if exceeded:
+ logger.warning(
+ "password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
+ identifier_key,
+ ip_key,
+ retry_after,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail="Too many password reset attempts. Try again shortly.",
+ headers={"Retry-After": str(retry_after)},
+ )
+
+
+def _normalize_username(value: str) -> str:
+ normalized = value.strip().lower()
+ if "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
+ return normalized
+
+
+def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
+ if not last_auth_at:
+ return False
+ try:
+ parsed = datetime.fromisoformat(last_auth_at)
+ except ValueError:
+ return False
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ age = datetime.now(timezone.utc) - parsed
+ return age <= timedelta(days=7)
+
+
+def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
+ if not user or not password:
+ return False
+ cached_hash = user.get("jellyfin_password_hash")
+ last_auth_at = user.get("last_jellyfin_auth_at")
+ if not cached_hash or not last_auth_at:
+ return False
+ if not verify_password(password, cached_hash):
+ return False
+ return _is_recent_jellyfin_auth(last_auth_at)
+
+def _extract_jellyseerr_user_id(response: dict) -> int | None:
+ if not isinstance(response, dict):
+ return None
+ candidate = response
+ if isinstance(response.get("user"), dict):
+ candidate = response.get("user")
+ for key in ("id", "userId", "Id"):
+ value = candidate.get(key) if isinstance(candidate, dict) else None
+ if value is None:
+ continue
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def _extract_jellyseerr_response_email(response: dict) -> str | None:
+ if not isinstance(response, dict):
+ return None
+ user_payload = response.get("user") if isinstance(response.get("user"), dict) else response
+ return extract_jellyseerr_user_email(user_payload)
+
+
+def _extract_http_error_detail(exc: Exception) -> str:
+ if isinstance(exc, httpx.HTTPStatusError):
+ response = exc.response
+ try:
+ text = response.text.strip()
+ except Exception:
+ text = ""
+ if text:
+ return text
+ return f"HTTP {response.status_code}"
+ return str(exc)
+
+
+def _requested_user_agent(request: Request) -> str:
+ user_agent = request.headers.get("user-agent", "")
+ return user_agent[:512]
+
+
+async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
+ try:
+ users = await client.get_users()
+ if isinstance(users, list):
+ save_jellyfin_users_cache(users)
+ except Exception:
+ # Cache refresh is best-effort and should not block auth/signup.
+ return
+
+
+def _is_user_expired(user: dict | None) -> bool:
if not user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ 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")
- token = create_access_token(user["username"], user["role"])
- set_last_login(user["username"])
+ 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 {
- "access_token": token,
- "token_type": "bearer",
- "user": {"username": user["username"], "role": user["role"]},
+ "authenticated": True,
+ "token_type": "cookie",
+ "user": user_payload,
}
+def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
+ return {
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "description": invite.get("description"),
+ "enabled": bool(invite.get("enabled")),
+ "expires_at": invite.get("expires_at"),
+ "max_uses": invite.get("max_uses"),
+ "use_count": invite.get("use_count", 0),
+ "remaining_uses": invite.get("remaining_uses"),
+ "is_expired": bool(invite.get("is_expired")),
+ "is_usable": bool(invite.get("is_usable")),
+ "profile": (
+ {
+ "id": profile.get("id"),
+ "name": profile.get("name"),
+ "description": profile.get("description"),
+ }
+ if profile
+ else None
+ ),
+ }
+
+
+def _parse_optional_positive_int(value: object, field_name: str) -> int | None:
+ if value is None or value == "":
+ return None
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc
+ if parsed <= 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"{field_name} must be greater than 0",
+ )
+ return parsed
+
+
+def _parse_optional_expires_at(value: object) -> str | None:
+ if value is None or value == "":
+ return None
+ if not isinstance(value, str):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="expires_at must be an ISO datetime string",
+ )
+ candidate = value.strip()
+ if not candidate:
+ return None
+ try:
+ parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="expires_at must be a valid ISO datetime",
+ ) from exc
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.isoformat()
+
+
+def _normalize_invite_code(value: str | None) -> str:
+ raw = (value or "").strip().upper()
+ filtered = "".join(ch for ch in raw if ch.isalnum())
+ if len(filtered) < 6:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invite code must be at least 6 letters/numbers.",
+ )
+ return filtered
+
+
+def _generate_invite_code(length: int = 12) -> str:
+ alphabet = string.ascii_uppercase + string.digits
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+def _same_username(a: object, b: object) -> bool:
+ if not isinstance(a, str) or not isinstance(b, str):
+ return False
+ return a.strip().lower() == b.strip().lower()
+
+
+def _serialize_self_invite(invite: dict) -> dict:
+ profile = None
+ profile_id = invite.get("profile_id")
+ if profile_id is not None:
+ try:
+ profile = get_user_profile(int(profile_id))
+ except Exception:
+ profile = None
+ return {
+ "id": invite.get("id"),
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "description": invite.get("description"),
+ "profile_id": invite.get("profile_id"),
+ "profile": (
+ {"id": profile.get("id"), "name": profile.get("name")}
+ if isinstance(profile, dict)
+ else None
+ ),
+ "role": invite.get("role"),
+ "max_uses": invite.get("max_uses"),
+ "use_count": invite.get("use_count", 0),
+ "remaining_uses": invite.get("remaining_uses"),
+ "enabled": bool(invite.get("enabled")),
+ "expires_at": invite.get("expires_at"),
+ "recipient_email": invite.get("recipient_email"),
+ "is_expired": bool(invite.get("is_expired")),
+ "is_usable": bool(invite.get("is_usable")),
+ "created_at": invite.get("created_at"),
+ "updated_at": invite.get("updated_at"),
+ "created_by": invite.get("created_by"),
+ }
+
+
+def _current_user_invites(username: str) -> list[dict]:
+ owned = [
+ invite
+ for invite in list_signup_invites()
+ if _same_username(invite.get("created_by"), username)
+ ]
+ owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True)
+ return owned
+
+
+def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
+ invite = get_signup_invite_by_id(invite_id)
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ if not _same_username(invite.get("created_by"), current_user.get("username")):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites")
+ return invite
+
+
+def _self_service_invite_access_enabled(current_user: dict) -> bool:
+ if str(current_user.get("role") or "").lower() == "admin":
+ return True
+ return bool(current_user.get("invite_management_enabled", False))
+
+
+def _require_self_service_invite_access(current_user: dict) -> None:
+ if _self_service_invite_access_enabled(current_user):
+ return
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Invite management is not enabled for your account.",
+ )
+
+
+def _get_self_service_master_invite() -> dict | None:
+ raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
+ if raw_value is None:
+ return None
+ candidate = str(raw_value).strip()
+ if not candidate:
+ return None
+ try:
+ invite_id = int(candidate)
+ except (TypeError, ValueError):
+ return None
+ if invite_id <= 0:
+ return None
+ return get_signup_invite_by_id(invite_id)
+
+
+def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
+ if not isinstance(invite, dict):
+ return None
+ profile = None
+ profile_id = invite.get("profile_id")
+ if isinstance(profile_id, int):
+ profile = get_user_profile(profile_id)
+ return {
+ "id": invite.get("id"),
+ "code": invite.get("code"),
+ "label": invite.get("label"),
+ "description": invite.get("description"),
+ "profile_id": invite.get("profile_id"),
+ "recipient_email": invite.get("recipient_email"),
+ "profile": (
+ {"id": profile.get("id"), "name": profile.get("name")}
+ if isinstance(profile, dict)
+ else None
+ ),
+ "role": invite.get("role"),
+ "max_uses": invite.get("max_uses"),
+ "enabled": bool(invite.get("enabled")),
+ "expires_at": invite.get("expires_at"),
+ "is_expired": bool(invite.get("is_expired")),
+ "is_usable": bool(invite.get("is_usable")),
+ "created_at": invite.get("created_at"),
+ "updated_at": invite.get("updated_at"),
+ }
+
+
+def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]:
+ profile_id_raw = master_invite.get("profile_id")
+ profile_id: int | None = None
+ if isinstance(profile_id_raw, int):
+ profile_id = profile_id_raw
+ elif profile_id_raw not in (None, ""):
+ try:
+ profile_id = int(profile_id_raw)
+ except (TypeError, ValueError):
+ profile_id = None
+ role_value = str(master_invite.get("role") or "").strip().lower()
+ role = role_value if role_value in {"user", "admin"} else "user"
+ max_uses_raw = master_invite.get("max_uses")
+ try:
+ max_uses = int(max_uses_raw) if max_uses_raw is not None else None
+ except (TypeError, ValueError):
+ max_uses = None
+ enabled = bool(master_invite.get("enabled", True))
+ expires_at_value = master_invite.get("expires_at")
+ expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None
+ return profile_id, role, max_uses, enabled, expires_at
+
+
+@router.post("/login")
+async def login(
+ request: Request,
+ response: Response,
+ form_data: OAuth2PasswordRequestForm = Depends(),
+) -> dict:
+ _enforce_login_rate_limit(request, form_data.username)
+ logger.info(
+ "login attempt provider=local username=%s client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ # Provider placeholder passwords must never be accepted by the local-login endpoint.
+ if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
+ _record_login_failure(request, form_data.username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ matching_users = get_users_by_username_ci(form_data.username)
+ has_external_match = any(
+ str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
+ )
+ if has_external_match:
+ logger.warning(
+ "login rejected provider=local username=%s reason=external-account client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This account uses external sign-in. Use the external sign-in option.",
+ )
+ user = verify_user_password(form_data.username, form_data.password)
+ if not user:
+ _record_login_failure(request, form_data.username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ if user.get("auth_provider") != "local":
+ logger.warning(
+ "login rejected provider=local username=%s reason=wrong-provider client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This account uses external sign-in. Use the external sign-in option.",
+ )
+ _assert_user_can_login(user)
+ token = create_access_token(user["username"], user["role"])
+ _clear_login_failures(request, form_data.username)
+ set_last_login(user["username"])
+ logger.info(
+ "login success provider=local username=%s role=%s client=%s",
+ user["username"],
+ user["role"],
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": user["username"], "role": user["role"]},
+ )
+
+
@router.post("/jellyfin/login")
-async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
+async def jellyfin_login(
+ request: Request,
+ response: Response,
+ form_data: OAuth2PasswordRequestForm = Depends(),
+) -> dict:
+ _enforce_login_rate_limit(request, form_data.username)
+ logger.info(
+ "login attempt provider=jellyfin username=%s client=%s",
+ _login_rate_key_user(form_data.username),
+ _auth_client_ip(request),
+ )
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
+ jellyseerr_users = get_cached_jellyseerr_users()
+ candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
+ username = form_data.username
+ password = form_data.password
+ ci_matches = get_users_by_username_ci(username)
+ preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
+ canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
+ user = preferred_match or get_user_by_username(username)
+ matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or [])
+ matched_email = extract_jellyseerr_user_email(matched_seerr_user)
+ _assert_user_can_login(user)
+ if user and _has_valid_jellyfin_cache(user, password):
+ token = create_access_token(canonical_username, "user")
+ _clear_login_failures(request, username)
+ set_last_login(canonical_username)
+ logger.info(
+ "login success provider=jellyfin username=%s source=cache client=%s",
+ canonical_username,
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": canonical_username, "role": "user"},
+ )
try:
- response = await client.authenticate_by_name(form_data.username, form_data.password)
+ 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(response, dict) or not response.get("User"):
+ 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")
- create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin")
- user = get_user_by_username(form_data.username)
- if user and user.get("is_blocked"):
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
+ 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):
- for user in users:
- if not isinstance(user, dict):
- continue
- name = user.get("Name")
- if isinstance(name, str) and name:
- create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
+ save_jellyfin_users_cache(users)
except Exception:
pass
- token = create_access_token(form_data.username, "user")
- set_last_login(form_data.username)
- return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
+ sync_jellyfin_password_state(canonical_username, password)
+ if user and user.get("jellyseerr_user_id") is None and candidate_map:
+ matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
+ if matched_id is not None:
+ set_user_jellyseerr_id(canonical_username, matched_id)
+ token = create_access_token(canonical_username, "user")
+ _clear_login_failures(request, username)
+ set_last_login(canonical_username)
+ logger.info(
+ "login success provider=jellyfin username=%s linked_seerr_id=%s client=%s",
+ canonical_username,
+ get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": canonical_username, "role": "user"},
+ )
+@router.post("/seerr/login")
@router.post("/jellyseerr/login")
-async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
+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="Jellyseerr not configured")
- payload = {"email": form_data.username, "password": form_data.password}
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
try:
- response = await client.post("/api/v1/auth/login", payload=payload)
+ 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(response, dict):
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
- create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr")
- user = get_user_by_username(form_data.username)
- if user and user.get("is_blocked"):
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
- token = create_access_token(form_data.username, "user")
- set_last_login(form_data.username)
- return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
+ if not isinstance(auth_response, dict):
+ _record_login_failure(request, form_data.username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
+ jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
+ jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
+ ci_matches = get_users_by_username_ci(form_data.username)
+ preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
+ canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
+ if not preferred_match:
+ create_user_if_missing(
+ canonical_username,
+ "jellyseerr-user",
+ role="user",
+ email=jellyseerr_email,
+ auth_provider="jellyseerr",
+ jellyseerr_user_id=jellyseerr_user_id,
+ )
+ elif (
+ preferred_match
+ and str(preferred_match.get("role") or "user").strip().lower() != "admin"
+ and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"}
+ ):
+ set_user_auth_provider(canonical_username, "jellyseerr")
+ user = get_user_by_username(canonical_username)
+ _assert_user_can_login(user)
+ if jellyseerr_user_id is not None:
+ set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
+ if jellyseerr_email:
+ set_user_email(canonical_username, jellyseerr_email)
+ token = create_access_token(canonical_username, "user")
+ _clear_login_failures(request, form_data.username)
+ set_last_login(canonical_username)
+ logger.info(
+ "login success provider=seerr username=%s seerr_user_id=%s client=%s",
+ canonical_username,
+ jellyseerr_user_id,
+ _auth_client_ip(request),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {"username": canonical_username, "role": "user"},
+ )
@router.get("/me")
@@ -92,23 +833,617 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
+@router.post("/logout")
+async def logout(response: Response) -> dict:
+ clear_auth_cookies(response)
+ return {"status": "ok"}
+
+
+@router.get("/stream-token")
+async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
+ token = create_stream_token(
+ current_user["username"],
+ current_user["role"],
+ expires_seconds=STREAM_TOKEN_TTL_SECONDS,
+ )
+ return {
+ "stream_token": token,
+ "token_type": "bearer",
+ "expires_in": STREAM_TOKEN_TTL_SECONDS,
+ }
+
+
+@router.get("/invites/{code}")
+async def invite_details(code: str) -> dict:
+ invite = get_signup_invite_by_code(code.strip())
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ profile = None
+ profile_id = invite.get("profile_id")
+ if profile_id is not None:
+ profile = get_user_profile(int(profile_id))
+ if profile and not profile.get("is_active", True):
+ invite = {**invite, "is_usable": False}
+ return {"invite": _public_invite_payload(invite, profile)}
+
+
+@router.post("/signup")
+async def signup(payload: dict, response: Response) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ invite_code = str(payload.get("invite_code") or "").strip()
+ username = str(payload.get("username") or "").strip()
+ password = str(payload.get("password") or "")
+ if not invite_code:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
+ try:
+ password_value = validate_password_policy(password)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ if get_user_by_username(username):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
+ logger.info(
+ "signup attempt username=%s invite_code=%s",
+ username,
+ invite_code,
+ )
+
+ invite = get_signup_invite_by_code(invite_code)
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ if not invite.get("enabled"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
+ if invite.get("is_expired"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
+ remaining_uses = invite.get("remaining_uses")
+ if remaining_uses is not None and int(remaining_uses) <= 0:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
+
+ profile = None
+ profile_id = invite.get("profile_id")
+ if profile_id is not None:
+ profile = get_user_profile(int(profile_id))
+ if not profile:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
+ if not profile.get("is_active", True):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
+
+ invite_role = invite.get("role")
+ profile_role = profile.get("role") if profile else None
+ role = invite_role if invite_role in {"user", "admin"} else profile_role
+ if role not in {"user", "admin"}:
+ role = "user"
+
+ auto_search_enabled = (
+ bool(profile.get("auto_search_enabled", True))
+ if profile is not None
+ else True
+ )
+
+ expires_at = None
+ account_expires_days = profile.get("account_expires_days") if profile else None
+ if isinstance(account_expires_days, int) and account_expires_days > 0:
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
+
+ runtime = get_runtime_settings()
+ auth_provider = "local"
+ local_password_value = password_value
+ matched_jellyseerr_user_id: int | None = None
+
+ jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if jellyfin_client.configured():
+ logger.info("signup provisioning jellyfin username=%s", username)
+ auth_provider = "jellyfin"
+ local_password_value = password_value
+ try:
+ await jellyfin_client.create_user_with_password(username, password_value)
+ except httpx.HTTPStatusError as exc:
+ status_code = exc.response.status_code if exc.response is not None else None
+ duplicate_like = status_code in {400, 409}
+ if duplicate_like:
+ try:
+ auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
+ except Exception as auth_exc:
+ detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
+ ) from exc
+ if not isinstance(auth_response, dict) or not auth_response.get("User"):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Jellyfin account already exists for that username.",
+ ) from exc
+ else:
+ detail = _extract_http_error_detail(exc)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin account provisioning failed: {detail}",
+ ) from exc
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin account provisioning failed: {detail}",
+ ) from exc
+
+ await _refresh_jellyfin_user_cache(jellyfin_client)
+ jellyseerr_users = get_cached_jellyseerr_users()
+ candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
+ if candidate_map:
+ matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
+
+ try:
+ create_user(
+ username,
+ local_password_value,
+ role=role,
+ email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
+ auth_provider=auth_provider,
+ jellyseerr_user_id=matched_jellyseerr_user_id,
+ auto_search_enabled=auto_search_enabled,
+ profile_id=int(profile_id) if profile_id is not None else None,
+ expires_at=expires_at,
+ invited_by_code=invite.get("code"),
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+ increment_signup_invite_use(int(invite["id"]))
+ created_user = get_user_by_username(username)
+ if auth_provider == "jellyfin":
+ sync_jellyfin_password_state(username, password_value)
+ if (
+ created_user
+ and created_user.get("jellyseerr_user_id") is None
+ and matched_jellyseerr_user_id is not None
+ ):
+ set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
+ created_user = get_user_by_username(username)
+ if created_user:
+ try:
+ await send_templated_email(
+ "welcome",
+ invite=invite,
+ user=created_user,
+ )
+ except Exception as exc:
+ # Welcome email delivery is best-effort and must not break signup.
+ logger.warning("Welcome email send skipped for %s: %s", username, exc)
+ _assert_user_can_login(created_user)
+ token = create_access_token(username, role)
+ set_last_login(username)
+ logger.info(
+ "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
+ username,
+ role,
+ created_user.get("auth_provider") if created_user else auth_provider,
+ created_user.get("profile_id") if created_user else None,
+ invite.get("code"),
+ )
+ return _auth_success_response(
+ response,
+ token,
+ {
+ "username": username,
+ "role": role,
+ "auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
+ "profile_id": created_user.get("profile_id") if created_user else None,
+ "expires_at": created_user.get("expires_at") if created_user else None,
+ },
+ )
+
+
+@router.post("/password/forgot")
+async def forgot_password(payload: dict, request: Request) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
+ if not isinstance(identifier, str) or not identifier.strip():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
+ _enforce_password_reset_rate_limit(request, identifier)
+ _record_password_reset_attempt(request, identifier)
+
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=f"Password reset email is unavailable: {detail}",
+ )
+
+ client_ip = _auth_client_ip(request)
+ safe_identifier = identifier.strip().lower()[:256]
+ logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
+ try:
+ reset_result = await request_password_reset(
+ identifier,
+ requested_by_ip=client_ip,
+ requested_user_agent=_requested_user_agent(request),
+ )
+ if reset_result.get("issued"):
+ logger.info(
+ "password reset issued username=%s provider=%s recipient=%s client=%s",
+ reset_result.get("username"),
+ reset_result.get("auth_provider"),
+ reset_result.get("recipient_email"),
+ client_ip,
+ )
+ else:
+ logger.info(
+ "password reset request completed with no eligible account identifier=%s client=%s",
+ safe_identifier,
+ client_ip,
+ )
+ except Exception as exc:
+ logger.warning(
+ "password reset email dispatch failed identifier=%s client=%s detail=%s",
+ safe_identifier,
+ client_ip,
+ str(exc),
+ )
+ return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
+
+
+@router.get("/password/reset/verify")
+async def password_reset_verify(token: str) -> dict:
+ if not isinstance(token, str) or not token.strip():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
+ try:
+ return verify_password_reset_token(token.strip())
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+
+@router.post("/password/reset")
+async def password_reset(payload: dict) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ token = payload.get("token")
+ new_password = payload.get("new_password")
+ if not isinstance(token, str) or not token.strip():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
+ if not isinstance(new_password, str):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE)
+ try:
+ new_password_clean = validate_password_policy(new_password)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+ try:
+ result = await apply_password_reset(token.strip(), new_password_clean)
+ except PasswordResetUnavailableError as exc:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ logger.warning("password reset failed token_present=%s detail=%s", bool(token), detail)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Password reset failed: {detail}",
+ ) from exc
+
+ logger.info(
+ "password reset completed username=%s provider=%s",
+ result.get("username"),
+ result.get("provider"),
+ )
+ return result
+
+
+@router.get("/profile")
+async def profile(current_user: dict = Depends(get_current_user)) -> dict:
+ username = current_user.get("username") or ""
+ username_norm = _normalize_username(username) if username else ""
+ stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id"))
+ global_total = get_global_request_total()
+ share = (stats.get("total", 0) / global_total) if global_total else 0
+ activity_summary = get_user_activity_summary(username) if username else {}
+ activity_recent = get_user_activity(username, limit=5) if username else []
+ stats_payload = {
+ **stats,
+ "share": share,
+ "global_total": global_total,
+ }
+ if current_user.get("role") == "admin":
+ stats_payload["most_active_user"] = get_global_request_leader()
+ return {
+ "user": current_user,
+ "stats": stats_payload,
+ "activity": {
+ **activity_summary,
+ "recent": activity_recent,
+ },
+ }
+
+
+@router.get("/profile/invites")
+async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+ master_invite = _get_self_service_master_invite()
+ invite_access_enabled = _self_service_invite_access_enabled(current_user)
+ invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
+ return {
+ "invites": invites,
+ "count": len(invites),
+ "invite_access": {
+ "enabled": invite_access_enabled,
+ "managed_by_master": bool(master_invite),
+ },
+ "master_invite": _serialize_self_service_master_invite(master_invite),
+ }
+
+
+@router.post("/profile/invites")
+async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ _require_self_service_invite_access(current_user)
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+
+ requested_code = payload.get("code")
+ if isinstance(requested_code, str) and requested_code.strip():
+ code = _normalize_invite_code(requested_code)
+ existing = get_signup_invite_by_code(code)
+ if existing:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
+ else:
+ code = ""
+ for _ in range(20):
+ candidate = _generate_invite_code()
+ if not get_signup_invite_by_code(candidate):
+ code = candidate
+ break
+ if not code:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code")
+
+ label = payload.get("label")
+ description = payload.get("description")
+ recipient_email = payload.get("recipient_email")
+ if label is not None:
+ label = str(label).strip() or None
+ if description is not None:
+ description = str(description).strip() or None
+ recipient_email = _require_recipient_email(recipient_email)
+ send_email = bool(payload.get("send_email"))
+ delivery_message = str(payload.get("message") or "").strip() or None
+
+ master_invite = _get_self_service_master_invite()
+ if master_invite:
+ if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).",
+ )
+ profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
+ if profile_id is not None and not get_user_profile(profile_id):
+ profile_id = None
+ role = "user"
+ else:
+ max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at"))
+ enabled = bool(payload.get("enabled", True))
+ profile_id = current_user.get("profile_id")
+ if not isinstance(profile_id, int) or profile_id <= 0:
+ profile_id = None
+ role = "user"
+
+ invite = create_signup_invite(
+ code=code,
+ label=label,
+ description=description,
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ created_by=username,
+ )
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ user=current_user,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ status_value = "partial" if email_error else "ok"
+ return {
+ "status": status_value,
+ "invite": _serialize_self_invite(invite),
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.put("/profile/invites/{invite_id}")
+async def update_profile_invite(
+ invite_id: int, payload: dict, current_user: dict = Depends(get_current_user)
+) -> dict:
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
+ _require_self_service_invite_access(current_user)
+ existing = _get_owned_invite(invite_id, current_user)
+
+ requested_code = payload.get("code", existing.get("code"))
+ if isinstance(requested_code, str) and requested_code.strip():
+ code = _normalize_invite_code(requested_code)
+ else:
+ code = str(existing.get("code") or "").strip()
+ if not code:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
+ duplicate = get_signup_invite_by_code(code)
+ if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
+
+ label = payload.get("label", existing.get("label"))
+ description = payload.get("description", existing.get("description"))
+ recipient_email = payload.get("recipient_email", existing.get("recipient_email"))
+ if label is not None:
+ label = str(label).strip() or None
+ if description is not None:
+ description = str(description).strip() or None
+ recipient_email = _require_recipient_email(recipient_email)
+ send_email = bool(payload.get("send_email"))
+ delivery_message = str(payload.get("message") or "").strip() or None
+
+ master_invite = _get_self_service_master_invite()
+ if master_invite:
+ profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
+ if profile_id is not None and not get_user_profile(profile_id):
+ profile_id = None
+ role = "user"
+ else:
+ max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
+ expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
+ enabled_raw = payload.get("enabled", existing.get("enabled"))
+ enabled = bool(enabled_raw)
+ profile_id = existing.get("profile_id")
+ role = existing.get("role")
+
+ invite = update_signup_invite(
+ invite_id,
+ code=code,
+ label=label,
+ description=description,
+ profile_id=profile_id,
+ role=role,
+ max_uses=max_uses,
+ enabled=enabled,
+ expires_at=expires_at,
+ recipient_email=recipient_email,
+ )
+ if not invite:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ email_result = None
+ email_error = None
+ if send_email:
+ try:
+ email_result = await send_templated_email(
+ "invited",
+ invite=invite,
+ user=current_user,
+ recipient_email=recipient_email,
+ message=delivery_message,
+ )
+ except Exception as exc:
+ email_error = str(exc)
+ status_value = "partial" if email_error else "ok"
+ return {
+ "status": status_value,
+ "invite": _serialize_self_invite(invite),
+ "email": (
+ {"status": "ok", **email_result}
+ if email_result
+ else {"status": "error", "detail": email_error}
+ if email_error
+ else None
+ ),
+ }
+
+
+@router.delete("/profile/invites/{invite_id}")
+async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
+ _require_self_service_invite_access(current_user)
+ _get_owned_invite(invite_id, current_user)
+ deleted = delete_signup_invite(invite_id)
+ if not deleted:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
+ return {"status": "ok"}
+
+
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
- if current_user.get("auth_provider") != "local":
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Password changes are only available for local users.",
- )
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")
- if len(new_password.strip()) < 8:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
- )
- user = verify_user_password(current_user["username"], current_password)
- if not user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
- set_user_password(current_user["username"], new_password.strip())
- return {"status": "ok"}
+ try:
+ new_password_clean = validate_password_policy(new_password)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ username = str(current_user.get("username") or "").strip()
+ if not username:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
+ stored_user = normalize_user_auth_provider(get_user_by_username(username))
+ auth_provider = resolve_user_auth_provider(stored_user or current_user)
+ logger.info("password change requested username=%s provider=%s", username, auth_provider)
+
+ if auth_provider == "local":
+ user = verify_user_password(username, current_password)
+ if not user:
+ logger.warning("password change rejected username=%s provider=local reason=invalid-current-password", username)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
+ set_user_password(username, new_password_clean)
+ logger.info("password change completed username=%s provider=local", username)
+ return {"status": "ok", "provider": "local"}
+
+ if auth_provider == "jellyfin":
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Jellyfin is not configured for password passthrough.",
+ )
+ try:
+ auth_result = await client.authenticate_by_name(username, current_password)
+ if not isinstance(auth_result, dict) or not auth_result.get("User"):
+ logger.warning("password change rejected username=%s provider=jellyfin reason=invalid-current-password", username)
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ logger.warning("password change validation failed username=%s provider=jellyfin detail=%s", username, detail)
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
+ ) from exc
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin password validation failed: {detail}",
+ ) from exc
+
+ try:
+ jf_user = await client.find_user_by_name(username)
+ user_id = client._extract_user_id(jf_user)
+ if not user_id:
+ raise RuntimeError("Jellyfin user ID not found")
+ await client.set_user_password(user_id, new_password_clean)
+ except Exception as exc:
+ detail = _extract_http_error_detail(exc)
+ logger.warning("password change update failed username=%s provider=jellyfin detail=%s", username, detail)
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Jellyfin password update failed: {detail}",
+ ) from exc
+
+ # Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
+ sync_jellyfin_password_state(username, new_password_clean)
+ logger.info("password change completed username=%s provider=jellyfin", username)
+ return {"status": "ok", "provider": "jellyfin"}
+
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Password changes are not available for this sign-in provider.",
+ )
diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py
index 7034810..a01ae85 100644
--- a/backend/app/routers/branding.py
+++ b/backend/app/routers/branding.py
@@ -11,6 +11,10 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
+_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
+_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
+_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
+_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
def _ensure_branding_dir() -> None:
@@ -41,6 +45,18 @@ 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)
@@ -65,24 +81,32 @@ def _ensure_default_branding() -> None:
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:
- if not os.path.exists(_LOGO_PATH):
- _ensure_default_branding()
- if not os.path.exists(_LOGO_PATH):
+ logo_path, _ = _resolve_branding_paths()
+ if not os.path.exists(logo_path):
raise HTTPException(status_code=404, detail="Logo not found")
- headers = {"Cache-Control": "public, max-age=300"}
- return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
+ headers = {"Cache-Control": "no-store"}
+ return FileResponse(logo_path, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
- if not os.path.exists(_FAVICON_PATH):
- _ensure_default_branding()
- if not os.path.exists(_FAVICON_PATH):
+ _, favicon_path = _resolve_branding_paths()
+ if not os.path.exists(favicon_path):
raise HTTPException(status_code=404, detail="Favicon not found")
- headers = {"Cache-Control": "public, max-age=300"}
- return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
+ 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]:
diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py
new file mode 100644
index 0000000..d90a601
--- /dev/null
+++ b/backend/app/routers/events.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+
+from ..auth import get_current_user_event_stream
+from . import requests as requests_router
+from .status import services_status
+
+router = APIRouter(prefix="/events", tags=["events"])
+
+
+def _sse_json(payload: Dict[str, Any]) -> str:
+ return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
+
+
+def _jsonable(value: Any) -> Any:
+ if hasattr(value, "model_dump"):
+ try:
+ return value.model_dump(mode="json")
+ except TypeError:
+ return value.model_dump()
+ if hasattr(value, "dict"):
+ try:
+ return value.dict()
+ except TypeError:
+ return value
+ return value
+
+
+def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
+ if not isinstance(entries, list):
+ return []
+ items: list[dict[str, Any]] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ items.append(
+ {
+ "request_id": entry.get("request_id"),
+ "state": entry.get("state"),
+ "state_reason": entry.get("state_reason"),
+ "created_at": entry.get("created_at"),
+ }
+ )
+ return items
+
+
+def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
+ if not isinstance(entries, list):
+ return []
+ items: list[dict[str, Any]] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ items.append(
+ {
+ "request_id": entry.get("request_id"),
+ "action_id": entry.get("action_id"),
+ "label": entry.get("label"),
+ "status": entry.get("status"),
+ "message": entry.get("message"),
+ "created_at": entry.get("created_at"),
+ }
+ )
+ return items
+
+
+@router.get("/stream")
+async def events_stream(
+ request: Request,
+ recent_days: int = 90,
+ recent_stage: str = "all",
+ user: Dict[str, Any] = Depends(get_current_user_event_stream),
+) -> StreamingResponse:
+ recent_days = max(0, min(int(recent_days or 90), 3650))
+ recent_take = 50 if user.get("role") == "admin" else 6
+
+ async def event_generator():
+ yield "retry: 2000\n\n"
+ last_recent_signature: Optional[str] = None
+ last_services_signature: Optional[str] = None
+ next_recent_at = 0.0
+ next_services_at = 0.0
+ heartbeat_counter = 0
+
+ while True:
+ if await request.is_disconnected():
+ break
+
+ now = time.monotonic()
+ sent_any = False
+
+ if now >= next_recent_at:
+ next_recent_at = now + 15.0
+ try:
+ recent_payload = await requests_router.recent_requests(
+ take=recent_take,
+ skip=0,
+ days=recent_days,
+ stage=recent_stage,
+ user=user,
+ )
+ results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
+ payload = {
+ "type": "home_recent",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "days": recent_days,
+ "stage": recent_stage,
+ "results": results if isinstance(results, list) else [],
+ }
+ except Exception as exc:
+ payload = {
+ "type": "home_recent",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "days": recent_days,
+ "stage": recent_stage,
+ "error": str(exc),
+ }
+ signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+ if signature != last_recent_signature:
+ last_recent_signature = signature
+ yield _sse_json(payload)
+ sent_any = True
+
+ if now >= next_services_at:
+ next_services_at = now + 30.0
+ try:
+ status_payload = await services_status()
+ payload = {
+ "type": "home_services",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "status": status_payload,
+ }
+ except Exception as exc:
+ payload = {
+ "type": "home_services",
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ }
+ signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+ if signature != last_services_signature:
+ last_services_signature = signature
+ yield _sse_json(payload)
+ sent_any = True
+
+ if sent_any:
+ heartbeat_counter = 0
+ else:
+ heartbeat_counter += 1
+ if heartbeat_counter >= 15:
+ yield ": ping\n\n"
+ heartbeat_counter = 0
+
+ await asyncio.sleep(1.0)
+
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+
+
+@router.get("/requests/{request_id}/stream")
+async def request_events_stream(
+ request_id: str,
+ request: Request,
+ user: Dict[str, Any] = Depends(get_current_user_event_stream),
+) -> StreamingResponse:
+ request_id = str(request_id).strip()
+ if not request_id:
+ raise HTTPException(status_code=400, detail="Missing request id")
+
+ async def event_generator():
+ yield "retry: 2000\n\n"
+ last_signature: Optional[str] = None
+ next_refresh_at = 0.0
+ heartbeat_counter = 0
+
+ while True:
+ if await request.is_disconnected():
+ break
+
+ now = time.monotonic()
+ sent_any = False
+
+ if now >= next_refresh_at:
+ next_refresh_at = now + 2.0
+ try:
+ snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
+ history_payload = await requests_router.request_history(
+ request_id=request_id, limit=5, user=user
+ )
+ actions_payload = await requests_router.request_actions(
+ request_id=request_id, limit=5, user=user
+ )
+ payload = {
+ "type": "request_live",
+ "request_id": request_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "snapshot": _jsonable(snapshot),
+ "history": _request_history_brief(
+ history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
+ ),
+ "actions": _request_actions_brief(
+ actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
+ ),
+ }
+ except HTTPException as exc:
+ payload = {
+ "type": "request_live",
+ "request_id": request_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc.detail),
+ "status_code": int(exc.status_code),
+ }
+ except Exception as exc:
+ payload = {
+ "type": "request_live",
+ "request_id": request_id,
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ }
+
+ signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+ if signature != last_signature:
+ last_signature = signature
+ yield _sse_json(payload)
+ sent_any = True
+
+ if sent_any:
+ heartbeat_counter = 0
+ else:
+ heartbeat_counter += 1
+ if heartbeat_counter >= 15:
+ yield ": ping\n\n"
+ heartbeat_counter = 0
+
+ await asyncio.sleep(1.0)
+
+ headers = {
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
diff --git a/backend/app/routers/feedback.py b/backend/app/routers/feedback.py
index d383724..b42f066 100644
--- a/backend/app/routers/feedback.py
+++ b/backend/app/routers/feedback.py
@@ -3,6 +3,7 @@ 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)])
@@ -11,9 +12,16 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
@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 = runtime.discord_webhook_url
+ 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"}:
diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py
index 4d40fd3..f7c149d 100644
--- a/backend/app/routers/images.py
+++ b/backend/app/routers/images.py
@@ -1,6 +1,8 @@
import os
import re
import mimetypes
+import logging
+from typing import Optional
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, RedirectResponse
import httpx
@@ -11,6 +13,7 @@ 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:
@@ -19,13 +22,24 @@ def _safe_filename(path: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
return safe or "image"
-
-async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
+def tmdb_cache_path(path: str, size: str) -> Optional[str]:
if not path or "://" in path or ".." in path:
- return False
+ 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()
@@ -33,9 +47,10 @@ async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if cache_mode != "cache":
return False
- cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
- os.makedirs(cache_dir, exist_ok=True)
- file_path = os.path.join(cache_dir, _safe_filename(path))
+ 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
@@ -64,9 +79,10 @@ async def tmdb_image(path: str, size: str = "w342"):
if cache_mode != "cache":
return RedirectResponse(url=url)
- cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
- os.makedirs(cache_dir, exist_ok=True)
- file_path = os.path.join(cache_dir, _safe_filename(path))
+ 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"
@@ -77,6 +93,8 @@ async def tmdb_image(path: str, size: str = "w342"):
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)
- raise HTTPException(status_code=502, detail="Image cache failed")
- except httpx.HTTPError as exc:
- raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
+ logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
+ except (httpx.HTTPError, OSError) as exc:
+ logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
+
+ return RedirectResponse(url=url)
diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py
new file mode 100644
index 0000000..2b95ff3
--- /dev/null
+++ b/backend/app/routers/portal.py
@@ -0,0 +1,1056 @@
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional, Tuple
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from ..auth import get_current_user
+from ..db import (
+ add_portal_comment,
+ count_portal_items,
+ create_portal_item,
+ get_portal_item,
+ get_portal_overview,
+ list_portal_comments,
+ list_portal_items,
+ update_portal_item,
+)
+from ..services.notifications import send_portal_notification
+
+router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
+logger = logging.getLogger(__name__)
+
+PORTAL_KINDS = {"request", "issue", "feature"}
+PORTAL_STATUSES = {
+ # Existing generic statuses
+ "new",
+ "triaging",
+ "planned",
+ "in_progress",
+ "blocked",
+ "done",
+ "declined",
+ "closed",
+ # Seerr-style request pipeline statuses
+ "pending",
+ "approved",
+ "processing",
+ "partially_available",
+ "available",
+ "failed",
+}
+PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"}
+PORTAL_MEDIA_TYPES = {"movie", "tv"}
+PORTAL_REQUEST_STATUSES = {"pending", "approved", "declined"}
+PORTAL_MEDIA_STATUSES = {
+ "unknown",
+ "pending",
+ "processing",
+ "partially_available",
+ "available",
+ "failed",
+}
+PORTAL_ISSUE_TYPES = {
+ "general",
+ "playback",
+ "subtitle",
+ "quality",
+ "metadata",
+ "missing_content",
+ "other",
+}
+
+REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
+ "pending": {"pending", "approved", "declined"},
+ "approved": {"approved", "declined"},
+ "declined": {"declined", "pending", "approved"},
+}
+
+MEDIA_STATUS_TRANSITIONS: Dict[str, set[str]] = {
+ "unknown": {"unknown", "pending", "processing", "failed"},
+ "pending": {"pending", "processing", "partially_available", "available", "failed"},
+ "processing": {"processing", "partially_available", "available", "failed"},
+ "partially_available": {"partially_available", "processing", "available", "failed"},
+ "available": {"available", "processing"},
+ "failed": {"failed", "processing", "available"},
+}
+
+LEGACY_STATUS_TO_WORKFLOW: Dict[str, Tuple[str, str]] = {
+ "new": ("pending", "pending"),
+ "triaging": ("pending", "pending"),
+ "planned": ("approved", "pending"),
+ "in_progress": ("approved", "processing"),
+ "blocked": ("approved", "failed"),
+ "done": ("approved", "available"),
+ "closed": ("approved", "available"),
+ "pending": ("pending", "pending"),
+ "approved": ("approved", "pending"),
+ "declined": ("declined", "unknown"),
+ "processing": ("approved", "processing"),
+ "partially_available": ("approved", "partially_available"),
+ "available": ("approved", "available"),
+ "failed": ("approved", "failed"),
+}
+
+
+def _clean_text(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else None
+ return str(value)
+
+
+def _require_text(value: Any, field: str, *, max_length: int = 5000) -> str:
+ normalized = _clean_text(value)
+ if not normalized:
+ raise HTTPException(status_code=400, detail=f"{field} is required")
+ if len(normalized) > max_length:
+ raise HTTPException(
+ status_code=400,
+ detail=f"{field} is too long (max {max_length} characters)",
+ )
+ return normalized
+
+
+def _normalize_choice(
+ value: Any,
+ *,
+ field: str,
+ allowed: set[str],
+ default: Optional[str] = None,
+ allow_empty: bool = False,
+) -> Optional[str]:
+ if value is None:
+ return default
+ normalized = _clean_text(value)
+ if not normalized:
+ return None if allow_empty else default
+ candidate = normalized.lower()
+ if candidate not in allowed:
+ allowed_values = ", ".join(sorted(allowed))
+ raise HTTPException(status_code=400, detail=f"Invalid {field}. Allowed: {allowed_values}")
+ return candidate
+
+
+def _normalize_year(value: Any, *, allow_empty: bool = True) -> Optional[int]:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return None if allow_empty else 0
+ value = stripped
+ try:
+ year = int(value)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="year must be an integer") from None
+ if year < 1800 or year > 2100:
+ raise HTTPException(status_code=400, detail="year must be between 1800 and 2100")
+ return year
+
+
+def _normalize_int(value: Any, field: str, *, allow_empty: bool = True) -> Optional[int]:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return None if allow_empty else 0
+ value = stripped
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail=f"{field} must be an integer") from None
+
+
+def _normalize_bool(value: Any, *, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ candidate = value.strip().lower()
+ if candidate in {"1", "true", "yes", "on"}:
+ return True
+ if candidate in {"0", "false", "no", "off"}:
+ return False
+ raise HTTPException(status_code=400, detail="Boolean value expected")
+
+
+def _workflow_to_item_status(request_status: str, media_status: str) -> str:
+ if request_status == "declined":
+ return "declined"
+ if request_status == "pending":
+ return "pending"
+ if media_status == "available":
+ return "available"
+ if media_status == "partially_available":
+ return "partially_available"
+ if media_status == "failed":
+ return "failed"
+ if media_status == "processing":
+ return "processing"
+ return "approved"
+
+
+def _item_status_to_workflow(item: Dict[str, Any]) -> Tuple[str, str]:
+ request_status = _normalize_choice(
+ item.get("workflow_request_status"),
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ allow_empty=True,
+ )
+ media_status = _normalize_choice(
+ item.get("workflow_media_status"),
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ allow_empty=True,
+ )
+ if request_status and media_status:
+ return request_status, media_status
+
+ status = _clean_text(item.get("status"))
+ if status:
+ mapped = LEGACY_STATUS_TO_WORKFLOW.get(status.lower())
+ if mapped:
+ return mapped
+ return "pending", "pending"
+
+
+def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
+ if request_status == "declined":
+ return "Declined"
+ if request_status == "pending":
+ return "Waiting for approval"
+ if media_status == "available":
+ return "Ready to watch"
+ if media_status == "partially_available":
+ return "Partially available"
+ if media_status == "processing":
+ return "Working on it"
+ if media_status == "failed":
+ return "Needs attention"
+ return "Approved"
+
+
+def _normalize_request_pipeline(
+ request_status: Optional[str],
+ media_status: Optional[str],
+ *,
+ fallback_request_status: str = "pending",
+ fallback_media_status: str = "pending",
+) -> Tuple[str, str]:
+ normalized_request = _normalize_choice(
+ request_status,
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ default=fallback_request_status,
+ )
+ normalized_media = _normalize_choice(
+ media_status,
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ default=fallback_media_status,
+ )
+ request_value = normalized_request or fallback_request_status
+ media_value = normalized_media or fallback_media_status
+
+ if request_value == "declined":
+ return request_value, "unknown"
+ if request_value == "pending":
+ if media_value not in {"pending", "unknown"}:
+ media_value = "pending"
+ return request_value, media_value
+ if media_value == "unknown":
+ media_value = "pending"
+ return request_value, media_value
+
+
+def _validate_pipeline_transition(
+ current_request: str,
+ current_media: str,
+ requested_request: str,
+ requested_media: str,
+) -> Tuple[str, str]:
+ allowed_request = REQUEST_STATUS_TRANSITIONS.get(current_request, {current_request})
+ if requested_request not in allowed_request:
+ allowed_text = ", ".join(sorted(allowed_request))
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Invalid request_status transition: {current_request} -> {requested_request}. "
+ f"Allowed: {allowed_text}"
+ ),
+ )
+
+ normalized_request, normalized_media = _normalize_request_pipeline(
+ requested_request,
+ requested_media,
+ fallback_request_status=current_request,
+ fallback_media_status=current_media,
+ )
+ if normalized_request != "approved":
+ return normalized_request, normalized_media
+
+ if current_request != "approved":
+ allowed_media = PORTAL_MEDIA_STATUSES - {"unknown"}
+ else:
+ allowed_media = MEDIA_STATUS_TRANSITIONS.get(current_media, {current_media})
+ if normalized_media not in allowed_media:
+ allowed_text = ", ".join(sorted(allowed_media))
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Invalid media_status transition: {current_media} -> {normalized_media}. "
+ f"Allowed: {allowed_text}"
+ ),
+ )
+ return normalized_request, normalized_media
+
+
+def _ensure_item_exists(item_id: Optional[int], *, field: str = "related_item_id") -> None:
+ if item_id is None:
+ return
+ target = get_portal_item(item_id)
+ if not target:
+ raise HTTPException(status_code=400, detail=f"{field} references an unknown portal item")
+
+
+def _sanitize_metadata_json(value: Any) -> Optional[str]:
+ text = _clean_text(value)
+ if text is None:
+ return None
+ if len(text) > 50000:
+ raise HTTPException(status_code=400, detail="metadata_json is too long (max 50000 characters)")
+ return text
+
+
+def _is_admin(user: Dict[str, Any]) -> bool:
+ return str(user.get("role") or "").strip().lower() == "admin"
+
+
+def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
+ return str(user.get("username") or "") == str(item.get("created_by_username") or "")
+
+
+def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
+ is_admin = _is_admin(user)
+ is_owner = _is_owner(user, item)
+ serialized = dict(item)
+ serialized["permissions"] = {
+ "can_edit": is_admin or is_owner,
+ "can_comment": True,
+ "can_moderate": is_admin,
+ "can_raise_issue": str(item.get("kind") or "") == "request",
+ }
+ kind = str(item.get("kind") or "").strip().lower()
+ if kind == "request":
+ request_status, media_status = _item_status_to_workflow(item)
+ serialized["workflow"] = {
+ "request_status": request_status,
+ "media_status": media_status,
+ "stage_label": _stage_label_for_workflow(request_status, media_status),
+ "is_terminal": media_status in {"available", "failed"} or request_status == "declined",
+ }
+ elif kind == "issue":
+ serialized["issue"] = {
+ "issue_type": _clean_text(item.get("issue_type")) or "general",
+ "related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
+ "is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
+ "resolved_at": _clean_text(item.get("issue_resolved_at")),
+ }
+ return serialized
+
+
+async def _notify(
+ *,
+ event_type: str,
+ item: Dict[str, Any],
+ user: Dict[str, Any],
+ note: Optional[str] = None,
+) -> None:
+ try:
+ result = await send_portal_notification(
+ event_type=event_type,
+ item=item,
+ actor_username=str(user.get("username") or "unknown"),
+ actor_role=str(user.get("role") or "user"),
+ note=note,
+ )
+ logger.info(
+ "portal notification dispatched event=%s item_id=%s status=%s",
+ event_type,
+ item.get("id"),
+ result.get("status"),
+ )
+ except Exception:
+ logger.exception(
+ "portal notification failed event=%s item_id=%s",
+ event_type,
+ item.get("id"),
+ )
+
+
+@router.get("/overview")
+async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ mine = count_portal_items(mine_username=str(current_user.get("username") or ""))
+ return {
+ "overview": get_portal_overview(),
+ "my_items": mine,
+ }
+
+
+@router.get("/items")
+async def portal_list_items(
+ kind: Optional[str] = None,
+ status: Optional[str] = None,
+ request_status: Optional[str] = None,
+ media_status: Optional[str] = None,
+ source_system: Optional[str] = None,
+ source_request_id: Optional[int] = None,
+ related_item_id: Optional[int] = None,
+ mine: bool = False,
+ search: Optional[str] = None,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ kind_value = _normalize_choice(
+ kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True
+ )
+ status_value = _normalize_choice(
+ status, field="status", allowed=PORTAL_STATUSES, allow_empty=True
+ )
+ request_status_value = _normalize_choice(
+ request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True
+ )
+ media_status_value = _normalize_choice(
+ media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True
+ )
+ source_system_value = _clean_text(source_system)
+ if source_system_value:
+ source_system_value = source_system_value.lower()
+ mine_username = str(current_user.get("username") or "") if mine else None
+ items = list_portal_items(
+ kind=kind_value,
+ status=status_value,
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ source_system=source_system_value,
+ source_request_id=source_request_id,
+ related_item_id=related_item_id,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ limit=limit,
+ offset=offset,
+ )
+ total = count_portal_items(
+ kind=kind_value,
+ status=status_value,
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ source_system=source_system_value,
+ source_request_id=source_request_id,
+ related_item_id=related_item_id,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ )
+ return {
+ "items": [_serialize_item(item, current_user) for item in items],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "has_more": offset + len(items) < total,
+ "filters": {
+ "kind": kind_value,
+ "status": status_value,
+ "request_status": request_status_value,
+ "media_status": media_status_value,
+ "source_system": source_system_value,
+ "source_request_id": source_request_id,
+ "related_item_id": related_item_id,
+ "mine": mine,
+ "search": _clean_text(search),
+ },
+ }
+
+
+@router.get("/requests")
+async def portal_list_requests(
+ request_status: Optional[str] = None,
+ media_status: Optional[str] = None,
+ mine: bool = False,
+ search: Optional[str] = None,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ mine_username = str(current_user.get("username") or "") if mine else None
+ request_status_value = _normalize_choice(
+ request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True
+ )
+ media_status_value = _normalize_choice(
+ media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True
+ )
+ items = list_portal_items(
+ kind="request",
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ limit=limit,
+ offset=offset,
+ )
+ total = count_portal_items(
+ kind="request",
+ workflow_request_status=request_status_value,
+ workflow_media_status=media_status_value,
+ mine_username=mine_username,
+ search=_clean_text(search),
+ )
+ return {
+ "items": [_serialize_item(item, current_user) for item in items],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "has_more": offset + len(items) < total,
+ "filters": {
+ "request_status": request_status_value,
+ "media_status": media_status_value,
+ "mine": mine,
+ "search": _clean_text(search),
+ },
+ }
+
+
+@router.post("/items")
+async def portal_create_item(
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ is_admin = _is_admin(current_user)
+ kind = _normalize_choice(
+ payload.get("kind"),
+ field="kind",
+ allowed=PORTAL_KINDS,
+ default="request",
+ )
+ title = _require_text(payload.get("title"), "title", max_length=220)
+ description = _require_text(payload.get("description"), "description", max_length=10000)
+ media_type = _normalize_choice(
+ payload.get("media_type"),
+ field="media_type",
+ allowed=PORTAL_MEDIA_TYPES,
+ allow_empty=True,
+ )
+ year = _normalize_year(payload.get("year"))
+ external_ref = _clean_text(payload.get("external_ref"))
+ source_system = _clean_text(payload.get("source_system")) if is_admin else None
+ if source_system:
+ source_system = source_system.lower()
+ source_request_id = (
+ _normalize_int(payload.get("source_request_id"), "source_request_id")
+ if is_admin
+ else None
+ )
+ related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id")
+ _ensure_item_exists(related_item_id)
+ workflow_request_status: Optional[str] = None
+ workflow_media_status: Optional[str] = None
+ issue_type: Optional[str] = None
+ issue_resolved_at: Optional[str] = None
+ status: Optional[str] = None
+ if kind == "request":
+ workflow_request_status, workflow_media_status = _normalize_request_pipeline(
+ payload.get("request_status"),
+ payload.get("media_status"),
+ fallback_request_status="pending",
+ fallback_media_status="pending",
+ )
+ status = _workflow_to_item_status(workflow_request_status, workflow_media_status)
+ else:
+ status = _normalize_choice(
+ payload.get("status") if is_admin else None,
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default="new",
+ )
+ if kind == "issue":
+ issue_type = _normalize_choice(
+ payload.get("issue_type"),
+ field="issue_type",
+ allowed=PORTAL_ISSUE_TYPES,
+ default="general",
+ )
+ if related_item_id is not None and not source_system:
+ source_system = "portal_request"
+ source_request_id = related_item_id
+ priority = _normalize_choice(
+ payload.get("priority"),
+ field="priority",
+ allowed=PORTAL_PRIORITIES,
+ default="normal",
+ )
+ assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None
+ metadata_json = _sanitize_metadata_json(payload.get("metadata_json")) if is_admin else None
+
+ created = create_portal_item(
+ kind=kind or "request",
+ title=title,
+ description=description,
+ created_by_username=str(current_user.get("username") or "unknown"),
+ created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"),
+ media_type=media_type,
+ year=year,
+ external_ref=external_ref,
+ source_system=source_system,
+ source_request_id=source_request_id,
+ related_item_id=related_item_id,
+ status=status or "new",
+ workflow_request_status=workflow_request_status,
+ workflow_media_status=workflow_media_status,
+ issue_type=issue_type,
+ issue_resolved_at=issue_resolved_at,
+ metadata_json=metadata_json,
+ priority=priority or "normal",
+ assignee_username=assignee_username,
+ )
+ initial_comment = _clean_text(payload.get("comment"))
+ if initial_comment:
+ add_portal_comment(
+ int(created["id"]),
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "user"),
+ message=initial_comment,
+ is_internal=False,
+ )
+ comments = list_portal_comments(int(created["id"]), include_internal=is_admin)
+ await _notify(
+ event_type="portal_item_created",
+ item=created,
+ user=current_user,
+ note=f"kind={created.get('kind')} priority={created.get('priority')}",
+ )
+ return {
+ "item": _serialize_item(created, current_user),
+ "comments": comments,
+ }
+
+
+@router.post("/requests/{item_id}/issues")
+async def portal_create_issue_for_request(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ request_item = get_portal_item(item_id)
+ if not request_item:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+ if str(request_item.get("kind") or "").lower() != "request":
+ raise HTTPException(status_code=400, detail="Only request items can have linked issues")
+
+ title = _require_text(payload.get("title"), "title", max_length=220)
+ description = _require_text(payload.get("description"), "description", max_length=10000)
+ issue_type = _normalize_choice(
+ payload.get("issue_type"),
+ field="issue_type",
+ allowed=PORTAL_ISSUE_TYPES,
+ default="general",
+ )
+ status = _normalize_choice(
+ payload.get("status"),
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default="new",
+ )
+ priority = _normalize_choice(
+ payload.get("priority"),
+ field="priority",
+ allowed=PORTAL_PRIORITIES,
+ default="normal",
+ )
+ created = create_portal_item(
+ kind="issue",
+ title=title,
+ description=description,
+ created_by_username=str(current_user.get("username") or "unknown"),
+ created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"),
+ media_type=request_item.get("media_type"),
+ year=request_item.get("year"),
+ external_ref=_clean_text(payload.get("external_ref")),
+ source_system="portal_request",
+ source_request_id=item_id,
+ related_item_id=item_id,
+ status=status or "new",
+ issue_type=issue_type,
+ priority=priority or "normal",
+ assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
+ )
+ initial_comment = _clean_text(payload.get("comment"))
+ if initial_comment:
+ add_portal_comment(
+ int(created["id"]),
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "user"),
+ message=initial_comment,
+ is_internal=False,
+ )
+ comments = list_portal_comments(int(created["id"]), include_internal=_is_admin(current_user))
+ await _notify(
+ event_type="portal_issue_created",
+ item=created,
+ user=current_user,
+ note=f"linked_request_id={item_id}",
+ )
+ return {
+ "item": _serialize_item(created, current_user),
+ "comments": comments,
+ "linked_request_id": item_id,
+ }
+
+
+@router.get("/requests/{item_id}/issues")
+async def portal_list_request_issues(
+ item_id: int,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ request_item = get_portal_item(item_id)
+ if not request_item:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+ if str(request_item.get("kind") or "").lower() != "request":
+ raise HTTPException(status_code=400, detail="Only request items can have linked issues")
+
+ items = list_portal_items(
+ kind="issue",
+ related_item_id=item_id,
+ limit=limit,
+ offset=offset,
+ )
+ total = count_portal_items(kind="issue", related_item_id=item_id)
+ return {
+ "items": [_serialize_item(item, current_user) for item in items],
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "has_more": offset + len(items) < total,
+ "linked_request_id": item_id,
+ }
+
+
+@router.patch("/requests/{item_id}/pipeline")
+async def portal_update_request_pipeline(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ if not _is_admin(current_user):
+ raise HTTPException(status_code=403, detail="Admin access required")
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+ if str(item.get("kind") or "").lower() != "request":
+ raise HTTPException(status_code=400, detail="Only request items support pipeline updates")
+
+ current_request_status, current_media_status = _item_status_to_workflow(item)
+ requested_request = _normalize_choice(
+ payload.get("request_status"),
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ default=current_request_status,
+ ) or current_request_status
+ requested_media = _normalize_choice(
+ payload.get("media_status"),
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ default=current_media_status,
+ ) or current_media_status
+ next_request_status, next_media_status = _validate_pipeline_transition(
+ current_request_status,
+ current_media_status,
+ requested_request,
+ requested_media,
+ )
+ next_status = _workflow_to_item_status(next_request_status, next_media_status)
+ updated = update_portal_item(
+ item_id,
+ status=next_status,
+ workflow_request_status=next_request_status,
+ workflow_media_status=next_media_status,
+ )
+ if not updated:
+ raise HTTPException(status_code=404, detail="Portal request not found")
+
+ comment_text = _clean_text(payload.get("comment"))
+ if comment_text:
+ add_portal_comment(
+ item_id,
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "admin"),
+ message=comment_text,
+ is_internal=_normalize_bool(payload.get("is_internal"), default=False),
+ )
+
+ await _notify(
+ event_type="portal_request_pipeline_updated",
+ item=updated,
+ user=current_user,
+ note=f"{current_request_status}/{current_media_status} -> {next_request_status}/{next_media_status}",
+ )
+ comments = list_portal_comments(item_id, include_internal=True)
+ return {
+ "item": _serialize_item(updated, current_user),
+ "comments": comments,
+ }
+
+
+@router.get("/items/{item_id}")
+async def portal_get_item(
+ item_id: int,
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ comments = list_portal_comments(item_id, include_internal=_is_admin(current_user))
+ return {
+ "item": _serialize_item(item, current_user),
+ "comments": comments,
+ }
+
+
+@router.patch("/items/{item_id}")
+async def portal_update_item(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ is_admin = _is_admin(current_user)
+ is_owner = _is_owner(current_user, item)
+ if not (is_admin or is_owner):
+ raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
+
+ editable_owner_fields = {"title", "description", "media_type", "year", "external_ref"}
+ editable_admin_fields = {
+ "status",
+ "priority",
+ "assignee_username",
+ "source_system",
+ "source_request_id",
+ "related_item_id",
+ "request_status",
+ "media_status",
+ "issue_type",
+ "issue_resolved_at",
+ "metadata_json",
+ }
+ provided_fields = set(payload.keys())
+ unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields)
+ if unknown_fields:
+ unknown = ", ".join(sorted(unknown_fields))
+ raise HTTPException(status_code=400, detail=f"Unsupported fields: {unknown}")
+ if not is_admin:
+ forbidden = provided_fields - editable_owner_fields
+ if forbidden:
+ forbidden_text = ", ".join(sorted(forbidden))
+ raise HTTPException(
+ status_code=403, detail=f"Admin access required to update: {forbidden_text}"
+ )
+
+ updates: Dict[str, Any] = {}
+ if "title" in payload:
+ updates["title"] = _require_text(payload.get("title"), "title", max_length=220)
+ if "description" in payload:
+ updates["description"] = _require_text(
+ payload.get("description"), "description", max_length=10000
+ )
+ if "media_type" in payload:
+ updates["media_type"] = _normalize_choice(
+ payload.get("media_type"),
+ field="media_type",
+ allowed=PORTAL_MEDIA_TYPES,
+ allow_empty=True,
+ )
+ if "year" in payload:
+ updates["year"] = _normalize_year(payload.get("year"))
+ if "external_ref" in payload:
+ updates["external_ref"] = _clean_text(payload.get("external_ref"))
+ if is_admin:
+ kind = str(item.get("kind") or "").lower()
+ if "priority" in payload:
+ updates["priority"] = _normalize_choice(
+ payload.get("priority"),
+ field="priority",
+ allowed=PORTAL_PRIORITIES,
+ default=item.get("priority") or "normal",
+ )
+ if "assignee_username" in payload:
+ updates["assignee_username"] = _clean_text(payload.get("assignee_username"))
+ if "source_system" in payload:
+ source_system = _clean_text(payload.get("source_system"))
+ updates["source_system"] = source_system.lower() if source_system else None
+ if "source_request_id" in payload:
+ updates["source_request_id"] = _normalize_int(
+ payload.get("source_request_id"), "source_request_id"
+ )
+ if "related_item_id" in payload:
+ related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id")
+ _ensure_item_exists(related_item_id)
+ updates["related_item_id"] = related_item_id
+ if "metadata_json" in payload:
+ updates["metadata_json"] = _sanitize_metadata_json(payload.get("metadata_json"))
+
+ if kind == "request":
+ current_request_status, current_media_status = _item_status_to_workflow(item)
+ request_status_input = payload.get("request_status")
+ media_status_input = payload.get("media_status")
+ explicit_status = payload.get("status")
+ if explicit_status is not None and request_status_input is None and media_status_input is None:
+ explicit_status_normalized = _normalize_choice(
+ explicit_status,
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default=item.get("status") or "pending",
+ )
+ request_status_input, media_status_input = LEGACY_STATUS_TO_WORKFLOW.get(
+ explicit_status_normalized or "pending",
+ (current_request_status, current_media_status),
+ )
+
+ if request_status_input is not None or media_status_input is not None:
+ requested_request = _normalize_choice(
+ request_status_input,
+ field="request_status",
+ allowed=PORTAL_REQUEST_STATUSES,
+ default=current_request_status,
+ ) or current_request_status
+ requested_media = _normalize_choice(
+ media_status_input,
+ field="media_status",
+ allowed=PORTAL_MEDIA_STATUSES,
+ default=current_media_status,
+ ) or current_media_status
+ next_request_status, next_media_status = _validate_pipeline_transition(
+ current_request_status,
+ current_media_status,
+ requested_request,
+ requested_media,
+ )
+ updates["workflow_request_status"] = next_request_status
+ updates["workflow_media_status"] = next_media_status
+ updates["status"] = _workflow_to_item_status(next_request_status, next_media_status)
+ elif "status" in payload:
+ updates["status"] = _normalize_choice(
+ payload.get("status"),
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default=item.get("status") or "pending",
+ )
+ else:
+ if "status" in payload:
+ updates["status"] = _normalize_choice(
+ payload.get("status"),
+ field="status",
+ allowed=PORTAL_STATUSES,
+ default=item.get("status") or "new",
+ )
+ if kind == "issue":
+ if "issue_type" in payload:
+ updates["issue_type"] = _normalize_choice(
+ payload.get("issue_type"),
+ field="issue_type",
+ allowed=PORTAL_ISSUE_TYPES,
+ default=item.get("issue_type") or "general",
+ )
+ if "issue_resolved_at" in payload:
+ updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
+ if "status" in payload:
+ next_status = str(updates.get("status") or item.get("status") or "").lower()
+ if next_status in {"done", "closed"}:
+ updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
+ elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
+ updates.setdefault("issue_resolved_at", None)
+
+ if not updates:
+ comments = list_portal_comments(item_id, include_internal=is_admin)
+ return {
+ "item": _serialize_item(item, current_user),
+ "comments": comments,
+ }
+
+ updated = update_portal_item(item_id, **updates)
+ if not updated:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+
+ changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
+ if changed_fields:
+ await _notify(
+ event_type="portal_item_updated",
+ item=updated,
+ user=current_user,
+ note=f"changed={','.join(sorted(changed_fields))}",
+ )
+ comments = list_portal_comments(item_id, include_internal=is_admin)
+ return {
+ "item": _serialize_item(updated, current_user),
+ "comments": comments,
+ }
+
+
+@router.get("/items/{item_id}/comments")
+async def portal_get_comments(
+ item_id: int,
+ limit: int = Query(default=200, ge=1, le=500),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ comments = list_portal_comments(
+ item_id,
+ include_internal=_is_admin(current_user),
+ limit=limit,
+ )
+ return {"comments": comments}
+
+
+@router.post("/items/{item_id}/comments")
+async def portal_create_comment(
+ item_id: int,
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(get_current_user),
+) -> Dict[str, Any]:
+ item = get_portal_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Portal item not found")
+ is_admin = _is_admin(current_user)
+ message = _require_text(payload.get("message"), "message", max_length=10000)
+ is_internal = _normalize_bool(payload.get("is_internal"), default=False)
+ if is_internal and not is_admin:
+ raise HTTPException(status_code=403, detail="Only admins can add internal comments")
+ comment = add_portal_comment(
+ item_id,
+ author_username=str(current_user.get("username") or "unknown"),
+ author_role=str(current_user.get("role") or "user"),
+ message=message,
+ is_internal=is_internal,
+ )
+ updated_item = get_portal_item(item_id)
+ if updated_item:
+ await _notify(
+ event_type="portal_comment_added",
+ item=updated_item,
+ user=current_user,
+ note=f"internal={is_internal}",
+ )
+ return {"comment": comment}
diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py
index 015ef47..bc0a8b0 100644
--- a/backend/app/routers/requests.py
+++ b/backend/app/routers/requests.py
@@ -3,6 +3,7 @@ import asyncio
import httpx
import json
import logging
+import os
import time
from urllib.parse import quote
from datetime import datetime, timezone, timedelta
@@ -17,7 +18,7 @@ from ..clients.prowlarr import ProwlarrClient
from ..ai.triage import triage_snapshot
from ..auth import get_current_user
from ..runtime import get_runtime_settings
-from .images import cache_tmdb_image
+from .images import cache_tmdb_image, is_tmdb_cached
from ..db import (
save_action,
get_recent_actions,
@@ -25,24 +26,37 @@ from ..db import (
get_cached_requests,
get_cached_requests_since,
get_cached_request_by_media_id,
- get_request_cache_by_id,
+ get_request_cache_lookup,
get_request_cache_payload,
get_request_cache_last_updated,
get_request_cache_count,
get_request_cache_payloads,
+ get_request_cache_payloads_missing,
+ repair_request_cache_titles,
prune_duplicate_requests_cache,
upsert_request_cache,
+ upsert_request_cache_many,
+ upsert_artwork_cache_status,
+ upsert_artwork_cache_status_many,
+ get_artwork_cache_missing_count,
+ get_artwork_cache_status_count,
get_setting,
set_setting,
+ update_artwork_cache_stats,
cleanup_history,
+ is_seerr_media_failure_suppressed,
+ record_seerr_media_failure,
+ clear_seerr_media_failure,
)
from ..models import Snapshot, TriageResult, RequestType
-from ..services.snapshot import build_snapshot
+from ..services.snapshot import build_snapshot, jellyfin_item_matches_request
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
+FAILED_DETAIL_CACHE_TTL_SECONDS = 3600
+_failed_detail_cache: Dict[str, float] = {}
REQUEST_CACHE_TTL_SECONDS = 600
logger = logging.getLogger(__name__)
_sync_state: Dict[str, Any] = {
@@ -64,6 +78,7 @@ _artwork_prefetch_state: Dict[str, Any] = {
"processed": 0,
"total": 0,
"message": "",
+ "only_missing": False,
"started_at": None,
"finished_at": None,
}
@@ -78,6 +93,17 @@ STATUS_LABELS = {
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)
@@ -94,19 +120,151 @@ def _cache_set(key: str, payload: Dict[str, Any]) -> None:
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
+def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
+ if not jellyfin_available:
+ return _status_label(current_status)
+ try:
+ status_code = int(current_status)
+ except (TypeError, ValueError):
+ status_code = None
+ if status_code == 6:
+ return STATUS_LABELS[6]
+ return STATUS_LABELS[4]
+
+
+async def _request_is_available_in_jellyfin(
+ jellyfin: JellyfinClient,
+ title: Optional[str],
+ year: Optional[int],
+ media_type: Optional[str],
+ request_payload: Optional[Dict[str, Any]],
+ availability_cache: Dict[str, bool],
+) -> bool:
+ if not jellyfin.configured() or not title:
+ return False
+ cache_key = f"{media_type or ''}:{title.lower()}:{year or ''}:{request_payload.get('id') if isinstance(request_payload, dict) else ''}"
+ cached_value = availability_cache.get(cache_key)
+ if cached_value is not None:
+ return cached_value
+ types = ["Movie"] if media_type == "movie" else ["Series"]
+ try:
+ search = await jellyfin.search_items(title, types, limit=50)
+ except Exception:
+ availability_cache[cache_key] = False
+ return False
+ if isinstance(search, dict):
+ items = search.get("Items") or search.get("items") or []
+ request_type = RequestType.movie if media_type == "movie" else RequestType.tv
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ if jellyfin_item_matches_request(
+ item,
+ title=title,
+ year=year,
+ request_type=request_type,
+ request_payload=request_payload,
+ ):
+ availability_cache[cache_key] = True
+ return True
+ availability_cache[cache_key] = False
+ return False
+ _failed_detail_cache.pop(key, None)
+
+
+def _failure_cache_has(key: str) -> bool:
+ expires_at = _failed_detail_cache.get(key)
+ if not expires_at:
+ return False
+ if expires_at < time.time():
+ _failed_detail_cache.pop(key, None)
+ return False
+ return True
+
+
+def _failure_cache_set(key: str, ttl_seconds: int = FAILED_DETAIL_CACHE_TTL_SECONDS) -> None:
+ _failed_detail_cache[key] = time.time() + ttl_seconds
+
+
+def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
+ response = exc.response
+ if response is None:
+ return None
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = response.text
+ if isinstance(payload, dict):
+ message = payload.get("message") or payload.get("error")
+ return str(message).strip() if message else json.dumps(payload, ensure_ascii=True)
+ if isinstance(payload, str):
+ trimmed = payload.strip()
+ return trimmed or None
+ return str(payload)
+
+
+def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
+ response = exc.response
+ if response is None:
+ return False
+ return response.status_code == 404 or response.status_code >= 500
+
+
def _status_label(value: Any) -> str:
if isinstance(value, int):
return STATUS_LABELS.get(value, f"Status {value}")
return "Unknown"
+def normalize_request_stage_filter(value: Optional[str]) -> str:
+ if not isinstance(value, str):
+ return "all"
+ normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
+ if not normalized:
+ return "all"
+ if normalized in {"processing", "inprogress"}:
+ normalized = "in_progress"
+ return normalized if normalized in REQUEST_STAGE_CODES else "all"
+
+
+def request_stage_filter_codes(value: Optional[str]) -> Optional[list[int]]:
+ normalized = normalize_request_stage_filter(value)
+ codes = REQUEST_STAGE_CODES.get(normalized)
+ return list(codes) if codes else None
+
+
def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip().lower()
+ if not normalized:
+ return None
+ if "@" in normalized:
+ normalized = normalized.split("@", 1)[0]
return normalized if normalized else None
+def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
+ if user.get("role") == "admin":
+ return True
+ return bool(user.get("auto_search_enabled", True))
+
+
+def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
+ if _user_can_use_search_auto(user):
+ return snapshot
+ snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
+ return snapshot
+
+
+def _quality_profile_id(value: Any) -> Optional[int]:
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str) and value.strip().isdigit():
+ return int(value.strip())
+ return None
+
+
def _request_matches_user(request_data: Any, username: str) -> bool:
requested_by = None
if isinstance(request_data, dict):
@@ -155,6 +313,21 @@ def _normalize_requested_by(request_data: Any) -> Optional[str]:
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
@@ -197,6 +370,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
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,
@@ -207,6 +381,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
"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,
}
@@ -225,15 +400,177 @@ def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optiona
backdrop_path = item.get("backdropPath") or item.get("backdrop_path")
return poster_path, backdrop_path
+def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Optional[str]]:
+ media = payload.get("media") or {}
+ if not isinstance(media, dict):
+ media = {}
+ tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
+ media_type = (
+ media.get("mediaType")
+ or payload.get("mediaType")
+ or payload.get("type")
+ )
+ try:
+ tmdb_id = int(tmdb_id) if tmdb_id is not None else None
+ except (TypeError, ValueError):
+ tmdb_id = None
+ if isinstance(media_type, str):
+ media_type = media_type.strip().lower() or None
+ else:
+ media_type = None
+ return tmdb_id, media_type
+
+
+def _normalize_media_type(value: Any) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ normalized = value.strip().lower()
+ if normalized in {"movie", "tv"}:
+ return normalized
+ return None
+
+
+def _normalize_seasons(value: Any) -> list[int]:
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise HTTPException(status_code=400, detail="seasons must be an array of positive integers")
+ normalized: list[int] = []
+ for raw in value:
+ try:
+ season = int(raw)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(
+ status_code=400, detail="seasons must contain only positive integers"
+ ) from exc
+ if season <= 0:
+ raise HTTPException(status_code=400, detail="seasons must contain only positive integers")
+ normalized.append(season)
+ return sorted(set(normalized))
+
+
+def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
+ poster_path, backdrop_path = _extract_artwork_paths(payload)
+ tmdb_id, media_type = _extract_tmdb_lookup(payload)
+ can_hydrate = bool(tmdb_id and media_type)
+ if poster_path:
+ if not is_tmdb_cached(poster_path, "w185") or not is_tmdb_cached(poster_path, "w342"):
+ return True
+ elif can_hydrate:
+ return True
+ if backdrop_path:
+ if not is_tmdb_cached(backdrop_path, "w780"):
+ return True
+ elif can_hydrate:
+ return True
+ return False
+
+
+def _compute_cached_flags(
+ poster_path: Optional[str],
+ backdrop_path: Optional[str],
+ cache_mode: str,
+ poster_cached: Optional[bool] = None,
+ backdrop_cached: Optional[bool] = None,
+) -> tuple[bool, bool]:
+ if cache_mode != "cache":
+ return True, True
+ poster = poster_cached
+ backdrop = backdrop_cached
+ if poster is None:
+ poster = bool(poster_path) and is_tmdb_cached(poster_path, "w185") and is_tmdb_cached(
+ poster_path, "w342"
+ )
+ if backdrop is None:
+ backdrop = bool(backdrop_path) and is_tmdb_cached(backdrop_path, "w780")
+ return bool(poster), bool(backdrop)
+
+
+def _upsert_artwork_status(
+ payload: Dict[str, Any],
+ cache_mode: str,
+ poster_cached: Optional[bool] = None,
+ backdrop_cached: Optional[bool] = None,
+) -> None:
+ record = _build_artwork_status_record(payload, cache_mode, poster_cached, backdrop_cached)
+ if not record:
+ return
+ upsert_artwork_cache_status(**record)
+
+
+def _build_request_cache_record(payload: Dict[str, Any], request_payload: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "request_id": payload.get("request_id"),
+ "media_id": payload.get("media_id"),
+ "media_type": payload.get("media_type"),
+ "status": payload.get("status"),
+ "title": payload.get("title"),
+ "year": payload.get("year"),
+ "requested_by": payload.get("requested_by"),
+ "requested_by_norm": payload.get("requested_by_norm"),
+ "requested_by_id": payload.get("requested_by_id"),
+ "created_at": payload.get("created_at"),
+ "updated_at": payload.get("updated_at"),
+ "payload_json": json.dumps(request_payload, ensure_ascii=True),
+ }
+
+
+def _build_artwork_status_record(
+ payload: Dict[str, Any],
+ cache_mode: str,
+ poster_cached: Optional[bool] = None,
+ backdrop_cached: Optional[bool] = None,
+) -> Optional[Dict[str, Any]]:
+ parsed = _parse_request_payload(payload)
+ request_id = parsed.get("request_id")
+ if not isinstance(request_id, int):
+ return None
+ tmdb_id, media_type = _extract_tmdb_lookup(payload)
+ poster_path, backdrop_path = _extract_artwork_paths(payload)
+ has_tmdb = bool(tmdb_id and media_type)
+ poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
+ poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
+ )
+ return {
+ "request_id": request_id,
+ "tmdb_id": tmdb_id,
+ "media_type": media_type,
+ "poster_path": poster_path,
+ "backdrop_path": backdrop_path,
+ "has_tmdb": has_tmdb,
+ "poster_cached": poster_cached_flag,
+ "backdrop_cached": backdrop_cached_flag,
+ }
+
+
+def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
+ cache_root = os.path.join(os.getcwd(), "data", "artwork")
+ total_bytes = 0
+ total_files = 0
+ if not os.path.isdir(cache_root):
+ return 0, 0
+ for root, _, files in os.walk(cache_root):
+ for name in files:
+ path = os.path.join(root, name)
+ try:
+ total_bytes += os.path.getsize(path)
+ total_files += 1
+ except OSError:
+ continue
+ return total_bytes, total_files
+
async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]:
cache_key = f"request:{request_id}"
cached = _cache_get(cache_key)
if isinstance(cached, dict):
return cached
+ if _failure_cache_has(cache_key):
+ return None
try:
fetched = await client.get_request(str(request_id))
except httpx.HTTPStatusError:
+ _failure_cache_set(cache_key)
return None
if isinstance(fetched, dict):
_cache_set(cache_key, fetched)
@@ -241,64 +578,80 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
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]]:
- if not tmdb_id or not media_type:
- return None, None
- try:
- if media_type == "movie":
- details = await client.get_movie(int(tmdb_id))
- if isinstance(details, dict):
- title = details.get("title")
- release_date = details.get("releaseDate")
- year = int(release_date[:4]) if release_date else None
- return title, year
- if media_type == "tv":
- details = await client.get_tv(int(tmdb_id))
- if isinstance(details, dict):
- 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
- except httpx.HTTPStatusError:
+ 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_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]:
- if not media_id:
- return None
- try:
- details = await client.get_media(int(media_id))
- except httpx.HTTPStatusError:
- return None
- return details if isinstance(details, dict) else None
-
-
async def _hydrate_artwork_from_tmdb(
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
) -> tuple[Optional[str], Optional[str]]:
- if not tmdb_id or not media_type:
+ details = await _get_media_details(client, media_type, tmdb_id)
+ if not isinstance(details, dict):
return None, None
- try:
- if media_type == "movie":
- details = await client.get_movie(int(tmdb_id))
- if isinstance(details, dict):
- return (
- details.get("posterPath") or details.get("poster_path"),
- details.get("backdropPath") or details.get("backdrop_path"),
- )
- if media_type == "tv":
- details = await client.get_tv(int(tmdb_id))
- if isinstance(details, dict):
- return (
- details.get("posterPath") or details.get("poster_path"),
- details.get("backdropPath") or details.get("backdrop_path"),
- )
- except httpx.HTTPStatusError:
- return None, None
- 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]:
@@ -351,7 +704,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
skip = 0
stored = 0
cache_mode = (get_runtime_settings().artwork_cache_mode or "remote").lower()
- logger.info("Jellyseerr sync starting: take=%s", take)
+ logger.info("Seerr sync starting: take=%s", take)
_sync_state.update(
{
"status": "running",
@@ -367,11 +720,11 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
try:
response = await client.get_recent_requests(take=take, skip=skip)
except httpx.HTTPError as exc:
- logger.warning("Jellyseerr sync failed at skip=%s: %s", skip, 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("Jellyseerr sync stopped: non-dict response at skip=%s", skip)
+ 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:
@@ -386,49 +739,47 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
_sync_state["total"] = total
items = response.get("results") or []
if not isinstance(items, list) or not items:
- logger.info("Jellyseerr sync completed: no more results at skip=%s", skip)
+ 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):
- if not payload.get("title") or not payload.get("media_id"):
- logger.debug("Jellyseerr sync hydrate request_id=%s", request_id)
+ 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
- if not payload.get("title") and payload.get("media_id"):
- media_details = await _hydrate_media_details(client, payload.get("media_id"))
- if isinstance(media_details, dict):
- media_title = media_details.get("title") or media_details.get("name")
- if media_title:
- payload["title"] = media_title
- if not payload.get("year") and media_details.get("year"):
- payload["year"] = media_details.get("year")
- if not payload.get("tmdb_id") and media_details.get("tmdbId"):
- payload["tmdb_id"] = media_details.get("tmdbId")
- if not payload.get("media_type") and media_details.get("mediaType"):
- payload["media_type"] = media_details.get("mediaType")
- if isinstance(item, dict):
- existing_media = item.get("media")
- if isinstance(existing_media, dict):
- merged = dict(media_details)
- for key, value in existing_media.items():
- if value is not None:
- merged[key] = value
- item["media"] = merged
- else:
- item["media"] = media_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"):
+ 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")
)
@@ -436,31 +787,28 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
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
- payload_json = json.dumps(item, ensure_ascii=True)
- 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"),
- created_at=payload.get("created_at"),
- updated_at=payload.get("updated_at"),
- payload_json=payload_json,
- )
+ 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("Jellyseerr sync completed: stored=%s", stored)
+ logger.info("Seerr sync completed: stored=%s", stored)
break
skip += take
_sync_state["skip"] = skip
_sync_state["message"] = f"Synced {stored} requests"
- logger.info("Jellyseerr sync progress: stored=%s skip=%s", stored, skip)
+ logger.debug("Seerr sync progress: stored=%s skip=%s", stored, skip)
_sync_state.update(
{
"status": "completed",
@@ -471,6 +819,11 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
)
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
@@ -480,7 +833,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
stored = 0
unchanged_pages = 0
cache_mode = (get_runtime_settings().artwork_cache_mode or "remote").lower()
- logger.info("Jellyseerr delta sync starting: take=%s", take)
+ logger.info("Seerr delta sync starting: take=%s", take)
_sync_state.update(
{
"status": "running",
@@ -496,17 +849,27 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
try:
response = await client.get_recent_requests(take=take, skip=skip)
except httpx.HTTPError as exc:
- logger.warning("Jellyseerr delta sync failed at skip=%s: %s", skip, 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("Jellyseerr delta sync stopped: non-dict response at skip=%s", skip)
+ 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("Jellyseerr delta sync completed: no more results at skip=%s", skip)
+ 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):
@@ -514,44 +877,29 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload = _parse_request_payload(item)
request_id = payload.get("request_id")
if isinstance(request_id, int):
- cached = get_request_cache_by_id(request_id)
+ 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
- if not payload.get("title") or not payload.get("media_id"):
+ 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
- if not payload.get("title") and payload.get("media_id"):
- media_details = await _hydrate_media_details(client, payload.get("media_id"))
- if isinstance(media_details, dict):
- media_title = media_details.get("title") or media_details.get("name")
- if media_title:
- payload["title"] = media_title
- if not payload.get("year") and media_details.get("year"):
- payload["year"] = media_details.get("year")
- if not payload.get("tmdb_id") and media_details.get("tmdbId"):
- payload["tmdb_id"] = media_details.get("tmdbId")
- if not payload.get("media_type") and media_details.get("mediaType"):
- payload["media_type"] = media_details.get("mediaType")
- if isinstance(item, dict):
- existing_media = item.get("media")
- if isinstance(existing_media, dict):
- merged = dict(media_details)
- for key, value in existing_media.items():
- if value is not None:
- merged[key] = value
- item["media"] = merged
- else:
- item["media"] = media_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"):
+ 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")
)
@@ -559,39 +907,36 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
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
- payload_json = json.dumps(item, ensure_ascii=True)
- 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"),
- created_at=payload.get("created_at"),
- updated_at=payload.get("updated_at"),
- payload_json=payload_json,
- )
+ 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("Jellyseerr delta sync completed: stored=%s", stored)
+ 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.info("Jellyseerr delta sync progress: stored=%s skip=%s", stored, skip)
+ logger.debug("Seerr delta sync progress: stored=%s skip=%s", stored, skip)
deduped = prune_duplicate_requests_cache()
if deduped:
- logger.info("Jellyseerr delta sync removed duplicate rows: %s", deduped)
+ logger.info("Seerr delta sync removed duplicate rows: %s", deduped)
_sync_state.update(
{
"status": "completed",
@@ -602,10 +947,20 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
)
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) -> None:
+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":
@@ -618,81 +973,112 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
)
return
- total = get_request_cache_count()
+ total = total if total is not None else get_request_cache_count()
_artwork_prefetch_state.update(
{
"status": "running",
"processed": 0,
"total": total,
- "message": "Starting artwork prefetch",
+ "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:
- batch = get_request_cache_payloads(limit=limit, offset=offset)
+ 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):
- processed += 1
+ 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)
- if not (poster_path or backdrop_path) and client.configured():
+ 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 {}
- tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
- media_type = media.get("mediaType") or payload.get("type")
- if tmdb_id and media_type:
- 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):
- upsert_request_cache(
- request_id=request_id,
- media_id=parsed.get("media_id"),
- media_type=parsed.get("media_type"),
- status=parsed.get("status"),
- title=parsed.get("title"),
- year=parsed.get("year"),
- requested_by=parsed.get("requested_by"),
- requested_by_norm=parsed.get("requested_by_norm"),
- created_at=parsed.get("created_at"),
- updated_at=parsed.get("updated_at"),
- payload_json=json.dumps(payload, ensure_ascii=True),
- )
+ 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:
- await cache_tmdb_image(poster_path, "w185")
- await cache_tmdb_image(poster_path, "w342")
+ poster_cached_flag = bool(
+ await cache_tmdb_image(poster_path, "w185")
+ ) and bool(await cache_tmdb_image(poster_path, "w342"))
except httpx.HTTPError:
- pass
+ poster_cached_flag = False
if backdrop_path:
try:
- await cache_tmdb_image(backdrop_path, "w780")
+ backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
except httpx.HTTPError:
- pass
+ 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",
@@ -703,25 +1089,52 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
)
-async def start_artwork_prefetch(base_url: Optional[str], api_key: Optional[str]) -> Dict[str, Any]:
+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": get_request_cache_count(),
- "message": "Starting artwork prefetch",
+ "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)
+ 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(
@@ -768,18 +1181,41 @@ def _recent_cache_stale() -> bool:
return (datetime.now(timezone.utc) - parsed).total_seconds() > RECENT_CACHE_TTL_SECONDS
+def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ return parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
def _get_recent_from_cache(
requested_by_norm: Optional[str],
+ requested_by_id: Optional[int],
limit: int,
offset: int,
since_iso: Optional[str],
+ status_codes: Optional[list[int]] = None,
) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or []
results = []
+ since_dt = _parse_iso_datetime(since_iso)
for item in items:
- if requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
+ 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_iso and item.get("created_at") and item["created_at"] < since_iso:
+ if since_dt:
+ candidate = item.get("created_at") or item.get("updated_at")
+ item_dt = _parse_iso_datetime(candidate)
+ if not item_dt or item_dt < since_dt:
+ continue
+ if status_codes and item.get("status") not in status_codes:
continue
results.append(item)
return results[offset : offset + limit]
@@ -788,13 +1224,14 @@ def _get_recent_from_cache(
async def startup_warmup_requests_cache() -> None:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
- if not client.configured():
- return
- try:
- await _ensure_requests_cache(client)
- except httpx.HTTPError as exc:
- logger.warning("Requests warmup skipped: %s", exc)
- return
+ 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()
@@ -835,7 +1272,7 @@ async def run_daily_requests_full_sync() -> None:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
- logger.info("Daily full sync skipped: Jellyseerr not 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.")
@@ -861,7 +1298,7 @@ async def start_requests_sync(base_url: Optional[str], api_key: Optional[str]) -
if _sync_task and not _sync_task.done():
return dict(_sync_state)
if not base_url:
- _sync_state.update({"status": "failed", "message": "Jellyseerr not configured"})
+ _sync_state.update({"status": "failed", "message": "Seerr not configured"})
return dict(_sync_state)
client = JellyseerrClient(base_url, api_key)
_sync_state.update(
@@ -880,7 +1317,7 @@ async def start_requests_sync(base_url: Optional[str], api_key: Optional[str]) -
try:
await _sync_all_requests(client)
except Exception as exc:
- logger.exception("Jellyseerr sync failed")
+ logger.exception("Seerr sync failed")
_sync_state.update(
{
"status": "failed",
@@ -898,7 +1335,7 @@ async def start_requests_delta_sync(base_url: Optional[str], api_key: Optional[s
if _sync_task and not _sync_task.done():
return dict(_sync_state)
if not base_url:
- _sync_state.update({"status": "failed", "message": "Jellyseerr not configured"})
+ _sync_state.update({"status": "failed", "message": "Seerr not configured"})
return dict(_sync_state)
client = JellyseerrClient(base_url, api_key)
_sync_state.update(
@@ -917,7 +1354,7 @@ async def start_requests_delta_sync(base_url: Optional[str], api_key: Optional[s
try:
await _sync_delta_requests(client)
except Exception as exc:
- logger.exception("Jellyseerr delta sync failed")
+ logger.exception("Seerr delta sync failed")
_sync_state.update(
{
"status": "failed",
@@ -937,20 +1374,9 @@ def get_requests_sync_state() -> Dict[str, Any]:
async def _ensure_request_access(
client: JellyseerrClient, request_id: int, user: Dict[str, str]
) -> None:
- if user.get("role") == "admin":
+ if user.get("role") == "admin" or user.get("username"):
return
- runtime = get_runtime_settings()
- mode = (runtime.requests_data_source or "prefer_cache").lower()
- cached = get_request_cache_payload(request_id)
- if mode != "always_js" and cached is not None:
- logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
- if _request_matches_user(cached, user.get("username", "")):
- return
- raise HTTPException(status_code=403, detail="Request not accessible for this user")
- logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
- details = await _get_request_details(client, request_id)
- if details is None or not _request_matches_user(details, user.get("username", "")):
- raise HTTPException(status_code=403, detail="Request not accessible for this user")
+ raise HTTPException(status_code=403, detail="Request not accessible for this user")
def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
@@ -999,6 +1425,148 @@ def _normalize_categories(categories: Any) -> List[str]:
return names
+def _normalize_indexer_name(value: Optional[str]) -> str:
+ if not isinstance(value, str):
+ return ""
+ return "".join(ch for ch in value.lower().strip() if ch.isalnum())
+
+
+def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
+ if exc.response is None:
+ logger.warning("%s %s failed: %s", service_label, action, exc)
+ return
+ status = exc.response.status_code
+ body = exc.response.text
+ if isinstance(body, str):
+ body = body.strip()
+ if len(body) > 800:
+ body = f"{body[:800]}...(truncated)"
+ logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body)
+
+
+def _format_rejections(rejections: Any) -> Optional[str]:
+ if isinstance(rejections, str):
+ return rejections.strip() or None
+ if isinstance(rejections, list):
+ reasons = []
+ for item in rejections:
+ reason = None
+ if isinstance(item, dict):
+ reason = (
+ item.get("reason")
+ or item.get("message")
+ or item.get("errorMessage")
+ )
+ if not reason and item is not None:
+ reason = str(item)
+ if isinstance(reason, str) and reason.strip():
+ reasons.append(reason.strip())
+ if reasons:
+ return "; ".join(reasons)
+ return None
+
+
+def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
+ if not isinstance(response, dict):
+ return True, None
+ rejections = response.get("rejections") or response.get("rejectionReasons")
+ reason = _format_rejections(rejections)
+ if reason:
+ return False, reason
+ if response.get("rejected") is True:
+ return False, "rejected"
+ if response.get("downloadAllowed") is False:
+ return False, "download not allowed"
+ if response.get("approved") is False:
+ return False, "not approved"
+ return True, None
+
+
+def _resolve_arr_indexer_id(
+ indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
+) -> Optional[int]:
+ if not isinstance(indexers, list):
+ return None
+ if not indexer_name:
+ if indexer_id is None:
+ return None
+ by_id = next(
+ (item for item in indexers if isinstance(item, dict) and item.get("id") == indexer_id),
+ None,
+ )
+ if by_id and by_id.get("id") is not None:
+ logger.debug("%s indexer id match: %s", service_label, by_id.get("id"))
+ return int(by_id["id"])
+ return None
+ target = indexer_name.lower().strip()
+ target_compact = _normalize_indexer_name(indexer_name)
+ exact = next(
+ (
+ item
+ for item in indexers
+ if isinstance(item, dict)
+ and str(item.get("name", "")).lower().strip() == target
+ ),
+ None,
+ )
+ if exact and exact.get("id") is not None:
+ logger.debug("%s indexer match: '%s' -> %s", service_label, indexer_name, exact.get("id"))
+ return int(exact["id"])
+ compact = next(
+ (
+ item
+ for item in indexers
+ if isinstance(item, dict)
+ and _normalize_indexer_name(str(item.get("name", ""))) == target_compact
+ ),
+ None,
+ )
+ if compact and compact.get("id") is not None:
+ logger.debug("%s indexer compact match: '%s' -> %s", service_label, indexer_name, compact.get("id"))
+ return int(compact["id"])
+ contains = next(
+ (
+ item
+ for item in indexers
+ if isinstance(item, dict)
+ and target in str(item.get("name", "")).lower()
+ ),
+ None,
+ )
+ if contains and contains.get("id") is not None:
+ logger.debug("%s indexer contains match: '%s' -> %s", service_label, indexer_name, contains.get("id"))
+ return int(contains["id"])
+ logger.warning(
+ "%s indexer not found for name '%s'. Check indexer names in the Arr app.",
+ service_label,
+ indexer_name,
+ )
+ return None
+
+
+async def _fallback_qbittorrent_download(download_url: Optional[str], category: str) -> bool:
+ if not download_url:
+ return False
+ runtime = get_runtime_settings()
+ client = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+ if not client.configured():
+ return False
+ await client.add_torrent_url(download_url, category=category)
+ return True
+
+
+def _resolve_qbittorrent_category(value: Optional[str], default: str) -> str:
+ if isinstance(value, str):
+ cleaned = value.strip()
+ if cleaned:
+ return cleaned
+ return default
+
+
def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]:
if not isinstance(results, list):
return []
@@ -1069,7 +1637,8 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
- return await build_snapshot(request_id)
+ snapshot = await build_snapshot(request_id)
+ return _filter_snapshot_actions_for_user(snapshot, user)
@router.get("/recent")
@@ -1077,63 +1646,44 @@ 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)
- if not client.configured():
- raise HTTPException(status_code=400, detail="Jellyseerr not configured")
-
- try:
- await _ensure_requests_cache(client)
- except httpx.HTTPStatusError as exc:
- raise HTTPException(status_code=502, detail=str(exc)) from exc
+ mode = (runtime.requests_data_source or "prefer_cache").lower()
+ allow_remote = mode == "always_js"
+ if allow_remote:
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+ try:
+ await _ensure_requests_cache(client)
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
username_norm = _normalize_username(user.get("username", ""))
+ requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
+ requested_by_id = None if user.get("role") == "admin" else requested_by_id
since_iso = None
if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+ status_codes = request_stage_filter_codes(stage)
if _recent_cache_stale():
_refresh_recent_cache_from_db()
- rows = _get_recent_from_cache(requested_by, take, skip, since_iso)
+ rows = _get_recent_from_cache(
+ requested_by,
+ requested_by_id,
+ take,
+ skip,
+ since_iso,
+ status_codes=status_codes,
+ )
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
- mode = (runtime.requests_data_source or "prefer_cache").lower()
- allow_remote = mode == "always_js"
- allow_title_hydrate = mode == "prefer_cache"
- allow_artwork_hydrate = allow_remote or allow_title_hydrate
+ allow_title_hydrate = False
+ allow_artwork_hydrate = client.configured()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
-
- async def _jellyfin_available(
- title_value: Optional[str], year_value: Optional[int], media_type_value: Optional[str]
- ) -> bool:
- if not jellyfin.configured() or not title_value:
- return False
- cache_key = f"{media_type_value or ''}:{title_value.lower()}:{year_value or ''}"
- cached_value = jellyfin_cache.get(cache_key)
- if cached_value is not None:
- return cached_value
- types = ["Movie"] if media_type_value == "movie" else ["Series"]
- try:
- search = await jellyfin.search_items(title_value, types)
- except Exception:
- jellyfin_cache[cache_key] = False
- return False
- if isinstance(search, dict):
- items = search.get("Items") or search.get("items") or []
- for item in items:
- if not isinstance(item, dict):
- continue
- name = item.get("Name") or item.get("title")
- year = item.get("ProductionYear") or item.get("Year")
- if name and name.strip().lower() == title_value.strip().lower():
- if year_value and year and int(year) != int(year_value):
- continue
- jellyfin_cache[cache_key] = True
- return True
- jellyfin_cache[cache_key] = False
- return False
results = []
for row in rows:
status = row.get("status")
@@ -1174,6 +1724,7 @@ async def recent_requests(
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),
@@ -1221,15 +1772,22 @@ async def recent_requests(
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
+ requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True),
)
status_label = _status_label(status)
- if status_label == "Working on it":
- is_available = await _jellyfin_available(title, year, row.get("media_type"))
- if is_available:
- status_label = "Available"
+ if status_label in {"Working on it", "Ready to watch", "Partially ready"}:
+ is_available = await _request_is_available_in_jellyfin(
+ jellyfin,
+ title,
+ year,
+ row.get("media_type"),
+ details if isinstance(details, dict) else None,
+ jellyfin_cache,
+ )
+ status_label = _status_label_with_jellyfin(status, is_available)
results.append(
{
"id": row.get("request_id"),
@@ -1239,6 +1797,7 @@ async def recent_requests(
"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),
@@ -1256,7 +1815,7 @@ async def search_requests(
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
- raise HTTPException(status_code=400, detail="Jellyseerr not configured")
+ raise HTTPException(status_code=400, detail="Seerr not configured")
try:
response = await client.search(query=query, page=page)
@@ -1272,6 +1831,8 @@ async def search_requests(
pass
results = []
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ jellyfin_cache: Dict[str, bool] = {}
for item in response.get("results", []):
media_type = item.get("mediaType")
title = item.get("title") or item.get("name")
@@ -1284,6 +1845,8 @@ async def search_requests(
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")
@@ -1292,21 +1855,31 @@ async def search_requests(
status = requests[0].get("status")
status_label = _status_label(status)
elif isinstance(media_info_id, int):
- username_norm = _normalize_username(user.get("username", ""))
- requested_by = None if user.get("role") == "admin" else username_norm
- cached = get_cached_request_by_media_id(media_info_id, requested_by_norm=requested_by)
+ 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 user.get("role") != "admin":
- if isinstance(request_id, int):
+ 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 not _request_matches_user(details, user.get("username", "")):
- continue
- else:
- continue
+ 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(
{
@@ -1317,19 +1890,144 @@ async def search_requests(
"requestId": request_id,
"status": status,
"statusLabel": status_label,
+ "requestedBy": requested_by,
+ "accessible": accessible,
+ "posterPath": item.get("posterPath") or item.get("poster_path"),
+ "backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
}
)
return {"results": results}
+@router.post("/create")
+async def create_request(
+ payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
+) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ raise HTTPException(status_code=400, detail="Seerr not configured")
+
+ media_type = _normalize_media_type(
+ payload.get("mediaType") or payload.get("type") or payload.get("media_type")
+ )
+ if media_type is None:
+ raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'")
+
+ raw_tmdb_id = payload.get("tmdbId")
+ if raw_tmdb_id is None:
+ raw_tmdb_id = payload.get("mediaId")
+ if raw_tmdb_id is None:
+ raw_tmdb_id = payload.get("id")
+ try:
+ tmdb_id = int(raw_tmdb_id)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc
+ if tmdb_id <= 0:
+ raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
+
+ seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
+ raw_is_4k = payload.get("is4k")
+ if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
+ raise HTTPException(status_code=400, detail="is4k must be true or false")
+ is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None
+
+ try:
+ details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id))
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
+
+ if not isinstance(details, dict):
+ raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
+
+ media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
+ requests_list = media_info.get("requests")
+ existing_request: Optional[Dict[str, Any]] = None
+ if isinstance(requests_list, list) and requests_list:
+ first_request = requests_list[0]
+ if isinstance(first_request, dict):
+ existing_request = first_request
+
+ title = details.get("title") or details.get("name")
+ year: Optional[int] = None
+ date_value = details.get("releaseDate") or details.get("firstAirDate")
+ if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit():
+ year = int(date_value[:4])
+
+ if isinstance(existing_request, dict):
+ existing_request_id = _quality_profile_id(existing_request.get("id"))
+ existing_status = existing_request.get("status")
+ if existing_request_id is not None:
+ request_payload = await _get_request_details(client, existing_request_id)
+ if isinstance(request_payload, dict):
+ parsed_payload = _parse_request_payload(request_payload)
+ upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload))
+ _cache_set(f"request:{existing_request_id}", request_payload)
+ title = parsed_payload.get("title") or title
+ year = parsed_payload.get("year") or year
+ return {
+ "status": "exists",
+ "requestId": existing_request_id,
+ "type": media_type,
+ "tmdbId": tmdb_id,
+ "title": title,
+ "year": year,
+ "statusCode": existing_status,
+ "statusLabel": _status_label(existing_status),
+ }
+
+ try:
+ created = await client.create_request(
+ media_type=media_type,
+ media_id=tmdb_id,
+ seasons=seasons if media_type == "tv" else None,
+ is_4k=is_4k,
+ )
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
+
+ if not isinstance(created, dict):
+ raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
+
+ parsed = _parse_request_payload(created)
+ request_id = _quality_profile_id(parsed.get("request_id"))
+ status_code = parsed.get("status")
+ title = parsed.get("title") or title
+ year = parsed.get("year") or year
+
+ if request_id is not None:
+ upsert_request_cache(**_build_request_cache_record(parsed, created))
+ _cache_set(f"request:{request_id}", created)
+ _recent_cache["updated_at"] = None
+ await asyncio.to_thread(
+ save_action,
+ str(request_id),
+ "request_created",
+ "Create request",
+ "ok",
+ f"{media_type} request created from discovery by {user.get('username')}.",
+ )
+
+ return {
+ "status": "created",
+ "requestId": request_id,
+ "type": media_type,
+ "tmdbId": tmdb_id,
+ "title": title,
+ "year": year,
+ "statusCode": status_code,
+ "statusLabel": _status_label(status_code),
+ }
+
+
@router.post("/{request_id}/ai/triage", response_model=TriageResult)
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
- snapshot = await build_snapshot(request_id)
+ snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
return triage_snapshot(snapshot)
@@ -1366,6 +2064,8 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/search_auto")
async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
+ if not _user_can_use_search_auto(user):
+ raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
@@ -1379,10 +2079,23 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured")
+ target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
+ current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
+ profile_message = None
+ series_id = _quality_profile_id(arr_item.get("id"))
+ if target_profile_id and series_id and current_profile_id != target_profile_id:
+ series = await client.get_series(series_id)
+ if not isinstance(series, dict):
+ raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
+ series["qualityProfileId"] = target_profile_id
+ await client.update_series(series)
+ profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
episodes = await client.get_episodes(int(arr_item["id"]))
missing_by_season = _missing_episode_ids_by_season(episodes)
if not missing_by_season:
message = "No missing monitored episodes found."
+ if profile_message:
+ message = f"{profile_message} {message}"
await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
)
@@ -1396,6 +2109,8 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
{"season": season_number, "episodeCount": len(episode_ids), "response": response}
)
message = "Search sent to Sonarr."
+ if profile_message:
+ message = f"{profile_message} {message}"
await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
)
@@ -1404,8 +2119,21 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
+ target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
+ current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
+ profile_message = None
+ movie_id = _quality_profile_id(arr_item.get("id"))
+ if target_profile_id and movie_id and current_profile_id != target_profile_id:
+ movie = await client.get_movie(movie_id)
+ if not isinstance(movie, dict):
+ raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
+ movie["qualityProfileId"] = target_profile_id
+ await client.update_movie(movie)
+ profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
response = await client.search(int(arr_item["id"]))
message = "Search sent to Radarr."
+ if profile_message:
+ message = f"{profile_message} {message}"
await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
)
@@ -1607,78 +2335,42 @@ async def action_grab(
snapshot = await build_snapshot(request_id)
guid = payload.get("guid")
indexer_id = payload.get("indexerId")
+ indexer_name = payload.get("indexerName") or payload.get("indexer")
download_url = payload.get("downloadUrl")
+ release_title = payload.get("title")
+ release_size = payload.get("size")
+ release_protocol = payload.get("protocol") or "torrent"
+ release_publish = payload.get("publishDate")
+ release_seeders = payload.get("seeders")
+ release_leechers = payload.get("leechers")
if not guid or not indexer_id:
raise HTTPException(status_code=400, detail="Missing guid or indexerId")
- runtime = get_runtime_settings()
- 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")
- try:
- response = await client.grab_release(str(guid), int(indexer_id))
- except httpx.HTTPStatusError as exc:
- status_code = exc.response.status_code if exc.response is not None else 502
- if status_code == 404 and download_url:
- qbit = QBittorrentClient(
- runtime.qbittorrent_base_url,
- runtime.qbittorrent_username,
- runtime.qbittorrent_password,
- )
- if not qbit.configured():
- raise HTTPException(status_code=400, detail="qBittorrent not configured")
- try:
- await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
- except httpx.HTTPStatusError as qbit_exc:
- raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
- await asyncio.to_thread(
- save_action,
- request_id,
- "grab",
- "Grab release",
- "ok",
- "Sent to qBittorrent via Prowlarr.",
- )
- return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
- raise HTTPException(status_code=502, detail=str(exc)) from exc
- await asyncio.to_thread(
- save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr."
- )
- return {"status": "ok", "response": response}
- 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")
- try:
- response = await client.grab_release(str(guid), int(indexer_id))
- except httpx.HTTPStatusError as exc:
- status_code = exc.response.status_code if exc.response is not None else 502
- if status_code == 404 and download_url:
- qbit = QBittorrentClient(
- runtime.qbittorrent_base_url,
- runtime.qbittorrent_username,
- runtime.qbittorrent_password,
- )
- if not qbit.configured():
- raise HTTPException(status_code=400, detail="qBittorrent not configured")
- try:
- await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
- except httpx.HTTPStatusError as qbit_exc:
- raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
- await asyncio.to_thread(
- save_action,
- request_id,
- "grab",
- "Grab release",
- "ok",
- "Sent to qBittorrent via Prowlarr.",
- )
- return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
- raise HTTPException(status_code=502, detail=str(exc)) from exc
- await asyncio.to_thread(
- save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr."
- )
- return {"status": "ok", "response": response}
+ logger.info(
+ "Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s",
+ request_id,
+ guid,
+ indexer_id,
+ indexer_name,
+ bool(download_url),
+ bool(release_title),
+ )
- raise HTTPException(status_code=400, detail="Unknown request type")
+ runtime = get_runtime_settings()
+ if not download_url:
+ raise HTTPException(status_code=400, detail="Missing downloadUrl")
+ if snapshot.request_type.value == "tv":
+ category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
+ if snapshot.request_type.value == "movie":
+ category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
+ if snapshot.request_type.value not in {"tv", "movie"}:
+ raise HTTPException(status_code=400, detail="Unknown request type")
+
+ qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
+ if not qbittorrent_added:
+ raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
+ action_message = f"Grab sent to qBittorrent (category {category})."
+ await asyncio.to_thread(
+ save_action, request_id, "grab", "Grab release", "ok", action_message
+ )
+ return {"status": "ok", "response": {"qbittorrent": "queued"}}
diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py
new file mode 100644
index 0000000..72985ee
--- /dev/null
+++ b/backend/app/routers/site.py
@@ -0,0 +1,46 @@
+from typing import Any, Dict
+
+from fastapi import APIRouter, Depends
+
+from ..auth import get_current_user
+from ..build_info import BUILD_NUMBER, CHANGELOG
+from ..runtime import get_runtime_settings
+
+router = APIRouter(prefix="/site", tags=["site"])
+
+_BANNER_TONES = {"info", "warning", "error", "maintenance"}
+
+
+def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ banner_message = (runtime.site_banner_message or "").strip()
+ tone = (runtime.site_banner_tone or "info").strip().lower()
+ if tone not in _BANNER_TONES:
+ tone = "info"
+ info = {
+ "buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
+ "banner": {
+ "enabled": bool(runtime.site_banner_enabled and banner_message),
+ "message": banner_message,
+ "tone": tone,
+ },
+ "login": {
+ "showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
+ "showLocalLogin": bool(runtime.site_login_show_local_login),
+ "showForgotPassword": bool(runtime.site_login_show_forgot_password),
+ "showSignupLink": bool(runtime.site_login_show_signup_link),
+ },
+ }
+ if include_changelog:
+ info["changelog"] = (CHANGELOG or "").strip()
+ return info
+
+
+@router.get("/public")
+async def site_public() -> Dict[str, Any]:
+ return _build_site_info(False)
+
+
+@router.get("/info")
+async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ return _build_site_info(True)
diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py
index 692dde1..d2c60a2 100644
--- a/backend/app/routers/status.py
+++ b/backend/app/routers/status.py
@@ -1,6 +1,6 @@
from typing import Any, Dict
import httpx
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..runtime import get_runtime_settings
@@ -41,7 +41,7 @@ async def services_status() -> Dict[str, Any]:
services = []
services.append(
await _check(
- "Jellyseerr",
+ "Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
)
@@ -93,3 +93,47 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded"
return {"overall": overall, "services": services}
+
+
+@router.post("/services/{service}/test")
+async def test_service(service: str) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ qbittorrent = QBittorrentClient(
+ runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
+ )
+ jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+
+ service_key = service.strip().lower()
+ checks = {
+ "seerr": (
+ "Seerr",
+ jellyseerr.configured(),
+ lambda: jellyseerr.get_recent_requests(take=1, skip=0),
+ ),
+ "jellyseerr": (
+ "Seerr",
+ jellyseerr.configured(),
+ lambda: jellyseerr.get_recent_requests(take=1, skip=0),
+ ),
+ "sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
+ "radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
+ "prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
+ "qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
+ "jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
+ }
+
+ 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
index 1ad0fc4..52bc149 100644
--- a/backend/app/runtime.py
+++ b/backend/app/runtime.py
@@ -2,17 +2,45 @@ from .config import settings
from .db import get_settings_overrides
_INT_FIELDS = {
+ "magent_application_port",
+ "magent_api_port",
+ "auth_rate_limit_window_seconds",
+ "auth_rate_limit_max_attempts_ip",
+ "auth_rate_limit_max_attempts_user",
+ "password_reset_rate_limit_window_seconds",
+ "password_reset_rate_limit_max_attempts_ip",
+ "password_reset_rate_limit_max_attempts_identifier",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"jwt_exp_minutes",
+ "log_file_max_bytes",
+ "log_file_backup_count",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
+ "magent_notify_email_smtp_port",
}
_BOOL_FIELDS = {
+ "magent_proxy_enabled",
+ "magent_proxy_trust_forwarded_headers",
+ "magent_ssl_bind_enabled",
+ "magent_notify_enabled",
+ "magent_notify_email_enabled",
+ "magent_notify_email_use_tls",
+ "magent_notify_email_use_ssl",
+ "magent_notify_discord_enabled",
+ "magent_notify_telegram_enabled",
+ "magent_notify_push_enabled",
+ "magent_notify_webhook_enabled",
"jellyfin_sync_to_arr",
+ "site_banner_enabled",
+ "site_login_show_jellyfin_login",
+ "site_login_show_local_login",
+ "site_login_show_forgot_password",
+ "site_login_show_signup_link",
}
+_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
@@ -21,6 +49,8 @@ def get_runtime_settings():
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)
diff --git a/backend/app/security.py b/backend/app/security.py
index 5632c8b..ce983d8 100644
--- a/backend/app/security.py
+++ b/backend/app/security.py
@@ -1,13 +1,16 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
-from jose import JWTError, jwt
from passlib.context import CryptContext
+import jwt
+from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_ALGORITHM = "HS256"
+MIN_PASSWORD_LENGTH = 8
+PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str:
@@ -18,11 +21,37 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password)
+def validate_password_policy(password: str) -> str:
+ candidate = password.strip()
+ if len(candidate) < MIN_PASSWORD_LENGTH:
+ raise ValueError(PASSWORD_POLICY_MESSAGE)
+ return candidate
+
+
+def _create_token(
+ subject: str,
+ role: str,
+ *,
+ expires_at: datetime,
+ token_type: str = "access",
+) -> str:
+ payload: Dict[str, Any] = {
+ "sub": subject,
+ "role": role,
+ "typ": token_type,
+ "exp": expires_at,
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
+
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
- payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
- return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
+ return _create_token(subject, role, expires_at=expires, token_type="access")
+
+
+def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
+ expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
+ return _create_token(subject, role, expires_at=expires, token_type="sse")
def decode_token(token: str) -> Dict[str, Any]:
@@ -36,5 +65,5 @@ class TokenError(Exception):
def safe_decode_token(token: str) -> Dict[str, Any]:
try:
return decode_token(token)
- except JWTError as exc:
+ except InvalidTokenError as exc:
raise TokenError("Invalid token") from exc
diff --git a/backend/app/services/diagnostics.py b/backend/app/services/diagnostics.py
new file mode 100644
index 0000000..394c527
--- /dev/null
+++ b/backend/app/services/diagnostics.py
@@ -0,0 +1,735 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from time import perf_counter
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
+from urllib.parse import urlparse
+
+import httpx
+
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..clients.prowlarr import ProwlarrClient
+from ..clients.qbittorrent import QBittorrentClient
+from ..clients.radarr import RadarrClient
+from ..clients.sonarr import SonarrClient
+from ..config import settings as env_settings
+from ..db import get_database_diagnostics
+from ..network_security import validate_notification_target_url
+from ..runtime import get_runtime_settings
+from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
+
+
+DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
+
+
+@dataclass(frozen=True)
+class DiagnosticCheck:
+ key: str
+ label: str
+ category: str
+ description: str
+ live_safe: bool
+ configured: bool
+ config_detail: str
+ target: Optional[str]
+ runner: DiagnosticRunner
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _clean_text(value: Any, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else fallback
+ return str(value)
+
+
+def _url_target(url: Optional[str]) -> Optional[str]:
+ raw = _clean_text(url)
+ if not raw:
+ return None
+ try:
+ parsed = urlparse(raw)
+ except Exception:
+ return raw
+ host = parsed.hostname or parsed.netloc or raw
+ if parsed.port:
+ host = f"{host}:{parsed.port}"
+ return host
+
+
+def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
+ resolved_host = _clean_text(host)
+ if not resolved_host:
+ return None
+ if port is None:
+ return resolved_host
+ return f"{resolved_host}:{port}"
+
+
+def _http_error_detail(exc: Exception) -> str:
+ if isinstance(exc, httpx.HTTPStatusError):
+ response = exc.response
+ body = ""
+ try:
+ body = response.text.strip()
+ except Exception:
+ body = ""
+ if body:
+ return f"HTTP {response.status_code}: {body}"
+ return f"HTTP {response.status_code}"
+ return str(exc)
+
+
+def _config_status(detail: str) -> str:
+ lowered = detail.lower()
+ if "disabled" in lowered:
+ return "disabled"
+ return "not_configured"
+
+
+def _discord_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
+ return False, "Discord notifications are disabled."
+ webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
+ if webhook_url:
+ try:
+ validate_notification_target_url(webhook_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Discord webhook URL is required."
+
+
+def _telegram_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
+ return False, "Telegram notifications are disabled."
+ if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
+ return True, "ok"
+ return False, "Telegram bot token and chat ID are required."
+
+
+def _webhook_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
+ return False, "Generic webhook notifications are disabled."
+ webhook_url = _clean_text(runtime.magent_notify_webhook_url)
+ if webhook_url:
+ try:
+ validate_notification_target_url(webhook_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Generic webhook URL is required."
+
+
+def _push_config_ready(runtime) -> tuple[bool, str]:
+ if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
+ return False, "Push notifications are disabled."
+ provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ if provider == "ntfy":
+ push_url = _clean_text(runtime.magent_notify_push_base_url)
+ if push_url and _clean_text(runtime.magent_notify_push_topic):
+ try:
+ validate_notification_target_url(push_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "ntfy requires a base URL and topic."
+ if provider == "gotify":
+ push_url = _clean_text(runtime.magent_notify_push_base_url)
+ if push_url and _clean_text(runtime.magent_notify_push_token):
+ try:
+ validate_notification_target_url(push_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Gotify requires a base URL and app token."
+ if provider == "pushover":
+ if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
+ return True, "ok"
+ return False, "Pushover requires an application token and user key."
+ if provider == "webhook":
+ push_url = _clean_text(runtime.magent_notify_push_base_url)
+ if push_url:
+ try:
+ validate_notification_target_url(push_url)
+ except ValueError as exc:
+ return False, str(exc)
+ return True, "ok"
+ return False, "Webhook relay requires a target URL."
+ if provider == "telegram":
+ return _telegram_config_ready(runtime)
+ if provider == "discord":
+ return _discord_config_ready(runtime)
+ return False, f"Unsupported push provider: {provider or 'unknown'}"
+
+
+def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
+ summary = {
+ "total": len(results),
+ "up": 0,
+ "down": 0,
+ "degraded": 0,
+ "not_configured": 0,
+ "disabled": 0,
+ }
+ for result in results:
+ status = str(result.get("status") or "").strip().lower()
+ if status in summary:
+ summary[status] += 1
+ return summary
+
+
+async def _run_http_json_get(
+ url: str,
+ *,
+ headers: Optional[Dict[str, str]] = None,
+ params: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
+ response = await client.get(url, headers=headers, params=params)
+ response.raise_for_status()
+ payload = response.json()
+ return {"response": payload}
+
+
+async def _run_http_text_get(url: str) -> Dict[str, Any]:
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
+ response = await client.get(url)
+ response.raise_for_status()
+ body = response.text
+ return {"response": body, "message": f"HTTP {response.status_code}"}
+
+
+async def _run_http_post(
+ url: str,
+ *,
+ json_payload: Optional[Dict[str, Any]] = None,
+ data_payload: Any = None,
+ params: Optional[Dict[str, Any]] = None,
+ headers: Optional[Dict[str, str]] = None,
+) -> Dict[str, Any]:
+ validate_notification_target_url(url)
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
+ response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
+ response.raise_for_status()
+ if not response.content:
+ return {"message": f"HTTP {response.status_code}"}
+ content_type = response.headers.get("content-type", "")
+ if "application/json" in content_type.lower():
+ try:
+ return {"response": response.json(), "message": f"HTTP {response.status_code}"}
+ except Exception:
+ pass
+ return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
+
+
+async def _run_database_check() -> Dict[str, Any]:
+ detail = await asyncio.to_thread(get_database_diagnostics)
+ integrity = _clean_text(detail.get("integrity_check"), "unknown")
+ requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
+ wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
+ wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
+ status = "up" if integrity == "ok" else "degraded"
+ return {
+ "status": status,
+ "message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
+ "detail": detail,
+ }
+
+
+async def _run_magent_api_check(runtime) -> Dict[str, Any]:
+ base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
+ result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
+ payload = result.get("response")
+ build_number = payload.get("build") if isinstance(payload, dict) else None
+ message = "Health endpoint responded"
+ if build_number:
+ message = f"Health endpoint responded (build {build_number})"
+ return {"message": message, "detail": payload}
+
+
+async def _run_magent_web_check(runtime) -> Dict[str, Any]:
+ base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
+ result = await _run_http_text_get(base_url.rstrip("/"))
+ body = result.get("response")
+ if isinstance(body, str) and " Dict[str, Any]:
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ payload = await client.get_status()
+ version = payload.get("version") if isinstance(payload, dict) else None
+ message = "Seerr responded"
+ if version:
+ message = f"Seerr version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_sonarr_check(runtime) -> Dict[str, Any]:
+ client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
+ payload = await client.get_system_status()
+ version = payload.get("version") if isinstance(payload, dict) else None
+ message = "Sonarr responded"
+ if version:
+ message = f"Sonarr version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_radarr_check(runtime) -> Dict[str, Any]:
+ client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
+ payload = await client.get_system_status()
+ version = payload.get("version") if isinstance(payload, dict) else None
+ message = "Radarr responded"
+ if version:
+ message = f"Radarr version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
+ client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
+ payload = await client.get_health()
+ if isinstance(payload, list) and payload:
+ return {
+ "status": "degraded",
+ "message": f"Prowlarr health warnings: {len(payload)}",
+ "detail": payload,
+ }
+ return {"message": "Prowlarr reported healthy", "detail": payload}
+
+
+async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
+ client = QBittorrentClient(
+ runtime.qbittorrent_base_url,
+ runtime.qbittorrent_username,
+ runtime.qbittorrent_password,
+ )
+ version = await client.get_app_version()
+ message = "qBittorrent responded"
+ if isinstance(version, str) and version:
+ message = f"qBittorrent version {version}"
+ return {"message": message, "detail": version}
+
+
+async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ payload = await client.get_system_info()
+ version = payload.get("Version") if isinstance(payload, dict) else None
+ message = "Jellyfin responded"
+ if version:
+ message = f"Jellyfin version {version}"
+ return {"message": message, "detail": payload}
+
+
+async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
+ result = await send_test_email(recipient_email=recipient_email)
+ recipient = _clean_text(result.get("recipient_email"), "configured recipient")
+ warning = _clean_text(result.get("warning"))
+ if warning:
+ return {
+ "status": "degraded",
+ "message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
+ "detail": result,
+ }
+ return {"message": f"Test email sent to {recipient}", "detail": result}
+
+
+async def _run_discord_check(runtime) -> Dict[str, Any]:
+ webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
+ payload = {
+ "content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
+ }
+ result = await _run_http_post(webhook_url, json_payload=payload)
+ return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
+
+
+async def _run_telegram_check(runtime) -> Dict[str, Any]:
+ bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
+ chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+ payload = {
+ "chat_id": chat_id,
+ "text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
+ }
+ result = await _run_http_post(url, json_payload=payload)
+ return {"message": "Telegram ping accepted", "detail": result.get("response")}
+
+
+async def _run_webhook_check(runtime) -> Dict[str, Any]:
+ webhook_url = _clean_text(runtime.magent_notify_webhook_url)
+ payload = {
+ "type": "diagnostics.ping",
+ "application": env_settings.app_name,
+ "build": env_settings.site_build_number,
+ "checked_at": _now_iso(),
+ }
+ result = await _run_http_post(webhook_url, json_payload=payload)
+ return {"message": "Webhook accepted ping", "detail": result.get("response")}
+
+
+async def _run_push_check(runtime) -> Dict[str, Any]:
+ provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ message = f"{env_settings.app_name} diagnostics ping"
+ build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
+
+ if provider == "ntfy":
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ topic = _clean_text(runtime.magent_notify_push_topic)
+ result = await _run_http_post(
+ f"{base_url.rstrip('/')}/{topic}",
+ data_payload=f"{message}\n{build_suffix}",
+ headers={"Content-Type": "text/plain; charset=utf-8"},
+ )
+ return {"message": "ntfy push accepted", "detail": result.get("response")}
+
+ if provider == "gotify":
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ token = _clean_text(runtime.magent_notify_push_token)
+ result = await _run_http_post(
+ f"{base_url.rstrip('/')}/message",
+ json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
+ params={"token": token},
+ )
+ return {"message": "Gotify push accepted", "detail": result.get("response")}
+
+ if provider == "pushover":
+ token = _clean_text(runtime.magent_notify_push_token)
+ user_key = _clean_text(runtime.magent_notify_push_user_key)
+ device = _clean_text(runtime.magent_notify_push_device)
+ payload = {
+ "token": token,
+ "user": user_key,
+ "message": f"{message}\n{build_suffix}",
+ "title": env_settings.app_name,
+ }
+ if device:
+ payload["device"] = device
+ result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
+ return {"message": "Pushover push accepted", "detail": result.get("response")}
+
+ if provider == "webhook":
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ payload = {
+ "type": "diagnostics.push",
+ "application": env_settings.app_name,
+ "build": env_settings.site_build_number,
+ "checked_at": _now_iso(),
+ }
+ result = await _run_http_post(base_url, json_payload=payload)
+ return {"message": "Push webhook accepted", "detail": result.get("response")}
+
+ if provider == "telegram":
+ return await _run_telegram_check(runtime)
+
+ if provider == "discord":
+ return await _run_discord_check(runtime)
+
+ raise RuntimeError(f"Unsupported push provider: {provider}")
+
+
+def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
+ runtime = get_runtime_settings()
+ seerr_target = _url_target(runtime.jellyseerr_base_url)
+ jellyfin_target = _url_target(runtime.jellyfin_base_url)
+ sonarr_target = _url_target(runtime.sonarr_base_url)
+ radarr_target = _url_target(runtime.radarr_base_url)
+ prowlarr_target = _url_target(runtime.prowlarr_base_url)
+ qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
+ application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
+ api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
+ smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
+ discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
+ telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
+ webhook_target = _url_target(runtime.magent_notify_webhook_url)
+
+ push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ push_target = None
+ if push_provider == "pushover":
+ push_target = "api.pushover.net"
+ elif push_provider == "telegram":
+ push_target = telegram_target or "api.telegram.org"
+ elif push_provider == "discord":
+ push_target = discord_target or "discord.com"
+ else:
+ push_target = _url_target(runtime.magent_notify_push_base_url)
+
+ email_ready, email_detail = smtp_email_config_ready()
+ email_warning = smtp_email_delivery_warning()
+ discord_ready, discord_detail = _discord_config_ready(runtime)
+ telegram_ready, telegram_detail = _telegram_config_ready(runtime)
+ push_ready, push_detail = _push_config_ready(runtime)
+ webhook_ready, webhook_detail = _webhook_config_ready(runtime)
+
+ checks = [
+ DiagnosticCheck(
+ key="magent-web",
+ label="Magent application",
+ category="Application",
+ description="Checks that the frontend application URL is responding.",
+ live_safe=True,
+ configured=True,
+ config_detail="ok",
+ target=application_target,
+ runner=lambda runtime=runtime: _run_magent_web_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="magent-api",
+ label="Magent API",
+ category="Application",
+ description="Checks the Magent API health endpoint.",
+ live_safe=True,
+ configured=True,
+ config_detail="ok",
+ target=api_target,
+ runner=lambda runtime=runtime: _run_magent_api_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="database",
+ label="SQLite database",
+ category="Application",
+ description="Runs SQLite integrity_check against the current Magent database.",
+ live_safe=True,
+ configured=True,
+ config_detail="ok",
+ target="sqlite",
+ runner=_run_database_check,
+ ),
+ DiagnosticCheck(
+ key="seerr",
+ label="Seerr",
+ category="Media services",
+ description="Checks Seerr API reachability and version.",
+ live_safe=True,
+ configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
+ config_detail="Seerr URL and API key are required.",
+ target=seerr_target,
+ runner=lambda runtime=runtime: _run_seerr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="jellyfin",
+ label="Jellyfin",
+ category="Media services",
+ description="Checks Jellyfin system info with the configured API key.",
+ live_safe=True,
+ configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
+ config_detail="Jellyfin URL and API key are required.",
+ target=jellyfin_target,
+ runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="sonarr",
+ label="Sonarr",
+ category="Media services",
+ description="Checks Sonarr system status with the configured API key.",
+ live_safe=True,
+ configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
+ config_detail="Sonarr URL and API key are required.",
+ target=sonarr_target,
+ runner=lambda runtime=runtime: _run_sonarr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="radarr",
+ label="Radarr",
+ category="Media services",
+ description="Checks Radarr system status with the configured API key.",
+ live_safe=True,
+ configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
+ config_detail="Radarr URL and API key are required.",
+ target=radarr_target,
+ runner=lambda runtime=runtime: _run_radarr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="prowlarr",
+ label="Prowlarr",
+ category="Media services",
+ description="Checks Prowlarr health and flags warnings as degraded.",
+ live_safe=True,
+ configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
+ config_detail="Prowlarr URL and API key are required.",
+ target=prowlarr_target,
+ runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="qbittorrent",
+ label="qBittorrent",
+ category="Media services",
+ description="Checks qBittorrent login and app version.",
+ live_safe=True,
+ configured=bool(
+ runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
+ ),
+ config_detail="qBittorrent URL, username, and password are required.",
+ target=qbittorrent_target,
+ runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="email",
+ label="SMTP email",
+ category="Notifications",
+ description="Sends a live test email using the configured SMTP provider.",
+ live_safe=False,
+ configured=email_ready,
+ config_detail=email_warning or email_detail,
+ target=smtp_target,
+ runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
+ ),
+ DiagnosticCheck(
+ key="discord",
+ label="Discord webhook",
+ category="Notifications",
+ description="Posts a live test message to the configured Discord webhook.",
+ live_safe=False,
+ configured=discord_ready,
+ config_detail=discord_detail,
+ target=discord_target,
+ runner=lambda runtime=runtime: _run_discord_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="telegram",
+ label="Telegram",
+ category="Notifications",
+ description="Sends a live test message to the configured Telegram chat.",
+ live_safe=False,
+ configured=telegram_ready,
+ config_detail=telegram_detail,
+ target=telegram_target,
+ runner=lambda runtime=runtime: _run_telegram_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="push",
+ label="Push/mobile provider",
+ category="Notifications",
+ description="Sends a live test message through the configured push provider.",
+ live_safe=False,
+ configured=push_ready,
+ config_detail=push_detail,
+ target=push_target,
+ runner=lambda runtime=runtime: _run_push_check(runtime),
+ ),
+ DiagnosticCheck(
+ key="webhook",
+ label="Generic webhook",
+ category="Notifications",
+ description="Posts a live test payload to the configured generic webhook.",
+ live_safe=False,
+ configured=webhook_ready,
+ config_detail=webhook_detail,
+ target=webhook_target,
+ runner=lambda runtime=runtime: _run_webhook_check(runtime),
+ ),
+ ]
+ return checks
+
+
+async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
+ if not check.configured:
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": False,
+ "status": _config_status(check.config_detail),
+ "message": check.config_detail,
+ "checked_at": _now_iso(),
+ "duration_ms": 0,
+ }
+
+ started = perf_counter()
+ checked_at = _now_iso()
+ try:
+ payload = await check.runner()
+ status = _clean_text(payload.get("status"), "up")
+ message = _clean_text(payload.get("message"), "Check passed")
+ detail = payload.get("detail")
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": True,
+ "status": status,
+ "message": message,
+ "detail": detail,
+ "checked_at": checked_at,
+ "duration_ms": round((perf_counter() - started) * 1000, 1),
+ }
+ except httpx.HTTPError as exc:
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": True,
+ "status": "down",
+ "message": _http_error_detail(exc),
+ "checked_at": checked_at,
+ "duration_ms": round((perf_counter() - started) * 1000, 1),
+ }
+ except Exception as exc:
+ return {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "target": check.target,
+ "live_safe": check.live_safe,
+ "configured": True,
+ "status": "down",
+ "message": str(exc),
+ "checked_at": checked_at,
+ "duration_ms": round((perf_counter() - started) * 1000, 1),
+ }
+
+
+def get_diagnostics_catalog() -> Dict[str, Any]:
+ checks = _build_diagnostic_checks()
+ items = []
+ for check in checks:
+ items.append(
+ {
+ "key": check.key,
+ "label": check.label,
+ "category": check.category,
+ "description": check.description,
+ "live_safe": check.live_safe,
+ "target": check.target,
+ "configured": check.configured,
+ "config_status": "configured" if check.configured else _config_status(check.config_detail),
+ "config_detail": "Ready to test." if check.configured else check.config_detail,
+ }
+ )
+ categories = sorted({item["category"] for item in items})
+ return {
+ "checks": items,
+ "categories": categories,
+ "generated_at": _now_iso(),
+ }
+
+
+async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
+ checks = _build_diagnostic_checks(recipient_email=recipient_email)
+ selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
+ if selected:
+ checks = [check for check in checks if check.key.lower() in selected]
+ results = await asyncio.gather(*(_execute_check(check) for check in checks))
+ return {
+ "results": results,
+ "summary": _summary_from_results(results),
+ "checked_at": _now_iso(),
+ }
diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py
new file mode 100644
index 0000000..cec7d41
--- /dev/null
+++ b/backend/app/services/invite_email.py
@@ -0,0 +1,1404 @@
+from __future__ import annotations
+
+import asyncio
+import html
+import json
+import logging
+import re
+import smtplib
+from functools import lru_cache
+from pathlib import Path
+from email.generator import BytesGenerator
+from email.message import EmailMessage
+from email.policy import SMTP as SMTP_POLICY
+from email.utils import formataddr, formatdate, make_msgid
+from io import BytesIO
+from typing import Any, Dict, Optional
+from urllib.parse import urlparse
+
+from ..build_info import BUILD_NUMBER
+from ..config import settings as env_settings
+from ..db import delete_setting, get_setting, set_setting
+from ..runtime import get_runtime_settings
+
+logger = logging.getLogger(__name__)
+
+TEMPLATE_SETTING_PREFIX = "invite_email_template_"
+TEMPLATE_KEYS = ("invited", "welcome", "warning", "banned")
+EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
+EXCHANGE_MESSAGE_ID_PATTERN = re.compile(r"<([^>]+)>")
+EXCHANGE_INTERNAL_ID_PATTERN = re.compile(r"\[InternalId=([^\],]+)")
+EMAIL_LOGO_CID = "magent-logo"
+
+TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = {
+ "invited": {
+ "label": "You have been invited",
+ "description": "Sent when an invite link is created and emailed to a recipient.",
+ },
+ "welcome": {
+ "label": "Welcome / How it works",
+ "description": "Sent after an invited user completes signup.",
+ },
+ "warning": {
+ "label": "Warning",
+ "description": "Manual warning template for account or behavior notices.",
+ },
+ "banned": {
+ "label": "Banned",
+ "description": "Sent when an account is banned or removed.",
+ },
+}
+
+TEMPLATE_PLACEHOLDERS = [
+ "app_name",
+ "app_url",
+ "build_number",
+ "how_it_works_url",
+ "invite_code",
+ "invite_description",
+ "invite_expires_at",
+ "invite_label",
+ "invite_link",
+ "invite_remaining_uses",
+ "inviter_username",
+ "message",
+ "reason",
+ "recipient_email",
+ "role",
+ "username",
+]
+
+EMAIL_TAGLINE = "Find and fix media requests fast."
+
+EMAIL_TONE_STYLES: Dict[str, Dict[str, str]] = {
+ "brand": {
+ "chip_bg": "rgba(255, 107, 43, 0.16)",
+ "chip_border": "rgba(255, 107, 43, 0.38)",
+ "chip_text": "#ffd2bf",
+ "accent_a": "#ff6b2b",
+ "accent_b": "#1c6bff",
+ },
+ "success": {
+ "chip_bg": "rgba(34, 197, 94, 0.16)",
+ "chip_border": "rgba(34, 197, 94, 0.38)",
+ "chip_text": "#c7f9d7",
+ "accent_a": "#22c55e",
+ "accent_b": "#1c6bff",
+ },
+ "warning": {
+ "chip_bg": "rgba(251, 146, 60, 0.16)",
+ "chip_border": "rgba(251, 146, 60, 0.38)",
+ "chip_text": "#ffe0ba",
+ "accent_a": "#fb923c",
+ "accent_b": "#ff6b2b",
+ },
+ "danger": {
+ "chip_bg": "rgba(248, 113, 113, 0.16)",
+ "chip_border": "rgba(248, 113, 113, 0.38)",
+ "chip_text": "#ffd0d0",
+ "accent_a": "#ef4444",
+ "accent_b": "#ff6b2b",
+ },
+}
+
+TEMPLATE_PRESENTATION: Dict[str, Dict[str, str]] = {
+ "invited": {
+ "tone": "brand",
+ "title": "You have been invited",
+ "subtitle": "A new account invitation is ready for you.",
+ "primary_label": "Accept invite",
+ "primary_url_key": "invite_link",
+ "secondary_label": "How it works",
+ "secondary_url_key": "how_it_works_url",
+ },
+ "welcome": {
+ "tone": "success",
+ "title": "Welcome to Magent",
+ "subtitle": "Your account is ready and synced.",
+ "primary_label": "Open Magent",
+ "primary_url_key": "app_url",
+ "secondary_label": "How it works",
+ "secondary_url_key": "how_it_works_url",
+ },
+ "warning": {
+ "tone": "warning",
+ "title": "Account warning",
+ "subtitle": "Please review the note below.",
+ "primary_label": "Open Magent",
+ "primary_url_key": "app_url",
+ "secondary_label": "How it works",
+ "secondary_url_key": "how_it_works_url",
+ },
+ "banned": {
+ "tone": "danger",
+ "title": "Account status changed",
+ "subtitle": "Your account has been restricted or removed.",
+ "primary_label": "How it works",
+ "primary_url_key": "how_it_works_url",
+ "secondary_label": "",
+ "secondary_url_key": "",
+ },
+}
+
+
+def _build_email_stat_card(label: str, value: str, detail: str = "") -> str:
+ detail_html = (
+ f""
+ f"{html.escape(detail)}
"
+ if detail
+ else ""
+ )
+ return (
+ ""
+ ""
+ f""
+ f"{html.escape(label)}
"
+ f""
+ f"{html.escape(value)}
"
+ f"{detail_html}"
+ "
"
+ )
+
+
+def _build_email_stat_grid(cards: list[str]) -> str:
+ if not cards:
+ return ""
+ rows: list[str] = []
+ for index in range(0, len(cards), 2):
+ left = cards[index]
+ right = cards[index + 1] if index + 1 < len(cards) else ""
+ rows.append(
+ ""
+ f"{left} "
+ f"{right} "
+ " "
+ )
+ return (
+ ""
+ f"{''.join(rows)}"
+ "
"
+ )
+
+
+def _build_email_list(items: list[str], *, ordered: bool = False) -> str:
+ tag = "ol" if ordered else "ul"
+ marker = "padding-left:20px;" if ordered else "padding-left:18px;"
+ rendered_items = "".join(
+ f"{html.escape(item)} " for item in items if item
+ )
+ return (
+ f"<{tag} style=\"margin:0; {marker} color:#132033; line-height:1.8; font-size:14px;\">"
+ f"{rendered_items}"
+ f"{tag}>"
+ )
+
+
+def _build_email_panel(title: str, body_html: str, *, variant: str = "neutral") -> str:
+ styles = {
+ "neutral": {
+ "background": "#f8fafc",
+ "border": "#d9e2ef",
+ "eyebrow": "#6b778c",
+ "text": "#132033",
+ },
+ "brand": {
+ "background": "#eef4ff",
+ "border": "#bfd2ff",
+ "eyebrow": "#2754b6",
+ "text": "#132033",
+ },
+ "success": {
+ "background": "#edf9f0",
+ "border": "#bfe4c6",
+ "eyebrow": "#1f7a3f",
+ "text": "#132033",
+ },
+ "warning": {
+ "background": "#fff5ea",
+ "border": "#ffd5a8",
+ "eyebrow": "#c46a10",
+ "text": "#132033",
+ },
+ "danger": {
+ "background": "#fff0f0",
+ "border": "#f3c1c1",
+ "eyebrow": "#bb2d2d",
+ "text": "#132033",
+ },
+ }.get(variant, {
+ "background": "#f8fafc",
+ "border": "#d9e2ef",
+ "eyebrow": "#6b778c",
+ "text": "#132033",
+ })
+ return (
+ ""
+ f""
+ f""
+ f"{html.escape(title)}
"
+ f"{body_html}
"
+ "
"
+ )
+
+
+DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
+ "invited": {
+ "subject": "{{app_name}} invite for {{recipient_email}}",
+ "body_text": (
+ "You have been invited to {{app_name}}.\n\n"
+ "Invite code: {{invite_code}}\n"
+ "Signup link: {{invite_link}}\n"
+ "Invited by: {{inviter_username}}\n"
+ "Invite label: {{invite_label}}\n"
+ "Expires: {{invite_expires_at}}\n"
+ "Remaining uses: {{invite_remaining_uses}}\n\n"
+ "{{invite_description}}\n\n"
+ "{{message}}\n\n"
+ "How it works: {{how_it_works_url}}\n"
+ "Build: {{build_number}}\n"
+ ),
+ "body_html": (
+ ""
+ "A new invitation has been prepared for {{recipient_email}} . Use the details below to sign up."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Invite code", "{{invite_code}}"),
+ _build_email_stat_card("Invited by", "{{inviter_username}}"),
+ _build_email_stat_card("Invite label", "{{invite_label}}"),
+ _build_email_stat_card(
+ "Access window",
+ "{{invite_expires_at}}",
+ "Remaining uses: {{invite_remaining_uses}}",
+ ),
+ ]
+ )
+ + _build_email_panel(
+ "Invitation details",
+ "{{invite_description}}
",
+ variant="brand",
+ )
+ + _build_email_panel(
+ "Message from admin",
+ "{{message}}
",
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "What happens next",
+ _build_email_list(
+ [
+ "Open the invite link and complete the signup flow.",
+ "Sign in using the shared credentials for Magent and Seerr.",
+ "Use the How it works page if you want a quick overview first.",
+ ],
+ ordered=True,
+ ),
+ variant="neutral",
+ )
+ ),
+ },
+ "welcome": {
+ "subject": "Welcome to {{app_name}}",
+ "body_text": (
+ "Welcome to {{app_name}}, {{username}}.\n\n"
+ "Your account is ready.\n"
+ "Open: {{app_url}}\n"
+ "How it works: {{how_it_works_url}}\n"
+ "Role: {{role}}\n\n"
+ "{{message}}\n"
+ ),
+ "body_html": (
+ ""
+ "Your account is live and ready to use. Everything below mirrors the current site behavior."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Username", "{{username}}"),
+ _build_email_stat_card("Role", "{{role}}"),
+ _build_email_stat_card("Magent", "{{app_url}}"),
+ _build_email_stat_card("Guides", "{{how_it_works_url}}"),
+ ]
+ )
+ + _build_email_panel(
+ "What to do next",
+ _build_email_list(
+ [
+ "Open Magent and sign in using your shared credentials.",
+ "Search all requests or review your own activity without refreshing the page.",
+ "Use the invite tools in your profile if your account allows it.",
+ ],
+ ordered=True,
+ ),
+ variant="success",
+ )
+ + _build_email_panel(
+ "Additional notes",
+ "{{message}}
",
+ variant="neutral",
+ )
+ ),
+ },
+ "warning": {
+ "subject": "{{app_name}} account warning",
+ "body_text": (
+ "Hello {{username}},\n\n"
+ "This is a warning regarding your {{app_name}} account.\n\n"
+ "Reason: {{reason}}\n\n"
+ "{{message}}\n\n"
+ "If you need help, contact the admin.\n"
+ ),
+ "body_html": (
+ ""
+ "Please review this account notice carefully. This message was sent by an administrator."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Account", "{{username}}"),
+ _build_email_stat_card("Role", "{{role}}"),
+ _build_email_stat_card("Application", "{{app_name}}"),
+ _build_email_stat_card("Support", "{{how_it_works_url}}"),
+ ]
+ )
+ + _build_email_panel(
+ "Reason",
+ "{{reason}}
",
+ variant="warning",
+ )
+ + _build_email_panel(
+ "Administrator note",
+ "{{message}}
",
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "What to do next",
+ _build_email_list(
+ [
+ "Review the note above and confirm you understand what needs to change.",
+ "If you need help, reply through your usual support path or contact an administrator.",
+ "Keep this email for reference until the matter is resolved.",
+ ]
+ ),
+ variant="neutral",
+ )
+ ),
+ },
+ "banned": {
+ "subject": "{{app_name}} account status changed",
+ "body_text": (
+ "Hello {{username}},\n\n"
+ "Your {{app_name}} account has been banned or removed.\n\n"
+ "Reason: {{reason}}\n\n"
+ "{{message}}\n"
+ ),
+ "body_html": (
+ ""
+ "Your account access has changed. Review the details below."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Account", "{{username}}"),
+ _build_email_stat_card("Status", "Restricted"),
+ _build_email_stat_card("Application", "{{app_name}}"),
+ _build_email_stat_card("Guidance", "{{how_it_works_url}}"),
+ ]
+ )
+ + _build_email_panel(
+ "Reason",
+ "{{reason}}
",
+ variant="danger",
+ )
+ + _build_email_panel(
+ "Administrator note",
+ "{{message}}
",
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "What this means",
+ _build_email_list(
+ [
+ "Your access has been removed or restricted across the linked services.",
+ "If you believe this is incorrect, contact the site administrator directly.",
+ "Do not rely on old links or cached sessions after this change.",
+ ]
+ ),
+ variant="neutral",
+ )
+ ),
+ },
+}
+
+
+def _template_setting_key(template_key: str) -> str:
+ return f"{TEMPLATE_SETTING_PREFIX}{template_key}"
+
+
+def _is_valid_email(value: object) -> bool:
+ if not isinstance(value, str):
+ return False
+ candidate = value.strip()
+ if not candidate:
+ return False
+ return bool(EMAIL_PATTERN.match(candidate))
+
+
+def _normalize_email(value: object) -> Optional[str]:
+ if not _is_valid_email(value):
+ return None
+ return str(value).strip()
+
+
+def normalize_delivery_email(value: object) -> Optional[str]:
+ return _normalize_email(value)
+
+
+def _normalize_display_text(value: object, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else fallback
+ return str(value)
+
+
+def _template_context_value(value: object, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ return value.strip()
+ return str(value)
+
+
+def _safe_template_context(context: Dict[str, object]) -> Dict[str, str]:
+ safe: Dict[str, str] = {}
+ for key in TEMPLATE_PLACEHOLDERS:
+ safe[key] = _template_context_value(context.get(key), "")
+ return safe
+
+
+def _render_template_string(template: str, context: Dict[str, str], *, escape_html: bool = False) -> str:
+ if not isinstance(template, str):
+ return ""
+
+ def _replace(match: re.Match[str]) -> str:
+ key = match.group(1)
+ value = context.get(key, "")
+ return html.escape(value) if escape_html else value
+
+ return PLACEHOLDER_PATTERN.sub(_replace, template)
+
+
+def _strip_html_for_text(value: str) -> str:
+ text = re.sub(r" ", "\n", value, flags=re.IGNORECASE)
+ text = re.sub(r"
", "\n\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"<[^>]+>", "", text)
+ return html.unescape(text).strip()
+
+
+def _build_default_base_url() -> str:
+ runtime = get_runtime_settings()
+ for candidate in (
+ runtime.magent_application_url,
+ runtime.magent_proxy_base_url,
+ env_settings.cors_allow_origin,
+ ):
+ normalized = _normalize_display_text(candidate)
+ if normalized:
+ return normalized.rstrip("/")
+ port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
+ return f"http://localhost:{port}"
+
+
+def _derive_mail_hostname(*, from_address: str) -> str:
+ runtime = get_runtime_settings()
+ candidates = (
+ runtime.magent_application_url,
+ runtime.magent_proxy_base_url,
+ env_settings.cors_allow_origin,
+ )
+ for candidate in candidates:
+ normalized = _normalize_display_text(candidate)
+ if not normalized:
+ continue
+ parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
+ hostname = _normalize_display_text(parsed.hostname)
+ if hostname and "." in hostname:
+ return hostname
+ domain = _normalize_display_text(from_address.split("@", 1)[1] if "@" in from_address else None)
+ if domain and "." in domain:
+ return domain
+ return "localhost"
+
+
+def _add_transactional_headers(
+ message: EmailMessage,
+ *,
+ from_name: str,
+ from_address: str,
+) -> None:
+ message["Reply-To"] = formataddr((from_name, from_address))
+ message["Organization"] = env_settings.app_name
+ message["X-Mailer"] = f"{env_settings.app_name}/{BUILD_NUMBER}"
+ message["Auto-Submitted"] = "auto-generated"
+ message["X-Auto-Response-Suppress"] = "All"
+
+
+def _looks_like_full_html_document(value: str) -> bool:
+ probe = value.lstrip().lower()
+ return probe.startswith(" str:
+ background = "linear-gradient(135deg, #ff6b2b 0%, #1c6bff 100%)" if primary else "#ffffff"
+ fallback = "#1c6bff" if primary else "#ffffff"
+ border = "1px solid rgba(28, 107, 255, 0.28)" if primary else "1px solid #d5deed"
+ color = "#ffffff" if primary else "#132033"
+ return (
+ f"{html.escape(label)} "
+ )
+
+
+@lru_cache(maxsize=1)
+def _get_email_logo_bytes() -> bytes:
+ logo_path = Path(__file__).resolve().parents[1] / "assets" / "branding" / "logo.png"
+ try:
+ return logo_path.read_bytes()
+ except OSError:
+ return b""
+
+
+def _build_email_logo_block(app_name: str) -> str:
+ if _get_email_logo_bytes():
+ return (
+ f" "
+ )
+ return (
+ "M
"
+ )
+
+
+def _build_outlook_safe_test_email_html(
+ *,
+ app_name: str,
+ application_url: str,
+ build_number: str,
+ smtp_target: str,
+ security_mode: str,
+ auth_mode: str,
+ warning: str,
+ primary_url: str = "",
+) -> str:
+ action_html = (
+ _build_email_action_button("Open Magent", primary_url, primary=True) if primary_url else ""
+ )
+ logo_block = _build_email_logo_block(app_name)
+ warning_block = (
+ ""
+ ""
+ ""
+ ""
+ ""
+ "Delivery notes
"
+ f"{html.escape(warning)}"
+ "
"
+ " "
+ " "
+ ) if warning else ""
+ return (
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ f"{logo_block} "
+ ""
+ f"{html.escape(app_name)} email test
"
+ "This confirms Magent can generate and hand off branded mail.
"
+ " "
+ " "
+ "
"
+ " "
+ " "
+ ""
+ "This is a test email from Magent .
"
+ " "
+ ""
+ ""
+ ""
+ ""
+ ""
+ f"{_build_email_stat_card('Build', build_number)}"
+ " "
+ ""
+ f"{_build_email_stat_card('Application URL', application_url)}"
+ " "
+ " "
+ ""
+ ""
+ f"{_build_email_stat_card('SMTP target', smtp_target)}"
+ " "
+ ""
+ f"{_build_email_stat_card('Security', security_mode, auth_mode)}"
+ " "
+ " "
+ "
"
+ " "
+ " "
+ ""
+ ""
+ ""
+ ""
+ ""
+ "What this verifies
"
+ "Magent can build the HTML template shell correctly.
"
+ "The configured SMTP route accepts and relays the message.
"
+ "Branding, links, and build metadata are rendering consistently.
"
+ "
"
+ " "
+ " "
+ f"{warning_block}"
+ ""
+ ""
+ f"{action_html}"
+ " "
+ " "
+ "
"
+ "
"
+ ""
+ ""
+ )
+
+
+def _wrap_email_html(
+ *,
+ app_name: str,
+ app_url: str,
+ build_number: str,
+ title: str,
+ subtitle: str,
+ tone: str,
+ body_html: str,
+ primary_label: str = "",
+ primary_url: str = "",
+ secondary_label: str = "",
+ secondary_url: str = "",
+ footer_note: str = "",
+) -> str:
+ styles = EMAIL_TONE_STYLES.get(tone, EMAIL_TONE_STYLES["brand"])
+ actions = []
+ if primary_label and primary_url:
+ actions.append(_build_email_action_button(primary_label, primary_url, primary=True))
+ if secondary_label and secondary_url:
+ actions.append(_build_email_action_button(secondary_label, secondary_url, primary=False))
+ actions_html = "".join(actions)
+
+ footer = footer_note or "This email was generated automatically by Magent."
+ logo_block = _build_email_logo_block(app_name)
+
+ return (
+ ""
+ ""
+ ""
+ f"{html.escape(title)} - {html.escape(subtitle)}"
+ "
"
+ ""
+ ""
+ ""
+ ""
+ f""
+ "
"
+ ""
+ f"{logo_block} "
+ ""
+ f"{html.escape(app_name)}
"
+ f"{html.escape(title)}
"
+ f"{html.escape(subtitle or EMAIL_TAGLINE)}
"
+ " "
+ " "
+ "
"
+ f"
"
+ f"
"
+ f"{html.escape(EMAIL_TAGLINE)}
"
+ f"
{body_html}
"
+ f"
{actions_html}
"
+ "
"
+ "
"
+ ""
+ f"{html.escape(footer)} "
+ f"Build {html.escape(build_number)} "
+ " "
+ "
"
+ "
"
+ "
"
+ "
"
+ "
"
+ ""
+ )
+
+
+def build_invite_email_context(
+ *,
+ invite: Optional[Dict[str, Any]] = None,
+ user: Optional[Dict[str, Any]] = None,
+ recipient_email: Optional[str] = None,
+ message: Optional[str] = None,
+ reason: Optional[str] = None,
+ overrides: Optional[Dict[str, object]] = None,
+) -> Dict[str, str]:
+ app_url = _build_default_base_url()
+ invite_code = _normalize_display_text(invite.get("code") if invite else None, "Not set")
+ invite_link = f"{app_url}/signup?code={invite_code}" if invite_code != "Not set" else f"{app_url}/signup"
+ remaining_uses = invite.get("remaining_uses") if invite else None
+ resolved_recipient = _normalize_email(recipient_email)
+ if not resolved_recipient and invite:
+ resolved_recipient = _normalize_email(invite.get("recipient_email"))
+ if not resolved_recipient and user:
+ resolved_recipient = resolve_user_delivery_email(user)
+
+ context: Dict[str, object] = {
+ "app_name": env_settings.app_name,
+ "app_url": app_url,
+ "build_number": BUILD_NUMBER,
+ "how_it_works_url": f"{app_url}/how-it-works",
+ "invite_code": invite_code,
+ "invite_description": _normalize_display_text(invite.get("description") if invite else None, "No extra details."),
+ "invite_expires_at": _normalize_display_text(invite.get("expires_at") if invite else None, "Never"),
+ "invite_label": _normalize_display_text(invite.get("label") if invite else None, "No label"),
+ "invite_link": invite_link,
+ "invite_remaining_uses": (
+ "Unlimited" if remaining_uses in (None, "") else _normalize_display_text(remaining_uses)
+ ),
+ "inviter_username": _normalize_display_text(
+ invite.get("created_by") if invite else (user.get("username") if user else None),
+ "Admin",
+ ),
+ "message": _normalize_display_text(message, "No additional note."),
+ "reason": _normalize_display_text(reason, "Not specified"),
+ "recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"),
+ "role": _normalize_display_text(user.get("role") if user else None, "user"),
+ "username": _normalize_display_text(user.get("username") if user else None, "there"),
+ }
+ if isinstance(overrides, dict):
+ context.update(overrides)
+ return _safe_template_context(context)
+
+
+def get_invite_email_templates() -> Dict[str, Dict[str, Any]]:
+ templates: Dict[str, Dict[str, Any]] = {}
+ for template_key in TEMPLATE_KEYS:
+ template = dict(DEFAULT_TEMPLATES[template_key])
+ raw_value = get_setting(_template_setting_key(template_key))
+ if raw_value:
+ try:
+ stored = json.loads(raw_value)
+ except (TypeError, json.JSONDecodeError):
+ stored = {}
+ if isinstance(stored, dict):
+ for field in ("subject", "body_text", "body_html"):
+ if isinstance(stored.get(field), str):
+ template[field] = stored[field]
+ templates[template_key] = {
+ "key": template_key,
+ "label": TEMPLATE_METADATA[template_key]["label"],
+ "description": TEMPLATE_METADATA[template_key]["description"],
+ "placeholders": TEMPLATE_PLACEHOLDERS,
+ **template,
+ }
+ return templates
+
+
+def get_invite_email_template(template_key: str) -> Dict[str, Any]:
+ if template_key not in TEMPLATE_KEYS:
+ raise ValueError(f"Unknown email template: {template_key}")
+ return get_invite_email_templates()[template_key]
+
+
+def save_invite_email_template(
+ template_key: str,
+ *,
+ subject: str,
+ body_text: str,
+ body_html: str,
+) -> Dict[str, Any]:
+ if template_key not in TEMPLATE_KEYS:
+ raise ValueError(f"Unknown email template: {template_key}")
+ payload = {
+ "subject": subject,
+ "body_text": body_text,
+ "body_html": body_html,
+ }
+ set_setting(_template_setting_key(template_key), json.dumps(payload))
+ return get_invite_email_template(template_key)
+
+
+def reset_invite_email_template(template_key: str) -> Dict[str, Any]:
+ if template_key not in TEMPLATE_KEYS:
+ raise ValueError(f"Unknown email template: {template_key}")
+ delete_setting(_template_setting_key(template_key))
+ return get_invite_email_template(template_key)
+
+
+def render_invite_email_template(
+ template_key: str,
+ *,
+ invite: Optional[Dict[str, Any]] = None,
+ user: Optional[Dict[str, Any]] = None,
+ recipient_email: Optional[str] = None,
+ message: Optional[str] = None,
+ reason: Optional[str] = None,
+ overrides: Optional[Dict[str, object]] = None,
+) -> Dict[str, str]:
+ template = get_invite_email_template(template_key)
+ context = build_invite_email_context(
+ invite=invite,
+ user=user,
+ recipient_email=recipient_email,
+ message=message,
+ reason=reason,
+ overrides=overrides,
+ )
+ raw_body_html = _render_template_string(template["body_html"], context, escape_html=True)
+ body_text = _render_template_string(template["body_text"], context, escape_html=False)
+ if not body_text.strip() and raw_body_html.strip():
+ body_text = _strip_html_for_text(raw_body_html)
+ subject = _render_template_string(template["subject"], context, escape_html=False)
+ presentation = TEMPLATE_PRESENTATION.get(template_key, TEMPLATE_PRESENTATION["invited"])
+ primary_url = _normalize_display_text(context.get(presentation["primary_url_key"], ""))
+ secondary_url = _normalize_display_text(context.get(presentation["secondary_url_key"], ""))
+ if _looks_like_full_html_document(raw_body_html):
+ body_html = raw_body_html.strip()
+ else:
+ body_html = _wrap_email_html(
+ app_name=_normalize_display_text(context.get("app_name"), env_settings.app_name),
+ app_url=_normalize_display_text(context.get("app_url"), _build_default_base_url()),
+ build_number=_normalize_display_text(context.get("build_number"), BUILD_NUMBER),
+ title=_normalize_display_text(context.get("title"), presentation["title"]),
+ subtitle=_normalize_display_text(context.get("subtitle"), presentation["subtitle"]),
+ tone=_normalize_display_text(context.get("tone"), presentation["tone"]),
+ body_html=raw_body_html.strip(),
+ primary_label=_normalize_display_text(
+ context.get("primary_label"), presentation["primary_label"]
+ ),
+ primary_url=primary_url,
+ secondary_label=_normalize_display_text(
+ context.get("secondary_label"), presentation["secondary_label"]
+ ),
+ secondary_url=secondary_url,
+ footer_note=_normalize_display_text(context.get("footer_note"), ""),
+ ).strip()
+ return {
+ "subject": subject.strip(),
+ "body_text": body_text.strip(),
+ "body_html": body_html.strip(),
+ }
+
+
+def resolve_user_delivery_email(user: Optional[Dict[str, Any]], invite: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ if not isinstance(user, dict):
+ return _normalize_email(invite.get("recipient_email") if isinstance(invite, dict) else None)
+ stored_email = _normalize_email(user.get("email"))
+ if stored_email:
+ return stored_email
+ username_email = _normalize_email(user.get("username"))
+ if username_email:
+ return username_email
+ if isinstance(invite, dict):
+ invite_email = _normalize_email(invite.get("recipient_email"))
+ if invite_email:
+ return invite_email
+ return None
+
+
+def smtp_email_config_ready() -> tuple[bool, str]:
+ runtime = get_runtime_settings()
+ if not runtime.magent_notify_enabled:
+ return False, "Notifications are disabled."
+ if not runtime.magent_notify_email_enabled:
+ return False, "Email notifications are disabled."
+ if not _normalize_display_text(runtime.magent_notify_email_smtp_host):
+ return False, "SMTP host is not configured."
+ if not _normalize_email(runtime.magent_notify_email_from_address):
+ return False, "From email address is not configured."
+ return True, "ok"
+
+
+def smtp_email_delivery_warning() -> Optional[str]:
+ runtime = get_runtime_settings()
+ host = _normalize_display_text(runtime.magent_notify_email_smtp_host).lower()
+ username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
+ password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
+ if host.endswith(".mail.protection.outlook.com") and not (username and password):
+ return (
+ "Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
+ "confirm mailbox delivery, and suspicious messages can still be filtered. For reliable "
+ "delivery, use smtp.office365.com:587 with SMTP credentials or configure a verified "
+ "Exchange relay connector and make sure SPF, DKIM, and DMARC are healthy for the "
+ "sender domain."
+ )
+ return None
+
+
+def _flatten_message(message: EmailMessage) -> bytes:
+ buffer = BytesIO()
+ BytesGenerator(buffer, policy=SMTP_POLICY).flatten(message)
+ return buffer.getvalue()
+
+
+def _decode_smtp_message(value: bytes | str | None) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return str(value)
+
+
+def _parse_exchange_receipt(value: bytes | str | None) -> Dict[str, str]:
+ message = _decode_smtp_message(value)
+ receipt: Dict[str, str] = {"raw": message}
+ message_id_match = EXCHANGE_MESSAGE_ID_PATTERN.search(message)
+ internal_id_match = EXCHANGE_INTERNAL_ID_PATTERN.search(message)
+ if message_id_match:
+ receipt["provider_message_id"] = message_id_match.group(1)
+ if internal_id_match:
+ receipt["provider_internal_id"] = internal_id_match.group(1)
+ return receipt
+
+
+def _send_via_smtp_session(
+ smtp: smtplib.SMTP,
+ *,
+ from_address: str,
+ recipient_email: str,
+ message: EmailMessage,
+) -> Dict[str, str]:
+ mail_code, mail_message = smtp.mail(from_address)
+ if mail_code >= 400:
+ raise smtplib.SMTPResponseException(mail_code, mail_message)
+ rcpt_code, rcpt_message = smtp.rcpt(recipient_email)
+ if rcpt_code >= 400:
+ raise smtplib.SMTPRecipientsRefused({recipient_email: (rcpt_code, rcpt_message)})
+ data_code, data_message = smtp.data(_flatten_message(message))
+ if data_code >= 400:
+ raise smtplib.SMTPDataError(data_code, data_message)
+ receipt = _parse_exchange_receipt(data_message)
+ receipt["mail_response"] = _decode_smtp_message(mail_message)
+ receipt["rcpt_response"] = _decode_smtp_message(rcpt_message)
+ receipt["data_response"] = _decode_smtp_message(data_message)
+ return receipt
+
+
+def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> Dict[str, str]:
+ runtime = get_runtime_settings()
+ host = _normalize_display_text(runtime.magent_notify_email_smtp_host)
+ port = int(runtime.magent_notify_email_smtp_port or 587)
+ username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
+ password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
+ from_address = _normalize_email(runtime.magent_notify_email_from_address)
+ from_name = _normalize_display_text(runtime.magent_notify_email_from_name, env_settings.app_name)
+ use_tls = bool(runtime.magent_notify_email_use_tls)
+ use_ssl = bool(runtime.magent_notify_email_use_ssl)
+ delivery_warning = smtp_email_delivery_warning()
+ if not host or not from_address:
+ raise RuntimeError("SMTP email settings are incomplete.")
+ local_hostname = _derive_mail_hostname(from_address=from_address)
+ logger.info(
+ "smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
+ recipient_email,
+ from_address,
+ host,
+ port,
+ use_tls,
+ use_ssl,
+ bool(username and password),
+ subject,
+ local_hostname,
+ )
+ if delivery_warning:
+ logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
+
+ message = EmailMessage()
+ message["Subject"] = subject
+ message["From"] = formataddr((from_name, from_address))
+ message["To"] = recipient_email
+ message["Date"] = formatdate(localtime=True)
+ if "@" in from_address:
+ message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
+ else:
+ message["Message-ID"] = make_msgid()
+ _add_transactional_headers(
+ message,
+ from_name=from_name,
+ from_address=from_address,
+ )
+ message.set_content(body_text or _strip_html_for_text(body_html))
+ if body_html.strip():
+ message.add_alternative(body_html, subtype="html")
+ if f"cid:{EMAIL_LOGO_CID}" in body_html:
+ logo_bytes = _get_email_logo_bytes()
+ if logo_bytes:
+ html_part = message.get_body(preferencelist=("html",))
+ if html_part is not None:
+ html_part.add_related(
+ logo_bytes,
+ maintype="image",
+ subtype="png",
+ cid=f"<{EMAIL_LOGO_CID}>",
+ filename="logo.png",
+ disposition="inline",
+ )
+
+ if use_ssl:
+ with smtplib.SMTP_SSL(host, port, timeout=20, local_hostname=local_hostname) as smtp:
+ logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
+ if username and password:
+ smtp.login(username, password)
+ logger.debug("smtp login succeeded host=%s username=%s", host, username)
+ receipt = _send_via_smtp_session(
+ smtp,
+ from_address=from_address,
+ recipient_email=recipient_email,
+ message=message,
+ )
+ logger.info(
+ "smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s",
+ recipient_email,
+ host,
+ receipt.get("provider_message_id"),
+ receipt.get("provider_internal_id"),
+ )
+ return receipt
+
+ with smtplib.SMTP(host, port, timeout=20, local_hostname=local_hostname) as smtp:
+ logger.debug("smtp connection opened host=%s port=%s", host, port)
+ smtp.ehlo()
+ if use_tls:
+ smtp.starttls()
+ smtp.ehlo()
+ logger.debug("smtp starttls negotiated host=%s port=%s", host, port)
+ if username and password:
+ smtp.login(username, password)
+ logger.debug("smtp login succeeded host=%s username=%s", host, username)
+ receipt = _send_via_smtp_session(
+ smtp,
+ from_address=from_address,
+ recipient_email=recipient_email,
+ message=message,
+ )
+ logger.info(
+ "smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s",
+ recipient_email,
+ host,
+ receipt.get("provider_message_id"),
+ receipt.get("provider_internal_id"),
+ )
+ return receipt
+
+
+async def send_templated_email(
+ template_key: str,
+ *,
+ invite: Optional[Dict[str, Any]] = None,
+ user: Optional[Dict[str, Any]] = None,
+ recipient_email: Optional[str] = None,
+ message: Optional[str] = None,
+ reason: Optional[str] = None,
+ overrides: Optional[Dict[str, object]] = None,
+) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+
+ resolved_email = _normalize_email(recipient_email)
+ if not resolved_email:
+ resolved_email = resolve_user_delivery_email(user, invite)
+ if not resolved_email:
+ raise RuntimeError("No valid recipient email is available for this action.")
+
+ rendered = render_invite_email_template(
+ template_key,
+ invite=invite,
+ user=user,
+ recipient_email=resolved_email,
+ message=message,
+ reason=reason,
+ overrides=overrides,
+ )
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=rendered["subject"],
+ body_text=rendered["body_text"],
+ body_html=rendered["body_html"],
+ )
+ logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email)
+ return {
+ "recipient_email": resolved_email,
+ "subject": rendered["subject"],
+ **{
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ },
+ }
+
+
+async def send_generic_email(
+ *,
+ recipient_email: str,
+ subject: str,
+ body_text: str,
+ body_html: str = "",
+) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+ resolved_email = _normalize_email(recipient_email)
+ if not resolved_email:
+ raise RuntimeError("A valid recipient email is required.")
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=subject.strip() or f"{env_settings.app_name} notification",
+ body_text=body_text.strip(),
+ body_html=body_html.strip(),
+ )
+ logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
+ return {
+ "recipient_email": resolved_email,
+ "subject": subject.strip() or f"{env_settings.app_name} notification",
+ **{
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ },
+ }
+
+
+async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+
+ runtime = get_runtime_settings()
+ resolved_email = _normalize_email(recipient_email) or _normalize_email(
+ runtime.magent_notify_email_from_address
+ )
+ if not resolved_email:
+ raise RuntimeError("No valid recipient email is configured for the test message.")
+
+ application_url = _normalize_display_text(runtime.magent_application_url, "Not configured")
+ primary_url = application_url if application_url.lower().startswith(("http://", "https://")) else ""
+ smtp_target = f"{_normalize_display_text(runtime.magent_notify_email_smtp_host, 'Not configured')}:{int(runtime.magent_notify_email_smtp_port or 587)}"
+ security_mode = "SSL" if runtime.magent_notify_email_use_ssl else ("STARTTLS" if runtime.magent_notify_email_use_tls else "Plain SMTP")
+ auth_mode = "Authenticated" if (
+ _normalize_display_text(runtime.magent_notify_email_smtp_username)
+ and _normalize_display_text(runtime.magent_notify_email_smtp_password)
+ ) else "No SMTP auth"
+ delivery_warning = smtp_email_delivery_warning()
+ subject = f"{env_settings.app_name} email test"
+ body_text = (
+ f"This is a test email from {env_settings.app_name}.\n\n"
+ f"Build: {BUILD_NUMBER}\n"
+ f"Application URL: {application_url}\n"
+ f"SMTP target: {smtp_target}\n"
+ f"Security: {security_mode} ({auth_mode})\n\n"
+ "What this verifies:\n"
+ "- Magent can build the HTML template shell correctly.\n"
+ "- The configured SMTP route accepts and relays the message.\n"
+ "- Branding, links, and build metadata are rendering consistently.\n"
+ )
+ body_html = _wrap_email_html(
+ app_name=env_settings.app_name,
+ app_url=_build_default_base_url(),
+ build_number=BUILD_NUMBER,
+ title="Email delivery test",
+ subtitle="This confirms Magent can generate and hand off branded mail.",
+ tone="brand",
+ body_html=(
+ ""
+ "This is a live test email from Magent. If this renders correctly, the HTML template shell and SMTP handoff are both working."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Recipient", resolved_email),
+ _build_email_stat_card("Build", BUILD_NUMBER),
+ _build_email_stat_card("SMTP target", smtp_target),
+ _build_email_stat_card("Security", security_mode, auth_mode),
+ _build_email_stat_card("Application URL", application_url),
+ _build_email_stat_card("Template shell", "Branded HTML", "Logo, gradient, action buttons"),
+ ]
+ )
+ + _build_email_panel(
+ "What this verifies",
+ _build_email_list(
+ [
+ "Magent can build the HTML template shell correctly.",
+ "The configured SMTP route accepts and relays the message.",
+ "Branding, links, and build metadata are rendering consistently.",
+ ]
+ ),
+ variant="brand",
+ )
+ + _build_email_panel(
+ "Delivery notes",
+ (
+ f"{html.escape(delivery_warning)}
"
+ if delivery_warning
+ else "Use this test when changing SMTP settings, relay targets, or branding."
+ ),
+ variant="warning" if delivery_warning else "neutral",
+ )
+ ),
+ primary_label="Open Magent" if primary_url else "",
+ primary_url=primary_url,
+ footer_note="SMTP test email generated by Magent.",
+ )
+
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ logger.info("SMTP test email sent: recipient=%s", resolved_email)
+ result = {"recipient_email": resolved_email, "subject": subject}
+ result.update(
+ {
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ }
+ )
+ warning = smtp_email_delivery_warning()
+ if warning:
+ result["warning"] = warning
+ return result
+
+
+async def send_password_reset_email(
+ *,
+ recipient_email: str,
+ username: str,
+ token: str,
+ expires_at: str,
+ auth_provider: str,
+) -> Dict[str, str]:
+ ready, detail = smtp_email_config_ready()
+ if not ready:
+ raise RuntimeError(detail)
+
+ resolved_email = _normalize_email(recipient_email)
+ if not resolved_email:
+ raise RuntimeError("No valid recipient email is available for password reset.")
+
+ app_url = _build_default_base_url()
+ reset_url = f"{app_url}/reset-password?token={token}"
+ provider_label = "Jellyfin, Seerr, and Magent" if auth_provider == "jellyfin" else "Magent"
+ subject = f"{env_settings.app_name} password reset"
+ body_text = (
+ f"A password reset was requested for {username}.\n\n"
+ f"This link will reset the password used for {provider_label}.\n"
+ f"Reset link: {reset_url}\n"
+ f"Expires: {expires_at}\n\n"
+ "If you did not request this reset, you can ignore this email.\n"
+ )
+ body_html = _wrap_email_html(
+ app_name=env_settings.app_name,
+ app_url=app_url,
+ build_number=BUILD_NUMBER,
+ title="Reset your password",
+ subtitle=f"This will update the credentials used for {provider_label}.",
+ tone="brand",
+ body_html=(
+ f""
+ f"A password reset was requested for {html.escape(username)} ."
+ "
"
+ + _build_email_stat_grid(
+ [
+ _build_email_stat_card("Account", username),
+ _build_email_stat_card("Expires", expires_at),
+ _build_email_stat_card("Credentials updated", provider_label),
+ _build_email_stat_card("Delivery target", resolved_email),
+ ]
+ )
+ + _build_email_panel(
+ "What will be updated",
+ f"This reset will update the password used for {html.escape(provider_label)} .",
+ variant="brand",
+ )
+ + _build_email_panel(
+ "What happens next",
+ _build_email_list(
+ [
+ "Open the reset link and choose a new password.",
+ "Complete the form before the expiry time shown above.",
+ "Use the new password the next time you sign in.",
+ ],
+ ordered=True,
+ ),
+ variant="neutral",
+ )
+ + _build_email_panel(
+ "Safety note",
+ "If you did not request this reset, ignore this email. No changes will be applied until the reset link is opened and completed.",
+ variant="warning",
+ )
+ ),
+ primary_label="Reset password",
+ primary_url=reset_url,
+ secondary_label="Open Magent",
+ secondary_url=app_url,
+ footer_note="Password reset email generated by Magent.",
+ )
+
+ receipt = await asyncio.to_thread(
+ _send_email_sync,
+ recipient_email=resolved_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ logger.info(
+ "Password reset email sent: username=%s recipient=%s provider=%s",
+ username,
+ resolved_email,
+ auth_provider,
+ )
+ result = {
+ "recipient_email": resolved_email,
+ "subject": subject,
+ "reset_url": reset_url,
+ **{
+ key: value
+ for key, value in receipt.items()
+ if key in {"provider_message_id", "provider_internal_id", "data_response"}
+ },
+ }
+ warning = smtp_email_delivery_warning()
+ if warning:
+ result["warning"] = warning
+ return result
diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py
index 3cd2ea5..0946ef3 100644
--- a/backend/app/services/jellyfin_sync.py
+++ b/backend/app/services/jellyfin_sync.py
@@ -3,8 +3,22 @@ import logging
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
-from ..db import create_user_if_missing
+from ..db import (
+ create_user_if_missing,
+ get_user_by_username,
+ set_user_email,
+ set_user_auth_provider,
+ set_user_jellyseerr_id,
+)
from ..runtime import get_runtime_settings
+from .user_cache import (
+ build_jellyseerr_candidate_map,
+ extract_jellyseerr_user_email,
+ find_matching_jellyseerr_user,
+ get_cached_jellyseerr_users,
+ match_jellyseerr_user_id,
+ save_jellyfin_users_cache,
+)
logger = logging.getLogger(__name__)
@@ -17,6 +31,11 @@ async def sync_jellyfin_users() -> int:
users = await client.get_users()
if not isinstance(users, list):
return 0
+ save_jellyfin_users_cache(users)
+ # Jellyfin is the canonical source for local user objects; Seerr IDs are
+ # matched as enrichment when possible.
+ jellyseerr_users = get_cached_jellyseerr_users()
+ candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0
for user in users:
if not isinstance(user, dict):
@@ -24,8 +43,31 @@ async def sync_jellyfin_users() -> int:
name = user.get("Name")
if not name:
continue
- if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
+ matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
+ matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
+ matched_email = extract_jellyseerr_user_email(matched_seerr_user)
+ created = create_user_if_missing(
+ name,
+ "jellyfin-user",
+ role="user",
+ email=matched_email,
+ auth_provider="jellyfin",
+ jellyseerr_user_id=matched_id,
+ )
+ if created:
imported += 1
+ else:
+ existing = get_user_by_username(name)
+ if (
+ existing
+ and str(existing.get("role") or "user").strip().lower() != "admin"
+ and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
+ ):
+ set_user_auth_provider(name, "jellyfin")
+ if matched_id is not None:
+ set_user_jellyseerr_id(name, matched_id)
+ if matched_email:
+ set_user_email(name, matched_email)
return imported
diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py
new file mode 100644
index 0000000..5816031
--- /dev/null
+++ b/backend/app/services/notifications.py
@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, Optional
+from urllib.parse import quote
+
+import httpx
+
+from ..config import settings as env_settings
+from ..db import get_setting
+from ..network_security import validate_notification_target_url
+from ..runtime import get_runtime_settings
+from .invite_email import send_generic_email
+
+logger = logging.getLogger(__name__)
+
+
+def _clean_text(value: Any, fallback: str = "") -> str:
+ if value is None:
+ return fallback
+ if isinstance(value, str):
+ trimmed = value.strip()
+ return trimmed if trimmed else fallback
+ return str(value)
+
+
+def _split_emails(value: str) -> list[str]:
+ if not value:
+ return []
+ parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
+ return [entry for entry in parts if entry and "@" in entry]
+
+
+def _resolve_app_url() -> str:
+ runtime = get_runtime_settings()
+ for candidate in (
+ runtime.magent_application_url,
+ runtime.magent_proxy_base_url,
+ env_settings.cors_allow_origin,
+ ):
+ normalized = _clean_text(candidate)
+ if normalized:
+ return normalized.rstrip("/")
+ port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
+ return f"http://localhost:{port}"
+
+
+def _portal_item_url(item_id: int) -> str:
+ return f"{_resolve_app_url()}/portal?item={item_id}"
+
+
+async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ validate_notification_target_url(url)
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post(url, json=payload)
+ response.raise_for_status()
+ try:
+ body = response.json()
+ except ValueError:
+ body = response.text
+ return {"status_code": response.status_code, "body": body}
+
+
+async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
+ runtime.discord_webhook_url
+ )
+ if not webhook:
+ return {"status": "skipped", "detail": "Discord webhook not configured."}
+ data = {
+ "content": f"**{title}**\n{message}",
+ "embeds": [
+ {
+ "title": title,
+ "description": message,
+ "fields": [
+ {"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
+ {"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
+ {"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
+ ],
+ "url": _clean_text(payload.get("item_url")),
+ }
+ ],
+ }
+ result = await _http_post_json(webhook, data)
+ return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
+
+
+async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
+ chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
+ if not bot_token or not chat_id:
+ return {"status": "skipped", "detail": "Telegram is not configured."}
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+ payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
+ result = await _http_post_json(url, payload)
+ return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
+
+
+async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ webhook = _clean_text(runtime.magent_notify_webhook_url)
+ if not webhook:
+ return {"status": "skipped", "detail": "Generic webhook is not configured."}
+ result = await _http_post_json(webhook, payload)
+ return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
+
+
+async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
+ base_url = _clean_text(runtime.magent_notify_push_base_url)
+ token = _clean_text(runtime.magent_notify_push_token)
+ topic = _clean_text(runtime.magent_notify_push_topic)
+ if provider == "ntfy":
+ if not base_url or not topic:
+ return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
+ validate_notification_target_url(base_url)
+ url = f"{base_url.rstrip('/')}/{quote(topic)}"
+ headers = {"Title": title, "Tags": "magent,portal"}
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post(url, content=message.encode("utf-8"), headers=headers)
+ response.raise_for_status()
+ return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
+ if provider == "gotify":
+ if not base_url or not token:
+ return {"status": "skipped", "detail": "Gotify needs base URL and token."}
+ validate_notification_target_url(base_url)
+ url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
+ body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
+ result = await _http_post_json(url, body)
+ return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
+ if provider == "pushover":
+ user_key = _clean_text(runtime.magent_notify_push_user_key)
+ if not token or not user_key:
+ return {"status": "skipped", "detail": "Pushover needs token and user key."}
+ form = {"token": token, "user": user_key, "title": title, "message": message}
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post("https://api.pushover.net/1/messages.json", data=form)
+ response.raise_for_status()
+ return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
+ if provider == "discord":
+ return await _send_discord(title, message, payload)
+ if provider == "telegram":
+ return await _send_telegram(title, message)
+ if provider == "webhook":
+ return await _send_webhook(payload)
+ return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
+
+
+async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
+ fallback = _clean_text(runtime.magent_notify_email_from_address)
+ if fallback and fallback not in recipients:
+ recipients.append(fallback)
+ if not recipients:
+ return {"status": "skipped", "detail": "No portal notification recipient is configured."}
+
+ body_text = (
+ f"{title}\n\n"
+ f"{message}\n\n"
+ f"Kind: {_clean_text(payload.get('kind'))}\n"
+ f"Status: {_clean_text(payload.get('status'))}\n"
+ f"Priority: {_clean_text(payload.get('priority'))}\n"
+ f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
+ f"Open: {_clean_text(payload.get('item_url'))}\n"
+ )
+ body_html = (
+ ""
+ f"
{title} "
+ f"
{message}
"
+ "
"
+ f"Kind {_clean_text(payload.get('kind'))} "
+ f"Status {_clean_text(payload.get('status'))} "
+ f"Priority {_clean_text(payload.get('priority'))} "
+ f"Requested by {_clean_text(payload.get('requested_by'))} "
+ "
"
+ f"
Open portal item "
+ "
"
+ )
+ deliveries: list[Dict[str, Any]] = []
+ for recipient in recipients:
+ try:
+ result = await send_generic_email(
+ recipient_email=recipient,
+ subject=title,
+ body_text=body_text,
+ body_html=body_html,
+ )
+ deliveries.append({"recipient": recipient, "status": "ok", **result})
+ except Exception as exc:
+ deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
+ successful = [entry for entry in deliveries if entry.get("status") == "ok"]
+ if successful:
+ return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
+ return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
+
+
+async def send_portal_notification(
+ *,
+ event_type: str,
+ item: Dict[str, Any],
+ actor_username: str,
+ actor_role: str,
+ note: Optional[str] = None,
+) -> Dict[str, Any]:
+ runtime = get_runtime_settings()
+ if not runtime.magent_notify_enabled:
+ return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
+
+ item_id = int(item.get("id") or 0)
+ title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
+ message_lines = [
+ f"Event: {event_type}",
+ f"Actor: {actor_username} ({actor_role})",
+ f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
+ ]
+ if note:
+ message_lines.append(f"Note: {note}")
+ message_lines.append(f"Open: {_portal_item_url(item_id)}")
+ message = "\n".join(message_lines)
+ payload = {
+ "type": "portal.notification",
+ "event": event_type,
+ "item_id": item_id,
+ "item_url": _portal_item_url(item_id),
+ "kind": _clean_text(item.get("kind")),
+ "status": _clean_text(item.get("status")),
+ "priority": _clean_text(item.get("priority")),
+ "requested_by": _clean_text(item.get("created_by_username")),
+ "actor_username": actor_username,
+ "actor_role": actor_role,
+ "note": note or "",
+ }
+
+ channels: Dict[str, Dict[str, Any]] = {}
+ if runtime.magent_notify_discord_enabled:
+ try:
+ channels["discord"] = await _send_discord(title, message, payload)
+ except Exception as exc:
+ channels["discord"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_telegram_enabled:
+ try:
+ channels["telegram"] = await _send_telegram(title, message)
+ except Exception as exc:
+ channels["telegram"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_webhook_enabled:
+ try:
+ channels["webhook"] = await _send_webhook(payload)
+ except Exception as exc:
+ channels["webhook"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_push_enabled:
+ try:
+ channels["push"] = await _send_push(title, message, payload)
+ except Exception as exc:
+ channels["push"] = {"status": "error", "detail": str(exc)}
+ if runtime.magent_notify_email_enabled:
+ try:
+ channels["email"] = await _send_email(title, message, payload)
+ except Exception as exc:
+ channels["email"] = {"status": "error", "detail": str(exc)}
+
+ successful = [name for name, value in channels.items() if value.get("status") == "ok"]
+ failed = [name for name, value in channels.items() if value.get("status") == "error"]
+ skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
+ logger.info(
+ "portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
+ event_type,
+ item_id,
+ successful,
+ failed,
+ skipped,
+ )
+ overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
+ if not channels:
+ overall = "skipped"
+ return {"status": overall, "channels": channels}
diff --git a/backend/app/services/password_reset.py b/backend/app/services/password_reset.py
new file mode 100644
index 0000000..4b4844b
--- /dev/null
+++ b/backend/app/services/password_reset.py
@@ -0,0 +1,333 @@
+from __future__ import annotations
+
+import logging
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, Optional
+
+from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
+from ..clients.jellyfin import JellyfinClient
+from ..clients.jellyseerr import JellyseerrClient
+from ..db import (
+ create_password_reset_token,
+ delete_expired_password_reset_tokens,
+ get_password_reset_token,
+ get_user_by_jellyseerr_id,
+ get_user_by_username,
+ get_users_by_username_ci,
+ mark_password_reset_token_used,
+ set_user_auth_provider,
+ set_user_password,
+ sync_jellyfin_password_state,
+)
+from ..runtime import get_runtime_settings
+from .invite_email import send_password_reset_email
+from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
+
+logger = logging.getLogger(__name__)
+
+PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
+
+
+class PasswordResetUnavailableError(RuntimeError):
+ pass
+
+
+def _normalize_handles(value: object) -> list[str]:
+ if not isinstance(value, str):
+ return []
+ normalized = value.strip().lower()
+ if not normalized:
+ return []
+ handles = [normalized]
+ if "@" in normalized:
+ handles.append(normalized.split("@", 1)[0])
+ return list(dict.fromkeys(handles))
+
+
+def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
+ if not users:
+ return None
+ requested = str(requested_identifier or "").strip().lower()
+
+ def _rank(user: dict) -> tuple[int, int, int, int]:
+ provider = str(user.get("auth_provider") or "local").strip().lower()
+ role = str(user.get("role") or "user").strip().lower()
+ username = str(user.get("username") or "").strip().lower()
+ return (
+ 0 if role == "admin" else 1,
+ 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
+ 0 if provider == "jellyfin" else (1 if provider == "local" else 2),
+ 0 if username == requested else 1,
+ )
+
+ return sorted(users, key=_rank)[0]
+
+
+def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
+ target_handles = set(_normalize_handles(identifier))
+ if not target_handles:
+ return None
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ for key in ("username", "email"):
+ value = user.get(key)
+ if target_handles.intersection(_normalize_handles(value)):
+ return user
+ return None
+
+
+async def _fetch_all_seerr_users() -> list[dict]:
+ cached = get_cached_jellyseerr_users()
+ if cached is not None:
+ return cached
+ runtime = get_runtime_settings()
+ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
+ if not client.configured():
+ return []
+ users: list[dict] = []
+ take = 100
+ skip = 0
+ while True:
+ payload = await client.get_users(take=take, skip=skip)
+ if not payload:
+ break
+ if isinstance(payload, list):
+ batch = payload
+ elif isinstance(payload, dict):
+ batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
+ else:
+ batch = None
+ if not isinstance(batch, list) or not batch:
+ break
+ users.extend([user for user in batch if isinstance(user, dict)])
+ if len(batch) < take:
+ break
+ skip += take
+ if users:
+ return save_jellyseerr_users_cache(users)
+ return users
+
+
+def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
+ if isinstance(local_user, dict):
+ stored_email = str(local_user.get("email") or "").strip()
+ if "@" in stored_email:
+ return stored_email
+ username = str(local_user.get("username") or "").strip()
+ if "@" in username:
+ return username
+ if isinstance(seerr_user, dict):
+ email = str(seerr_user.get("email") or "").strip()
+ if "@" in email:
+ return email
+ return None
+
+
+async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
+ normalized_identifier = str(identifier or "").strip()
+ if not normalized_identifier:
+ return None
+
+ local_user = normalize_user_auth_provider(
+ _pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
+ )
+ seerr_users: list[dict] | None = None
+ seerr_user: dict | None = None
+
+ if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
+ seerr_users = await _fetch_all_seerr_users()
+ seerr_user = next(
+ (
+ user
+ for user in seerr_users
+ if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
+ ),
+ None,
+ )
+
+ if not local_user:
+ seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
+ seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
+ if seerr_user:
+ seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
+ try:
+ seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
+ except (TypeError, ValueError):
+ seerr_user_id = None
+ if seerr_user_id is not None:
+ local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
+ if not local_user:
+ for candidate in (seerr_user.get("email"), seerr_user.get("username")):
+ if not isinstance(candidate, str) or not candidate.strip():
+ continue
+ local_user = normalize_user_auth_provider(
+ _pick_preferred_user(get_users_by_username_ci(candidate), candidate)
+ )
+ if local_user:
+ break
+
+ if not local_user:
+ return None
+
+ auth_provider = resolve_user_auth_provider(local_user)
+ username = str(local_user.get("username") or "").strip()
+ recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
+ if not recipient_email:
+ seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
+ if isinstance(local_user.get("jellyseerr_user_id"), int):
+ seerr_user = next(
+ (
+ user
+ for user in seerr_users
+ if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
+ ),
+ None,
+ )
+ if not seerr_user:
+ seerr_user = _find_matching_seerr_user(username, seerr_users)
+ recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
+ if not recipient_email:
+ return None
+
+ if auth_provider == "jellyseerr":
+ runtime = get_runtime_settings()
+ jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if jellyfin_client.configured():
+ try:
+ jellyfin_user = await jellyfin_client.find_user_by_name(username)
+ except Exception:
+ jellyfin_user = None
+ if isinstance(jellyfin_user, dict):
+ auth_provider = "jellyfin"
+
+ if auth_provider not in {"local", "jellyfin"}:
+ return None
+
+ return {
+ "username": username,
+ "recipient_email": recipient_email,
+ "auth_provider": auth_provider,
+ }
+
+
+def _token_record_is_usable(record: Optional[dict]) -> bool:
+ if not isinstance(record, dict):
+ return False
+ if record.get("is_used"):
+ return False
+ if record.get("is_expired"):
+ return False
+ return True
+
+
+def _mask_email(email: str) -> str:
+ candidate = str(email or "").strip()
+ if "@" not in candidate:
+ return "valid reset link"
+ local_part, domain = candidate.split("@", 1)
+ if not local_part:
+ return f"***@{domain}"
+ if len(local_part) == 1:
+ return f"{local_part}***@{domain}"
+ return f"{local_part[0]}***{local_part[-1]}@{domain}"
+
+
+async def request_password_reset(
+ identifier: str,
+ *,
+ requested_by_ip: Optional[str] = None,
+ requested_user_agent: Optional[str] = None,
+) -> Dict[str, Any]:
+ delete_expired_password_reset_tokens()
+ target = await _resolve_reset_target(identifier)
+ if not target:
+ logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
+ return {"status": "ok", "issued": False}
+
+ token = secrets.token_urlsafe(32)
+ expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
+ create_password_reset_token(
+ token,
+ target["username"],
+ target["recipient_email"],
+ target["auth_provider"],
+ expires_at,
+ requested_by_ip=requested_by_ip,
+ requested_user_agent=requested_user_agent,
+ )
+ await send_password_reset_email(
+ recipient_email=target["recipient_email"],
+ username=target["username"],
+ token=token,
+ expires_at=expires_at,
+ auth_provider=target["auth_provider"],
+ )
+ return {
+ "status": "ok",
+ "issued": True,
+ "username": target["username"],
+ "recipient_email": target["recipient_email"],
+ "auth_provider": target["auth_provider"],
+ "expires_at": expires_at,
+ }
+
+
+def verify_password_reset_token(token: str) -> Dict[str, Any]:
+ delete_expired_password_reset_tokens()
+ record = get_password_reset_token(token)
+ if not _token_record_is_usable(record):
+ raise ValueError("Password reset link is invalid or has expired.")
+ return {
+ "status": "ok",
+ "recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
+ "auth_provider": record.get("auth_provider"),
+ "expires_at": record.get("expires_at"),
+ }
+
+
+async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
+ delete_expired_password_reset_tokens()
+ record = get_password_reset_token(token)
+ if not _token_record_is_usable(record):
+ raise ValueError("Password reset link is invalid or has expired.")
+
+ username = str(record.get("username") or "").strip()
+ if not username:
+ raise ValueError("Password reset link is invalid or has expired.")
+
+ stored_user = normalize_user_auth_provider(get_user_by_username(username))
+ if not stored_user:
+ raise ValueError("Password reset link is invalid or has expired.")
+
+ auth_provider = resolve_user_auth_provider(stored_user)
+ if auth_provider == "jellyseerr":
+ auth_provider = "jellyfin"
+
+ if auth_provider == "local":
+ set_user_password(username, new_password)
+ if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
+ set_user_auth_provider(username, "local")
+ mark_password_reset_token_used(token)
+ logger.info("password reset applied username=%s provider=local", username)
+ return {"status": "ok", "provider": "local", "username": username}
+
+ if auth_provider == "jellyfin":
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
+ jellyfin_user = await client.find_user_by_name(username)
+ user_id = client._extract_user_id(jellyfin_user)
+ if not user_id:
+ raise ValueError("Password reset link is invalid or has expired.")
+ await client.set_user_password(user_id, new_password)
+ sync_jellyfin_password_state(username, new_password)
+ if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
+ set_user_auth_provider(username, "jellyfin")
+ mark_password_reset_token_used(token)
+ logger.info("password reset applied username=%s provider=jellyfin", username)
+ return {"status": "ok", "provider": "jellyfin", "username": username}
+
+ raise ValueError("Password reset is not available for this sign-in provider.")
diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py
index 4c089b1..9b31230 100644
--- a/backend/app/services/snapshot.py
+++ b/backend/app/services/snapshot.py
@@ -1,8 +1,10 @@
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
@@ -11,9 +13,24 @@ from ..clients.radarr import RadarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings
-from ..db import save_snapshot, get_request_cache_payload
+from ..db import (
+ save_snapshot,
+ get_request_cache_payload,
+ get_request_cache_by_id,
+ get_recent_snapshots,
+ get_setting,
+ set_setting,
+ is_seerr_media_failure_suppressed,
+ record_seerr_media_failure,
+ clear_seerr_media_failure,
+)
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
+logger = logging.getLogger(__name__)
+
+JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
+_jellyfin_scan_key = "jellyfin_scan_last_at"
+
STATUS_LABELS = {
1: "Waiting for approval",
@@ -41,6 +58,182 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
+def _normalize_media_title(value: Any) -> Optional[str]:
+ if not isinstance(value, str):
+ return None
+ normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
+ return normalized or None
+
+
+def _canonical_provider_key(value: str) -> str:
+ normalized = value.strip().lower()
+ if normalized.endswith("id"):
+ normalized = normalized[:-2]
+ return normalized
+
+
+def extract_request_provider_ids(payload: Any) -> Dict[str, str]:
+ provider_ids: Dict[str, str] = {}
+ candidates: List[Any] = []
+ if isinstance(payload, dict):
+ candidates.append(payload)
+ media = payload.get("media")
+ if isinstance(media, dict):
+ candidates.append(media)
+ for candidate in candidates:
+ if not isinstance(candidate, dict):
+ continue
+ embedded = candidate.get("ProviderIds") or candidate.get("providerIds")
+ if isinstance(embedded, dict):
+ for key, value in embedded.items():
+ if value is None:
+ continue
+ text = str(value).strip()
+ if text:
+ provider_ids[_canonical_provider_key(str(key))] = text
+ for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"):
+ value = candidate.get(key)
+ if value is None:
+ continue
+ text = str(value).strip()
+ if text:
+ provider_ids[_canonical_provider_key(key)] = text
+ return provider_ids
+
+
+def jellyfin_item_matches_request(
+ item: Dict[str, Any],
+ *,
+ title: Optional[str],
+ year: Optional[int],
+ request_type: RequestType,
+ request_payload: Optional[Dict[str, Any]] = None,
+) -> bool:
+ request_provider_ids = extract_request_provider_ids(request_payload or {})
+ item_provider_ids = extract_request_provider_ids(item)
+
+ provider_priority = ("tmdb", "tvdb", "imdb")
+ for key in provider_priority:
+ request_id = request_provider_ids.get(key)
+ item_id = item_provider_ids.get(key)
+ if request_id and item_id and request_id == item_id:
+ return True
+
+ request_title = _normalize_media_title(title)
+ if not request_title:
+ return False
+
+ item_titles = [
+ _normalize_media_title(item.get("Name")),
+ _normalize_media_title(item.get("OriginalTitle")),
+ _normalize_media_title(item.get("SortName")),
+ _normalize_media_title(item.get("SeriesName")),
+ _normalize_media_title(item.get("title")),
+ ]
+ item_titles = [candidate for candidate in item_titles if candidate]
+
+ item_year = item.get("ProductionYear") or item.get("Year")
+ try:
+ item_year_value = int(item_year) if item_year is not None else None
+ except (TypeError, ValueError):
+ item_year_value = None
+
+ if year and item_year_value and int(year) != item_year_value:
+ return False
+
+ if request_title in item_titles:
+ return True
+
+ if request_type == RequestType.tv:
+ for candidate in item_titles:
+ if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
+ return True
+
+ return False
+
+
+def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
+ response = exc.response
+ if response is None:
+ return None
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = response.text
+ if isinstance(payload, dict):
+ message = payload.get("message") or payload.get("error")
+ return str(message).strip() if message else str(payload)
+ if isinstance(payload, str):
+ trimmed = payload.strip()
+ return trimmed or None
+ return str(payload)
+
+
+def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
+ response = exc.response
+ if response is None:
+ return False
+ return response.status_code == 404 or response.status_code >= 500
+
+
+async def _get_seerr_media_details(
+ jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int
+) -> Optional[Dict[str, Any]]:
+ media_type = request_type.value
+ if media_type not in {"movie", "tv"}:
+ return None
+ if is_seerr_media_failure_suppressed(media_type, tmdb_id):
+ logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id)
+ return None
+ try:
+ if request_type == RequestType.movie:
+ details = await jellyseerr.get_movie(int(tmdb_id))
+ else:
+ details = await jellyseerr.get_tv(int(tmdb_id))
+ except httpx.HTTPStatusError as exc:
+ if _should_persist_seerr_media_failure(exc):
+ record_seerr_media_failure(
+ media_type,
+ int(tmdb_id),
+ status_code=exc.response.status_code if exc.response is not None else None,
+ error_message=_extract_http_error_message(exc),
+ )
+ return None
+ if isinstance(details, dict):
+ clear_seerr_media_failure(media_type, int(tmdb_id))
+ return details
+ return None
+
+
+async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
+ if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
+ return
+ runtime = get_runtime_settings()
+ client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
+ if not client.configured():
+ return
+ last_scan = get_setting(_jellyfin_scan_key)
+ if last_scan:
+ try:
+ parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00"))
+ if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS:
+ return
+ except ValueError:
+ pass
+ previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
+ if previous:
+ prev_state = previous[0].get("state")
+ if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
+ return
+ try:
+ await client.refresh_library()
+ except Exception as exc:
+ logger.warning("Jellyfin library refresh failed: %s", exc)
+ return
+ set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat())
+ logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id)
+
+
def _queue_records(queue: Any) -> List[Dict[str, Any]]:
if isinstance(queue, dict):
records = queue.get("records")
@@ -185,79 +378,96 @@ async def build_snapshot(request_id: str) -> Snapshot:
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="Jellyseerr", status="not_configured"))
+ 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 (jelly_request is None or mode == "always_js") and jellyseerr.configured():
+ 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 jellyseerr fetch: request_id=%s mode=%s", request_id, mode
+ "snapshot Seerr fetch: request_id=%s mode=%s", request_id, mode
)
except Exception as exc:
- timeline.append(TimelineHop(service="Jellyseerr", status="error", details={"error": str(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 Jellyseerr"
+ snapshot.state_reason = "Failed to reach Seerr"
return snapshot
if not jelly_request:
- timeline.append(TimelineHop(service="Jellyseerr", status="not_found"))
+ timeline.append(TimelineHop(service="Seerr", status="not_found"))
snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown
- snapshot.state_reason = "Request not found in Jellyseerr"
+ 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"
- snapshot.title = jelly_request.get("media", {}).get("title", "Unknown")
- snapshot.year = jelly_request.get("media", {}).get("year")
- snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.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():
+ if snapshot.title in {None, "", "Unknown"} and allow_remote:
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
- try:
+ details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
+ if isinstance(details, dict):
if snapshot.request_type == RequestType.movie:
- details = await jellyseerr.get_movie(int(tmdb_id))
- if isinstance(details, dict):
- 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
- 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")
- )
+ 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:
- details = await jellyseerr.get_tv(int(tmdb_id))
- if isinstance(details, dict):
- 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")
- )
- except Exception:
- pass
+ 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 = {
@@ -269,7 +479,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
timeline.append(
TimelineHop(
- service="Jellyseerr",
+ service="Seerr",
status=jelly_status_label,
details={
"requestedBy": jelly_request.get("requestedBy", {}).get("displayName")
@@ -381,10 +591,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
if arr_state is None:
arr_state = "unknown"
- if arr_state == "missing" and media_status_code in {4}:
- arr_state = "available"
- elif arr_state == "missing" and media_status_code in {6}:
- arr_state = "added"
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
@@ -402,7 +608,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
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)
+ search = await jellyfin.search_items(snapshot.title, types, limit=50)
except Exception:
search = None
if isinstance(search, dict):
@@ -410,11 +616,13 @@ async def build_snapshot(request_id: str) -> Snapshot:
for item in items:
if not isinstance(item, dict):
continue
- name = item.get("Name") or item.get("title")
- year = item.get("ProductionYear") or item.get("Year")
- if name and name.strip().lower() == (snapshot.title or "").strip().lower():
- if snapshot.year and year and int(year) != int(snapshot.year):
- 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
@@ -524,7 +732,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
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 added to the library yet."
+ 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."
@@ -535,12 +743,22 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state = NormalizedState.added_to_arr
snapshot.state_reason = "Item is present in Sonarr/Radarr"
- if jellyfin_available and snapshot.state not in {
- NormalizedState.downloading,
- NormalizedState.importing,
- }:
- snapshot.state = NormalizedState.completed
- snapshot.state_reason = "Ready to watch in Jellyfin."
+ 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] = []
@@ -548,7 +766,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
actions.append(
ActionOption(
id="readd_to_arr",
- label="Add to the library queue (Sonarr/Radarr)",
+ label="Push to Sonarr/Radarr",
risk="medium",
)
)
@@ -604,5 +822,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
},
}
+ await _maybe_refresh_jellyfin(snapshot)
await asyncio.to_thread(save_snapshot, snapshot)
return snapshot
diff --git a/backend/app/services/user_cache.py b/backend/app/services/user_cache.py
new file mode 100644
index 0000000..abd5913
--- /dev/null
+++ b/backend/app/services/user_cache.py
@@ -0,0 +1,185 @@
+import json
+import logging
+from datetime import datetime, timezone, timedelta
+from typing import Any, Dict, List, Optional
+
+from ..db import get_setting, set_setting, delete_setting
+
+logger = logging.getLogger(__name__)
+
+JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
+JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
+JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
+JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _parse_iso(value: Optional[str]) -> Optional[datetime]:
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(value)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
+ parsed = _parse_iso(cached_at)
+ if not parsed:
+ return False
+ age = datetime.now(timezone.utc) - parsed
+ return age <= timedelta(minutes=max_age_minutes)
+
+
+def _load_cached_users(
+ cache_key: str, cache_at_key: str, max_age_minutes: int
+) -> Optional[List[Dict[str, Any]]]:
+ cached_at = get_setting(cache_at_key)
+ if not _cache_is_fresh(cached_at, max_age_minutes):
+ return None
+ raw = get_setting(cache_key)
+ if not raw:
+ return None
+ try:
+ data = json.loads(raw)
+ except (TypeError, json.JSONDecodeError):
+ return None
+ if isinstance(data, list):
+ return [item for item in data if isinstance(item, dict)]
+ return None
+
+
+def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
+ payload = json.dumps(users, ensure_ascii=True)
+ set_setting(cache_key, payload)
+ set_setting(cache_at_key, _now_iso())
+
+
+def _normalized_handles(value: Any) -> List[str]:
+ if not isinstance(value, str):
+ return []
+ normalized = value.strip().lower()
+ if not normalized:
+ return []
+ handles = [normalized]
+ if "@" in normalized:
+ handles.append(normalized.split("@", 1)[0])
+ return list(dict.fromkeys(handles))
+
+
+def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
+ candidate_to_id: Dict[str, int] = {}
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ user_id = user.get("id") or user.get("userId") or user.get("Id")
+ try:
+ user_id = int(user_id)
+ except (TypeError, ValueError):
+ continue
+ for key in ("username", "email", "displayName", "name"):
+ for handle in _normalized_handles(user.get(key)):
+ candidate_to_id.setdefault(handle, user_id)
+ return candidate_to_id
+
+
+def find_matching_jellyseerr_user(
+ identifier: str, users: List[Dict[str, Any]]
+) -> Optional[Dict[str, Any]]:
+ target_handles = set(_normalized_handles(identifier))
+ if not target_handles:
+ return None
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ for key in ("username", "email", "displayName", "name"):
+ if target_handles.intersection(_normalized_handles(user.get(key))):
+ return user
+ return None
+
+
+def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
+ if not isinstance(user, dict):
+ return None
+ value = user.get("email")
+ if not isinstance(value, str):
+ return None
+ candidate = value.strip()
+ if not candidate or "@" not in candidate:
+ return None
+ return candidate
+
+
+def match_jellyseerr_user_id(
+ username: str, candidate_map: Dict[str, int]
+) -> Optional[int]:
+ for handle in _normalized_handles(username):
+ matched = candidate_map.get(handle)
+ if matched is not None:
+ return matched
+ return None
+
+
+def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ normalized: List[Dict[str, Any]] = []
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ normalized.append(
+ {
+ "id": user.get("id") or user.get("userId") or user.get("Id"),
+ "email": user.get("email"),
+ "username": user.get("username"),
+ "displayName": user.get("displayName"),
+ "name": user.get("name"),
+ }
+ )
+ _save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
+ logger.debug("Cached Seerr users: %s", len(normalized))
+ return normalized
+
+
+def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
+ return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
+
+
+def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ normalized: List[Dict[str, Any]] = []
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ normalized.append(
+ {
+ "id": user.get("Id"),
+ "name": user.get("Name"),
+ "hasPassword": user.get("HasPassword"),
+ "lastLoginDate": user.get("LastLoginDate"),
+ }
+ )
+ _save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
+ logger.debug("Cached Jellyfin users: %s", len(normalized))
+ return normalized
+
+
+def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
+ return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
+
+
+def clear_user_import_caches() -> Dict[str, int]:
+ cleared = 0
+ for key in (
+ JELLYSEERR_CACHE_KEY,
+ JELLYSEERR_CACHE_AT_KEY,
+ JELLYFIN_CACHE_KEY,
+ JELLYFIN_CACHE_AT_KEY,
+ ):
+ delete_setting(key)
+ cleared += 1
+ logger.debug("Cleared user import cache keys: %s", cleared)
+ return {"settingsKeysCleared": cleared}
diff --git a/backend/requirements.txt b/backend/requirements.txt
index ea7c5c7..2887725 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,9 +1,9 @@
-fastapi==0.115.0
-uvicorn==0.30.6
-httpx==0.27.2
-pydantic==2.9.2
-pydantic-settings==2.5.2
-python-jose[cryptography]==3.3.0
+fastapi==0.134.0
+uvicorn==0.41.0
+httpx==0.28.1
+pydantic==2.12.5
+pydantic-settings==2.13.1
+PyJWT==2.11.0
passlib==1.7.4
-python-multipart==0.0.9
-Pillow==10.4.0
+python-multipart==0.0.22
+Pillow==12.1.1
diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py
new file mode 100644
index 0000000..4bcd3ef
--- /dev/null
+++ b/backend/tests/test_backend_quality.py
@@ -0,0 +1,223 @@
+import os
+import tempfile
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from fastapi import HTTPException
+from starlette.requests import Request
+
+from backend.app import db
+from backend.app.config import settings
+from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
+from backend.app.routers import auth as auth_router
+from backend.app.routers import portal as portal_router
+from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
+from backend.app.services import password_reset
+
+
+def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
+ scope = {
+ "type": "http",
+ "http_version": "1.1",
+ "method": "POST",
+ "scheme": "http",
+ "path": "/auth/password/forgot",
+ "raw_path": b"/auth/password/forgot",
+ "query_string": b"",
+ "headers": [(b"user-agent", user_agent.encode("utf-8"))],
+ "client": (ip, 12345),
+ "server": ("testserver", 8000),
+ }
+
+ async def receive() -> dict:
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ return Request(scope, receive)
+
+
+class TempDatabaseMixin:
+ def setUp(self) -> None:
+ super_method = getattr(super(), "setUp", None)
+ if callable(super_method):
+ super_method()
+ self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self._original_sqlite_path = settings.sqlite_path
+ self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
+ settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
+ settings.sqlite_journal_mode = "DELETE"
+ auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
+ auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
+ auth_router._RESET_ATTEMPTS_BY_IP.clear()
+ auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
+ db.init_db()
+
+ def tearDown(self) -> None:
+ settings.sqlite_path = self._original_sqlite_path
+ settings.sqlite_journal_mode = self._original_journal_mode
+ auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
+ auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
+ auth_router._RESET_ATTEMPTS_BY_IP.clear()
+ auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
+ self._tempdir.cleanup()
+ super_method = getattr(super(), "tearDown", None)
+ if callable(super_method):
+ super_method()
+
+
+class PasswordPolicyTests(unittest.TestCase):
+ def test_validate_password_policy_rejects_short_passwords(self) -> None:
+ with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE):
+ validate_password_policy("short")
+
+ def test_validate_password_policy_trims_whitespace(self) -> None:
+ self.assertEqual(validate_password_policy(" password123 "), "password123")
+
+
+class NetworkSecurityTests(unittest.TestCase):
+ def test_notification_targets_reject_loopback(self) -> None:
+ with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
+ validate_notification_target_url("http://127.0.0.1:8080/webhook")
+
+ def test_forwarded_headers_require_trusted_proxy(self) -> None:
+ original_enabled = settings.magent_proxy_enabled
+ original_trust = settings.magent_proxy_trust_forwarded_headers
+ original_proxies = settings.magent_proxy_trusted_proxies
+ settings.magent_proxy_enabled = True
+ settings.magent_proxy_trust_forwarded_headers = True
+ settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
+ try:
+ self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
+ self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
+ finally:
+ settings.magent_proxy_enabled = original_enabled
+ settings.magent_proxy_trust_forwarded_headers = original_trust
+ settings.magent_proxy_trusted_proxies = original_proxies
+
+
+class 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 AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ async def test_forgot_password_is_rate_limited(self) -> None:
+ request = _build_request(ip="10.1.2.3")
+ payload = {"identifier": "resetuser@example.com"}
+ with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object(
+ auth_router,
+ "request_password_reset",
+ new=AsyncMock(return_value={"status": "ok", "issued": False}),
+ ):
+ for _ in range(3):
+ result = await auth_router.forgot_password(payload, request)
+ self.assertEqual(result["status"], "ok")
+
+ with self.assertRaises(HTTPException) as context:
+ await auth_router.forgot_password(payload, request)
+
+ self.assertEqual(context.exception.status_code, 429)
+ self.assertEqual(
+ context.exception.detail,
+ "Too many password reset attempts. Try again shortly.",
+ )
+
+ async def test_request_password_reset_prefers_local_user_email(self) -> None:
+ db.create_user_if_missing(
+ "ResetUser",
+ "password123",
+ email="local@example.com",
+ auth_provider="local",
+ )
+ with patch.object(
+ password_reset,
+ "send_password_reset_email",
+ new=AsyncMock(return_value={"status": "ok"}),
+ ) as send_email:
+ result = await password_reset.request_password_reset("ResetUser")
+
+ self.assertTrue(result["issued"])
+ self.assertEqual(result["recipient_email"], "local@example.com")
+ send_email.assert_awaited_once()
+ self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com")
+
+ async def test_profile_invite_requires_recipient_email(self) -> None:
+ current_user = {
+ "username": "invite-owner",
+ "role": "user",
+ "invite_management_enabled": True,
+ "profile_id": None,
+ }
+ with self.assertRaises(HTTPException) as context:
+ await auth_router.create_profile_invite({"label": "Missing email"}, current_user)
+
+ self.assertEqual(context.exception.status_code, 400)
+ self.assertEqual(
+ context.exception.detail,
+ "recipient_email is required and must be a valid email address.",
+ )
+
+
+class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
+ def test_legacy_request_status_maps_to_workflow(self) -> None:
+ item = {"kind": "request", "status": "in_progress"}
+ serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
+ workflow = serialized.get("workflow") or {}
+ self.assertEqual(workflow.get("request_status"), "approved")
+ self.assertEqual(workflow.get("media_status"), "processing")
+
+ def test_invalid_pipeline_transition_is_rejected(self) -> None:
+ with self.assertRaises(HTTPException) as context:
+ portal_router._validate_pipeline_transition(
+ "approved",
+ "processing",
+ "pending",
+ "pending",
+ )
+ self.assertEqual(context.exception.status_code, 400)
+
+ def test_portal_workflow_filters(self) -> None:
+ db.create_portal_item(
+ kind="request",
+ title="Request A",
+ description="A",
+ created_by_username="alpha",
+ created_by_id=None,
+ status="processing",
+ workflow_request_status="approved",
+ workflow_media_status="processing",
+ )
+ db.create_portal_item(
+ kind="request",
+ title="Request B",
+ description="B",
+ created_by_username="bravo",
+ created_by_id=None,
+ status="pending",
+ workflow_request_status="pending",
+ workflow_media_status="pending",
+ )
+ processing = db.list_portal_items(
+ kind="request",
+ workflow_request_status="approved",
+ workflow_media_status="processing",
+ limit=10,
+ offset=0,
+ )
+ pending_count = db.count_portal_items(
+ kind="request",
+ workflow_request_status="pending",
+ workflow_media_status="pending",
+ )
+ self.assertEqual(len(processing), 1)
+ self.assertEqual(pending_count, 1)
diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml
index 6b41387..f3c695c 100644
--- a/docker-compose.hub.yml
+++ b/docker-compose.hub.yml
@@ -1,19 +1,10 @@
services:
- backend:
- image: rephl3xnz/magent-backend:latest
+ magent:
+ image: rephl3xnz/magent:latest
env_file:
- ./.env
ports:
+ - "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
-
- frontend:
- image: rephl3xnz/magent-frontend:latest
- environment:
- - NEXT_PUBLIC_API_BASE=/api
- - BACKEND_INTERNAL_URL=http://backend:8000
- ports:
- - "3000:3000"
- depends_on:
- - backend
diff --git a/docker-compose.yml b/docker-compose.yml
index 97b0ead..ebbc613 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,23 +1,12 @@
services:
- backend:
+ magent:
build:
context: .
- dockerfile: backend/Dockerfile
+ dockerfile: Dockerfile
env_file:
- ./.env
ports:
+ - "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
-
- frontend:
- build:
- context: ./frontend
- dockerfile: Dockerfile
- environment:
- - NEXT_PUBLIC_API_BASE=/api
- - BACKEND_INTERNAL_URL=http://backend:8000
- ports:
- - "3000:3000"
- depends_on:
- - backend
diff --git a/docker/supervisord.conf b/docker/supervisord.conf
new file mode 100644
index 0000000..2cb5ceb
--- /dev/null
+++ b/docker/supervisord.conf
@@ -0,0 +1,28 @@
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/tmp/supervisord.pid
+
+[program:backend]
+directory=/app
+command=uvicorn app.main:app --host 0.0.0.0 --port 8000
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=10
+
+[program:frontend]
+directory=/app/frontend
+command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
+environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=20
diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx
index 2db337d..0e0673e 100644
--- a/frontend/app/admin/SettingsPage.tsx
+++ b/frontend/app/admin/SettingsPage.tsx
@@ -1,9 +1,10 @@
'use client'
-import { useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
-import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
+import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
+import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
type AdminSetting = {
key: string
@@ -19,37 +20,105 @@ type ServiceOptions = {
}
const SECTION_LABELS: Record = {
- jellyseerr: 'Jellyseerr',
+ magent: 'Magent',
+ general: 'General',
+ notifications: 'Notifications',
+ seerr: 'Seerr',
+ jellyseerr: 'Seerr',
jellyfin: 'Jellyfin',
- artwork: 'Artwork',
- cache: 'Cache',
+ artwork: 'Artwork cache',
+ cache: 'Cache Control',
sonarr: 'Sonarr',
radarr: 'Radarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
log: 'Activity log',
- requests: 'Request syncing',
+ requests: 'Request sync',
+ site: 'Site',
}
-const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr'])
+const BOOL_SETTINGS = new Set([
+ 'jellyfin_sync_to_arr',
+ 'site_banner_enabled',
+ 'site_login_show_jellyfin_login',
+ 'site_login_show_local_login',
+ 'site_login_show_forgot_password',
+ 'site_login_show_signup_link',
+ 'magent_proxy_enabled',
+ 'magent_proxy_trust_forwarded_headers',
+ 'magent_ssl_bind_enabled',
+ 'magent_notify_enabled',
+ 'magent_notify_email_enabled',
+ 'magent_notify_email_use_tls',
+ 'magent_notify_email_use_ssl',
+ 'magent_notify_discord_enabled',
+ 'magent_notify_telegram_enabled',
+ 'magent_notify_push_enabled',
+ 'magent_notify_webhook_enabled',
+])
+const TEXTAREA_SETTINGS = new Set([
+ 'site_banner_message',
+ 'site_changelog',
+ 'magent_ssl_certificate_pem',
+ 'magent_ssl_private_key_pem',
+])
+const URL_SETTINGS = new Set([
+ 'magent_application_url',
+ 'magent_api_url',
+ 'magent_proxy_base_url',
+ 'magent_notify_discord_webhook_url',
+ 'magent_notify_push_base_url',
+ 'magent_notify_webhook_url',
+ 'jellyseerr_base_url',
+ 'jellyfin_base_url',
+ 'jellyfin_public_url',
+ 'sonarr_base_url',
+ 'radarr_base_url',
+ 'prowlarr_base_url',
+ 'qbittorrent_base_url',
+])
+const NUMBER_SETTINGS = new Set([
+ 'magent_application_port',
+ 'magent_api_port',
+ 'magent_notify_email_smtp_port',
+ 'log_file_max_bytes',
+ 'log_file_backup_count',
+ 'requests_sync_ttl_minutes',
+ 'requests_poll_interval_seconds',
+ 'requests_delta_sync_interval_minutes',
+ 'requests_cleanup_days',
+])
+const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record = {
- jellyseerr: 'Connect the request system where users submit content.',
+ magent:
+ 'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
+ general:
+ 'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
+ notifications:
+ 'Notification providers and delivery channel settings used by Magent messaging features.',
+ seerr: 'Connect Seerr where users submit content requests.',
+ jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Control Jellyfin login and availability checks.',
- artwork: 'Configure how posters and artwork are loaded.',
- cache: 'Manage saved request data and offline artwork.',
+ artwork: 'Cache posters/backdrops and review artwork coverage.',
+ cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
- requests: 'Sync and refresh cadence for requests.',
+ requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.',
+ site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
}
const SETTINGS_SECTION_MAP: Record = {
+ magent: 'magent',
+ general: 'magent',
+ notifications: 'magent',
+ seerr: 'jellyseerr',
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
- artwork: 'artwork',
+ artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
@@ -58,36 +127,271 @@ const SETTINGS_SECTION_MAP: Record = {
cache: null,
logs: 'log',
maintenance: null,
+ site: 'site',
+}
+
+const MAGENT_SECTION_GROUPS: Array<{
+ key: string
+ title: string
+ description: string
+ keys: string[]
+}> = [
+ {
+ key: 'magent-runtime',
+ title: 'Application',
+ description:
+ 'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
+ keys: [
+ 'magent_application_url',
+ 'magent_application_port',
+ 'magent_api_url',
+ 'magent_api_port',
+ 'magent_bind_host',
+ ],
+ },
+ {
+ key: 'magent-proxy',
+ title: 'Proxy',
+ description:
+ 'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
+ keys: [
+ 'magent_proxy_enabled',
+ 'magent_proxy_base_url',
+ 'magent_proxy_trust_forwarded_headers',
+ 'magent_proxy_forwarded_prefix',
+ ],
+ },
+ {
+ key: 'magent-ssl',
+ title: 'Manual SSL Bind',
+ description:
+ 'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
+ keys: [
+ 'magent_ssl_bind_enabled',
+ 'magent_ssl_certificate_path',
+ 'magent_ssl_private_key_path',
+ 'magent_ssl_certificate_pem',
+ 'magent_ssl_private_key_pem',
+ ],
+ },
+ {
+ key: 'magent-notify-core',
+ title: 'Notifications',
+ description:
+ 'Global notification controls and provider-independent defaults used by Magent messaging features.',
+ keys: ['magent_notify_enabled'],
+ },
+ {
+ key: 'magent-notify-email',
+ title: 'Email',
+ description: 'SMTP configuration for email notifications.',
+ keys: [
+ 'magent_notify_email_enabled',
+ 'magent_notify_email_smtp_host',
+ 'magent_notify_email_smtp_port',
+ 'magent_notify_email_smtp_username',
+ 'magent_notify_email_smtp_password',
+ 'magent_notify_email_from_address',
+ 'magent_notify_email_from_name',
+ 'magent_notify_email_use_tls',
+ 'magent_notify_email_use_ssl',
+ ],
+ },
+ {
+ key: 'magent-notify-discord',
+ title: 'Discord',
+ description: 'Webhook settings for Discord notifications and feedback routing.',
+ keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
+ },
+ {
+ key: 'magent-notify-telegram',
+ title: 'Telegram',
+ description: 'Bot token and chat target for Telegram notifications.',
+ keys: [
+ 'magent_notify_telegram_enabled',
+ 'magent_notify_telegram_bot_token',
+ 'magent_notify_telegram_chat_id',
+ ],
+ },
+ {
+ key: 'magent-notify-push',
+ title: 'Push / Mobile',
+ description:
+ 'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
+ keys: [
+ 'magent_notify_push_enabled',
+ 'magent_notify_push_provider',
+ 'magent_notify_push_base_url',
+ 'magent_notify_push_topic',
+ 'magent_notify_push_token',
+ 'magent_notify_push_user_key',
+ 'magent_notify_push_device',
+ 'magent_notify_webhook_enabled',
+ 'magent_notify_webhook_url',
+ ],
+ },
+]
+
+const MAGENT_GROUPS_BY_SECTION: Record> = {
+ general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']),
+ notifications: new Set([
+ 'magent-notify-core',
+ 'magent-notify-email',
+ 'magent-notify-discord',
+ 'magent-notify-telegram',
+ 'magent-notify-push',
+ ]),
+}
+
+const SITE_SECTION_GROUPS: Array<{
+ key: string
+ title: string
+ description: string
+ keys: string[]
+}> = [
+ {
+ key: 'site-banner',
+ title: 'Site Banner',
+ description: 'Control the sitewide banner message, tone, and visibility.',
+ keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'],
+ },
+ {
+ key: 'site-login',
+ title: 'Login Page Behaviour',
+ description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
+ keys: [
+ 'site_login_show_jellyfin_login',
+ 'site_login_show_local_login',
+ 'site_login_show_forgot_password',
+ 'site_login_show_signup_link',
+ ],
+ },
+]
+
+const SETTING_LABEL_OVERRIDES: Record = {
+ jellyseerr_base_url: 'Seerr base URL',
+ jellyseerr_api_key: 'Seerr API key',
+ magent_application_url: 'Application URL',
+ magent_application_port: 'Application port',
+ magent_api_url: 'API URL',
+ magent_api_port: 'API port',
+ magent_bind_host: 'Bind host',
+ magent_proxy_enabled: 'Proxy support enabled',
+ magent_proxy_base_url: 'Proxy base URL',
+ magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
+ magent_proxy_forwarded_prefix: 'Forwarded path prefix',
+ magent_ssl_bind_enabled: 'Manual SSL bind enabled',
+ magent_ssl_certificate_path: 'Certificate path',
+ magent_ssl_private_key_path: 'Private key path',
+ magent_ssl_certificate_pem: 'Certificate (PEM)',
+ magent_ssl_private_key_pem: 'Private key (PEM)',
+ magent_notify_enabled: 'Notifications enabled',
+ magent_notify_email_enabled: 'Email notifications enabled',
+ magent_notify_email_smtp_host: 'SMTP host',
+ magent_notify_email_smtp_port: 'SMTP port',
+ magent_notify_email_smtp_username: 'SMTP username',
+ magent_notify_email_smtp_password: 'SMTP password',
+ magent_notify_email_from_address: 'From email address',
+ magent_notify_email_from_name: 'From display name',
+ magent_notify_email_use_tls: 'Use STARTTLS',
+ magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
+ magent_notify_discord_enabled: 'Discord notifications enabled',
+ magent_notify_discord_webhook_url: 'Discord webhook URL',
+ magent_notify_telegram_enabled: 'Telegram notifications enabled',
+ magent_notify_telegram_bot_token: 'Telegram bot token',
+ magent_notify_telegram_chat_id: 'Telegram chat ID',
+ magent_notify_push_enabled: 'Push notifications enabled',
+ magent_notify_push_provider: 'Push provider',
+ magent_notify_push_base_url: 'Push provider/base URL',
+ magent_notify_push_topic: 'Topic / channel',
+ magent_notify_push_token: 'API token / password',
+ magent_notify_push_user_key: 'User key / recipient key',
+ magent_notify_push_device: 'Device / target',
+ magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
+ magent_notify_webhook_url: 'Generic webhook URL',
+ site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
+ site_login_show_local_login: 'Login page: local Magent sign-in',
+ site_login_show_forgot_password: 'Login page: forgot password',
+ site_login_show_signup_link: 'Login page: invite signup link',
+ log_file_max_bytes: 'Log file max size (bytes)',
+ log_file_backup_count: 'Rotated log files to keep',
+ log_http_client_level: 'Service HTTP log level',
+ log_background_sync_level: 'Background sync log level',
}
const labelFromKey = (key: string) =>
+ SETTING_LABEL_OVERRIDES[key] ??
key
.replaceAll('_', ' ')
+ .replace('jellyseerr', 'Seerr')
.replace('base url', 'URL')
.replace('api key', 'API key')
.replace('quality profile id', 'Quality profile ID')
.replace('root folder', 'Root folder')
.replace('qbittorrent', 'qBittorrent')
- .replace('requests sync ttl minutes', 'Refresh saved requests if older than (minutes)')
- .replace('requests poll interval seconds', 'Background refresh check (seconds)')
- .replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)')
- .replace('requests full sync time', 'Full refresh time (24h)')
- .replace('requests cleanup time', 'Clean up old history time (24h)')
- .replace('requests cleanup days', 'Remove history older than (days)')
- .replace('requests data source', 'Where requests are loaded from')
+ .replace('requests sync ttl minutes', 'Saved request refresh TTL (minutes)')
+ .replace('requests poll interval seconds', 'Full refresh check interval (seconds)')
+ .replace('requests delta sync interval minutes', 'Delta sync interval (minutes)')
+ .replace('requests full sync time', 'Daily full refresh time (24h)')
+ .replace('requests cleanup time', 'Daily history cleanup time (24h)')
+ .replace('requests cleanup days', 'History retention window (days)')
+ .replace('requests data source', 'Request source (cache vs Seerr)')
.replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode')
+ .replace('site build number', 'Build number')
+ .replace('site banner enabled', 'Sitewide banner enabled')
+ .replace('site banner message', 'Sitewide banner message')
+ .replace('site banner tone', 'Sitewide banner tone')
+ .replace('site changelog', 'Changelog text')
+
+const formatBytes = (value?: number | null) => {
+ if (!value || value <= 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ let size = value
+ let unitIndex = 0
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024
+ unitIndex += 1
+ }
+ const decimals = unitIndex === 0 || size >= 10 ? 0 : 1
+ return `${size.toFixed(decimals)} ${units[unitIndex]}`
+}
type SettingsPageProps = {
section: string
}
+type SettingsSectionGroup = {
+ key: string
+ title: string
+ items: AdminSetting[]
+ description?: string
+}
+
+type SectionFeedback = {
+ tone: 'status' | 'error'
+ message: string
+}
+
+const SERVICE_TEST_ENDPOINTS: Record = {
+ jellyseerr: 'seerr',
+ jellyfin: 'jellyfin',
+ sonarr: 'sonarr',
+ radarr: 'radarr',
+ prowlarr: 'prowlarr',
+ qbittorrent: 'qbittorrent',
+}
+
export default function SettingsPage({ section }: SettingsPageProps) {
const router = useRouter()
const [settings, setSettings] = useState([])
const [formValues, setFormValues] = useState>({})
const [status, setStatus] = useState(null)
+ const [sectionFeedback, setSectionFeedback] = useState>({})
+ const [sectionSaving, setSectionSaving] = useState>({})
+ const [sectionTesting, setSectionTesting] = useState>({})
+ const [emailTestRecipient, setEmailTestRecipient] = useState('')
const [loading, setLoading] = useState(true)
const [sonarrOptions, setSonarrOptions] = useState(null)
const [radarrOptions, setRadarrOptions] = useState(null)
@@ -102,12 +406,33 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [cacheRows, setCacheRows] = useState([])
const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState(null)
+ const [cacheLoading, setCacheLoading] = useState(false)
const [requestsSync, setRequestsSync] = useState(null)
const [artworkPrefetch, setArtworkPrefetch] = useState(null)
+ const [artworkSummary, setArtworkSummary] = useState(null)
+ const [artworkSummaryStatus, setArtworkSummaryStatus] = useState(null)
const [maintenanceStatus, setMaintenanceStatus] = useState(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
+ const [liveStreamConnected, setLiveStreamConnected] = useState(false)
+ const requestsSyncRef = useRef(null)
+ const artworkPrefetchRef = useRef(null)
+ const computeProgressPercent = (
+ completedValue: unknown,
+ totalValue: unknown,
+ statusValue: unknown
+ ): number => {
+ if (String(statusValue).toLowerCase() === 'completed') {
+ return 100
+ }
+ const completed = Number(completedValue)
+ const total = Number(totalValue)
+ if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0 || completed <= 0) {
+ return 0
+ }
+ return Math.max(0, Math.min(100, Math.round((completed / total) * 100)))
+ }
- const loadSettings = async () => {
+ const loadSettings = useCallback(async (refreshedKeys?: Set) => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) {
@@ -137,11 +462,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
initialValues[setting.key] = ''
}
}
- setFormValues(initialValues)
+ setFormValues((current) => {
+ if (!refreshedKeys || refreshedKeys.size === 0) {
+ return initialValues
+ }
+ const nextValues = { ...initialValues }
+ for (const [key, value] of Object.entries(current)) {
+ if (!refreshedKeys.has(key)) {
+ nextValues[key] = value
+ }
+ }
+ return nextValues
+ })
setStatus(null)
- }
+ }, [router])
- const loadArtworkPrefetchStatus = async () => {
+ const loadArtworkPrefetchStatus = useCallback(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
@@ -153,10 +489,30 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} catch (err) {
console.error(err)
}
- }
+ }, [])
+ const loadArtworkSummary = useCallback(async () => {
+ setArtworkSummaryStatus(null)
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`)
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Artwork summary fetch failed')
+ }
+ const data = await response.json()
+ setArtworkSummary(data?.summary ?? null)
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
+ : 'Could not load artwork stats.'
+ setArtworkSummaryStatus(message)
+ }
+ }, [])
- const loadOptions = async (service: 'sonarr' | 'radarr') => {
+ const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/${service}/options`)
@@ -185,7 +541,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRadarrError('Could not load Radarr options.')
}
}
- }
+ }, [])
useEffect(() => {
const load = async () => {
@@ -195,8 +551,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
try {
await loadSettings()
- if (section === 'artwork') {
+ if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus()
+ await loadArtworkSummary()
}
} catch (err) {
console.error(err)
@@ -213,7 +570,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') {
void loadOptions('radarr')
}
- }, [router, section])
+ }, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
const groupedSettings = useMemo(() => {
const groups: Record = {}
@@ -226,30 +583,107 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [settings])
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
+ const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
+ const isSiteGroupedSection = section === 'site'
const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache'
- const cacheSettingKeys = new Set([
- 'requests_sync_ttl_minutes',
- 'requests_data_source',
- 'artwork_cache_mode',
- ])
+ const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
+ const artworkSettingKeys = new Set(['artwork_cache_mode'])
+ const generatedSettingKeys = new Set(['site_changelog'])
+ const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
+ const requestSettingOrder = [
+ 'requests_poll_interval_seconds',
+ 'requests_delta_sync_interval_minutes',
+ 'requests_full_sync_time',
+ 'requests_cleanup_time',
+ 'requests_cleanup_days',
+ ]
+ const siteSettingOrder = [
+ 'site_banner_enabled',
+ 'site_banner_message',
+ 'site_banner_tone',
+ 'site_login_show_jellyfin_login',
+ 'site_login_show_local_login',
+ 'site_login_show_forgot_password',
+ 'site_login_show_signup_link',
+ ]
+ const sortByOrder = (items: AdminSetting[], order: string[]) => {
+ const position = new Map(order.map((key, index) => [key, index]))
+ return [...items].sort((a, b) => {
+ const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
+ const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
+ if (aIndex !== bIndex) return aIndex - bIndex
+ return a.key.localeCompare(b.key)
+ })
+ }
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
- const settingsSections = isCacheSection
- ? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }]
- : visibleSections.map((sectionKey) => ({
- key: sectionKey,
- title: SECTION_LABELS[sectionKey] ?? sectionKey,
- items:
- sectionKey === 'requests' || sectionKey === 'artwork'
- ? (groupedSettings[sectionKey] ?? []).filter(
- (setting) => !cacheSettingKeys.has(setting.key)
- )
- : groupedSettings[sectionKey] ?? [],
- }))
+ const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
+ const settingsSections: SettingsSectionGroup[] = isCacheSection
+ ? [
+ { key: 'cache', title: 'Cache control', items: cacheSettings },
+ { key: 'artwork', title: 'Artwork cache', items: artworkSettings },
+ ]
+ : isMagentGroupedSection
+ ? (() => {
+ if (section === 'magent') {
+ return []
+ }
+ const magentItems = groupedSettings.magent ?? []
+ const byKey = new Map(magentItems.map((item) => [item.key, item]))
+ const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set()
+ const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
+ allowedGroupKeys.has(group.key),
+ ).map((group) => {
+ const items = group.keys
+ .map((key) => byKey.get(key))
+ .filter((item): item is AdminSetting => Boolean(item))
+ return {
+ key: group.key,
+ title: group.title,
+ description: group.description,
+ items,
+ }
+ })
+ return groups
+ })()
+ : isSiteGroupedSection
+ ? (() => {
+ const siteItems = groupedSettings.site ?? []
+ const byKey = new Map(siteItems.map((item) => [item.key, item]))
+ return SITE_SECTION_GROUPS.map((group) => {
+ const items = group.keys
+ .map((key) => byKey.get(key))
+ .filter((item): item is AdminSetting => Boolean(item))
+ return {
+ key: group.key,
+ title: group.title,
+ description: group.description,
+ items,
+ }
+ })
+ })()
+ : visibleSections.map((sectionKey) => ({
+ key: sectionKey,
+ title: SECTION_LABELS[sectionKey] ?? sectionKey,
+ items: (() => {
+ const sectionItems = groupedSettings[sectionKey] ?? []
+ const filtered =
+ sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
+ ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
+ : sectionItems
+ if (sectionKey === 'requests') {
+ return sortByOrder(filtered, requestSettingOrder)
+ }
+ if (sectionKey === 'site') {
+ return sortByOrder(filtered, siteSettingOrder)
+ }
+ return filtered
+ })(),
+ }))
const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests'
- const showArtworkExtras = section === 'artwork'
+ const showArtworkExtras = section === 'cache'
const showCacheExtras = section === 'cache'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true
@@ -259,36 +693,163 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return false
}
+ useEffect(() => {
+ requestsSyncRef.current = requestsSync
+ }, [requestsSync])
+
+ useEffect(() => {
+ artworkPrefetchRef.current = artworkPrefetch
+ }, [artworkPrefetch])
+
const settingDescriptions: Record = {
- jellyseerr_base_url: 'Base URL for your Jellyseerr server.',
+ magent_application_url:
+ 'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
+ magent_application_port:
+ 'Preferred frontend/UI port for local or direct-hosted deployments.',
+ magent_api_url:
+ 'Canonical public URL for the Magent API when it differs from the app URL.',
+ magent_api_port: 'Preferred API port for local or direct-hosted deployments.',
+ magent_bind_host:
+ 'Host/IP to bind the application services to when running without an external process manager.',
+ magent_proxy_enabled:
+ 'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.',
+ magent_proxy_base_url:
+ 'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.',
+ magent_proxy_trust_forwarded_headers:
+ 'Trust X-Forwarded-* headers from your reverse proxy.',
+ magent_proxy_forwarded_prefix:
+ 'Optional path prefix added by your proxy (example: /magent).',
+ magent_ssl_bind_enabled:
+ 'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).',
+ magent_ssl_certificate_path:
+ 'Path to the TLS certificate file on disk (PEM).',
+ magent_ssl_private_key_path:
+ 'Path to the TLS private key file on disk (PEM).',
+ magent_ssl_certificate_pem:
+ 'Paste the TLS certificate PEM if you want Magent to store it directly.',
+ magent_ssl_private_key_pem:
+ 'Paste the TLS private key PEM if you want Magent to store it directly.',
+ magent_notify_enabled:
+ 'Master switch for Magent notifications. Individual provider toggles still apply.',
+ magent_notify_email_enabled: 'Enable SMTP email notifications.',
+ magent_notify_email_smtp_host: 'SMTP server hostname or IP.',
+ magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).',
+ magent_notify_email_smtp_username: 'SMTP account username.',
+ magent_notify_email_smtp_password: 'SMTP account password or app password.',
+ magent_notify_email_from_address: 'Sender email address used by Magent.',
+ magent_notify_email_from_name: 'Sender display name shown to recipients.',
+ magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.',
+ magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).',
+ magent_notify_discord_enabled: 'Enable Discord webhook notifications.',
+ magent_notify_discord_webhook_url:
+ 'Discord channel webhook URL used for notifications and optional feedback routing.',
+ magent_notify_telegram_enabled: 'Enable Telegram notifications.',
+ magent_notify_telegram_bot_token: 'Bot token from BotFather.',
+ magent_notify_telegram_chat_id:
+ 'Default Telegram chat/group/user ID for notifications.',
+ magent_notify_push_enabled: 'Enable generic push notifications.',
+ magent_notify_push_provider:
+ 'Push backend to target (ntfy, gotify, pushover, webhook, etc.).',
+ magent_notify_push_base_url:
+ 'Base URL for your push provider (for example ntfy/gotify server URL).',
+ magent_notify_push_topic: 'Topic/channel/room name used by the push provider.',
+ magent_notify_push_token: 'Provider token/API key/password.',
+ magent_notify_push_user_key:
+ 'Provider recipient key/user key (for example Pushover user key).',
+ magent_notify_push_device:
+ 'Optional device or target override, depending on provider.',
+ magent_notify_webhook_enabled: 'Enable generic webhook notifications.',
+ magent_notify_webhook_url:
+ 'Generic webhook endpoint for custom integrations or automation flows.',
+ jellyseerr_base_url:
+ 'Base URL for your Seerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.',
- jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.',
+ jellyfin_base_url:
+ 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
- jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.',
+ jellyfin_public_url:
+ 'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
- sonarr_base_url: 'Sonarr server URL for TV tracking.',
+ sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
- radarr_base_url: 'Radarr server URL for movies.',
+ sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
+ radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
- prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
+ radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
+ prowlarr_base_url:
+ 'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.',
- qbittorrent_base_url: 'qBittorrent server URL for download status.',
+ qbittorrent_base_url:
+ 'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
- requests_poll_interval_seconds: 'How often the background checker runs.',
- requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.',
- requests_full_sync_time: 'Daily time to refresh the full request list.',
- requests_cleanup_time: 'Daily time to trim old history.',
+ requests_poll_interval_seconds:
+ 'How often Magent checks if a full refresh should run.',
+ requests_delta_sync_interval_minutes:
+ 'How often we poll for new or updated requests.',
+ requests_full_sync_time: 'Daily time to rebuild the full request cache.',
+ requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
- requests_data_source: 'Pick where Magent should read requests from.',
+ requests_data_source:
+ 'Pick where Magent should read requests from. Cache-only avoids Seerr lookups on reads.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
+ log_file_max_bytes: 'Rotate the log file when it reaches this size in bytes.',
+ log_file_backup_count: 'How many rotated log files to retain on disk.',
+ log_http_client_level:
+ 'Verbosity for per-call outbound service traffic logs from Seerr, Jellyfin, Sonarr, Radarr, and related clients.',
+ log_background_sync_level:
+ 'Verbosity for scheduled background sync progress messages.',
+ site_build_number: 'Build number shown in the account menu (auto-set from releases).',
+ site_banner_enabled: 'Enable a sitewide banner for announcements.',
+ site_banner_message: 'Short banner message for maintenance or updates.',
+ site_banner_tone: 'Visual tone for the banner.',
+ site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
+ site_login_show_local_login: 'Show the local Magent login button on the login page.',
+ site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
+ site_login_show_signup_link: 'Show the invite signup link on the login page.',
+ site_changelog: 'One update per line for the public changelog.',
+ }
+
+ const settingPlaceholders: Record = {
+ magent_application_url: 'https://magent.example.com',
+ magent_application_port: '3000',
+ magent_api_url: 'https://api.example.com or https://magent.example.com/api',
+ magent_api_port: '8000',
+ magent_bind_host: '0.0.0.0',
+ magent_proxy_base_url: 'https://proxy.example.com/magent',
+ magent_proxy_forwarded_prefix: '/magent',
+ magent_ssl_certificate_path: '/certs/fullchain.pem',
+ magent_ssl_private_key_path: '/certs/privkey.pem',
+ magent_ssl_certificate_pem: '-----BEGIN CERTIFICATE-----',
+ magent_ssl_private_key_pem: '-----BEGIN PRIVATE KEY-----',
+ magent_notify_email_smtp_host: 'smtp.office365.com',
+ magent_notify_email_smtp_port: '587',
+ magent_notify_email_smtp_username: 'notifications@example.com',
+ magent_notify_email_from_address: 'notifications@example.com',
+ magent_notify_email_from_name: 'Magent',
+ log_file_max_bytes: '20000000',
+ log_file_backup_count: '10',
+ magent_notify_discord_webhook_url: 'https://discord.com/api/webhooks/...',
+ magent_notify_telegram_bot_token: '123456789:AA...',
+ magent_notify_telegram_chat_id: '-1001234567890',
+ magent_notify_push_base_url: 'https://ntfy.example.com or https://gotify.example.com',
+ magent_notify_push_topic: 'magent-alerts',
+ magent_notify_push_device: 'iphone-zak',
+ magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
+ jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
+ jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
+ jellyfin_public_url: 'https://jelly.example.com',
+ sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
+ radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
+ prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
+ qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
}
const buildSelectOptions = (
@@ -312,23 +873,41 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return list
}
- const submit = async (event: React.FormEvent) => {
- event.preventDefault()
- setStatus(null)
+ const parseActionError = (err: unknown, fallback: string) => {
+ if (err instanceof Error && err.message) {
+ return err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
+ }
+ return fallback
+ }
+
+ const buildSettingsPayload = (items: AdminSetting[]) => {
const payload: Record = {}
- const formData = new FormData(event.currentTarget)
- for (const setting of settings) {
- const rawValue = formData.get(setting.key)
+ for (const setting of items) {
+ const rawValue = formValues[setting.key]
if (typeof rawValue !== 'string') {
continue
}
const value = rawValue.trim()
- if (value === '') {
+ if (setting.sensitive && value === '') {
continue
}
payload[setting.key] = value
}
+ return payload
+ }
+
+ const saveSettingGroup = async (
+ sectionGroup: SettingsSectionGroup,
+ options?: { successMessage?: string | null },
+ ) => {
+ setSectionFeedback((current) => {
+ const next = { ...current }
+ delete next[sectionGroup.key]
+ return next
+ })
+ setSectionSaving((current) => ({ ...current, [sectionGroup.key]: true }))
try {
+ const payload = buildSettingsPayload(sectionGroup.items)
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`, {
method: 'PUT',
@@ -339,15 +918,131 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
- setStatus('Settings saved. New values take effect immediately.')
- await loadSettings()
+ await loadSettings(new Set(sectionGroup.items.map((item) => item.key)))
+ if (options?.successMessage !== null) {
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: 'status',
+ message: options?.successMessage ?? `${sectionGroup.title} settings saved.`,
+ },
+ }))
+ }
+ return true
} catch (err) {
console.error(err)
- const message =
- err instanceof Error && err.message
- ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
- : 'Could not save settings.'
- setStatus(message)
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: 'error',
+ message: parseActionError(err, 'Could not save settings.'),
+ },
+ }))
+ return false
+ } finally {
+ setSectionSaving((current) => ({ ...current, [sectionGroup.key]: false }))
+ }
+ }
+
+ const formatServiceTestFeedback = (result: any): SectionFeedback => {
+ const name = result?.name ?? 'Service'
+ const state = String(result?.status ?? 'unknown').toLowerCase()
+ if (state === 'up') {
+ return { tone: 'status', message: `${name} connection test passed.` }
+ }
+ if (state === 'degraded') {
+ return {
+ tone: 'error',
+ message: result?.message ? `${name}: ${result.message}` : `${name} reported warnings.`,
+ }
+ }
+ if (state === 'not_configured') {
+ return { tone: 'error', message: `${name} is not fully configured yet.` }
+ }
+ return {
+ tone: 'error',
+ message: result?.message ? `${name}: ${result.message}` : `${name} connection test failed.`,
+ }
+ }
+
+ const getSectionTestLabel = (sectionKey: string) => {
+ if (sectionKey === 'magent-notify-email') {
+ return 'Send test email'
+ }
+ if (sectionKey in SERVICE_TEST_ENDPOINTS) {
+ return 'Test connection'
+ }
+ return null
+ }
+
+ const testSettingGroup = async (sectionGroup: SettingsSectionGroup) => {
+ setSectionFeedback((current) => {
+ const next = { ...current }
+ delete next[sectionGroup.key]
+ return next
+ })
+ setSectionTesting((current) => ({ ...current, [sectionGroup.key]: true }))
+ try {
+ const saved = await saveSettingGroup(sectionGroup, { successMessage: null })
+ if (!saved) {
+ return
+ }
+
+ const baseUrl = getApiBase()
+ if (sectionGroup.key === 'magent-notify-email') {
+ const recipientEmail =
+ emailTestRecipient.trim() || formValues.magent_notify_email_from_address?.trim()
+ const response = await authFetch(`${baseUrl}/admin/settings/test/email`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(
+ recipientEmail ? { recipient_email: recipientEmail } : {},
+ ),
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Email test failed')
+ }
+ const data = await response.json()
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: data?.warning ? 'error' : 'status',
+ message: data?.warning
+ ? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? 'the configured mailbox'}, but delivery is not guaranteed. ${data.warning}`
+ : `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
+ },
+ }))
+ return
+ }
+
+ const serviceKey = SERVICE_TEST_ENDPOINTS[sectionGroup.key]
+ if (!serviceKey) {
+ return
+ }
+ const response = await authFetch(`${baseUrl}/status/services/${serviceKey}/test`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Connection test failed')
+ }
+ const data = await response.json()
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: formatServiceTestFeedback(data),
+ }))
+ } catch (err) {
+ console.error(err)
+ setSectionFeedback((current) => ({
+ ...current,
+ [sectionGroup.key]: {
+ tone: 'error',
+ message: parseActionError(err, 'Could not run test.'),
+ },
+ }))
+ } finally {
+ setSectionTesting((current) => ({ ...current, [sectionGroup.key]: false }))
}
}
@@ -376,6 +1071,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const syncRequests = async () => {
setRequestsSyncStatus(null)
+ setRequestsSync({
+ status: 'running',
+ stored: 0,
+ total: 0,
+ skip: 0,
+ message: 'Starting sync',
+ })
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
@@ -400,6 +1102,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const syncRequestsDelta = async () => {
setRequestsSyncStatus(null)
+ setRequestsSync({
+ status: 'running',
+ stored: 0,
+ total: 0,
+ skip: 0,
+ message: 'Starting delta sync',
+ })
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
@@ -424,6 +1133,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null)
+ setArtworkPrefetch({
+ status: 'running',
+ processed: 0,
+ total: 0,
+ message: 'Starting artwork caching',
+ })
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
@@ -446,8 +1161,150 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}
+ const prefetchArtworkMissing = async () => {
+ setArtworkPrefetchStatus(null)
+ setArtworkPrefetch({
+ status: 'running',
+ processed: 0,
+ total: 0,
+ message: 'Starting missing artwork caching',
+ })
+ try {
+ const baseUrl = getApiBase()
+ const response = await authFetch(
+ `${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`,
+ { method: 'POST' }
+ )
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(text || 'Missing artwork prefetch failed')
+ }
+ const data = await response.json()
+ setArtworkPrefetch(data?.prefetch ?? null)
+ setArtworkPrefetchStatus('Missing artwork caching started.')
+ } catch (err) {
+ console.error(err)
+ const message =
+ err instanceof Error && err.message
+ ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
+ : 'Could not cache missing artwork.'
+ setArtworkPrefetchStatus(message)
+ }
+ }
+
useEffect(() => {
- if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
+ const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
+ if (!shouldSubscribe) {
+ setLiveStreamConnected(false)
+ return
+ }
+ const token = getToken()
+ if (!token) {
+ setLiveStreamConnected(false)
+ return
+ }
+
+ const baseUrl = getApiBase()
+ let closed = false
+ let source: EventSource | null = null
+
+ const connect = async () => {
+ try {
+ const streamToken = await getEventStreamToken()
+ if (closed) return
+ const params = new URLSearchParams()
+ params.set('stream_token', streamToken)
+ if (showLogs) {
+ params.set('include_logs', '1')
+ params.set('log_lines', String(logsCount))
+ }
+ const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
+ source = new EventSource(streamUrl)
+
+ source.onopen = () => {
+ if (closed) return
+ setLiveStreamConnected(true)
+ }
+
+ source.onmessage = (event) => {
+ if (closed) return
+ setLiveStreamConnected(true)
+ try {
+ const payload = JSON.parse(event.data)
+ if (!payload || payload.type !== 'admin_live_state') {
+ return
+ }
+
+ const rawSync =
+ payload.requestsSync && typeof payload.requestsSync === 'object'
+ ? payload.requestsSync
+ : null
+ const nextSync = rawSync?.status === 'idle' ? null : rawSync
+ const prevSync = requestsSyncRef.current
+ requestsSyncRef.current = nextSync
+ setRequestsSync(nextSync)
+ if (
+ prevSync?.status === 'running' &&
+ nextSync?.status &&
+ nextSync.status !== 'running'
+ ) {
+ setRequestsSyncStatus(nextSync.message || 'Sync complete.')
+ }
+
+ const rawArtwork =
+ payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
+ ? payload.artworkPrefetch
+ : null
+ const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
+ const prevArtwork = artworkPrefetchRef.current
+ artworkPrefetchRef.current = nextArtwork
+ setArtworkPrefetch(nextArtwork)
+ if (
+ prevArtwork?.status === 'running' &&
+ nextArtwork?.status &&
+ nextArtwork.status !== 'running'
+ ) {
+ setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
+ if (showArtworkExtras) {
+ void loadArtworkSummary()
+ }
+ }
+
+ if (payload.logs && typeof payload.logs === 'object') {
+ if (Array.isArray(payload.logs.lines)) {
+ setLogsLines(payload.logs.lines)
+ setLogsStatus(null)
+ } else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
+ setLogsStatus(payload.logs.error)
+ }
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ source.onerror = () => {
+ if (closed) return
+ setLiveStreamConnected(false)
+ }
+ } catch (err) {
+ if (closed) return
+ console.error(err)
+ setLiveStreamConnected(false)
+ }
+ }
+
+ void connect()
+
+ return () => {
+ closed = true
+ setLiveStreamConnected(false)
+ source?.close()
+ }
+ }, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
+
+ useEffect(() => {
+ if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
return
}
let active = true
@@ -463,6 +1320,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(data?.prefetch ?? null)
if (data?.prefetch?.status && data.prefetch.status !== 'running') {
setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
+ void loadArtworkSummary()
}
} catch (err) {
console.error(err)
@@ -472,7 +1330,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
- }, [artworkPrefetch?.status])
+ }, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -482,10 +1340,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(null)
}, 5000)
return () => clearTimeout(timer)
- }, [artworkPrefetch?.status])
+ }, [artworkPrefetch])
useEffect(() => {
- if (!requestsSync || requestsSync.status !== 'running') {
+ if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
return
}
let active = true
@@ -510,7 +1368,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
- }, [requestsSync?.status])
+ }, [liveStreamConnected, requestsSync])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
@@ -520,9 +1378,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRequestsSync(null)
}, 5000)
return () => clearTimeout(timer)
- }, [requestsSync?.status])
+ }, [requestsSync])
- const loadLogs = async () => {
+ const loadLogs = useCallback(async () => {
setLogsStatus(null)
try {
const baseUrl = getApiBase()
@@ -547,21 +1405,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
: 'Could not load logs.'
setLogsStatus(message)
}
- }
+ }, [logsCount])
useEffect(() => {
if (!showLogs) {
return
}
+ if (liveStreamConnected) {
+ return
+ }
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
- }, [logsCount, showLogs])
+ }, [liveStreamConnected, loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
+ setCacheLoading(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(
@@ -584,6 +1446,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.'
setCacheStatus(message)
+ } finally {
+ setCacheLoading(false)
}
}
@@ -630,7 +1494,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
- 'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
+ 'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Seerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
@@ -639,7 +1503,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
try {
const baseUrl = getApiBase()
- setMaintenanceStatus('Flushing database...')
+ setMaintenanceStatus('Running nuclear flush...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST',
})
@@ -647,12 +1511,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await flushResponse.text()
throw new Error(text || 'Flush failed')
}
- setMaintenanceStatus('Database flushed. Starting re-sync...')
+ const flushData = await flushResponse.json()
+ const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
+ setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
+ const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
+ method: 'POST',
+ })
+ if (!usersResyncResponse.ok) {
+ const text = await usersResyncResponse.text()
+ throw new Error(text || 'User resync failed')
+ }
+ const usersResyncData = await usersResyncResponse.json()
+ setMaintenanceStatus(
+ `Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
+ )
await syncRequests()
- setMaintenanceStatus('Database flushed. Re-sync running now.')
+ setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
} catch (err) {
console.error(err)
- setMaintenanceStatus('Flush + resync failed.')
+ setMaintenanceStatus('Nuclear flush + resync failed.')
} finally {
setMaintenanceBusy(false)
}
@@ -677,6 +1554,117 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}
+ const cacheSourceLabel =
+ formValues.requests_data_source === 'always_js'
+ ? 'Seerr direct'
+ : formValues.requests_data_source === 'prefer_cache'
+ ? 'Saved requests only'
+ : 'Saved requests only'
+ const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
+ const maintenanceRail = showMaintenance ? (
+
+
+
Maintenance
+
Admin tools
+
Repair, cleanup, diagnostics, and nuclear resync are grouped into a single operating page.
+
+
+
Runtime
+
Service state
+
+
+ Maintenance job
+ {maintenanceBusy ? 'Running' : 'Idle'}
+
+
+ Live updates
+ {liveStreamConnected ? 'Connected' : 'Polling'}
+
+
+ Log lines in view
+ {logsLines.length}
+
+
+ Last tool status
+ {maintenanceStatus || 'Idle'}
+
+
+
+
+ ) : undefined
+ const cacheRail = showCacheExtras ? (
+
+
+
Cache control
+
Saved requests
+
Load and inspect cached request entries from the right rail.
+
+
+ Data source
+ {cacheSourceLabel}
+
+
+ Refresh TTL
+ {cacheTtlLabel} min
+
+
+ Rows loaded
+ {cacheRows.length}
+
+
+ Live updates
+ {liveStreamConnected ? 'Connected' : 'Polling'}
+
+
+
+ Rows to load
+ setCacheCount(Number(event.target.value))}
+ >
+ 25
+ 50
+ 100
+ 200
+
+
+
+ {cacheLoading ? (
+ <>
+
+ Loading saved requests
+ >
+ ) : (
+ 'Load saved requests'
+ )}
+
+ {cacheStatus &&
{cacheStatus}
}
+
+
+
Artwork
+
Cache stats
+
+
+ Missing artwork
+ {artworkSummary?.missing_artwork ?? '--'}
+
+
+ Cache size
+ {formatBytes(artworkSummary?.cache_bytes)}
+
+
+ Cached files
+ {artworkSummary?.cache_files ?? '--'}
+
+
+ Mode
+ {artworkSummary?.cache_mode ?? '--'}
+
+
+
+
+ ) : undefined
+
if (loading) {
return Loading admin settings...
}
@@ -685,20 +1673,24 @@ export default function SettingsPage({ section }: SettingsPageProps) {
router.push('/admin')}>
Back to settings
}
>
+ {status && {status}
}
{settingsSections.length > 0 ? (
-