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"" + ) + + +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"\"{html.escape(app_name)}\"" + ) + 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"{warning_block}" + "" + "" + "" + "
    " + "" + "" + 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"{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"" + f"" + "" + "
    {html.escape(footer)}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"" + f"" + f"" + f"" + "
    Kind{_clean_text(payload.get('kind'))}
    Status{_clean_text(payload.get('status'))}
    Priority{_clean_text(payload.get('priority'))}
    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'} +
    +
    + + + {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 ? ( -
    +
    {settingsSections .filter(shouldRenderSection) .map((sectionGroup) => ( -
    +
    -

    {sectionGroup.title}

    +

    + {sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title} +

    {sectionGroup.key === 'sonarr' && ( )} - {(showArtworkExtras && sectionGroup.key === 'artwork') || - (showCacheExtras && sectionGroup.key === 'cache') ? ( - + {showArtworkExtras && sectionGroup.key === 'artwork' ? ( +
    + + +
    ) : null} {showRequestsExtras && sectionGroup.key === 'requests' && ( -
    - - +
    +
    + + +
    +
    + Full refresh rebuilds the entire cache. Delta sync only checks new or updated + requests. +
    )}
    - {SECTION_DESCRIPTIONS[sectionGroup.key] && ( -

    {SECTION_DESCRIPTIONS[sectionGroup.key]}

    + {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && + (!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && ( +

    + {sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]} +

    + )} + {section === 'general' && sectionGroup.key === 'magent-runtime' && ( +
    + Runtime host/port and SSL values are configuration settings. Container/process + restarts may still be required before bind/port changes take effect. +
    )} {sectionGroup.key === 'sonarr' && sonarrError && (
    {sonarrError}
    @@ -743,17 +1758,48 @@ export default function SettingsPage({ section }: SettingsPageProps) { {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
    {jellyfinSyncStatus}
    )} - {((showArtworkExtras && sectionGroup.key === 'artwork') || - (showCacheExtras && sectionGroup.key === 'cache')) && - artworkPrefetchStatus && ( + {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
    {artworkPrefetchStatus}
    )} + {showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && ( +
    {artworkSummaryStatus}
    + )} + {showArtworkExtras && sectionGroup.key === 'artwork' && ( +
    +
    + Missing artwork +

    {artworkSummary?.missing_artwork ?? '--'}

    +
    Requests missing poster/backdrop or cache files.
    +
    +
    + Artwork cache size +

    {formatBytes(artworkSummary?.cache_bytes)}

    +
    + {artworkSummary?.cache_files ?? '--'} cached files +
    +
    +
    + Total requests +

    {artworkSummary?.total_requests ?? '--'}

    +
    Requests currently tracked in cache.
    +
    +
    + Cache mode +

    {artworkSummary?.cache_mode ?? '--'}

    +
    Artwork setting applied to posters/backdrops.
    +
    +
    + )} {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
    {requestsSyncStatus}
    )} - {((showArtworkExtras && sectionGroup.key === 'artwork') || - (showCacheExtras && sectionGroup.key === 'cache')) && - artworkPrefetch && ( + {showRequestsExtras && sectionGroup.key === 'requests' && ( +
    + Full refresh checks only decide when to run a full refresh. The delta sync interval + polls for new or updated requests. +
    + )} + {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
    Status: {artworkPrefetch.status} @@ -763,22 +1809,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
    @@ -795,22 +1835,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
    @@ -826,6 +1860,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { const isRadarrProfile = setting.key === 'radarr_quality_profile_id' const isRadarrRoot = setting.key === 'radarr_root_folder' const isBoolSetting = BOOL_SETTINGS.has(setting.key) + const isUrlSetting = URL_SETTINGS.has(setting.key) + const inputPlaceholder = setting.sensitive && setting.isSet + ? 'Configured (enter to replace)' + : settingPlaceholders[setting.key] ?? '' if (isBoolSetting) { return ( ) } + if ( + setting.key === 'log_http_client_level' || + setting.key === 'log_background_sync_level' + ) { + return ( + + ) + } if (setting.key === 'artwork_cache_mode') { return ( ) } + if (setting.key === 'site_banner_tone') { + return ( + + ) + } + if (setting.key === 'magent_notify_push_provider') { + return ( + + ) + } if ( setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time' @@ -1033,10 +2158,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) } - if ( - setting.key === 'requests_delta_sync_interval_minutes' || - setting.key === 'requests_cleanup_days' - ) { + if (NUMBER_SETTINGS.has(setting.key)) { return ( ) } + if (TEXTAREA_SETTINGS.has(setting.key)) { + const isPemField = + setting.key === 'magent_ssl_certificate_pem' || + setting.key === 'magent_ssl_private_key_pem' + const shouldSpanFull = isPemField || setting.key === 'site_banner_message' + return ( +