Compare commits

...

4 Commits

40 changed files with 5168 additions and 325 deletions

View File

@@ -1 +1 @@
2702261153 0103262231

17
.gitattributes vendored Normal file
View File

@@ -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

View File

@@ -1,4 +1,4 @@
FROM node:20-slim AS frontend-builder FROM node:24-slim AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -6,8 +6,8 @@ ENV NODE_ENV=production \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \ BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api NEXT_PUBLIC_API_BASE=/api
COPY frontend/package.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm install RUN npm ci --include=dev
COPY frontend/app ./app COPY frontend/app ./app
COPY frontend/public ./public COPY frontend/public ./public
@@ -17,7 +17,7 @@ COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build RUN npm run build
FROM python:3.12-slim FROM python:3.14-slim
WORKDIR /app WORKDIR /app
@@ -27,7 +27,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \ && apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -1,10 +1,10 @@
# Magent # 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 ## 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. 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. 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. 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. - Request search by title/year or request ID.
- Recent requests list with posters and status. - 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. - Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.). - Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders. - 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 ### No recent requests
- Confirm Jellyseerr credentials in Settings. - Confirm Seerr credentials in Settings.
- Run a full sync from Settings -> Requests. - Run a full sync from Settings -> Requests.
### Docker images not updating ### Docker images not updating

View File

@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
if snapshot.state == NormalizedState.requested: if snapshot.state == NormalizedState.requested:
root_cause = "approval" root_cause = "approval"
summary = "The request is waiting for approval in Jellyseerr." summary = "The request is waiting for approval in Seerr."
recommendations.append( recommendations.append(
TriageRecommendation( TriageRecommendation(
action_id="wait_for_approval", action_id="wait_for_approval",
title="Ask an admin to approve the request", 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", risk="low",
) )
) )

View File

@@ -1,4 +1,7 @@
BUILD_NUMBER = "2702261314" BUILD_NUMBER = "0103262231"
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' 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 Seerr 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 Seerr (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'

View File

@@ -1,11 +1,16 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import logging
import time
import httpx import httpx
from ..logging_config import sanitize_headers, sanitize_value
class ApiClient: class ApiClient:
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None): def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
self.base_url = base_url.rstrip("/") if base_url else None self.base_url = base_url.rstrip("/") if base_url else None
self.api_key = api_key self.api_key = api_key
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.base_url) return bool(self.base_url)
@@ -13,42 +18,66 @@ class ApiClient:
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key} if self.api_key else {} 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]: 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: if not self.base_url:
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
return None return None
url = f"{self.base_url}{path}" url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client: started_at = time.perf_counter()
response = await client.get(url, headers=self.headers(), params=params) self.logger.debug(
response.raise_for_status() "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() return response.json()
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]: async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url: return await self._request("POST", path, payload=payload)
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()
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url: return await self._request("PUT", path, payload=payload)
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.put(url, headers=self.headers(), json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def delete(self, path: str) -> Optional[Any]: async def delete(self, path: str) -> Optional[Any]:
if not self.base_url: return await self._request("DELETE", path)
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.delete(url, headers=self.headers())
response.raise_for_status()
if not response.content:
return None
return response.json()

View File

@@ -1,4 +1,5 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import httpx
from .base import ApiClient 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]]: async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/movie/{tmdb_id}") return await self.get(f"/api/v1/movie/{tmdb_id}")
@@ -50,3 +48,14 @@ class JellyseerrClient(ApiClient):
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]: async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.delete(f"/api/v1/user/{user_id}") 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

View File

@@ -25,6 +25,18 @@ class Settings(BaseSettings):
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) 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: 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( requests_sync_ttl_minutes: int = Field(
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES") default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
) )

View File

@@ -210,6 +210,7 @@ def init_db() -> None:
use_count INTEGER NOT NULL DEFAULT 0, use_count INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT, expires_at TEXT,
recipient_email TEXT,
created_by TEXT, created_by TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@@ -362,6 +363,10 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT") conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
pass
try: try:
conn.execute( conn.execute(
""" """
@@ -594,7 +599,20 @@ def create_user_if_missing(
created_at if invited_by_code else None, 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]]: def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
@@ -703,7 +721,7 @@ def get_all_users() -> list[Dict[str, Any]]:
} }
) )
# Admin user management uses Jellyfin as the source of truth for non-admin # Admin user management uses Jellyfin as the source of truth for non-admin
# user objects. Jellyseerr rows are treated as enrichment-only and hidden # user objects. Seerr rows are treated as enrichment-only and hidden
# from admin/user-management views to avoid duplicate accounts in the UI. # from admin/user-management views to avoid duplicate accounts in the UI.
def _provider_rank(user: Dict[str, Any]) -> int: def _provider_rank(user: Dict[str, Any]) -> int:
provider = str(user.get("auth_provider") or "local").strip().lower() provider = str(user.get("auth_provider") or "local").strip().lower()
@@ -804,6 +822,7 @@ def set_user_blocked(username: str, blocked: bool) -> None:
""", """,
(1 if blocked else 0, username), (1 if blocked else 0, username),
) )
logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
def delete_user_by_username(username: str) -> bool: def delete_user_by_username(username: str) -> bool:
@@ -814,7 +833,9 @@ def delete_user_by_username(username: str) -> bool:
""", """,
(username,), (username,),
) )
return cursor.rowcount > 0 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: def delete_user_activity_by_username(username: str) -> int:
@@ -850,6 +871,7 @@ def set_user_role(username: str, role: str) -> None:
""", """,
(role, username), (role, username),
) )
logger.info("user role updated username=%s role=%s", username, role)
def set_user_auto_search_enabled(username: str, enabled: bool) -> None: def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
@@ -860,6 +882,7 @@ def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
""", """,
(1 if enabled else 0, username), (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: def set_user_invite_management_enabled(username: str, enabled: bool) -> None:
@@ -870,6 +893,7 @@ def set_user_invite_management_enabled(username: str, enabled: bool) -> None:
""", """,
(1 if enabled else 0, username), (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: def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
@@ -891,6 +915,11 @@ def set_invite_management_enabled_for_non_admin_users(enabled: bool) -> int:
""", """,
(1 if enabled else 0,), (1 if enabled else 0,),
) )
logger.info(
"bulk invite-management updated non_admin_users=%s enabled=%s",
cursor.rowcount,
enabled,
)
return cursor.rowcount return cursor.rowcount
@@ -902,6 +931,7 @@ def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
""", """,
(profile_id, username), (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: def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
@@ -912,6 +942,7 @@ def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
""", """,
(expires_at, username), (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]: def _row_to_user_profile(row: Any) -> Dict[str, Any]:
@@ -1063,9 +1094,10 @@ def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
"use_count": use_count, "use_count": use_count,
"enabled": bool(row[8]), "enabled": bool(row[8]),
"expires_at": expires_at, "expires_at": expires_at,
"created_by": row[10], "recipient_email": row[10],
"created_at": row[11], "created_by": row[11],
"updated_at": row[12], "created_at": row[12],
"updated_at": row[13],
"is_expired": is_expired, "is_expired": is_expired,
"remaining_uses": remaining_uses, "remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0), "is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
@@ -1077,7 +1109,7 @@ def list_signup_invites() -> list[Dict[str, Any]]:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
""" """
@@ -1090,7 +1122,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
WHERE id = ? WHERE id = ?
""", """,
@@ -1106,7 +1138,7 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
WHERE code = ? COLLATE NOCASE WHERE code = ? COLLATE NOCASE
""", """,
@@ -1127,6 +1159,7 @@ def create_signup_invite(
max_uses: Optional[int] = None, max_uses: Optional[int] = None,
enabled: bool = True, enabled: bool = True,
expires_at: Optional[str] = None, expires_at: Optional[str] = None,
recipient_email: Optional[str] = None,
created_by: Optional[str] = None, created_by: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
@@ -1135,9 +1168,9 @@ def create_signup_invite(
""" """
INSERT INTO signup_invites ( INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
""", """,
( (
code, code,
@@ -1148,12 +1181,25 @@ def create_signup_invite(
max_uses, max_uses,
1 if enabled else 0, 1 if enabled else 0,
expires_at, expires_at,
recipient_email,
created_by, created_by,
timestamp, timestamp,
timestamp, timestamp,
), ),
) )
invite_id = int(cursor.lastrowid) 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) invite = get_signup_invite_by_id(invite_id)
if not invite: if not invite:
raise RuntimeError("Invite creation failed") raise RuntimeError("Invite creation failed")
@@ -1171,6 +1217,7 @@ def update_signup_invite(
max_uses: Optional[int], max_uses: Optional[int],
enabled: bool, enabled: bool,
expires_at: Optional[str], expires_at: Optional[str],
recipient_email: Optional[str],
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
@@ -1178,7 +1225,7 @@ def update_signup_invite(
""" """
UPDATE signup_invites UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, updated_at = ? enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ? WHERE id = ?
""", """,
( (
@@ -1190,6 +1237,7 @@ def update_signup_invite(
max_uses, max_uses,
1 if enabled else 0, 1 if enabled else 0,
expires_at, expires_at,
recipient_email,
timestamp, timestamp,
invite_id, invite_id,
), ),

View File

@@ -1,10 +1,148 @@
import contextvars
import json
import logging import logging
import os import os
from logging.handlers import RotatingFileHandler 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"<bytes:{len(value)}>"
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_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO) 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) log_path = os.path.join(os.getcwd(), log_path)
os.makedirs(os.path.dirname(log_path), exist_ok=True) os.makedirs(os.path.dirname(log_path), exist_ok=True)
file_handler = RotatingFileHandler( 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) handlers.append(file_handler)
context_filter = RequestContextFilter()
formatter = logging.Formatter( 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", datefmt="%Y-%m-%d %H:%M:%S",
) )
for handler in handlers: for handler in handlers:
handler.addFilter(context_filter)
handler.setFormatter(formatter) handler.setFormatter(formatter)
root = logging.getLogger() 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").setLevel(level)
logging.getLogger("uvicorn.error").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)

View File

@@ -1,4 +1,8 @@
import asyncio import asyncio
import logging
import time
import uuid
from typing import Awaitable, Callable
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -21,9 +25,19 @@ from .routers.feedback import router as feedback_router
from .routers.site import router as site_router from .routers.site import router as site_router
from .routers.events import router as events_router from .routers.events import router as events_router
from .services.jellyfin_sync import run_daily_jellyfin_sync 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 from .runtime import get_runtime_settings
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
app = FastAPI( app = FastAPI(
title=settings.app_name, title=settings.app_name,
docs_url="/docs" if settings.api_docs_enabled else None, docs_url="/docs" if settings.api_docs_enabled else None,
@@ -41,8 +55,56 @@ app.add_middleware(
@app.middleware("http") @app.middleware("http")
async def add_security_headers(request: Request, call_next): async def log_requests_and_add_security_headers(request: Request, call_next):
response = await call_next(request) 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-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("Referrer-Policy", "no-referrer")
@@ -53,6 +115,21 @@ async def add_security_headers(request: Request, call_next):
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'", "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 return response
@@ -60,16 +137,69 @@ async def add_security_headers(request: Request, call_next):
async def health() -> dict: async def health() -> dict:
return {"status": "ok"} 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)
@app.on_event("startup") @app.on_event("startup")
async def startup() -> None: 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)
init_db() init_db()
runtime = get_runtime_settings() runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file) configure_logging(
asyncio.create_task(run_daily_jellyfin_sync()) runtime.log_level,
asyncio.create_task(startup_warmup_requests_cache()) runtime.log_file,
asyncio.create_task(run_requests_delta_loop()) log_file_max_bytes=runtime.log_file_max_bytes,
asyncio.create_task(run_daily_requests_full_sync()) log_file_backup_count=runtime.log_file_backup_count,
asyncio.create_task(run_daily_db_cleanup()) 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(requests_router)

View File

@@ -79,6 +79,16 @@ from ..services.user_cache import (
save_jellyseerr_users_cache, save_jellyseerr_users_cache,
clear_user_import_caches, clear_user_import_caches,
) )
from ..services.invite_email import (
TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
get_invite_email_templates,
reset_invite_email_template,
save_invite_email_template,
send_test_email,
send_templated_email,
smtp_email_config_ready,
)
from ..services.diagnostics import get_diagnostics_catalog, run_diagnostics
import logging import logging
from ..logging_config import configure_logging from ..logging_config import configure_logging
from ..routers import requests as requests_router from ..routers import requests as requests_router
@@ -184,6 +194,10 @@ SETTING_KEYS: List[str] = [
"qbittorrent_password", "qbittorrent_password",
"log_level", "log_level",
"log_file", "log_file",
"log_file_max_bytes",
"log_file_backup_count",
"log_http_client_level",
"log_background_sync_level",
"requests_sync_ttl_minutes", "requests_sync_ttl_minutes",
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
@@ -240,10 +254,20 @@ def _user_inviter_details(user: Optional[Dict[str, Any]]) -> Optional[Dict[str,
"created_at": invite.get("created_at"), "created_at": invite.get("created_at"),
"enabled": invite.get("enabled"), "enabled": invite.get("enabled"),
"is_usable": invite.get("is_usable"), "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]: def _build_invite_trace_payload() -> Dict[str, Any]:
users = get_all_users() users = get_all_users()
invites = list_signup_invites() invites = list_signup_invites()
@@ -591,6 +615,7 @@ async def list_settings() -> Dict[str, Any]:
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
updates = 0 updates = 0
touched_logging = False touched_logging = False
changed_keys: List[str] = []
for key, value in payload.items(): for key, value in payload.items():
if key not in SETTING_KEYS: if key not in SETTING_KEYS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}") raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
@@ -599,6 +624,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(value, str) and value.strip() == "": if isinstance(value, str) and value.strip() == "":
delete_setting(key) delete_setting(key)
updates += 1 updates += 1
changed_keys.append(key)
continue continue
value_to_store = str(value).strip() if isinstance(value, str) else 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: if key in URL_SETTING_KEYS and value_to_store:
@@ -609,14 +635,79 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store) set_setting(key, value_to_store)
updates += 1 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 touched_logging = True
if touched_logging: if touched_logging:
runtime = get_runtime_settings() 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} 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") @router.get("/sonarr/options")
async def sonarr_options() -> Dict[str, Any]: async def sonarr_options() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -696,12 +787,13 @@ async def _fetch_all_jellyseerr_users(
return save_jellyseerr_users_cache(users) return save_jellyseerr_users_cache(users)
return users return users
@router.post("/seerr/users/sync")
@router.post("/jellyseerr/users/sync") @router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]: async def jellyseerr_users_sync() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured") raise HTTPException(status_code=400, detail="Seerr not configured")
jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False) jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
if not jellyseerr_users: if not jellyseerr_users:
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0} return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
@@ -733,12 +825,13 @@ def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
return None return None
@router.post("/seerr/users/resync")
@router.post("/jellyseerr/users/resync") @router.post("/jellyseerr/users/resync")
async def jellyseerr_users_resync() -> Dict[str, Any]: async def jellyseerr_users_resync() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured") raise HTTPException(status_code=400, detail="Seerr not configured")
jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False) jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
if not jellyseerr_users: if not jellyseerr_users:
return {"status": "ok", "imported": 0, "cleared": 0} return {"status": "ok", "imported": 0, "cleared": 0}
@@ -772,7 +865,7 @@ async def requests_sync() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): 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( state = await requests_router.start_requests_sync(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
) )
@@ -785,7 +878,7 @@ async def requests_sync_delta() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): 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( state = await requests_router.start_requests_delta_sync(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
) )
@@ -902,7 +995,7 @@ async def requests_cache(limit: int = 50) -> Dict[str, Any]:
logger.info("Requests cache titles repaired via settings view: %s", repaired) logger.info("Requests cache titles repaired via settings view: %s", repaired)
hydrated = await _hydrate_cache_titles_from_jellyseerr(limit) hydrated = await _hydrate_cache_titles_from_jellyseerr(limit)
if hydrated: if hydrated:
logger.info("Requests cache titles hydrated via Jellyseerr: %s", hydrated) logger.info("Requests cache titles hydrated via Seerr: %s", hydrated)
rows = get_request_cache_overview(limit) rows = get_request_cache_overview(limit)
return {"rows": rows} return {"rows": rows}
@@ -1041,12 +1134,14 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
@router.post("/users/{username}/block") @router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]: async def block_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, True) set_user_blocked(username, True)
logger.warning("Admin blocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": True} return {"status": "ok", "username": username, "blocked": True}
@router.post("/users/{username}/unblock") @router.post("/users/{username}/unblock")
async def unblock_user(username: str) -> Dict[str, Any]: async def unblock_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, False) set_user_blocked(username, False)
logger.info("Admin unblocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": False} return {"status": "ok", "username": username, "blocked": False}
@@ -1073,8 +1168,9 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
"username": user.get("username"), "username": user.get("username"),
"local": {"status": "pending"}, "local": {"status": "pending"},
"jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"}, "jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"},
"jellyseerr": {"status": "skipped", "detail": "Jellyseerr not configured or no linked user ID"}, "jellyseerr": {"status": "skipped", "detail": "Seerr not configured or no linked user ID"},
"invites": {"status": "pending", "disabled": 0}, "invites": {"status": "pending", "disabled": 0},
"email": {"status": "skipped", "detail": "No email action required"},
} }
if action == "ban": if action == "ban":
@@ -1091,6 +1187,19 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
else: else:
result["invites"] = {"status": "ok", "disabled": 0} 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(): if jellyfin.configured():
try: try:
jellyfin_user = await jellyfin.find_user_by_name(username) jellyfin_user = await jellyfin.find_user_by_name(username)
@@ -1136,9 +1245,20 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
if any( if any(
isinstance(system, dict) and system.get("status") == "error" isinstance(system, dict) and system.get("status") == "error"
for system in (result.get("jellyfin"), result.get("jellyseerr")) for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email"))
): ):
result["status"] = "partial" 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 return result
@@ -1416,6 +1536,15 @@ async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
) )
except sqlite3.IntegrityError as exc: except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from 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} return {"status": "ok", "profile": profile}
@@ -1453,6 +1582,15 @@ async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, An
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
if not profile: if not profile:
raise HTTPException(status_code=404, detail="Profile not found") 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} return {"status": "ok", "profile": profile}
@@ -1464,6 +1602,7 @@ async def remove_profile(profile_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Profile not found") 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} return {"status": "ok", "deleted": True, "profile_id": profile_id}
@@ -1527,6 +1666,7 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
master_invite_value = payload.get("master_invite_id") master_invite_value = payload.get("master_invite_id")
if master_invite_value in (None, "", 0, "0"): if master_invite_value in (None, "", 0, "0"):
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None) 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}} return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
try: try:
master_invite_id = int(master_invite_value) master_invite_id = int(master_invite_value)
@@ -1538,6 +1678,7 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
if not invite: if not invite:
raise HTTPException(status_code=404, detail="Master invite not found") raise HTTPException(status_code=404, detail="Master invite not found")
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id)) 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 { return {
"status": "ok", "status": "ok",
"policy": { "policy": {
@@ -1547,6 +1688,108 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
} }
@router.get("/invites/email/templates")
async def get_invite_email_template_settings() -> Dict[str, Any]:
ready, detail = smtp_email_config_ready()
return {
"status": "ok",
"email": {
"configured": ready,
"detail": 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 = _normalize_optional_text(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") @router.get("/invites/trace")
async def get_invite_trace() -> Dict[str, Any]: async def get_invite_trace() -> Dict[str, Any]:
return {"status": "ok", "trace": _build_invite_trace_payload()} return {"status": "ok", "trace": _build_invite_trace_payload()}
@@ -1567,6 +1810,9 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
role = _normalize_role_or_none(payload.get("role")) role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at")) 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: try:
invite = create_signup_invite( invite = create_signup_invite(
code=code, code=code,
@@ -1577,11 +1823,47 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
max_uses=max_uses, max_uses=max_uses,
enabled=enabled, enabled=enabled,
expires_at=expires_at, expires_at=expires_at,
recipient_email=recipient_email,
created_by=current_user.get("username"), created_by=current_user.get("username"),
) )
except sqlite3.IntegrityError as exc: except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
return {"status": "ok", "invite": invite} 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}") @router.put("/invites/{invite_id}")
@@ -1599,6 +1881,9 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
role = _normalize_role_or_none(payload.get("role")) role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at")) 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: try:
invite = update_signup_invite( invite = update_signup_invite(
invite_id, invite_id,
@@ -1610,12 +1895,47 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
max_uses=max_uses, max_uses=max_uses,
enabled=enabled, enabled=enabled,
expires_at=expires_at, expires_at=expires_at,
recipient_email=recipient_email,
) )
except sqlite3.IntegrityError as exc: except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
if not invite: if not invite:
raise HTTPException(status_code=404, detail="Invite not found") raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "invite": invite} 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}") @router.delete("/invites/{invite_id}")
@@ -1623,4 +1943,5 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id) deleted = delete_signup_invite(invite_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Invite not found") 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} return {"status": "ok", "deleted": True, "invite_id": invite_id}

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque from collections import defaultdict, deque
import logging
import secrets import secrets
import string import string
import time import time
@@ -48,8 +49,10 @@ from ..services.user_cache import (
match_jellyseerr_user_id, match_jellyseerr_user_id,
save_jellyfin_users_cache, save_jellyfin_users_cache,
) )
from ..services.invite_email import send_templated_email
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120 STREAM_TOKEN_TTL_SECONDS = 120
@@ -112,6 +115,7 @@ def _record_login_failure(request: Request, username: str) -> None:
_prune_attempts(user_bucket, now, window) _prune_attempts(user_bucket, now, window)
ip_bucket.append(now) ip_bucket.append(now)
user_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: def _clear_login_failures(request: Request, username: str) -> None:
@@ -145,6 +149,12 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
if retry_candidates: if retry_candidates:
retry_after = max(retry_candidates) retry_after = max(retry_candidates)
if exceeded: if exceeded:
logger.warning(
"login rate limit exceeded username=%s client=%s retry_after=%s",
user_key,
ip_key,
retry_after,
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again shortly.", detail="Too many login attempts. Try again shortly.",
@@ -356,6 +366,7 @@ def _serialize_self_invite(invite: dict) -> dict:
"remaining_uses": invite.get("remaining_uses"), "remaining_uses": invite.get("remaining_uses"),
"enabled": bool(invite.get("enabled")), "enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"), "expires_at": invite.get("expires_at"),
"recipient_email": invite.get("recipient_email"),
"is_expired": bool(invite.get("is_expired")), "is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")), "is_usable": bool(invite.get("is_usable")),
"created_at": invite.get("created_at"), "created_at": invite.get("created_at"),
@@ -427,6 +438,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
"label": invite.get("label"), "label": invite.get("label"),
"description": invite.get("description"), "description": invite.get("description"),
"profile_id": invite.get("profile_id"), "profile_id": invite.get("profile_id"),
"recipient_email": invite.get("recipient_email"),
"profile": ( "profile": (
{"id": profile.get("id"), "name": profile.get("name")} {"id": profile.get("id"), "name": profile.get("name")}
if isinstance(profile, dict) if isinstance(profile, dict)
@@ -469,6 +481,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login") @router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username) _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. # Provider placeholder passwords must never be accepted by the local-login endpoint.
if form_data.password in {"jellyfin-user", "jellyseerr-user"}: if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
_record_login_failure(request, form_data.username) _record_login_failure(request, form_data.username)
@@ -478,6 +495,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
) )
if has_external_match: 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.", detail="This account uses external sign-in. Use the external sign-in option.",
@@ -487,6 +509,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
_record_login_failure(request, form_data.username) _record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local": 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.", detail="This account uses external sign-in. Use the external sign-in option.",
@@ -495,6 +522,12 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
token = create_access_token(user["username"], user["role"]) token = create_access_token(user["username"], user["role"])
_clear_login_failures(request, form_data.username) _clear_login_failures(request, form_data.username)
set_last_login(user["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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -505,6 +538,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
@router.post("/jellyfin/login") @router.post("/jellyfin/login")
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username) _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() runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured(): if not client.configured():
@@ -522,6 +560,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
token = create_access_token(canonical_username, "user") token = create_access_token(canonical_username, "user")
_clear_login_failures(request, username) _clear_login_failures(request, username)
set_last_login(canonical_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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -530,6 +573,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
try: try:
response = await client.authenticate_by_name(username, password) response = await client.authenticate_by_name(username, password)
except Exception as exc: 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 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(response, dict) or not response.get("User"):
_record_login_failure(request, username) _record_login_failure(request, username)
@@ -559,6 +607,12 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
token = create_access_token(canonical_username, "user") token = create_access_token(canonical_username, "user")
_clear_login_failures(request, username) _clear_login_failures(request, username)
set_last_login(canonical_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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -566,21 +620,31 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
} }
@router.post("/seerr/login")
@router.post("/jellyseerr/login") @router.post("/jellyseerr/login")
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username) _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() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyseerr not configured") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
payload = {"email": form_data.username, "password": form_data.password}
try: try:
response = await client.post("/api/v1/auth/login", payload=payload) response = await client.login_local(form_data.username, form_data.password)
except Exception as exc: 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 raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict): if not isinstance(response, dict):
_record_login_failure(request, form_data.username) _record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response) jellyseerr_user_id = _extract_jellyseerr_user_id(response)
ci_matches = get_users_by_username_ci(form_data.username) ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username) preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
@@ -600,6 +664,12 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
token = create_access_token(canonical_username, "user") token = create_access_token(canonical_username, "user")
_clear_login_failures(request, form_data.username) _clear_login_failures(request, form_data.username)
set_last_login(canonical_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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -658,6 +728,11 @@ async def signup(payload: dict) -> dict:
) )
if get_user_by_username(username): if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists") 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) invite = get_signup_invite_by_code(invite_code)
if not invite: if not invite:
@@ -704,6 +779,7 @@ async def signup(payload: dict) -> dict:
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured(): if jellyfin_client.configured():
logger.info("signup provisioning jellyfin username=%s", username)
auth_provider = "jellyfin" auth_provider = "jellyfin"
local_password_value = "jellyfin-user" local_password_value = "jellyfin-user"
try: try:
@@ -770,9 +846,27 @@ async def signup(payload: dict) -> dict:
): ):
set_user_jellyseerr_id(username, matched_jellyseerr_user_id) set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
created_user = get_user_by_username(username) 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) _assert_user_can_login(created_user)
token = create_access_token(username, role) token = create_access_token(username, role)
set_last_login(username) 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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -858,10 +952,15 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
label = payload.get("label") label = payload.get("label")
description = payload.get("description") description = payload.get("description")
recipient_email = payload.get("recipient_email")
if label is not None: if label is not None:
label = str(label).strip() or None label = str(label).strip() or None
if description is not None: if description is not None:
description = str(description).strip() or None description = str(description).strip() or None
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
send_email = bool(payload.get("send_email"))
delivery_message = str(payload.get("message") or "").strip() or None
master_invite = _get_self_service_master_invite() master_invite = _get_self_service_master_invite()
if master_invite: if master_invite:
@@ -892,9 +991,34 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
max_uses=max_uses, max_uses=max_uses,
enabled=enabled, enabled=enabled,
expires_at=expires_at, expires_at=expires_at,
recipient_email=recipient_email,
created_by=username, created_by=username,
) )
return {"status": "ok", "invite": _serialize_self_invite(invite)} 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}") @router.put("/profile/invites/{invite_id}")
@@ -919,10 +1043,15 @@ async def update_profile_invite(
label = payload.get("label", existing.get("label")) label = payload.get("label", existing.get("label"))
description = payload.get("description", existing.get("description")) description = payload.get("description", existing.get("description"))
recipient_email = payload.get("recipient_email", existing.get("recipient_email"))
if label is not None: if label is not None:
label = str(label).strip() or None label = str(label).strip() or None
if description is not None: if description is not None:
description = str(description).strip() or None description = str(description).strip() or None
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
send_email = bool(payload.get("send_email"))
delivery_message = str(payload.get("message") or "").strip() or None
master_invite = _get_self_service_master_invite() master_invite = _get_self_service_master_invite()
if master_invite: if master_invite:
@@ -948,10 +1077,35 @@ async def update_profile_invite(
max_uses=max_uses, max_uses=max_uses,
enabled=enabled, enabled=enabled,
expires_at=expires_at, expires_at=expires_at,
recipient_email=recipient_email,
) )
if not invite: if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
return {"status": "ok", "invite": _serialize_self_invite(invite)} 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}") @router.delete("/profile/invites/{invite_id}")

View File

@@ -76,7 +76,6 @@ _artwork_prefetch_state: Dict[str, Any] = {
"finished_at": None, "finished_at": None,
} }
_artwork_prefetch_task: Optional[asyncio.Task] = None _artwork_prefetch_task: Optional[asyncio.Task] = None
_media_endpoint_supported: Optional[bool] = None
STATUS_LABELS = { STATUS_LABELS = {
1: "Waiting for approval", 1: "Waiting for approval",
@@ -419,23 +418,6 @@ async def _hydrate_title_from_tmdb(
return None, None return None, None
async def _hydrate_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_id:
return None
global _media_endpoint_supported
if _media_endpoint_supported is False:
return None
try:
details = await client.get_media(int(media_id))
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 405:
_media_endpoint_supported = False
logger.info("Jellyseerr media endpoint rejected GET requests; skipping media lookups.")
return None
_media_endpoint_supported = True
return details if isinstance(details, dict) else None
async def _hydrate_artwork_from_tmdb( async def _hydrate_artwork_from_tmdb(
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int] client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
) -> tuple[Optional[str], Optional[str]]: ) -> tuple[Optional[str], Optional[str]]:
@@ -511,7 +493,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
skip = 0 skip = 0
stored = 0 stored = 0
cache_mode = (get_runtime_settings().artwork_cache_mode or "remote").lower() 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( _sync_state.update(
{ {
"status": "running", "status": "running",
@@ -527,11 +509,11 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
try: try:
response = await client.get_recent_requests(take=take, skip=skip) response = await client.get_recent_requests(take=take, skip=skip)
except httpx.HTTPError as exc: 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}"}) _sync_state.update({"status": "failed", "message": f"Sync failed: {exc}"})
break break
if not isinstance(response, dict): 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"}) _sync_state.update({"status": "failed", "message": "Invalid response"})
break break
if _sync_state["total"] is None: if _sync_state["total"] is None:
@@ -546,7 +528,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
_sync_state["total"] = total _sync_state["total"] = total
items = response.get("results") or [] items = response.get("results") or []
if not isinstance(items, list) or not items: 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 break
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -559,38 +541,18 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
cached = get_request_cache_by_id(request_id) cached = get_request_cache_by_id(request_id)
if cached and cached.get("title"): if cached and cached.get("title"):
cached_title = cached.get("title") cached_title = cached.get("title")
if not payload.get("title") or not payload.get("media_id"): needs_details = (
logger.debug("Jellyseerr sync hydrate request_id=%s", request_id) 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) details = await _get_request_details(client, request_id)
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if (
not payload.get("title")
and payload.get("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
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) poster_path, backdrop_path = _extract_artwork_paths(item)
if cache_mode == "cache" and not (poster_path or backdrop_path): if cache_mode == "cache" and not (poster_path or backdrop_path):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
@@ -629,12 +591,12 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
stored += 1 stored += 1
_sync_state["stored"] = stored _sync_state["stored"] = stored
if len(items) < take: if len(items) < take:
logger.info("Jellyseerr sync completed: stored=%s", stored) logger.info("Seerr sync completed: stored=%s", stored)
break break
skip += take skip += take
_sync_state["skip"] = skip _sync_state["skip"] = skip
_sync_state["message"] = f"Synced {stored} requests" _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( _sync_state.update(
{ {
"status": "completed", "status": "completed",
@@ -659,7 +621,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
stored = 0 stored = 0
unchanged_pages = 0 unchanged_pages = 0
cache_mode = (get_runtime_settings().artwork_cache_mode or "remote").lower() 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( _sync_state.update(
{ {
"status": "running", "status": "running",
@@ -675,16 +637,16 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
try: try:
response = await client.get_recent_requests(take=take, skip=skip) response = await client.get_recent_requests(take=take, skip=skip)
except httpx.HTTPError as exc: 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}"}) _sync_state.update({"status": "failed", "message": f"Delta sync failed: {exc}"})
break break
if not isinstance(response, dict): 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"}) _sync_state.update({"status": "failed", "message": "Invalid response"})
break break
items = response.get("results") or [] items = response.get("results") or []
if not isinstance(items, list) or not items: 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 break
page_changed = False page_changed = False
for item in items: for item in items:
@@ -698,37 +660,17 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
cached_title = cached.get("title") if cached else None 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"): if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
continue 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) details = await _get_request_details(client, request_id)
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if (
not payload.get("title")
and payload.get("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
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) poster_path, backdrop_path = _extract_artwork_paths(item)
if cache_mode == "cache" and not (poster_path or backdrop_path): if cache_mode == "cache" and not (poster_path or backdrop_path):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
@@ -772,15 +714,15 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
else: else:
unchanged_pages = 0 unchanged_pages = 0
if len(items) < take or unchanged_pages >= 2: 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 break
skip += take skip += take
_sync_state["skip"] = skip _sync_state["skip"] = skip
_sync_state["message"] = f"Delta synced {stored} requests" _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() deduped = prune_duplicate_requests_cache()
if deduped: 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( _sync_state.update(
{ {
"status": "completed", "status": "completed",
@@ -1118,7 +1060,7 @@ async def run_daily_requests_full_sync() -> None:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if not client.configured():
logger.info("Daily full sync skipped: Jellyseerr not configured.") logger.info("Daily full sync skipped: Seerr not configured.")
continue continue
if _sync_task and not _sync_task.done(): if _sync_task and not _sync_task.done():
logger.info("Daily full sync skipped: another sync is running.") logger.info("Daily full sync skipped: another sync is running.")
@@ -1144,7 +1086,7 @@ async def start_requests_sync(base_url: Optional[str], api_key: Optional[str]) -
if _sync_task and not _sync_task.done(): if _sync_task and not _sync_task.done():
return dict(_sync_state) return dict(_sync_state)
if not base_url: 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) return dict(_sync_state)
client = JellyseerrClient(base_url, api_key) client = JellyseerrClient(base_url, api_key)
_sync_state.update( _sync_state.update(
@@ -1163,7 +1105,7 @@ async def start_requests_sync(base_url: Optional[str], api_key: Optional[str]) -
try: try:
await _sync_all_requests(client) await _sync_all_requests(client)
except Exception as exc: except Exception as exc:
logger.exception("Jellyseerr sync failed") logger.exception("Seerr sync failed")
_sync_state.update( _sync_state.update(
{ {
"status": "failed", "status": "failed",
@@ -1181,7 +1123,7 @@ async def start_requests_delta_sync(base_url: Optional[str], api_key: Optional[s
if _sync_task and not _sync_task.done(): if _sync_task and not _sync_task.done():
return dict(_sync_state) return dict(_sync_state)
if not base_url: 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) return dict(_sync_state)
client = JellyseerrClient(base_url, api_key) client = JellyseerrClient(base_url, api_key)
_sync_state.update( _sync_state.update(
@@ -1200,7 +1142,7 @@ async def start_requests_delta_sync(base_url: Optional[str], api_key: Optional[s
try: try:
await _sync_delta_requests(client) await _sync_delta_requests(client)
except Exception as exc: except Exception as exc:
logger.exception("Jellyseerr delta sync failed") logger.exception("Seerr delta sync failed")
_sync_state.update( _sync_state.update(
{ {
"status": "failed", "status": "failed",
@@ -1514,7 +1456,7 @@ async def recent_requests(
allow_remote = mode == "always_js" allow_remote = mode == "always_js"
if allow_remote: if allow_remote:
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured") raise HTTPException(status_code=400, detail="Seerr not configured")
try: try:
await _ensure_requests_cache(client) await _ensure_requests_cache(client)
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
@@ -1690,7 +1632,7 @@ async def search_requests(
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured") raise HTTPException(status_code=400, detail="Seerr not configured")
try: try:
response = await client.search(query=query, page=page) response = await client.search(query=query, page=page)

View File

@@ -41,7 +41,7 @@ async def services_status() -> Dict[str, Any]:
services = [] services = []
services.append( services.append(
await _check( await _check(
"Jellyseerr", "Seerr",
jellyseerr.configured(), jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0), lambda: jellyseerr.get_recent_requests(take=1, skip=0),
) )
@@ -109,8 +109,13 @@ async def test_service(service: str) -> Dict[str, Any]:
service_key = service.strip().lower() service_key = service.strip().lower()
checks = { checks = {
"seerr": (
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"jellyseerr": ( "jellyseerr": (
"Jellyseerr", "Seerr",
jellyseerr.configured(), jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0), lambda: jellyseerr.get_recent_requests(take=1, skip=0),
), ),

View File

@@ -7,6 +7,8 @@ _INT_FIELDS = {
"sonarr_quality_profile_id", "sonarr_quality_profile_id",
"radarr_quality_profile_id", "radarr_quality_profile_id",
"jwt_exp_minutes", "jwt_exp_minutes",
"log_file_max_bytes",
"log_file_backup_count",
"requests_sync_ttl_minutes", "requests_sync_ttl_minutes",
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",

View File

@@ -0,0 +1,696 @@
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 run_integrity_check
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready
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."
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
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."
if _clean_text(runtime.magent_notify_webhook_url):
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":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
return True, "ok"
return False, "ntfy requires a base URL and topic."
if provider == "gotify":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
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":
if _clean_text(runtime.magent_notify_push_base_url):
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]:
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]:
integrity = await asyncio.to_thread(run_integrity_check)
status = "up" if integrity == "ok" else "degraded"
return {
"status": status,
"message": f"SQLite integrity_check returned {integrity}",
"detail": integrity,
}
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 "<html" in body.lower():
return {"message": "Application page responded", "detail": "html"}
return {"status": "degraded", "message": "Application responded with unexpected content"}
async def _run_seerr_check(runtime) -> 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")
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()
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_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(),
}

View File

@@ -0,0 +1,518 @@
from __future__ import annotations
import asyncio
import html
import json
import logging
import re
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from typing import Any, Dict, Optional
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*}}")
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",
]
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": (
"<h1>You have been invited</h1>"
"<p>You have been invited to <strong>{{app_name}}</strong>.</p>"
"<p><strong>Invite code:</strong> {{invite_code}}<br />"
"<strong>Invited by:</strong> {{inviter_username}}<br />"
"<strong>Invite label:</strong> {{invite_label}}<br />"
"<strong>Expires:</strong> {{invite_expires_at}}<br />"
"<strong>Remaining uses:</strong> {{invite_remaining_uses}}</p>"
"<p>{{invite_description}}</p>"
"<p>{{message}}</p>"
"<p><a href=\"{{invite_link}}\">Accept invite and create account</a></p>"
"<p><a href=\"{{how_it_works_url}}\">How it works</a></p>"
"<p class=\"meta\">Build {{build_number}}</p>"
),
},
"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": (
"<h1>Welcome</h1>"
"<p>Your {{app_name}} account is ready, <strong>{{username}}</strong>.</p>"
"<p><strong>Role:</strong> {{role}}</p>"
"<p><a href=\"{{app_url}}\">Open {{app_name}}</a><br />"
"<a href=\"{{how_it_works_url}}\">Read how it works</a></p>"
"<p>{{message}}</p>"
),
},
"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": (
"<h1>Account warning</h1>"
"<p>Hello <strong>{{username}}</strong>,</p>"
"<p>This is a warning regarding your {{app_name}} account.</p>"
"<p><strong>Reason:</strong> {{reason}}</p>"
"<p>{{message}}</p>"
"<p>If you need help, contact the admin.</p>"
),
},
"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": (
"<h1>Account status changed</h1>"
"<p>Hello <strong>{{username}}</strong>,</p>"
"<p>Your {{app_name}} account has been banned or removed.</p>"
"<p><strong>Reason:</strong> {{reason}}</p>"
"<p>{{message}}</p>"
),
},
}
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_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"<br\s*/?>", "\n", value, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\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 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, ""),
"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,
)
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 body_html.strip():
body_text = _strip_html_for_text(body_html)
subject = _render_template_string(template["subject"], context, escape_html=False)
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)
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 _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None:
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)
if not host or not from_address:
raise RuntimeError("SMTP email settings are incomplete.")
logger.info(
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s",
recipient_email,
from_address,
host,
port,
use_tls,
use_ssl,
bool(username and password),
subject,
)
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((from_name, from_address))
message["To"] = recipient_email
message.set_content(body_text or _strip_html_for_text(body_html))
if body_html.strip():
message.add_alternative(body_html, subtype="html")
if use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=20) 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)
smtp.send_message(message)
logger.info("smtp send accepted recipient=%s host=%s mode=ssl", recipient_email, host)
return
with smtplib.SMTP(host, port, timeout=20) 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)
smtp.send_message(message)
logger.info("smtp send accepted recipient=%s host=%s mode=plain", recipient_email, host)
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,
)
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"],
}
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")
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"
)
body_html = (
f"<h1>{html.escape(env_settings.app_name)} email test</h1>"
f"<p>This is a test email from <strong>{html.escape(env_settings.app_name)}</strong>.</p>"
f"<p><strong>Build:</strong> {html.escape(BUILD_NUMBER)}<br />"
f"<strong>Application URL:</strong> {html.escape(application_url)}</p>"
)
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)
return {"recipient_email": resolved_email, "subject": subject}

View File

@@ -29,7 +29,7 @@ async def sync_jellyfin_users() -> int:
if not isinstance(users, list): if not isinstance(users, list):
return 0 return 0
save_jellyfin_users_cache(users) save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Jellyseerr IDs are # Jellyfin is the canonical source for local user objects; Seerr IDs are
# matched as enrichment when possible. # matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users() jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])

View File

@@ -242,14 +242,14 @@ async def build_snapshot(request_id: str) -> Snapshot:
allow_remote = mode == "always_js" and jellyseerr.configured() allow_remote = mode == "always_js" and jellyseerr.configured()
if not jellyseerr.configured() and not cached_request: 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="Sonarr/Radarr", status="not_configured"))
timeline.append(TimelineHop(service="Prowlarr", status="not_configured")) timeline.append(TimelineHop(service="Prowlarr", status="not_configured"))
timeline.append(TimelineHop(service="qBittorrent", status="not_configured")) timeline.append(TimelineHop(service="qBittorrent", status="not_configured"))
snapshot.timeline = timeline snapshot.timeline = timeline
return snapshot return snapshot
if cached_request is None and not allow_remote: if cached_request is None and not allow_remote:
timeline.append(TimelineHop(service="Jellyseerr", status="cache_miss")) timeline.append(TimelineHop(service="Seerr", status="cache_miss"))
snapshot.timeline = timeline snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown snapshot.state = NormalizedState.unknown
snapshot.state_reason = "Request not found in cache" snapshot.state_reason = "Request not found in cache"
@@ -260,20 +260,20 @@ async def build_snapshot(request_id: str) -> Snapshot:
try: try:
jelly_request = await jellyseerr.get_request(request_id) jelly_request = await jellyseerr.get_request(request_id)
logging.getLogger(__name__).debug( 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: 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.timeline = timeline
snapshot.state = NormalizedState.failed snapshot.state = NormalizedState.failed
snapshot.state_reason = "Failed to reach Jellyseerr" snapshot.state_reason = "Failed to reach Seerr"
return snapshot return snapshot
if not jelly_request: if not jelly_request:
timeline.append(TimelineHop(service="Jellyseerr", status="not_found")) timeline.append(TimelineHop(service="Seerr", status="not_found"))
snapshot.timeline = timeline snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown snapshot.state = NormalizedState.unknown
snapshot.state_reason = "Request not found in Jellyseerr" snapshot.state_reason = "Request not found in Seerr"
return snapshot return snapshot
jelly_status = jelly_request.get("status", "unknown") jelly_status = jelly_request.get("status", "unknown")
@@ -338,7 +338,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
timeline.append( timeline.append(
TimelineHop( TimelineHop(
service="Jellyseerr", service="Seerr",
status=jelly_status_label, status=jelly_status_label,
details={ details={
"requestedBy": jelly_request.get("requestedBy", {}).get("displayName") "requestedBy": jelly_request.get("requestedBy", {}).get("displayName")

View File

@@ -114,7 +114,7 @@ def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, A
} }
) )
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized) _save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
logger.debug("Cached Jellyseerr users: %s", len(normalized)) logger.debug("Cached Seerr users: %s", len(normalized))
return normalized return normalized

View File

@@ -1,9 +1,9 @@
fastapi==0.115.0 fastapi==0.134.0
uvicorn==0.30.6 uvicorn==0.41.0
httpx==0.27.2 httpx==0.28.1
pydantic==2.9.2 pydantic==2.12.5
pydantic-settings==2.5.2 pydantic-settings==2.13.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.5.0
passlib==1.7.4 passlib==1.7.4
python-multipart==0.0.9 python-multipart==0.0.22
Pillow==10.4.0 Pillow==12.1.1

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
type AdminSetting = { type AdminSetting = {
key: string key: string
@@ -22,7 +23,8 @@ const SECTION_LABELS: Record<string, string> = {
magent: 'Magent', magent: 'Magent',
general: 'General', general: 'General',
notifications: 'Notifications', notifications: 'Notifications',
jellyseerr: 'Jellyseerr', seerr: 'Seerr',
jellyseerr: 'Seerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
artwork: 'Artwork cache', artwork: 'Artwork cache',
cache: 'Cache Control', cache: 'Cache Control',
@@ -75,6 +77,8 @@ const NUMBER_SETTINGS = new Set([
'magent_application_port', 'magent_application_port',
'magent_api_port', 'magent_api_port',
'magent_notify_email_smtp_port', 'magent_notify_email_smtp_port',
'log_file_max_bytes',
'log_file_backup_count',
'requests_sync_ttl_minutes', 'requests_sync_ttl_minutes',
'requests_poll_interval_seconds', 'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes', 'requests_delta_sync_interval_minutes',
@@ -89,7 +93,8 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.', 'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
notifications: notifications:
'Notification providers and delivery channel settings used by Magent messaging features.', 'Notification providers and delivery channel settings used by Magent messaging features.',
jellyseerr: 'Connect the request system where users submit content.', seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Control Jellyfin login and availability checks.', jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.', artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.', cache: 'Manage saved requests cache and refresh behavior.',
@@ -106,6 +111,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
magent: 'magent', magent: 'magent',
general: 'magent', general: 'magent',
notifications: 'magent', notifications: 'magent',
seerr: 'jellyseerr',
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
artwork: null, artwork: null,
@@ -234,6 +240,8 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
} }
const SETTING_LABEL_OVERRIDES: Record<string, string> = { const SETTING_LABEL_OVERRIDES: Record<string, string> = {
jellyseerr_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key',
magent_application_url: 'Application URL', magent_application_url: 'Application URL',
magent_application_port: 'Application port', magent_application_port: 'Application port',
magent_api_url: 'API URL', magent_api_url: 'API URL',
@@ -272,12 +280,17 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_push_device: 'Device / target', magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled', magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL', magent_notify_webhook_url: 'Generic webhook URL',
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) => const labelFromKey = (key: string) =>
SETTING_LABEL_OVERRIDES[key] ?? SETTING_LABEL_OVERRIDES[key] ??
key key
.replaceAll('_', ' ') .replaceAll('_', ' ')
.replace('jellyseerr', 'Seerr')
.replace('base url', 'URL') .replace('base url', 'URL')
.replace('api key', 'API key') .replace('api key', 'API key')
.replace('quality profile id', 'Quality profile ID') .replace('quality profile id', 'Quality profile ID')
@@ -289,7 +302,7 @@ const labelFromKey = (key: string) =>
.replace('requests full sync time', 'Daily full refresh time (24h)') .replace('requests full sync time', 'Daily full refresh time (24h)')
.replace('requests cleanup time', 'Daily history cleanup time (24h)') .replace('requests cleanup time', 'Daily history cleanup time (24h)')
.replace('requests cleanup days', 'History retention window (days)') .replace('requests cleanup days', 'History retention window (days)')
.replace('requests data source', 'Request source (cache vs Jellyseerr)') .replace('requests data source', 'Request source (cache vs Seerr)')
.replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode') .replace('artwork cache mode', 'Artwork cache mode')
@@ -323,11 +336,29 @@ type SettingsSectionGroup = {
description?: string description?: string
} }
type SectionFeedback = {
tone: 'status' | 'error'
message: string
}
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
jellyseerr: 'seerr',
jellyfin: 'jellyfin',
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
}
export default function SettingsPage({ section }: SettingsPageProps) { export default function SettingsPage({ section }: SettingsPageProps) {
const router = useRouter() const router = useRouter()
const [settings, setSettings] = useState<AdminSetting[]>([]) const [settings, setSettings] = useState<AdminSetting[]>([])
const [formValues, setFormValues] = useState<Record<string, string>>({}) const [formValues, setFormValues] = useState<Record<string, string>>({})
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
const [sectionFeedback, setSectionFeedback] = useState<Record<string, SectionFeedback>>({})
const [sectionSaving, setSectionSaving] = useState<Record<string, boolean>>({})
const [sectionTesting, setSectionTesting] = useState<Record<string, boolean>>({})
const [emailTestRecipient, setEmailTestRecipient] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [sonarrOptions, setSonarrOptions] = useState<ServiceOptions | null>(null) const [sonarrOptions, setSonarrOptions] = useState<ServiceOptions | null>(null)
const [radarrOptions, setRadarrOptions] = useState<ServiceOptions | null>(null) const [radarrOptions, setRadarrOptions] = useState<ServiceOptions | null>(null)
@@ -352,8 +383,23 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [liveStreamConnected, setLiveStreamConnected] = useState(false) const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null) const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null) const artworkPrefetchRef = useRef<any | null>(null)
const computeProgressPercent = (
completedValue: unknown,
totalValue: unknown,
statusValue: unknown
): number => {
if (String(statusValue).toLowerCase() === 'completed') {
return 100
}
const completed = Number(completedValue)
const total = Number(totalValue)
if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0 || completed <= 0) {
return 0
}
return Math.max(0, Math.min(100, Math.round((completed / total) * 100)))
}
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async (refreshedKeys?: Set<string>) => {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`) const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) { if (!response.ok) {
@@ -383,7 +429,18 @@ export default function SettingsPage({ section }: SettingsPageProps) {
initialValues[setting.key] = '' 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) setStatus(null)
}, [router]) }, [router])
@@ -642,7 +699,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_webhook_url: magent_notify_webhook_url:
'Generic webhook endpoint for custom integrations or automation flows.', 'Generic webhook endpoint for custom integrations or automation flows.',
jellyseerr_base_url: jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.', 'Base URL for your Seerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.', jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.', 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
@@ -677,9 +734,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_cleanup_time: 'Daily time to trim old request history.', requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.', requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source: requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.', '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_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.', 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_build_number: 'Build number shown in the account menu (auto-set from releases).',
site_banner_enabled: 'Enable a sitewide banner for announcements.', site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.', site_banner_message: 'Short banner message for maintenance or updates.',
@@ -704,6 +767,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_email_smtp_username: 'notifications@example.com', magent_notify_email_smtp_username: 'notifications@example.com',
magent_notify_email_from_address: 'notifications@example.com', magent_notify_email_from_address: 'notifications@example.com',
magent_notify_email_from_name: 'Magent', 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_discord_webhook_url: 'https://discord.com/api/webhooks/...',
magent_notify_telegram_bot_token: '123456789:AA...', magent_notify_telegram_bot_token: '123456789:AA...',
magent_notify_telegram_chat_id: '-1001234567890', magent_notify_telegram_chat_id: '-1001234567890',
@@ -741,23 +806,41 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return list return list
} }
const submit = async (event: React.FormEvent<HTMLFormElement>) => { const parseActionError = (err: unknown, fallback: string) => {
event.preventDefault() if (err instanceof Error && err.message) {
setStatus(null) return err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
}
return fallback
}
const buildSettingsPayload = (items: AdminSetting[]) => {
const payload: Record<string, string> = {} const payload: Record<string, string> = {}
const formData = new FormData(event.currentTarget) for (const setting of items) {
for (const setting of settings) { const rawValue = formValues[setting.key]
const rawValue = formData.get(setting.key)
if (typeof rawValue !== 'string') { if (typeof rawValue !== 'string') {
continue continue
} }
const value = rawValue.trim() const value = rawValue.trim()
if (value === '') { if (setting.sensitive && value === '') {
continue continue
} }
payload[setting.key] = value 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 { try {
const payload = buildSettingsPayload(sectionGroup.items)
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`, { const response = await authFetch(`${baseUrl}/admin/settings`, {
method: 'PUT', method: 'PUT',
@@ -768,15 +851,129 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await response.text() const text = await response.text()
throw new Error(text || 'Update failed') throw new Error(text || 'Update failed')
} }
setStatus('Settings saved. New values take effect immediately.') await loadSettings(new Set(sectionGroup.items.map((item) => item.key)))
await loadSettings() if (options?.successMessage !== null) {
setSectionFeedback((current) => ({
...current,
[sectionGroup.key]: {
tone: 'status',
message: options?.successMessage ?? `${sectionGroup.title} settings saved.`,
},
}))
}
return true
} catch (err) { } catch (err) {
console.error(err) console.error(err)
const message = setSectionFeedback((current) => ({
err instanceof Error && err.message ...current,
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '') [sectionGroup.key]: {
: 'Could not save settings.' tone: 'error',
setStatus(message) 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: 'status',
message: `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 }))
} }
} }
@@ -805,6 +1002,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const syncRequests = async () => { const syncRequests = async () => {
setRequestsSyncStatus(null) setRequestsSyncStatus(null)
setRequestsSync({
status: 'running',
stored: 0,
total: 0,
skip: 0,
message: 'Starting sync',
})
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync`, { const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
@@ -829,6 +1033,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const syncRequestsDelta = async () => { const syncRequestsDelta = async () => {
setRequestsSyncStatus(null) setRequestsSyncStatus(null)
setRequestsSync({
status: 'running',
stored: 0,
total: 0,
skip: 0,
message: 'Starting delta sync',
})
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, { const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
@@ -853,6 +1064,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const prefetchArtwork = async () => { const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null) setArtworkPrefetchStatus(null)
setArtworkPrefetch({
status: 'running',
processed: 0,
total: 0,
message: 'Starting artwork caching',
})
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, { const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
@@ -877,6 +1094,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const prefetchArtworkMissing = async () => { const prefetchArtworkMissing = async () => {
setArtworkPrefetchStatus(null) setArtworkPrefetchStatus(null)
setArtworkPrefetch({
status: 'running',
processed: 0,
total: 0,
message: 'Starting missing artwork caching',
})
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
@@ -1202,7 +1425,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true) setMaintenanceBusy(true)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const ok = window.confirm( const ok = window.confirm(
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from 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) { if (!ok) {
setMaintenanceBusy(false) setMaintenanceBusy(false)
@@ -1264,11 +1487,42 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const cacheSourceLabel = const cacheSourceLabel =
formValues.requests_data_source === 'always_js' formValues.requests_data_source === 'always_js'
? 'Jellyseerr direct' ? 'Seerr direct'
: formValues.requests_data_source === 'prefer_cache' : formValues.requests_data_source === 'prefer_cache'
? 'Saved requests only' ? 'Saved requests only'
: 'Saved requests only' : 'Saved requests only'
const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60' const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
const maintenanceRail = showMaintenance ? (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Maintenance</span>
<h2>Admin tools</h2>
<p>Repair, cleanup, diagnostics, and nuclear resync are grouped into a single operating page.</p>
</div>
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Runtime</span>
<h2>Service state</h2>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Maintenance job</span>
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
</div>
<div className="cache-rail-metric">
<span>Live updates</span>
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
</div>
<div className="cache-rail-metric">
<span>Log lines in view</span>
<strong>{logsLines.length}</strong>
</div>
<div className="cache-rail-metric">
<span>Last tool status</span>
<strong>{maintenanceStatus || 'Idle'}</strong>
</div>
</div>
</div>
</div>
) : undefined
const cacheRail = showCacheExtras ? ( const cacheRail = showCacheExtras ? (
<div className="admin-rail-stack"> <div className="admin-rail-stack">
<div className="admin-rail-card cache-rail-card"> <div className="admin-rail-card cache-rail-card">
@@ -1350,15 +1604,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<AdminShell <AdminShell
title={SECTION_LABELS[section] ?? 'Settings'} title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'} subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
rail={cacheRail} rail={maintenanceRail ?? cacheRail}
actions={ actions={
<button type="button" onClick={() => router.push('/admin')}> <button type="button" onClick={() => router.push('/admin')}>
Back to settings Back to settings
</button> </button>
} }
> >
{status && <div className="error-banner">{status}</div>}
{settingsSections.length > 0 ? ( {settingsSections.length > 0 ? (
<form onSubmit={submit} className="admin-form"> <div className="admin-form">
{settingsSections {settingsSections
.filter(shouldRenderSection) .filter(shouldRenderSection)
.map((sectionGroup) => ( .map((sectionGroup) => (
@@ -1485,22 +1740,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span> </span>
</div> </div>
<div <div
className={`progress ${artworkPrefetch.total ? '' : 'progress-indeterminate'} ${ className={`progress ${artworkPrefetch.status === 'completed' ? 'progress-complete' : ''}`}
artworkPrefetch.status === 'completed' ? 'progress-complete' : ''
}`}
> >
<div <div
className="progress-fill" className="progress-fill"
style={{ style={{
width: width: `${computeProgressPercent(
artworkPrefetch.status === 'completed' artworkPrefetch.processed,
? '100%' artworkPrefetch.total,
: artworkPrefetch.total artworkPrefetch.status
? `${Math.min( )}%`,
100,
Math.round((artworkPrefetch.processed / artworkPrefetch.total) * 100)
)}%`
: '30%',
}} }}
/> />
</div> </div>
@@ -1517,22 +1766,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span> </span>
</div> </div>
<div <div
className={`progress ${requestsSync.total ? '' : 'progress-indeterminate'} ${ className={`progress ${requestsSync.status === 'completed' ? 'progress-complete' : ''}`}
requestsSync.status === 'completed' ? 'progress-complete' : ''
}`}
> >
<div <div
className="progress-fill" className="progress-fill"
style={{ style={{
width: width: `${computeProgressPercent(
requestsSync.status === 'completed' requestsSync.stored,
? '100%' requestsSync.total,
: requestsSync.total requestsSync.status
? `${Math.min( )}%`,
100,
Math.round((requestsSync.stored / requestsSync.total) * 100)
)}%`
: '30%',
}} }}
/> />
</div> </div>
@@ -1708,6 +1951,36 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (
setting.key === 'log_http_client_level' ||
setting.key === 'log_background_sync_level'
) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'INFO'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</label>
)
}
if (setting.key === 'artwork_cache_mode') { if (setting.key === 'artwork_cache_mode') {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
@@ -1860,7 +2133,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
})) }))
} }
> >
<option value="always_js">Always use Jellyseerr (slower)</option> <option value="always_js">Always use Seerr (slower)</option>
<option value="prefer_cache"> <option value="prefer_cache">
Use saved requests only (fastest) Use saved requests only (fastest)
</option> </option>
@@ -1930,13 +2203,52 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) )
})} })}
</div> </div>
{sectionFeedback[sectionGroup.key] && (
<div
className={
sectionFeedback[sectionGroup.key]?.tone === 'error'
? 'error-banner'
: 'status-banner'
}
>
{sectionFeedback[sectionGroup.key]?.message}
</div>
)}
<div className="settings-section-actions">
{sectionGroup.key === 'magent-notify-email' ? (
<label className="settings-inline-field">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use the configured sender"
value={emailTestRecipient}
onChange={(event) => setEmailTestRecipient(event.target.value)}
/>
</label>
) : null}
<button
type="button"
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
</button>
{getSectionTestLabel(sectionGroup.key) ? (
<button
type="button"
className="ghost-button"
onClick={() => void testSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionTesting[sectionGroup.key]
? 'Testing...'
: getSectionTestLabel(sectionGroup.key)}
</button>
) : null}
</div>
</section> </section>
))} ))}
{status && <div className="status-banner">{status}</div>} </div>
<div className="admin-actions">
<button type="submit">Save changes</button>
</div>
</form>
) : ( ) : (
<div className="status-banner"> <div className="status-banner">
{section === 'magent' {section === 'magent'
@@ -2004,28 +2316,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<div className="section-header"> <div className="section-header">
<h2>Maintenance</h2> <h2>Maintenance</h2>
</div> </div>
<div className="status-banner"> <div className="maintenance-layout">
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests. <div className="admin-panel maintenance-tools-panel">
</div> <div className="maintenance-panel-copy">
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>} <h3>Recovery and cleanup</h3>
<div className="maintenance-grid"> <p className="lede">
<button type="button" onClick={runRepair}> Run repair, cleanup, logging, and full reset actions from one place. Nuclear flush
Repair database wipes non-admin users, invite links, profiles, cached requests, and history before
</button> re-syncing Seerr users and requests.
<button type="button" className="ghost-button" onClick={runCleanup}> </p>
Clean history (older than 90 days) </div>
</button> <div className="status-banner">
<button type="button" className="ghost-button" onClick={clearLogFile}> Emergency tools. Use with care, especially on live data.
Clear activity log </div>
</button> {maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<button <div className="maintenance-action-grid">
type="button" <div className="maintenance-action-card">
className="danger-button" <div className="maintenance-action-copy">
onClick={runFlushAndResync} <h3>Repair database</h3>
disabled={maintenanceBusy} <p>Run integrity and repair routines against the local Magent database.</p>
> </div>
Nuclear flush + resync <button type="button" onClick={runRepair}>
</button> Repair database
</button>
</div>
<div className="maintenance-action-card">
<div className="maintenance-action-copy">
<h3>Clean request history</h3>
<p>Remove request history entries older than 90 days.</p>
</div>
<button type="button" className="ghost-button" onClick={runCleanup}>
Clean history
</button>
</div>
<div className="maintenance-action-card">
<div className="maintenance-action-copy">
<h3>Clear activity log</h3>
<p>Truncate the local activity log file so fresh troubleshooting starts clean.</p>
</div>
<button type="button" className="ghost-button" onClick={clearLogFile}>
Clear activity log
</button>
</div>
<div className="maintenance-action-card maintenance-action-card-danger">
<div className="maintenance-action-copy">
<h3>Nuclear flush + resync</h3>
<p>Wipe non-admin user and request objects, then rebuild from Seerr.</p>
</div>
<button
type="button"
className="danger-button"
onClick={runFlushAndResync}
disabled={maintenanceBusy}
>
{maintenanceBusy ? 'Running...' : 'Nuclear flush + resync'}
</button>
</div>
</div>
</div>
<AdminDiagnosticsPanel embedded />
</div> </div>
</section> </section>
)} )}

View File

@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'
import SettingsPage from '../SettingsPage' import SettingsPage from '../SettingsPage'
const ALLOWED_SECTIONS = new Set([ const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr', 'jellyseerr',
'jellyfin', 'jellyfin',
'artwork', 'artwork',
@@ -20,12 +21,13 @@ const ALLOWED_SECTIONS = new Set([
]) ])
type PageProps = { type PageProps = {
params: { section: string } params: Promise<{ section: string }>
} }
export default function AdminSectionPage({ params }: PageProps) { export default async function AdminSectionPage({ params }: PageProps) {
if (!ALLOWED_SECTIONS.has(params.section)) { const { section } = await params
if (!ALLOWED_SECTIONS.has(section)) {
notFound() notFound()
} }
return <SettingsPage section={params.section} /> return <SettingsPage section={section} />
} }

View File

@@ -0,0 +1,27 @@
'use client'
import AdminShell from '../../ui/AdminShell'
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
export default function AdminDiagnosticsPage() {
return (
<AdminShell
title="Diagnostics"
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
rail={
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Diagnostics</span>
<h2>Shared console</h2>
<p>
This page and Maintenance now use the same diagnostics panel, so every test target and
notification ping stays in one source of truth.
</p>
</div>
</div>
}
>
<AdminDiagnosticsPanel />
</AdminShell>
)
}

View File

@@ -43,6 +43,7 @@ type Invite = {
remaining_uses?: number | null remaining_uses?: number | null
enabled: boolean enabled: boolean
expires_at?: string | null expires_at?: string | null
recipient_email?: string | null
is_expired?: boolean is_expired?: boolean
is_usable?: boolean is_usable?: boolean
created_at?: string | null created_at?: string | null
@@ -58,6 +59,9 @@ type InviteForm = {
max_uses: string max_uses: string
enabled: boolean enabled: boolean
expires_at: string expires_at: string
recipient_email: string
send_email: boolean
message: string
} }
type ProfileForm = { type ProfileForm = {
@@ -69,10 +73,30 @@ type ProfileForm = {
is_active: boolean is_active: boolean
} }
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
type InviteTraceScope = 'all' | 'invited' | 'direct' type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph' type InviteTraceView = 'list' | 'graph'
type InviteEmailTemplate = {
key: InviteEmailTemplateKey
label: string
description: string
placeholders: string[]
subject: string
body_text: string
body_html: string
}
type InviteEmailSendForm = {
template_key: InviteEmailTemplateKey
recipient_email: string
invite_id: string
username: string
message: string
reason: string
}
type InviteTraceRow = { type InviteTraceRow = {
username: string username: string
role: string role: string
@@ -102,6 +126,18 @@ const defaultInviteForm = (): InviteForm => ({
max_uses: '', max_uses: '',
enabled: true, enabled: true,
expires_at: '', expires_at: '',
recipient_email: '',
send_email: false,
message: '',
})
const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({
template_key: 'invited',
recipient_email: '',
invite_id: '',
username: '',
message: '',
reason: '',
}) })
const defaultProfileForm = (): ProfileForm => ({ const defaultProfileForm = (): ProfileForm => ({
@@ -137,6 +173,9 @@ export default function AdminInviteManagementPage() {
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false) const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false) const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false)
const [invitePolicySaving, setInvitePolicySaving] = useState(false) const [invitePolicySaving, setInvitePolicySaving] = useState(false)
const [templateSaving, setTemplateSaving] = useState(false)
const [templateResetting, setTemplateResetting] = useState(false)
const [emailSending, setEmailSending] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
@@ -152,6 +191,15 @@ export default function AdminInviteManagementPage() {
const [masterInviteSelection, setMasterInviteSelection] = useState('') const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null) const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk') const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
const [templateForm, setTemplateForm] = useState({
subject: '',
body_text: '',
body_html: '',
})
const [emailSendForm, setEmailSendForm] = useState<InviteEmailSendForm>(defaultInviteEmailSendForm())
const [traceFilter, setTraceFilter] = useState('') const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all') const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('graph') const [traceView, setTraceView] = useState<InviteTraceView>('graph')
@@ -161,6 +209,23 @@ export default function AdminInviteManagementPage() {
return `${window.location.origin}/signup` return `${window.location.origin}/signup`
}, []) }, [])
const loadTemplateEditor = (
templateKey: InviteEmailTemplateKey,
templates: InviteEmailTemplate[]
) => {
const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null
if (!template) {
setTemplateForm({ subject: '', body_text: '', body_html: '' })
return
}
setSelectedTemplateKey(template.key)
setTemplateForm({
subject: template.subject ?? '',
body_text: template.body_text ?? '',
body_html: template.body_html ?? '',
})
}
const handleAuthResponse = (response: Response) => { const handleAuthResponse = (response: Response) => {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken()
@@ -183,11 +248,12 @@ export default function AdminInviteManagementPage() {
setError(null) setError(null)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([ const [inviteRes, profileRes, usersRes, policyRes, emailTemplateRes] = await Promise.all([
authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/invites`),
authFetch(`${baseUrl}/admin/profiles`), authFetch(`${baseUrl}/admin/profiles`),
authFetch(`${baseUrl}/admin/users`), authFetch(`${baseUrl}/admin/users`),
authFetch(`${baseUrl}/admin/invites/policy`), authFetch(`${baseUrl}/admin/invites/policy`),
authFetch(`${baseUrl}/admin/invites/email/templates`),
]) ])
if (!inviteRes.ok) { if (!inviteRes.ok) {
if (handleAuthResponse(inviteRes)) return if (handleAuthResponse(inviteRes)) return
@@ -205,11 +271,16 @@ export default function AdminInviteManagementPage() {
if (handleAuthResponse(policyRes)) return if (handleAuthResponse(policyRes)) return
throw new Error(`Failed to load invite policy (${policyRes.status})`) throw new Error(`Failed to load invite policy (${policyRes.status})`)
} }
const [inviteData, profileData, usersData, policyData] = await Promise.all([ if (!emailTemplateRes.ok) {
if (handleAuthResponse(emailTemplateRes)) return
throw new Error(`Failed to load email templates (${emailTemplateRes.status})`)
}
const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([
inviteRes.json(), inviteRes.json(),
profileRes.json(), profileRes.json(),
usersRes.json(), usersRes.json(),
policyRes.json(), policyRes.json(),
emailTemplateRes.json(),
]) ])
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
@@ -219,6 +290,10 @@ export default function AdminInviteManagementPage() {
setMasterInviteSelection( setMasterInviteSelection(
nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id) nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
) )
const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : []
setEmailTemplates(nextTemplates)
setEmailConfigured(emailTemplateData?.email ?? null)
loadTemplateEditor(selectedTemplateKey, nextTemplates)
try { try {
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`) const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
if (jellyfinRes.ok) { if (jellyfinRes.ok) {
@@ -264,6 +339,9 @@ export default function AdminInviteManagementPage() {
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
enabled: invite.enabled !== false, enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? '', expires_at: invite.expires_at ?? '',
recipient_email: invite.recipient_email ?? '',
send_email: false,
message: '',
}) })
setStatus(null) setStatus(null)
setError(null) setError(null)
@@ -285,6 +363,9 @@ export default function AdminInviteManagementPage() {
max_uses: inviteForm.max_uses || null, max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled, enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null, expires_at: inviteForm.expires_at || null,
recipient_email: inviteForm.recipient_email || null,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
} }
const url = const url =
inviteEditingId == null inviteEditingId == null
@@ -300,8 +381,19 @@ export default function AdminInviteManagementPage() {
const text = await response.text() const text = await response.text()
throw new Error(text || 'Save failed') throw new Error(text || 'Save failed')
} }
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
resetInviteEditor() resetInviteEditor()
const data = await response.json()
if (data?.email?.status === 'ok') {
setStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
await loadData() await loadData()
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -349,6 +441,117 @@ export default function AdminInviteManagementPage() {
} }
} }
const prepareInviteEmail = (invite: Invite) => {
setEmailSendForm({
template_key: 'invited',
recipient_email: invite.recipient_email ?? '',
invite_id: String(invite.id),
username: '',
message: '',
reason: '',
})
setActiveTab('emails')
setStatus(
invite.recipient_email
? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.`
: `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.`
)
setError(null)
}
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
setSelectedTemplateKey(templateKey)
loadTemplateEditor(templateKey, emailTemplates)
}
const saveEmailTemplate = async () => {
setTemplateSaving(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templateForm),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Template save failed')
}
setStatus(`Saved ${selectedTemplateKey} email template.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save email template.')
} finally {
setTemplateSaving(false)
}
}
const resetEmailTemplate = async () => {
if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return
setTemplateResetting(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
method: 'DELETE',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Template reset failed')
}
setStatus(`Reset ${selectedTemplateKey} template to default.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not reset email template.')
} finally {
setTemplateResetting(false)
}
}
const sendEmailTemplate = async (event: React.FormEvent) => {
event.preventDefault()
setEmailSending(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/email/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_key: emailSendForm.template_key,
recipient_email: emailSendForm.recipient_email || null,
invite_id: emailSendForm.invite_id || null,
username: emailSendForm.username || null,
message: emailSendForm.message || null,
reason: emailSendForm.reason || null,
}),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Email send failed')
}
const data = await response.json()
setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? 'recipient'}.`)
if (emailSendForm.template_key === 'invited') {
await loadData()
}
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not send email.')
} finally {
setEmailSending(false)
}
}
const resetProfileEditor = () => { const resetProfileEditor = () => {
setProfileEditingId(null) setProfileEditingId(null)
setProfileForm(defaultProfileForm()) setProfileForm(defaultProfileForm())
@@ -588,8 +791,11 @@ export default function AdminInviteManagementPage() {
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
const disabledInvites = invites.filter((invite) => invite.enabled === false).length const disabledInvites = invites.filter((invite) => invite.enabled === false).length
const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const masterInvite = invitePolicy?.master_invite ?? null const masterInvite = invitePolicy?.master_invite ?? null
const selectedTemplate =
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
const inviteTraceRows = useMemo(() => { const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>() const inviteByCode = new Map<string, Invite>()
@@ -813,6 +1019,20 @@ export default function AdminInviteManagementPage() {
<span>users with custom expiry</span> <span>users with custom expiry</span>
</div> </div>
</div> </div>
<div className="invite-admin-summary-row">
<span className="label">Email templates</span>
<div className="invite-admin-summary-row__value">
<strong>{emailTemplates.length}</strong>
<span>{invitesWithRecipient} invites with recipient email</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">SMTP email</span>
<div className="invite-admin-summary-row__value">
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
<span>{emailConfigured?.detail ?? 'Email settings unavailable'}</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -866,6 +1086,15 @@ export default function AdminInviteManagementPage() {
> >
Trace map Trace map
</button> </button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'emails'}
className={activeTab === 'emails' ? 'is-active' : ''}
onClick={() => setActiveTab('emails')}
>
Email
</button>
</div> </div>
<div className="admin-inline-actions invite-admin-tab-actions"> <div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}> <button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
@@ -1229,6 +1458,7 @@ export default function AdminInviteManagementPage() {
</span> </span>
<span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span> <span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Expires: {formatDate(invite.expires_at)}</span> <span>Expires: {formatDate(invite.expires_at)}</span>
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>Created: {formatDate(invite.created_at)}</span> <span>Created: {formatDate(invite.created_at)}</span>
</div> </div>
</div> </div>
@@ -1236,6 +1466,9 @@ export default function AdminInviteManagementPage() {
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}> <button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link Copy link
</button> </button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Email invite
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}> <button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit Edit
</button> </button>
@@ -1371,6 +1604,47 @@ export default function AdminInviteManagementPage() {
</div> </div>
</div> </div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="person@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(e) =>
setInviteForm((current) => ({ ...current, message: e.target.value }))
}
placeholder="Optional message appended to the invite email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
}
/>
Send You have been invited email after saving
</label>
</div>
</div>
<div className="invite-form-row"> <div className="invite-form-row">
<div className="invite-form-row-label"> <div className="invite-form-row-label">
<span>Status</span> <span>Status</span>
@@ -1404,6 +1678,249 @@ export default function AdminInviteManagementPage() {
</div> </div>
)} )}
{activeTab === 'emails' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Email templates</h2>
<p className="lede">
Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
</p>
</div>
</div>
{!emailConfigured?.configured && (
<div className="status-banner">
{emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
</div>
)}
<div className="invite-email-template-picker" role="tablist" aria-label="Email templates">
{emailTemplates.map((template) => (
<button
key={template.key}
type="button"
className={selectedTemplateKey === template.key ? 'is-active' : ''}
onClick={() => selectEmailTemplate(template.key)}
>
{template.label}
</button>
))}
</div>
{selectedTemplate ? (
<div className="invite-email-template-meta">
<h3>{selectedTemplate.label}</h3>
<p className="lede">{selectedTemplate.description}</p>
</div>
) : null}
<form
className="admin-form compact-form invite-form-layout invite-email-template-form"
onSubmit={(event) => {
event.preventDefault()
void saveEmailTemplate()
}}
>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Subject</span>
<small>Rendered with the same placeholder variables as the body.</small>
</div>
<div className="invite-form-row-control">
<input
value={templateForm.subject}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, subject: event.target.value }))
}
placeholder="Email subject"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Plain text body</span>
<small>Used for mail clients that prefer text only.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={12}
value={templateForm.body_text}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
}
placeholder="Plain text email body"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>HTML body</span>
<small>Optional rich HTML version. Basic HTML is supported.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={14}
value={templateForm.body_html}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
}
placeholder="<h1>Hello</h1><p>HTML email body</p>"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Placeholders</span>
<small>Use these anywhere in the subject or body.</small>
</div>
<div className="invite-form-row-control invite-email-placeholder-list">
{(selectedTemplate?.placeholders ?? []).map((placeholder) => (
<code key={placeholder}>{`{{${placeholder}}}`}</code>
))}
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={templateSaving}>
{templateSaving ? 'Saving…' : 'Save template'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void resetEmailTemplate()}
disabled={templateResetting}
>
{templateResetting ? 'Resetting…' : 'Reset to default'}
</button>
</div>
</form>
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>Send email</h2>
<p className="lede">
Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
</p>
<form onSubmit={sendEmailTemplate} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Template</span>
<small>Select which lifecycle email to send.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Template</span>
<select
value={emailSendForm.template_key}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
template_key: event.target.value as InviteEmailTemplateKey,
}))
}
>
{emailTemplates.map((template) => (
<option key={template.key} value={template.key}>
{template.label}
</option>
))}
</select>
</label>
<label>
<span>Recipient email</span>
<input
type="email"
value={emailSendForm.recipient_email}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Optional if invite/user already has one"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Context</span>
<small>Link the email to an invite or username to fill placeholders automatically.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Invite</span>
<select
value={emailSendForm.invite_id}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
invite_id: event.target.value,
}))
}
>
<option value="">None</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ''}
</option>
))}
</select>
</label>
<label>
<span>Username</span>
<input
value={emailSendForm.username}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
username: event.target.value,
}))
}
placeholder="Optional user lookup"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Reason / note</span>
<small>Used by warning and banned templates, and appended to other emails.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Reason</span>
<input
value={emailSendForm.reason}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
}
placeholder="Optional reason"
/>
</label>
<label>
<span>Message</span>
<textarea
rows={6}
value={emailSendForm.message}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="Optional message"
/>
</label>
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={emailSending || !emailConfigured?.configured}>
{emailSending ? 'Sending…' : 'Send email'}
</button>
</div>
</form>
</div>
</div>
)}
{activeTab === 'trace' && ( {activeTab === 'trace' && (
<div className="invite-admin-stack"> <div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel"> <div className="admin-panel invite-admin-list-panel">

View File

@@ -21,7 +21,7 @@ const REQUEST_FLOW: FlowStage[] = [
}, },
{ {
title: 'Request intake', title: 'Request intake',
input: 'Jellyseerr request ID', input: 'Seerr request ID',
action: 'Magent snapshots request + media metadata', action: 'Magent snapshots request + media metadata',
output: 'Unified request state', output: 'Unified request state',
}, },

View File

@@ -1537,6 +1537,29 @@ button span {
justify-content: flex-end; justify-content: flex-end;
} }
.settings-section-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
align-items: end;
}
.settings-inline-field {
display: grid;
gap: 6px;
min-width: min(100%, 320px);
flex: 1 1 320px;
}
.settings-inline-field span {
color: var(--ink-muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 700;
}
.settings-nav { .settings-nav {
display: flex; display: flex;
gap: 16px; gap: 16px;
@@ -1631,6 +1654,61 @@ button span {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
} }
.maintenance-layout {
display: grid;
gap: 14px;
}
.maintenance-tools-panel {
display: grid;
gap: 14px;
}
.maintenance-panel-copy {
display: grid;
gap: 8px;
}
.maintenance-action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.maintenance-action-card {
display: grid;
gap: 12px;
align-content: start;
padding: 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.035);
}
.maintenance-action-card button {
justify-self: start;
}
.maintenance-action-copy {
display: grid;
gap: 6px;
}
.maintenance-action-copy h3 {
font-size: 16px;
}
.maintenance-action-copy p {
color: var(--ink-muted);
font-size: 14px;
line-height: 1.45;
}
.maintenance-action-card-danger {
border-color: rgba(255, 107, 43, 0.24);
background: rgba(255, 107, 43, 0.05);
}
.schedule-grid { .schedule-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -2197,7 +2275,7 @@ button span {
pointer-events: none; pointer-events: none;
} }
.step-jellyseerr::before { .step-seerr::before {
background: linear-gradient(135deg, rgba(255, 187, 92, 0.35), transparent 60%); background: linear-gradient(135deg, rgba(255, 187, 92, 0.35), transparent 60%);
} }
@@ -4641,6 +4719,48 @@ button:hover:not(:disabled) {
gap: 10px; gap: 10px;
} }
.invite-email-template-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.invite-email-template-picker button {
border-radius: 8px;
}
.invite-email-template-picker button.is-active {
border-color: rgba(135, 182, 255, 0.4);
background: rgba(86, 132, 220, 0.14);
color: #eef2f7;
}
.invite-email-template-meta {
display: grid;
gap: 4px;
margin-bottom: 10px;
}
.invite-email-template-meta h3 {
margin: 0;
}
.invite-email-placeholder-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.invite-email-placeholder-list code {
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #d6dde8;
font-size: 0.78rem;
}
.admin-panel > h2 + .lede { .admin-panel > h2 + .lede {
margin-top: -2px; margin-top: -2px;
} }
@@ -5647,3 +5767,306 @@ textarea {
grid-column: 1; grid-column: 1;
} }
} }
.diagnostics-page {
display: grid;
gap: 1.25rem;
}
.diagnostics-header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.diagnostics-control-panel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.diagnostics-control-copy {
max-width: 52rem;
}
.diagnostics-control-actions {
display: flex;
align-items: end;
gap: 0.75rem;
flex-wrap: wrap;
}
.diagnostics-email-recipient {
display: grid;
gap: 0.35rem;
min-width: min(100%, 20rem);
flex: 1 1 20rem;
}
.diagnostics-email-recipient span {
color: var(--ink-muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.diagnostics-inline-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.85rem;
align-items: stretch;
}
.diagnostics-inline-metric {
display: grid;
gap: 0.2rem;
min-width: 0;
padding: 0.85rem 0.95rem;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.diagnostics-inline-metric span {
color: var(--ink-muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.diagnostics-inline-metric strong {
font-size: 1rem;
line-height: 1.2;
}
.diagnostics-inline-last-run {
grid-column: 1 / -1;
color: var(--ink-muted);
font-size: 0.9rem;
}
.diagnostics-control-actions .is-active {
border-color: rgba(92, 141, 255, 0.44);
background: rgba(92, 141, 255, 0.12);
}
.diagnostics-error {
color: #ffb4b4;
border-color: rgba(255, 118, 118, 0.32);
background: rgba(96, 20, 20, 0.28);
}
.diagnostics-category-panel {
display: grid;
gap: 1rem;
}
.diagnostics-category-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.diagnostics-category-header h2 {
margin: 0 0 0.35rem;
}
.diagnostics-category-header p {
margin: 0;
color: var(--muted);
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1rem;
}
.diagnostic-card {
display: grid;
gap: 1rem;
padding: 1rem;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)),
rgba(8, 12, 20, 0.82);
}
.diagnostic-card-up {
border-color: rgba(78, 201, 140, 0.28);
}
.diagnostic-card-down {
border-color: rgba(255, 116, 116, 0.26);
}
.diagnostic-card-degraded {
border-color: rgba(255, 194, 99, 0.24);
}
.diagnostic-card-disabled,
.diagnostic-card-not_configured {
border-color: rgba(161, 173, 192, 0.2);
}
.diagnostic-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.diagnostic-card-copy {
display: grid;
gap: 0.45rem;
}
.diagnostic-card-title-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.diagnostic-card-title-row h3 {
margin: 0;
font-size: 1.05rem;
}
.diagnostic-card-copy p {
margin: 0;
color: var(--muted);
}
.diagnostic-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.diagnostic-meta-item {
display: grid;
gap: 0.2rem;
min-width: 0;
padding: 0.75rem;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.diagnostic-meta-item span {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.diagnostic-meta-item strong {
font-size: 0.95rem;
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
}
.diagnostic-message {
display: flex;
align-items: center;
gap: 0.7rem;
min-height: 2.8rem;
padding: 0.8rem 0.95rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.03);
}
.diagnostic-message-up {
background: rgba(40, 95, 66, 0.22);
}
.diagnostic-message-down {
background: rgba(105, 33, 33, 0.24);
}
.diagnostic-message-degraded {
background: rgba(115, 82, 27, 0.2);
}
.diagnostic-message-disabled,
.diagnostic-message-not_configured,
.diagnostic-message-idle {
background: rgba(255, 255, 255, 0.03);
}
.diagnostics-rail-metrics {
display: grid;
gap: 0.75rem;
}
.diagnostics-rail-metric {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.diagnostics-rail-metric span {
color: var(--muted);
font-size: 0.85rem;
}
.diagnostics-rail-metric strong {
font-size: 1rem;
}
.diagnostics-rail-status {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.diagnostics-rail-last-run {
margin: 0.85rem 0 0;
}
.small-pill.is-positive {
border-color: rgba(78, 201, 140, 0.34);
color: rgba(206, 255, 227, 0.92);
background: rgba(31, 92, 62, 0.22);
}
.system-pill-idle,
.system-pill-not_configured,
.system-pill-disabled {
color: rgba(224, 230, 239, 0.84);
background: rgba(129, 141, 158, 0.18);
border-color: rgba(129, 141, 158, 0.26);
}
.system-disabled .system-dot {
background: rgba(151, 164, 184, 0.76);
}
@media (max-width: 1024px) {
.diagnostics-control-panel,
.diagnostic-card-top,
.diagnostics-category-header {
flex-direction: column;
}
.settings-section-actions {
justify-content: stretch;
}
.settings-section-actions > * {
width: 100%;
}
}
@media (max-width: 720px) {
.diagnostic-meta-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -14,7 +14,7 @@ export default function HowItWorksPage() {
<section className="how-grid"> <section className="how-grid">
<article className="how-card"> <article className="how-card">
<h2>Jellyseerr</h2> <h2>Seerr</h2>
<p className="how-title">The request box</p> <p className="how-title">The request box</p>
<p> <p>
This is where you ask for a movie or show. It keeps the request and whether it is This is where you ask for a movie or show. It keeps the request and whether it is
@@ -55,7 +55,7 @@ export default function HowItWorksPage() {
<h2>The pipeline (request to ready)</h2> <h2>The pipeline (request to ready)</h2>
<ol className="how-steps"> <ol className="how-steps">
<li> <li>
<strong>Request created</strong> in Jellyseerr. <strong>Request created</strong> in Seerr.
</li> </li>
<li> <li>
<strong>Approved</strong> and sent to Sonarr/Radarr. <strong>Approved</strong> and sent to Sonarr/Radarr.
@@ -108,7 +108,7 @@ export default function HowItWorksPage() {
<section className="how-flow"> <section className="how-flow">
<h2>Request actions and when to use them</h2> <h2>Request actions and when to use them</h2>
<div className="how-step-grid"> <div className="how-step-grid">
<article className="how-step-card step-jellyseerr"> <article className="how-step-card step-seerr">
<div className="step-badge">1</div> <div className="step-badge">1</div>
<h3>Re-add to Arr</h3> <h3>Re-add to Arr</h3>
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p> <p className="step-note">Use when a request is approved but never entered the Arr queue.</p>

View File

@@ -352,7 +352,7 @@ export default function HomePage() {
<div className="system-list"> <div className="system-list">
{(() => { {(() => {
const order = [ const order = [
'Jellyseerr', 'Seerr',
'Sonarr', 'Sonarr',
'Radarr', 'Radarr',
'Prowlarr', 'Prowlarr',

View File

@@ -53,6 +53,7 @@ type OwnedInvite = {
code: string code: string
label?: string | null label?: string | null
description?: string | null description?: string | null
recipient_email?: string | null
max_uses?: number | null max_uses?: number | null
use_count: number use_count: number
remaining_uses?: number | null remaining_uses?: number | null
@@ -87,9 +88,12 @@ type OwnedInviteForm = {
code: string code: string
label: string label: string
description: string description: string
recipient_email: string
max_uses: string max_uses: string
expires_at: string expires_at: string
enabled: boolean enabled: boolean
send_email: boolean
message: string
} }
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security' type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
@@ -98,9 +102,12 @@ const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '', code: '',
label: '', label: '',
description: '', description: '',
recipient_email: '',
max_uses: '', max_uses: '',
expires_at: '', expires_at: '',
enabled: true, enabled: true,
send_email: false,
message: '',
}) })
const formatDate = (value?: string | null) => { const formatDate = (value?: string | null) => {
@@ -250,9 +257,12 @@ export default function ProfilePage() {
code: invite.code ?? '', code: invite.code ?? '',
label: invite.label ?? '', label: invite.label ?? '',
description: invite.description ?? '', description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '', expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false, enabled: invite.enabled !== false,
send_email: false,
message: '',
}) })
} }
@@ -292,9 +302,12 @@ export default function ProfilePage() {
code: inviteForm.code || null, code: inviteForm.code || null,
label: inviteForm.label || null, label: inviteForm.label || null,
description: inviteForm.description || null, description: inviteForm.description || null,
recipient_email: inviteForm.recipient_email || null,
max_uses: inviteForm.max_uses || null, max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null, expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled, enabled: inviteForm.enabled,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}), }),
} }
) )
@@ -307,7 +320,18 @@ export default function ProfilePage() {
const text = await response.text() const text = await response.text()
throw new Error(text || 'Invite save failed') throw new Error(text || 'Invite save failed')
} }
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') const data = await response.json().catch(() => ({}))
if (data?.email?.status === 'ok') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
resetInviteEditor() resetInviteEditor()
await reloadInvites() await reloadInvites()
} catch (err) { } catch (err) {
@@ -603,6 +627,56 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="friend@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row"> <div className="invite-form-row">
<div className="invite-form-row-label"> <div className="invite-form-row-label">
<span>Limits</span> <span>Limits</span>
@@ -700,6 +774,7 @@ export default function ProfilePage() {
</p> </p>
)} )}
<div className="admin-meta-row"> <div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span> <span>
Uses: {invite.use_count} Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''} {typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}

View File

@@ -2,7 +2,7 @@
import Image from 'next/image' import Image from 'next/image'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
type TimelineHop = { type TimelineHop = {
@@ -140,7 +140,7 @@ const friendlyState = (value: string) => {
} }
const friendlyTimelineStatus = (service: string, status: string) => { const friendlyTimelineStatus = (service: string, status: string) => {
if (service === 'Jellyseerr') { if (service === 'Seerr') {
const map: Record<string, string> = { const map: Record<string, string> = {
Pending: 'Waiting for approval', Pending: 'Waiting for approval',
Approved: 'Approved', Approved: 'Approved',
@@ -195,7 +195,9 @@ const friendlyTimelineStatus = (service: string, status: string) => {
return status return status
} }
export default function RequestTimelinePage({ params }: { params: { id: string } }) { export default function RequestTimelinePage() {
const params = useParams<{ id: string | string[] }>()
const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
const router = useRouter() const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null) const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -208,6 +210,9 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]) const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
useEffect(() => { useEffect(() => {
if (!requestId) {
return
}
const load = async () => { const load = async () => {
try { try {
if (!getToken()) { if (!getToken()) {
@@ -216,9 +221,9 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
} }
const baseUrl = getApiBase() const baseUrl = getApiBase()
const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([ const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${params.id}/snapshot`), authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
authFetch(`${baseUrl}/requests/${params.id}/history?limit=5`), authFetch(`${baseUrl}/requests/${requestId}/history?limit=5`),
authFetch(`${baseUrl}/requests/${params.id}/actions?limit=5`), authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`),
]) ])
if (snapshotResponse.status === 401) { if (snapshotResponse.status === 401) {
@@ -252,10 +257,10 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
} }
load() load()
}, [params.id, router]) }, [requestId, router])
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken() || !requestId) {
return return
} }
const baseUrl = getApiBase() const baseUrl = getApiBase()
@@ -267,7 +272,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const streamToken = await getEventStreamToken() const streamToken = await getEventStreamToken()
if (closed) return if (closed) return
const streamUrl = `${baseUrl}/events/requests/${encodeURIComponent( const streamUrl = `${baseUrl}/events/requests/${encodeURIComponent(
params.id requestId
)}/stream?stream_token=${encodeURIComponent(streamToken)}` )}/stream?stream_token=${encodeURIComponent(streamToken)}`
source = new EventSource(streamUrl) source = new EventSource(streamUrl)
@@ -278,7 +283,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') { if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') {
return return
} }
if (String(payload.request_id ?? '') !== String(params.id)) { if (String(payload.request_id ?? '') !== String(requestId)) {
return return
} }
if (payload.snapshot && typeof payload.snapshot === 'object') { if (payload.snapshot && typeof payload.snapshot === 'object') {
@@ -310,7 +315,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
closed = true closed = true
source?.close() source?.close()
} }
}, [params.id]) }, [requestId])
if (loading) { if (loading) {
return ( return (
@@ -337,7 +342,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const arrStageLabel = const arrStageLabel =
snapshot.state === 'NEEDS_ADD' ? 'Push to Sonarr/Radarr' : 'Library queue' snapshot.state === 'NEEDS_ADD' ? 'Push to Sonarr/Radarr' : 'Library queue'
const pipelineSteps = [ const pipelineSteps = [
{ key: 'Jellyseerr', label: 'Jellyseerr' }, { key: 'Seerr', label: 'Seerr' },
{ key: 'Sonarr/Radarr', label: arrStageLabel }, { key: 'Sonarr/Radarr', label: arrStageLabel },
{ key: 'Prowlarr', label: 'Search' }, { key: 'Prowlarr', label: 'Search' },
{ key: 'qBittorrent', label: 'Download' }, { key: 'qBittorrent', label: 'Download' },

View File

@@ -0,0 +1,417 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type DiagnosticCatalogItem = {
key: string
label: string
category: string
description: string
live_safe: boolean
target: string | null
configured: boolean
config_status: string
config_detail: string
}
type DiagnosticResult = {
key: string
label: string
category: string
description: string
target: string | null
live_safe: boolean
configured: boolean
status: string
message: string
detail?: unknown
checked_at?: string
duration_ms?: number
}
type DiagnosticsResponse = {
checks: DiagnosticCatalogItem[]
categories: string[]
generated_at: string
}
type RunDiagnosticsResponse = {
results: DiagnosticResult[]
summary: {
total: number
up: number
down: number
degraded: number
not_configured: number
disabled: number
}
checked_at: string
}
type RunMode = 'safe' | 'all' | 'single'
type AdminDiagnosticsPanelProps = {
embedded?: boolean
}
const REFRESH_INTERVAL_MS = 30000
const STATUS_LABELS: Record<string, string> = {
idle: 'Ready',
up: 'Up',
down: 'Down',
degraded: 'Degraded',
disabled: 'Disabled',
not_configured: 'Not configured',
}
function formatCheckedAt(value?: string) {
if (!value) return 'Not yet run'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
}
function formatDuration(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
return 'Pending'
}
return `${value.toFixed(1)} ms`
}
function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [authorized, setAuthorized] = useState(false)
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([])
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({})
const [runningKeys, setRunningKeys] = useState<string[]>([])
const [autoRefresh, setAutoRefresh] = useState(true)
const [pageError, setPageError] = useState('')
const [lastRunAt, setLastRunAt] = useState<string | null>(null)
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null)
const [emailRecipient, setEmailRecipient] = useState('')
const liveSafeKeys = checks.filter((check) => check.live_safe).map((check) => check.key)
async function runDiagnostics(keys?: string[], mode: RunMode = 'single') {
const baseUrl = getApiBase()
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key)
if (effectiveKeys.length === 0) {
return
}
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])))
setPageError('')
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Diagnostics run failed: ${response.status}`)
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse
const nextResults: Record<string, DiagnosticResult> = {}
for (const result of data.results ?? []) {
nextResults[result.key] = result
}
setResultsByKey((current) => ({ ...current, ...nextResults }))
setLastRunAt(data.checked_at ?? new Date().toISOString())
setLastRunMode(mode)
} catch (error) {
console.error(error)
setPageError(error instanceof Error ? error.message : 'Diagnostics run failed.')
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)))
}
}
useEffect(() => {
let active = true
const loadPage = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
const baseUrl = getApiBase()
const authResponse = await authFetch(`${baseUrl}/auth/me`)
if (!authResponse.ok) {
if (authResponse.status === 401) {
clearToken()
router.push('/login')
return
}
router.push('/')
return
}
const me = await authResponse.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
}
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`)
if (!diagnosticsResponse.ok) {
const text = await diagnosticsResponse.text()
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`)
}
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse
if (!active) return
setChecks(data.checks ?? [])
setAuthorized(true)
setLoading(false)
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key)
if (safeKeys.length > 0) {
void runDiagnostics(safeKeys, 'safe')
}
} catch (error) {
console.error(error)
if (!active) return
setPageError(error instanceof Error ? error.message : 'Unable to load diagnostics.')
setLoading(false)
}
}
void loadPage()
return () => {
active = false
}
}, [router])
useEffect(() => {
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
return
}
const interval = window.setInterval(() => {
void runDiagnostics(liveSafeKeys, 'safe')
}, REFRESH_INTERVAL_MS)
return () => {
window.clearInterval(interval)
}
}, [authorized, autoRefresh, liveSafeKeys.join('|')])
if (loading) {
return <div className="admin-panel">Loading diagnostics...</div>
}
if (!authorized) {
return null
}
const orderedCategories: string[] = []
for (const check of checks) {
if (!orderedCategories.includes(check.category)) {
orderedCategories.push(check.category)
}
}
const mergedResults = checks.map((check) => {
const result = resultsByKey[check.key]
if (result) {
return result
}
return {
key: check.key,
label: check.label,
category: check.category,
description: check.description,
target: check.target,
live_safe: check.live_safe,
configured: check.configured,
status: check.configured ? 'idle' : check.config_status,
message: check.configured ? 'Ready to test.' : check.config_detail,
checked_at: undefined,
duration_ms: undefined,
} satisfies DiagnosticResult
})
const summary = {
total: mergedResults.length,
up: 0,
down: 0,
degraded: 0,
disabled: 0,
not_configured: 0,
idle: 0,
}
for (const result of mergedResults) {
const key = result.status as keyof typeof summary
if (key in summary) {
summary[key] += 1
}
}
return (
<div className={`diagnostics-page${embedded ? ' diagnostics-page-embedded' : ''}`}>
<div className="admin-panel diagnostics-control-panel">
<div className="diagnostics-control-copy">
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2>
<p className="lede">
Use live checks for Magent and service connectivity. Use run all when you want outbound notification
channels to send a real ping through the configured provider.
</p>
</div>
<div className="diagnostics-control-actions">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<button
type="button"
className={autoRefresh ? 'is-active' : ''}
onClick={() => setAutoRefresh((current) => !current)}
>
{autoRefresh ? 'Disable auto refresh' : 'Enable auto refresh'}
</button>
<button
type="button"
onClick={() => {
void runDiagnostics(liveSafeKeys, 'safe')
}}
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
>
Run live checks
</button>
<button
type="button"
onClick={() => {
void runDiagnostics(undefined, 'all')
}}
disabled={runningKeys.length > 0 || checks.length === 0}
>
Run all tests
</button>
<span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}>
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
</span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : 'No run yet'}</span>
</div>
</div>
<div className="admin-panel diagnostics-inline-summary">
<div className="diagnostics-inline-metric">
<span>Total</span>
<strong>{summary.total}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Up</span>
<strong>{summary.up}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Degraded</span>
<strong>{summary.degraded}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Down</span>
<strong>{summary.down}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Disabled</span>
<strong>{summary.disabled}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Not configured</span>
<strong>{summary.not_configured}</strong>
</div>
<div className="diagnostics-inline-last-run">
Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}
</div>
</div>
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
{orderedCategories.map((category) => {
const categoryChecks = mergedResults.filter((check) => check.category === category)
return (
<div key={category} className="admin-panel diagnostics-category-panel">
<div className="diagnostics-category-header">
<div>
<h2>{category}</h2>
<p>{category === 'Notifications' ? 'These tests can emit real messages.' : 'Safe live health checks.'}</p>
</div>
<span className="small-pill">{categoryChecks.length} checks</span>
</div>
<div className="diagnostics-grid">
{categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key)
return (
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
<div className="diagnostic-card-top">
<div className="diagnostic-card-copy">
<div className="diagnostic-card-title-row">
<h3>{check.label}</h3>
<span className={`system-pill system-pill-${check.status}`}>{statusLabel(check.status)}</span>
</div>
<p>{check.description}</p>
</div>
<button
type="button"
className="system-test"
onClick={() => {
void runDiagnostics([check.key], 'single')
}}
disabled={isRunning}
>
{check.live_safe ? 'Ping' : 'Send test'}
</button>
</div>
<div className="diagnostic-meta-grid">
<div className="diagnostic-meta-item">
<span>Target</span>
<strong>{check.target || 'Not set'}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Latency</span>
<strong>{formatDuration(check.duration_ms)}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Mode</span>
<strong>{check.live_safe ? 'Live safe' : 'Manual only'}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Last checked</span>
<strong>{formatCheckedAt(check.checked_at)}</strong>
</div>
</div>
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
<span className="system-dot" />
<span>{isRunning ? 'Running diagnostic...' : check.message}</span>
</div>
</article>
)
})}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -7,7 +7,7 @@ const NAV_GROUPS = [
title: 'Services', title: 'Services',
items: [ items: [
{ href: '/admin/general', label: 'General' }, { href: '/admin/general', label: 'General' },
{ href: '/admin/jellyseerr', label: 'Jellyseerr' }, { href: '/admin/seerr', label: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' }, { href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' }, { href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' }, { href: '/admin/radarr', label: 'Radarr' },

View File

@@ -460,7 +460,7 @@ export default function UserDetailPage() {
</div> </div>
<div className="user-detail-meta-grid"> <div className="user-detail-meta-grid">
<div className="user-detail-meta-item"> <div className="user-detail-meta-item">
<span className="label">Jellyseerr ID</span> <span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong> <strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
</div> </div>
<div className="user-detail-meta-item"> <div className="user-detail-meta-item">

View File

@@ -155,7 +155,7 @@ export default function UsersPage() {
await loadUsers() await loadUsers()
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setJellyseerrSyncStatus('Could not sync Jellyseerr users.') setJellyseerrSyncStatus('Could not sync Seerr users.')
} finally { } finally {
setJellyseerrSyncBusy(false) setJellyseerrSyncBusy(false)
} }
@@ -163,7 +163,7 @@ export default function UsersPage() {
const resyncJellyseerrUsers = async () => { const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm( const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Jellyseerr. Continue?' 'This will remove all non-admin users and re-import from Seerr. Continue?'
) )
if (!confirmed) return if (!confirmed) return
setJellyseerrSyncStatus(null) setJellyseerrSyncStatus(null)
@@ -184,7 +184,7 @@ export default function UsersPage() {
await loadUsers() await loadUsers()
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setJellyseerrSyncStatus('Could not resync Jellyseerr users.') setJellyseerrSyncStatus('Could not resync Seerr users.')
} finally { } finally {
setJellyseerrResyncBusy(false) setJellyseerrResyncBusy(false)
} }
@@ -322,17 +322,17 @@ export default function UsersPage() {
</div> </div>
</div> </div>
<div className="users-page-toolbar-group"> <div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Jellyseerr sync</span> <span className="users-page-toolbar-label">Seerr sync</span>
<div className="users-page-toolbar-actions"> <div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}> <button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'} {jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
</button> </button>
<button <button
type="button" type="button"
onClick={resyncJellyseerrUsers} onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy} disabled={jellyseerrResyncBusy}
> >
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'} {jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
</button> </button>
</div> </div>
</div> </div>

979
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,979 @@
{
"name": "magent-frontend",
"version": "0103262231",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
"version": "0103262231",
"dependencies": {
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/node": "24.11.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.6",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"sharp": "^0.34.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "2702261314", "version": "0103262231",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -9,14 +9,17 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"next": "14.2.5", "next": "16.1.6",
"react": "18.3.1", "react": "19.2.4",
"react-dom": "18.3.1" "react-dom": "19.2.4"
}, },
"devDependencies": { "devDependencies": {
"typescript": "5.5.4", "typescript": "5.9.3",
"@types/node": "20.14.10", "@types/node": "24.11.0",
"@types/react": "18.3.3", "@types/react": "19.2.14",
"@types/react-dom": "18.3.0" "@types/react-dom": "19.2.3"
} }
} }

View File

@@ -11,9 +11,20 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true "incremental": true,
"plugins": [
{
"name": "next"
}
]
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }