diff --git a/.build_number b/.build_number index 9db278b..31d0323 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602261731 +2602262030 diff --git a/backend/app/auth.py b/backend/app/auth.py index 479973d..3e8f203 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -38,11 +38,18 @@ def _extract_client_ip(request: Request) -> str: return "unknown" -def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]: +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: @@ -79,16 +86,24 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non def get_current_user_event_stream(request: Request) -> Dict[str, Any]: - """EventSource cannot send Authorization headers, so allow a query token here only.""" + """EventSource cannot send Authorization headers, so allow a short-lived stream token via query.""" token = None + stream_query_token = None auth_header = request.headers.get("authorization", "") if auth_header.lower().startswith("bearer "): token = auth_header.split(" ", 1)[1].strip() if not token: - token = request.query_params.get("access_token") - if not token: + stream_query_token = request.query_params.get("stream_token") + if not token and not stream_query_token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") - return _load_current_user_from_token(token, None) + if token: + # Allow standard bearer tokens in Authorization for non-browser EventSource clients. + return _load_current_user_from_token(token, None) + 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]: diff --git a/backend/app/build_info.py b/backend/app/build_info.py index e2e705c..d117f32 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602261731" +BUILD_NUMBER = "2602262030" CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' diff --git a/backend/app/config.py b/backend/app/config.py index 743f79c..d71cf1e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,6 +11,16 @@ class Settings(BaseSettings): 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")) 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") + ) admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) @@ -51,6 +61,126 @@ class Settings(BaseSettings): ) 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_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") + ) + jellyseerr_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") ) diff --git a/backend/app/main.py b/backend/app/main.py index a282c3a..ac4940b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ import asyncio -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from .config import settings @@ -24,7 +24,12 @@ from .services.jellyfin_sync import run_daily_jellyfin_sync from .logging_config import configure_logging from .runtime import get_runtime_settings -app = FastAPI(title=settings.app_name) +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, @@ -35,6 +40,22 @@ app.add_middleware( ) +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + 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'", + ) + return response + + @app.get("/health") async def health() -> dict: return {"status": "ok"} diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 98bfd0d..a1c2395 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -90,6 +90,14 @@ logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" 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", @@ -99,6 +107,11 @@ SENSITIVE_KEYS = { } 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", @@ -109,6 +122,44 @@ URL_SETTING_KEYS = { } 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", diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 1722277..a1f4f05 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,9 +1,12 @@ from datetime import datetime, timedelta, timezone +from collections import defaultdict, deque import secrets import string +import time +from threading import Lock import httpx -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, Request from fastapi.security import OAuth2PasswordRequestForm from ..db import ( @@ -34,7 +37,9 @@ from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient from ..clients.jellyseerr import JellyseerrClient from ..security import create_access_token, verify_password +from ..security import create_stream_token from ..auth import get_current_user +from ..config import settings from ..services.user_cache import ( build_jellyseerr_candidate_map, get_cached_jellyseerr_users, @@ -44,6 +49,85 @@ from ..services.user_cache import ( router = APIRouter(prefix="/auth", tags=["auth"]) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" +STREAM_TOKEN_TTL_SECONDS = 120 + +_LOGIN_RATE_LOCK = Lock() +_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) +_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque) + + +def _auth_client_ip(request: Request) -> str: + 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 _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None: + cutoff = now - window_seconds + while bucket and bucket[0] < cutoff: + bucket.popleft() + + +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) + + +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: + 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 _normalize_username(value: str) -> str: @@ -361,9 +445,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s @router.post("/login") -async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: +async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: + _enforce_login_rate_limit(request, form_data.username) 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": raise HTTPException( @@ -372,6 +458,7 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: ) _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"]) return { "access_token": token, @@ -381,7 +468,8 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: @router.post("/jellyfin/login") -async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: +async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: + _enforce_login_rate_limit(request, form_data.username) runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): @@ -394,6 +482,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di _assert_user_can_login(user) if user and _has_valid_jellyfin_cache(user, password): token = create_access_token(username, "user") + _clear_login_failures(request, username) set_last_login(username) return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} try: @@ -401,6 +490,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if not isinstance(response, dict) or not response.get("User"): + _record_login_failure(request, username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") user = get_user_by_username(username) @@ -423,12 +513,14 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di if matched_id is not None: set_user_jellyseerr_id(username, matched_id) token = create_access_token(username, "user") + _clear_login_failures(request, username) set_last_login(username) return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} @router.post("/jellyseerr/login") -async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: +async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: + _enforce_login_rate_limit(request, form_data.username) runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if not client.configured(): @@ -439,6 +531,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if not isinstance(response, dict): + _record_login_failure(request, form_data.username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") jellyseerr_user_id = _extract_jellyseerr_user_id(response) create_user_if_missing( @@ -453,6 +546,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> if jellyseerr_user_id is not None: set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) token = create_access_token(form_data.username, "user") + _clear_login_failures(request, form_data.username) set_last_login(form_data.username) return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} @@ -462,6 +556,20 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict: return current_user +@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()) diff --git a/backend/app/routers/feedback.py b/backend/app/routers/feedback.py index d383724..d523e43 100644 --- a/backend/app/routers/feedback.py +++ b/backend/app/routers/feedback.py @@ -11,7 +11,10 @@ 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") diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 3fd03e6..8e094cf 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -2,6 +2,8 @@ from .config import settings from .db import get_settings_overrides _INT_FIELDS = { + "magent_application_port", + "magent_api_port", "sonarr_quality_profile_id", "radarr_quality_profile_id", "jwt_exp_minutes", @@ -9,8 +11,20 @@ _INT_FIELDS = { "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", } diff --git a/backend/app/security.py b/backend/app/security.py index 5632c8b..de971a8 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -18,11 +18,30 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return _pwd_context.verify(plain_password, hashed_password) +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]: diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index eac4153..fbcdda7 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -2,7 +2,7 @@ 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' type AdminSetting = { @@ -19,6 +19,7 @@ type ServiceOptions = { } const SECTION_LABELS: Record = { + magent: 'Magent', jellyseerr: 'Jellyseerr', jellyfin: 'Jellyfin', artwork: 'Artwork cache', @@ -32,9 +33,34 @@ const SECTION_LABELS: Record = { site: 'Site', } -const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) -const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) +const BOOL_SETTINGS = new Set([ + 'jellyfin_sync_to_arr', + 'site_banner_enabled', + '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', @@ -43,9 +69,20 @@ const URL_SETTINGS = new Set([ 'prowlarr_base_url', 'qbittorrent_base_url', ]) +const NUMBER_SETTINGS = new Set([ + 'magent_application_port', + 'magent_api_port', + 'magent_notify_email_smtp_port', + 'requests_sync_ttl_minutes', + 'requests_poll_interval_seconds', + 'requests_delta_sync_interval_minutes', + 'requests_cleanup_days', +]) const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const SECTION_DESCRIPTIONS: Record = { + magent: + 'Application-level Magent settings including proxy, binding, TLS, and notification channels.', jellyseerr: 'Connect the request system where users submit content.', jellyfin: 'Control Jellyfin login and availability checks.', artwork: 'Cache posters/backdrops and review artwork coverage.', @@ -60,6 +97,7 @@ const SECTION_DESCRIPTIONS: Record = { } const SETTINGS_SECTION_MAP: Record = { + magent: 'magent', jellyseerr: 'jellyseerr', jellyfin: 'jellyfin', artwork: null, @@ -74,7 +112,151 @@ const SETTINGS_SECTION_MAP: Record = { 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 SETTING_LABEL_OVERRIDES: Record = { + 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', +} + const labelFromKey = (key: string) => + SETTING_LABEL_OVERRIDES[key] ?? key .replaceAll('_', ' ') .replace('base url', 'URL') @@ -115,6 +297,13 @@ type SettingsPageProps = { section: string } +type SettingsSectionGroup = { + key: string + title: string + items: AdminSetting[] + description?: string +} + export default function SettingsPage({ section }: SettingsPageProps) { const router = useRouter() const [settings, setSettings] = useState([]) @@ -308,26 +497,56 @@ export default function SettingsPage({ section }: SettingsPageProps) { } const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key)) - const settingsSections = isCacheSection + const settingsSections: SettingsSectionGroup[] = isCacheSection ? [ { key: 'cache', title: 'Cache control', items: cacheSettings }, { key: 'artwork', title: 'Artwork cache', items: artworkSettings }, ] - : visibleSections.map((sectionKey) => ({ - key: sectionKey, - title: SECTION_LABELS[sectionKey] ?? sectionKey, - items: (() => { - const sectionItems = groupedSettings[sectionKey] ?? [] - const filtered = - sectionKey === 'requests' || sectionKey === 'artwork' - ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key)) - : sectionItems - if (sectionKey === 'requests') { - return sortByOrder(filtered, requestSettingOrder) + : section === 'magent' + ? (() => { + const magentItems = groupedSettings.magent ?? [] + const byKey = new Map(magentItems.map((item) => [item.key, item])) + const used = new Set() + const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.map((group) => { + const items = group.keys + .map((key) => byKey.get(key)) + .filter((item): item is AdminSetting => Boolean(item)) + for (const item of items) { + used.add(item.key) + } + return { + key: group.key, + title: group.title, + description: group.description, + items, + } + }) + const remaining = magentItems.filter((item) => !used.has(item.key)) + if (remaining.length) { + groups.push({ + key: 'magent-other', + title: 'Additional Magent settings', + description: 'Uncategorized Magent settings.', + items: remaining, + }) } - return filtered - })(), - })) + return groups + })() + : visibleSections.map((sectionKey) => ({ + key: sectionKey, + title: SECTION_LABELS[sectionKey] ?? sectionKey, + items: (() => { + const sectionItems = groupedSettings[sectionKey] ?? [] + const filtered = + sectionKey === 'requests' || sectionKey === 'artwork' + ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key)) + : sectionItems + if (sectionKey === 'requests') { + return sortByOrder(filtered, requestSettingOrder) + } + return filtered + })(), + })) const showLogs = section === 'logs' const showMaintenance = section === 'maintenance' const showRequestsExtras = section === 'requests' @@ -350,6 +569,65 @@ export default function SettingsPage({ section }: SettingsPageProps) { }, [artworkPrefetch]) const settingDescriptions: Record = { + magent_application_url: + 'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).', + magent_application_port: + 'Preferred frontend/UI port for local or direct-hosted deployments.', + magent_api_url: + 'Canonical public URL for the Magent API when it differs from the app URL.', + magent_api_port: 'Preferred API port for local or direct-hosted deployments.', + magent_bind_host: + 'Host/IP to bind the application services to when running without an external process manager.', + magent_proxy_enabled: + 'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.', + magent_proxy_base_url: + 'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.', + magent_proxy_trust_forwarded_headers: + 'Trust X-Forwarded-* headers from your reverse proxy.', + magent_proxy_forwarded_prefix: + 'Optional path prefix added by your proxy (example: /magent).', + magent_ssl_bind_enabled: + 'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).', + magent_ssl_certificate_path: + 'Path to the TLS certificate file on disk (PEM).', + magent_ssl_private_key_path: + 'Path to the TLS private key file on disk (PEM).', + magent_ssl_certificate_pem: + 'Paste the TLS certificate PEM if you want Magent to store it directly.', + magent_ssl_private_key_pem: + 'Paste the TLS private key PEM if you want Magent to store it directly.', + magent_notify_enabled: + 'Master switch for Magent notifications. Individual provider toggles still apply.', + magent_notify_email_enabled: 'Enable SMTP email notifications.', + magent_notify_email_smtp_host: 'SMTP server hostname or IP.', + magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).', + magent_notify_email_smtp_username: 'SMTP account username.', + magent_notify_email_smtp_password: 'SMTP account password or app password.', + magent_notify_email_from_address: 'Sender email address used by Magent.', + magent_notify_email_from_name: 'Sender display name shown to recipients.', + magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.', + magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).', + magent_notify_discord_enabled: 'Enable Discord webhook notifications.', + magent_notify_discord_webhook_url: + 'Discord channel webhook URL used for notifications and optional feedback routing.', + magent_notify_telegram_enabled: 'Enable Telegram notifications.', + magent_notify_telegram_bot_token: 'Bot token from BotFather.', + magent_notify_telegram_chat_id: + 'Default Telegram chat/group/user ID for notifications.', + magent_notify_push_enabled: 'Enable generic push notifications.', + magent_notify_push_provider: + 'Push backend to target (ntfy, gotify, pushover, webhook, etc.).', + magent_notify_push_base_url: + 'Base URL for your push provider (for example ntfy/gotify server URL).', + magent_notify_push_topic: 'Topic/channel/room name used by the push provider.', + magent_notify_push_token: 'Provider token/API key/password.', + magent_notify_push_user_key: + 'Provider recipient key/user key (for example Pushover user key).', + magent_notify_push_device: + 'Optional device or target override, depending on provider.', + magent_notify_webhook_enabled: 'Enable generic webhook notifications.', + magent_notify_webhook_url: + 'Generic webhook endpoint for custom integrations or automation flows.', jellyseerr_base_url: 'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.', jellyseerr_api_key: 'API key used to read requests and status.', @@ -397,6 +675,29 @@ export default function SettingsPage({ section }: SettingsPageProps) { } 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', + 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', @@ -599,83 +900,101 @@ export default function SettingsPage({ section }: SettingsPageProps) { } const baseUrl = getApiBase() - const params = new URLSearchParams() - params.set('access_token', token) - if (showLogs) { - params.set('include_logs', '1') - params.set('log_lines', String(logsCount)) - } - const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}` let closed = false - const source = new EventSource(streamUrl) + let source: EventSource | null = null - source.onopen = () => { - if (closed) return - setLiveStreamConnected(true) - } - - source.onmessage = (event) => { - if (closed) return - setLiveStreamConnected(true) + const connect = async () => { try { - const payload = JSON.parse(event.data) - if (!payload || payload.type !== 'admin_live_state') { - return + 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) } - 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.') - } + source.onmessage = (event) => { + if (closed) return + setLiveStreamConnected(true) + try { + const payload = JSON.parse(event.data) + if (!payload || payload.type !== 'admin_live_state') { + return + } - 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() + 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) } } - 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) - } + source.onerror = () => { + if (closed) return + setLiveStreamConnected(false) } } catch (err) { + if (closed) return console.error(err) + setLiveStreamConnected(false) } } - source.onerror = () => { - if (closed) return - setLiveStreamConnected(false) - } + void connect() return () => { closed = true setLiveStreamConnected(false) - source.close() + source?.close() } }, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras]) @@ -1000,8 +1319,17 @@ export default function SettingsPage({ section }: SettingsPageProps) { )} - {SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && ( -

{SECTION_DESCRIPTIONS[sectionGroup.key]}

+ {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && + (!settingsSection || section === 'magent') && ( +

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

+ )} + {section === 'magent' && 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}
@@ -1339,6 +1667,35 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) } + if (setting.key === 'magent_notify_push_provider') { + return ( + + ) + } if ( setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time' @@ -1365,10 +1722,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 (