Start Magent beta overhaul
Magent CI/CD / verify (push) Successful in 13m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-08-29 20:27:17 +12:00
commit 655e2f8158
111 changed files with 41873 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
0803262237
+11
View File
@@ -0,0 +1,11 @@
.git
.env
*.log
data/*
!data/branding/
!data/branding/**
frontend/node_modules/
frontend/.next/
backend/__pycache__/
**/__pycache__/
**/*.pyc
+17
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
+104
View File
@@ -0,0 +1,104 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- prod
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Build frontend
working-directory: frontend
run: npm run build
deploy-prod:
if: github.ref_name == 'prod'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_ams_dev01.sh
deploy-beta:
if: github.ref_name == 'beta'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy beta to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_beta_ams_dev01.sh
+12
View File
@@ -0,0 +1,12 @@
.env
.venv/
data/
!data/branding/
!data/branding/**
backend/__pycache__/
**/__pycache__/
*.pyc
backend/.pytest_cache/
frontend/node_modules/
frontend/.next/
*.log
+53
View File
@@ -0,0 +1,53 @@
FROM node:24-slim AS frontend-builder
WORKDIR /frontend
ENV NODE_ENV=production \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --include=dev
COPY frontend/app ./app
COPY frontend/public ./public
COPY frontend/next-env.d.ts ./next-env.d.ts
COPY frontend/next.config.js ./next.config.js
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
FROM python:3.14-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --from=frontend-builder /frontend/public /app/frontend/public
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
EXPOSE 3000 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
+189
View File
@@ -0,0 +1,189 @@
# Magent
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
## How it works
1) Requests are pulled from Seerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
5) Optional AI triage summarizes the likely cause and safest next steps.
## Core features
- Request search by title/year or request ID.
- Recent requests list with posters and status.
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders.
- Health status for each service in the pipeline.
- Cache and sync controls (full sync, delta sync, scheduled syncs).
- Local database for speed and audit history.
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Docker-first deployment for easy hosting.
## Quick start (Docker - primary)
Docker is the recommended way to run Magent. It includes the backend and frontend with sane defaults.
```bash
docker compose up --build
```
Then open:
- Frontend: http://localhost:3000
- Backend: http://localhost:8000
### Docker setup steps
1) Create `.env` with your service URLs and API keys.
2) Run `docker compose up --build`.
3) Log in at http://localhost:3000.
4) Visit Settings to confirm service health.
### Docker environment variables (sample)
```bash
JELLYSEERR_URL="http://localhost:5055"
JELLYSEERR_API_KEY="..."
SONARR_URL="http://localhost:8989"
SONARR_API_KEY="..."
SONARR_QUALITY_PROFILE_ID="1"
SONARR_ROOT_FOLDER="/tv"
RADARR_URL="http://localhost:7878"
RADARR_API_KEY="..."
RADARR_QUALITY_PROFILE_ID="1"
RADARR_ROOT_FOLDER="/movies"
PROWLARR_URL="http://localhost:9696"
PROWLARR_API_KEY="..."
QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="replace-with-a-long-random-secret"
JWT_EXP_MINUTES="720"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
## Screenshots
Add screenshots here once available:
- `docs/screenshots/home.png`
- `docs/screenshots/request-timeline.png`
- `docs/screenshots/settings.png`
- `docs/screenshots/profile.png`
## Local development (secondary)
Use this only when you need to modify code locally.
### Backend (FastAPI)
```bash
cd backend
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
Environment variables (sample):
```bash
$env:JELLYSEERR_URL="http://localhost:5055"
$env:JELLYSEERR_API_KEY="..."
$env:SONARR_URL="http://localhost:8989"
$env:SONARR_API_KEY="..."
$env:SONARR_QUALITY_PROFILE_ID="1"
$env:SONARR_ROOT_FOLDER="/tv"
$env:RADARR_URL="http://localhost:7878"
$env:RADARR_API_KEY="..."
$env:RADARR_QUALITY_PROFILE_ID="1"
$env:RADARR_ROOT_FOLDER="/movies"
$env:PROWLARR_URL="http://localhost:9696"
$env:PROWLARR_API_KEY="..."
$env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="replace-with-a-long-random-secret"
$env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
### Frontend (Next.js)
```bash
cd frontend
npm install
npm run dev
```
Open http://localhost:3000
Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other local user you create in SQLite).
## Public Hosting Notes
The frontend proxies `/api/*` to the backend container. Set:
- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
## Gitea CI/CD
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
- `http://127.0.0.1:8000/health`
- `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job:
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots
- `GET /requests/{id}/actions?limit=10` recent action logs
## Troubleshooting
### Login fails
- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
### Services show as down
- Check the URLs and API keys in Settings.
- Verify containers can reach each service (network/DNS).
### No recent requests
- Confirm Seerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
- Run `docker compose up --build` again.
- If needed, run `docker compose down` first, then rebuild.
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.env
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+64
View File
@@ -0,0 +1,64 @@
from ..models import NormalizedState, TriageRecommendation, TriageResult, Snapshot
def triage_snapshot(snapshot: Snapshot) -> TriageResult:
recommendations = []
root_cause = "unknown"
summary = "No clear blocker detected yet."
confidence = 0.2
if snapshot.state == NormalizedState.requested:
root_cause = "approval"
summary = "The request is waiting for approval in Seerr."
recommendations.append(
TriageRecommendation(
action_id="wait_for_approval",
title="Ask an admin to approve the request",
reason="Seerr has not marked this request as approved.",
risk="low",
)
)
confidence = 0.6
if snapshot.state == NormalizedState.needs_add:
root_cause = "not_added"
summary = "The request is approved but not added to Sonarr/Radarr yet."
recommendations.append(
TriageRecommendation(
action_id="readd_to_arr",
title="Push to Sonarr/Radarr",
reason="Sonarr/Radarr has not created the entry for this request.",
risk="medium",
)
)
confidence = 0.7
if snapshot.state == NormalizedState.added_to_arr:
root_cause = "search"
summary = "The item is in Sonarr/Radarr but has not been downloaded yet."
recommendations.append(
TriageRecommendation(
action_id="search",
title="Re-run search",
reason="A fresh search can locate new releases.",
risk="low",
)
)
confidence = 0.55
if not recommendations:
recommendations.append(
TriageRecommendation(
action_id="diagnostics",
title="Generate diagnostics bundle",
reason="Collect service status and recent errors for review.",
risk="low",
)
)
return TriageResult(
summary=summary,
confidence=confidence,
root_cause=root_cause,
recommendations=recommendations,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+226
View File
@@ -0,0 +1,226 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
def _is_expired(expires_at: str | None) -> bool:
if not isinstance(expires_at, str) or not expires_at.strip():
return False
candidate = expires_at.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str:
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if direct_host:
return direct_host
return "unknown"
def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
return {
"secure": bool(settings.auth_cookie_secure),
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
"path": "/",
}
def _state_cookie_settings() -> dict[str, Any]:
cookie = _cookie_settings()
cookie["httponly"] = False
return cookie
def set_auth_cookies(response: Response, token: str) -> None:
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
response.set_cookie(
settings.auth_cookie_name,
token,
max_age=max_age,
**_cookie_settings(),
)
response.set_cookie(
settings.auth_state_cookie_name,
"1",
max_age=max_age,
**_state_cookie_settings(),
)
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
response.delete_cookie(
settings.auth_state_cookie_name,
path="/",
domain=settings.auth_cookie_domain or None,
)
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header.split(" ", 1)[1].strip()
if oauth_token:
return oauth_token
cookie_token = request.cookies.get(settings.auth_cookie_name)
if isinstance(cookie_token, str) and cookie_token.strip():
return cookie_token.strip()
return None
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict):
return "local"
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if provider != "local":
return provider
password_hash = user.get("password_hash")
if isinstance(password_hash, str) and password_hash:
if verify_password("jellyfin-user", password_hash):
return "jellyfin"
if verify_password("jellyseerr-user", password_hash):
return "jellyseerr"
return provider
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(user, dict):
return {}
resolved_provider = resolve_user_auth_provider(user)
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if resolved_provider != stored_provider:
username = str(user.get("username") or "").strip()
if username:
set_user_auth_provider(username, resolved_provider)
refreshed_user = get_user_by_username(username)
if refreshed_user:
user = refreshed_user
normalized = dict(user)
normalized["auth_provider"] = resolved_provider
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
normalized["password_provider"] = (
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
)
return normalized
def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
allowed_token_types: Optional[set[str]] = None,
) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
token_type = str(payload.get("typ") or "access").strip().lower()
if allowed_token_types and token_type not in allowed_token_types:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
username = payload.get("sub")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject")
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
user = normalize_user_auth_provider(user)
if request is not None:
ip = _extract_client_ip(request)
user_agent = request.headers.get("user-agent", "unknown")
upsert_user_activity(user["username"], ip, user_agent)
return {
"username": user["username"],
"email": user.get("email"),
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
"password_change_supported": bool(user.get("password_change_supported", False)),
"password_provider": user.get("password_provider"),
}
def get_current_user(
request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
resolved_token = _extract_access_token(request, token)
if not resolved_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(resolved_token, request)
def get_current_user_event_stream(
request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
resolved_token = _extract_access_token(request, token)
stream_query_token = request.query_params.get("stream_token")
if resolved_token:
# Allow standard bearer tokens for non-browser EventSource clients.
return _load_current_user_from_token(resolved_token, None)
if not stream_query_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(
str(stream_query_token),
None,
allowed_token_types={"sse"},
)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
def require_admin_event_stream(
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
File diff suppressed because one or more lines are too long
+108
View File
@@ -0,0 +1,108 @@
from typing import Any, Dict, Optional
import logging
import time
import httpx
from ..logging_config import sanitize_headers, sanitize_value
class ApiClient:
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
self.base_url = base_url.rstrip("/") if base_url else None
self.api_key = api_key
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
def configured(self) -> bool:
return bool(self.base_url)
def headers(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key} if self.api_key else {}
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
if response is None:
return None
try:
payload = sanitize_value(response.json())
except ValueError:
payload = sanitize_value(response.text)
if isinstance(payload, str) and len(payload) > 500:
return f"{payload[:500]}..."
return payload
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
if not self.base_url:
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
return None
url = f"{self.base_url}{path}"
started_at = time.perf_counter()
self.logger.debug(
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
method,
url,
sanitize_value(params),
sanitize_value(payload),
sanitize_headers(self.headers()),
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.request(
method,
url,
headers=self.headers(),
params=params,
json=payload,
)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self.logger.debug(
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
method,
url,
response.status_code,
duration_ms,
)
if not response.content:
return None
return response.json()
except httpx.HTTPStatusError as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
response = exc.response
status = response.status_code if response is not None else "unknown"
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
log_fn(
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
method,
url,
status,
duration_ms,
self._response_summary(response),
)
raise
except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self.logger.exception(
"outbound request failed method=%s url=%s duration_ms=%s",
method,
url,
duration_ms,
)
raise
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("GET", path, params=params)
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("POST", path, payload=payload)
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("PUT", path, payload=payload)
async def delete(self, path: str) -> Optional[Any]:
return await self._request("DELETE", path)
+201
View File
@@ -0,0 +1,201 @@
from typing import Any, Dict, Optional
import httpx
from .base import ApiClient
class JellyfinClient(ApiClient):
def __init__(self, base_url: Optional[str], api_key: Optional[str]):
super().__init__(base_url, api_key)
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
def _emby_headers(self) -> Dict[str, str]:
return {"X-Emby-Token": self.api_key} if self.api_key else {}
@staticmethod
def _extract_user_id(payload: Any) -> Optional[str]:
if not isinstance(payload, dict):
return None
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
if not isinstance(candidate, dict):
return None
for key in ("Id", "id", "UserId", "userId"):
value = candidate.get(key)
if value is None:
continue
if isinstance(value, (str, int)):
text = str(value).strip()
if text:
return text
return None
async def get_users(self) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
users = await self.get_users()
if not isinstance(users, list):
return None
target = username.strip().lower()
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip().lower()
if name and name == target:
return user
return None
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users/AuthenticateByName"
headers = self._emby_headers()
payload = {"Username": username, "Pw": password}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/New"
headers = self._emby_headers()
payload = {"Name": username}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def set_user_password(self, user_id: str, password: str) -> None:
if not self.base_url or not self.api_key:
return None
headers = self._emby_headers()
payloads = [
{"CurrentPw": "", "NewPw": password},
{"CurrentPwd": "", "NewPw": password},
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
{"NewPw": password, "ResetPassword": False},
]
paths = [
f"/Users/{user_id}/Password",
f"/Users/{user_id}/EasyPassword",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=10.0) as client:
for path in paths:
url = f"{self.base_url}{path}"
for payload in payloads:
try:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return
except httpx.HTTPStatusError as exc:
last_error = exc
continue
except Exception as exc:
last_error = exc
continue
if last_error:
raise last_error
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
user = await self.get_user(user_id)
if not isinstance(user, dict):
raise RuntimeError("Jellyfin user details not available")
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
payload = {**policy, "IsDisabled": bool(disabled)}
url = f"{self.base_url}/Users/{user_id}/Policy"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
async def delete_user(self, user_id: str) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.delete(url, headers=headers)
response.raise_for_status()
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
created = await self.create_user(username)
user_id = self._extract_user_id(created)
if not user_id:
users = await self.get_users()
if isinstance(users, list):
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip()
if name.lower() == username.strip().lower():
created = user
user_id = self._extract_user_id(user)
break
if not user_id:
raise RuntimeError("Jellyfin user created but user ID was not returned")
await self.set_user_password(user_id, password)
return created
async def search_items(
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Items"
params = {
"SearchTerm": term,
"IncludeItemTypes": ",".join(item_types or []),
"Recursive": "true",
"Limit": limit,
}
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()
async def get_system_info(self) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/System/Info"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers()
params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
+79
View File
@@ -0,0 +1,79 @@
from typing import Any, Dict, Optional
import httpx
from .base import ApiClient
class JellyseerrClient(ApiClient):
async def get_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v1/status")
async def get_request(self, request_id: str) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/request/{request_id}")
async def get_recent_requests(self, take: int = 10, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/request",
params={
"take": take,
"skip": skip,
},
)
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/movie/{tmdb_id}")
async def get_tv(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/tv/{tmdb_id}")
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/search",
params={
"query": query,
"page": page,
},
)
async def create_request(
self,
*,
media_type: str,
media_id: int,
seasons: Optional[list[int]] = None,
is_4k: Optional[bool] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {
"mediaType": media_type,
"mediaId": media_id,
}
if isinstance(seasons, list) and seasons:
payload["seasons"] = seasons
if isinstance(is_4k, bool):
payload["is4k"] = is_4k
return await self.post("/api/v1/request", payload=payload)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/user",
params={
"take": take,
"skip": skip,
},
)
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/user/{user_id}")
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.delete(f"/api/v1/user/{user_id}")
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
payload = {"email": email, "password": password}
try:
return await self.post("/api/v1/auth/local", payload=payload)
except httpx.HTTPStatusError as exc:
# Backward compatibility for older Seerr/Overseerr deployments
# that still expose /auth/login instead of /auth/local.
if exc.response is not None and exc.response.status_code in {404, 405}:
return await self.post("/api/v1/auth/login", payload=payload)
raise
+10
View File
@@ -0,0 +1,10 @@
from typing import Any, Dict, Optional
from .base import ApiClient
class ProwlarrClient(ApiClient):
async def get_health(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v1/health")
async def search(self, query: str) -> Optional[Any]:
return await self.get("/api/v1/search", params={"query": query})
+108
View File
@@ -0,0 +1,108 @@
from typing import Any, Dict, Optional
import httpx
import logging
from .base import ApiClient
class QBittorrentClient(ApiClient):
def __init__(self, base_url: Optional[str], username: Optional[str], password: Optional[str]):
super().__init__(base_url, None)
self.username = username
self.password = password
self.logger = logging.getLogger(__name__)
def configured(self) -> bool:
return bool(self.base_url and self.username and self.password)
async def _login(self, client: httpx.AsyncClient) -> None:
if not self.base_url or not self.username or not self.password:
raise RuntimeError("qBittorrent not configured")
response = await client.post(
f"{self.base_url}/api/v2/auth/login",
data={"username": self.username, "password": self.password},
headers={"Referer": self.base_url},
)
response.raise_for_status()
text = response.text.strip().lower()
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
raise RuntimeError("qBittorrent login failed")
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.json()
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.text.strip()
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
async def is_webui_reachable(self) -> bool:
if not self.base_url:
return False
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(self.base_url)
response.raise_for_status()
return True
except httpx.HTTPError:
return False
async def get_torrents(self) -> Optional[Any]:
return await self._get("/api/v2/torrents/info")
async def get_torrents_by_hashes(self, hashes: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"hashes": hashes})
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"category": category})
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"tag": tag})
async def get_app_version(self) -> Optional[Any]:
return await self._get_text("/api/v2/app/version")
async def resume_torrents(self, hashes: str) -> None:
try:
await self._post_form("/api/v2/torrents/resume", data={"hashes": hashes})
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 404:
await self._post_form("/api/v2/torrents/start", data={"hashes": hashes})
return
raise
async def add_torrent_url(
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
) -> None:
url_host = None
if isinstance(url, str) and "://" in url:
url_host = url.split("://", 1)[-1].split("/", 1)[0]
self.logger.warning(
"qBittorrent add_torrent_url invoked: category=%s host=%s",
category,
url_host or "unknown",
)
data: Dict[str, Any] = {"urls": url}
if category:
data["category"] = category
if tags:
data["tags"] = tags
await self._post_form("/api/v2/torrents/add", data=data)
+63
View File
@@ -0,0 +1,63 @@
from typing import Any, Dict, Optional
from .base import ApiClient
class RadarrClient(ApiClient):
async def get_system_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/system/status")
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/movie/{movie_id}")
async def get_movies(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie")
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder")
async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/qualityprofile")
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
async def add_movie(
self,
tmdb_id: int,
quality_profile_id: int,
root_folder: str,
monitored: bool = True,
search_for_movie: bool = True,
) -> Optional[Dict[str, Any]]:
payload = {
"tmdbId": tmdb_id,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
"addOptions": {"searchForMovie": search_for_movie},
}
return await self.post("/api/v3/movie", payload=payload)
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/movie", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release/push", payload=payload)
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post(
"/api/v3/command",
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
)
+70
View File
@@ -0,0 +1,70 @@
from typing import Any, Dict, Optional
from .base import ApiClient
class SonarrClient(ApiClient):
async def get_system_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/system/status")
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/series/{series_id}")
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder")
async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/qualityprofile")
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"seriesId": series_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
async def add_series(
self,
tvdb_id: int,
quality_profile_id: int,
root_folder: str,
monitored: bool = True,
title: Optional[str] = None,
search_missing: bool = True,
) -> Optional[Dict[str, Any]]:
payload = {
"tvdbId": tvdb_id,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
"seasonFolder": True,
"addOptions": {"searchForMissingEpisodes": search_missing},
}
if title:
payload["title"] = title
return await self.post("/api/v3/series", payload=payload)
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/series", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release/push", payload=payload)
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post(
"/api/v3/command",
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
)
+322
View File
@@ -0,0 +1,322 @@
from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000"
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
sqlite_journal_mode: str = Field(
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
)
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
)
auth_rate_limit_max_attempts_ip: int = Field(
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
password_reset_rate_limit_window_seconds: int = Field(
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
)
password_reset_rate_limit_max_attempts_ip: int = Field(
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
password_reset_rate_limit_max_attempts_identifier: int = Field(
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
auth_cookie_secure: bool = Field(
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
)
auth_cookie_samesite: str = Field(
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
)
auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
)
auth_state_cookie_name: str = Field(
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field(
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
)
log_file_backup_count: int = Field(
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
)
log_http_client_level: str = Field(
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
)
log_background_sync_level: str = Field(
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
)
requests_sync_ttl_minutes: int = Field(
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
)
requests_poll_interval_seconds: int = Field(
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
)
requests_delta_sync_interval_minutes: int = Field(
default=5, validation_alias=AliasChoices("REQUESTS_DELTA_SYNC_INTERVAL_MINUTES")
)
requests_full_sync_time: str = Field(
default="00:00", validation_alias=AliasChoices("REQUESTS_FULL_SYNC_TIME")
)
requests_cleanup_time: str = Field(
default="02:00", validation_alias=AliasChoices("REQUESTS_CLEANUP_TIME")
)
requests_cleanup_days: int = Field(
default=90, validation_alias=AliasChoices("REQUESTS_CLEANUP_DAYS")
)
requests_data_source: str = Field(
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
)
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
site_banner_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
)
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
)
site_login_show_local_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
)
site_login_show_forgot_password: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
)
site_login_show_signup_link: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
)
site_nav_show_requests: bool = Field(
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
)
magent_application_port: int = Field(
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
)
magent_api_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
)
magent_api_port: int = Field(
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
)
magent_bind_host: str = Field(
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
)
magent_proxy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
)
magent_proxy_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
)
magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
)
magent_proxy_trusted_proxies: str = Field(
default="127.0.0.1,::1",
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
)
magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
)
magent_ssl_bind_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
)
magent_ssl_certificate_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
)
magent_ssl_private_key_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
)
magent_ssl_certificate_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
)
magent_ssl_private_key_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
)
magent_notify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
)
magent_notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
)
magent_notify_email_smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
)
magent_notify_email_smtp_port: int = Field(
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
)
magent_notify_email_smtp_username: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
)
magent_notify_email_smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
)
magent_notify_email_from_address: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
)
magent_notify_email_from_name: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
)
magent_notify_email_use_tls: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
)
magent_notify_email_use_ssl: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
)
magent_notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
)
magent_notify_discord_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
)
magent_notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
)
magent_notify_telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
)
magent_notify_telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
)
magent_notify_push_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
)
magent_notify_push_provider: Optional[str] = Field(
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
)
magent_notify_push_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
)
magent_notify_push_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
)
magent_notify_push_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
)
magent_notify_push_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
)
magent_notify_push_device: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
)
magent_notify_webhook_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
)
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
magent_allow_private_notification_targets: bool = Field(
default=False,
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
)
jellyseerr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
)
jellyfin_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
)
jellyfin_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_API_KEY", "JELLYFIN_KEY")
)
jellyfin_public_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_PUBLIC_URL")
)
jellyfin_sync_to_arr: bool = Field(
default=True, validation_alias=AliasChoices("JELLYFIN_SYNC_TO_ARR")
)
sonarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_URL", "SONARR_BASE_URL")
)
sonarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_API_KEY", "SONARR_KEY")
)
sonarr_quality_profile_id: Optional[int] = Field(
default=None, validation_alias=AliasChoices("SONARR_QUALITY_PROFILE_ID")
)
sonarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
)
sonarr_qbittorrent_category: Optional[str] = Field(
default="sonarr",
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
)
radarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
)
radarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_API_KEY", "RADARR_KEY")
)
radarr_quality_profile_id: Optional[int] = Field(
default=None, validation_alias=AliasChoices("RADARR_QUALITY_PROFILE_ID")
)
radarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
)
radarr_qbittorrent_category: Optional[str] = Field(
default="radarr",
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
)
prowlarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_API_KEY", "PROWLARR_KEY")
)
qbittorrent_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("QBIT_URL", "QBITTORRENT_URL", "QBITTORRENT_BASE_URL")
)
qbittorrent_username: Optional[str] = Field(
default=None, validation_alias=AliasChoices("QBIT_USERNAME", "QBITTORRENT_USERNAME")
)
qbittorrent_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD")
)
discord_webhook_url: Optional[str] = Field(
default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
settings = Settings()
+3758
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import contextvars
import json
import logging
import os
from logging.handlers import RotatingFileHandler
from typing import Any, Mapping, Optional
from urllib.parse import parse_qs
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
"magent_request_id", default="-"
)
_SENSITIVE_KEYWORDS = (
"api_key",
"authorization",
"cert",
"cookie",
"jwt",
"key",
"pass",
"password",
"pem",
"private",
"secret",
"session",
"signature",
"token",
)
_MAX_BODY_BYTES = 4096
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 = getattr(logging, level_name, logging.INFO)
handlers: list[logging.Handler] = []
stream_handler = logging.StreamHandler()
handlers.append(stream_handler)
if log_file:
log_path = log_file
if not os.path.isabs(log_path):
log_path = os.path.join(os.getcwd(), log_path)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
file_handler = RotatingFileHandler(
log_path,
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
backupCount=max(1, int(log_file_backup_count or 10)),
encoding="utf-8",
)
handlers.append(file_handler)
context_filter = RequestContextFilter()
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers:
handler.addFilter(context_filter)
handler.setFormatter(formatter)
root = logging.getLogger()
for handler in list(root.handlers):
root.removeHandler(handler)
for handler in handlers:
root.addHandler(handler)
root.setLevel(level)
logging.getLogger("uvicorn").setLevel(level)
logging.getLogger("uvicorn.error").setLevel(level)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
logging.getLogger("app.clients.base").setLevel(http_client_level)
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+246
View File
@@ -0,0 +1,246 @@
import asyncio
import logging
import time
import uuid
from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .db import has_admin_user, init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
run_requests_delta_loop,
run_daily_requests_full_sync,
run_daily_db_cleanup,
)
from .routers.auth import router as auth_router
from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.images import router as images_router
from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
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
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
app = FastAPI(
title=settings.app_name,
docs_url="/docs" if settings.api_docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.cors_allow_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_requests_and_add_security_headers(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = bind_request_id(request_id)
request.state.request_id = request_id
started_at = time.perf_counter()
body = await request.body()
body_summary = summarize_http_body(body, request.headers.get("content-type"))
async def receive() -> dict:
return {"type": "http.request", "body": body, "more_body": False}
request._receive = receive
logger.info(
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
request.method,
request.url.path,
sanitize_value(dict(request.query_params)),
request.client.host if request.client else "-",
sanitize_headers(
{
key: value
for key, value in request.headers.items()
if key.lower()
in {
"content-type",
"content-length",
"user-agent",
"x-forwarded-for",
"x-forwarded-proto",
"x-request-id",
}
}
),
body_summary,
)
try:
response = await call_next(request)
except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
logger.exception(
"request failed method=%s path=%s duration_ms=%s",
request.method,
request.url.path,
duration_ms,
)
reset_request_id(token)
raise
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
response.headers.setdefault("X-Request-ID", request_id)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
# Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
)
logger.info(
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
sanitize_headers(
{
key: value
for key, value in response.headers.items()
if key.lower() in {"content-type", "content-length", "x-request-id"}
}
),
)
reset_request_id(token)
return response
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
async def _run_background_task(
name: str, coroutine_factory: Callable[[], Awaitable[None]]
) -> None:
token = bind_request_id(f"task-{name}")
logger.info("background task started task=%s", name)
try:
await coroutine_factory()
logger.warning("background task exited task=%s", name)
except asyncio.CancelledError:
logger.info("background task cancelled task=%s", name)
raise
except Exception:
logger.exception("background task crashed task=%s", name)
raise
finally:
reset_request_id(token)
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
task = asyncio.create_task(
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
)
_background_tasks.append(task)
def _log_security_configuration_warnings() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is unset or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
)
def _enforce_secure_startup_configuration() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError(
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
)
@app.on_event("startup")
async def startup() -> None:
configure_logging(
settings.log_level,
settings.log_file,
log_file_max_bytes=settings.log_file_max_bytes,
log_file_backup_count=settings.log_file_backup_count,
log_http_client_level=settings.log_http_client_level,
log_background_sync_level=settings.log_background_sync_level,
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
configure_logging(
runtime.log_level,
runtime.log_file,
log_file_max_bytes=runtime.log_file_max_bytes,
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
)
logger.info(
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
runtime.log_level,
runtime.log_file,
runtime.log_file_max_bytes,
runtime.log_file_backup_count,
runtime.log_http_client_level,
runtime.log_background_sync_level,
runtime.requests_data_source,
)
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup)
logger.info("startup complete")
app.include_router(requests_router)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(admin_events_router)
app.include_router(images_router)
app.include_router(branding_router)
app.include_router(status_router)
app.include_router(feedback_router)
app.include_router(site_router)
app.include_router(events_router)
app.include_router(portal_router)
+67
View File
@@ -0,0 +1,67 @@
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class RequestType(str, Enum):
movie = "movie"
tv = "tv"
unknown = "unknown"
class NormalizedState(str, Enum):
requested = "REQUESTED"
approved = "APPROVED"
needs_add = "NEEDS_ADD"
added_to_arr = "ADDED_TO_ARR"
searching = "SEARCHING"
grabbed = "GRABBED"
downloading = "DOWNLOADING"
importing = "IMPORTING"
completed = "COMPLETED"
failed = "FAILED"
available = "AVAILABLE"
unknown = "UNKNOWN"
class TimelineHop(BaseModel):
service: str
status: str
details: Dict[str, Any] = Field(default_factory=dict)
timestamp: Optional[str] = None
class ActionOption(BaseModel):
id: str
label: str
risk: str
description: Optional[str] = None
requires_confirmation: bool = True
class Snapshot(BaseModel):
request_id: str
title: str
year: Optional[int] = None
request_type: RequestType = RequestType.unknown
state: NormalizedState = NormalizedState.unknown
state_reason: Optional[str] = None
timeline: List[TimelineHop] = Field(default_factory=list)
actions: List[ActionOption] = Field(default_factory=list)
artwork: Dict[str, Any] = Field(default_factory=dict)
presentation: Dict[str, Any] = Field(default_factory=dict)
raw: Dict[str, Any] = Field(default_factory=dict)
class TriageRecommendation(BaseModel):
action_id: str
title: str
reason: str
risk: str
class TriageResult(BaseModel):
summary: str
confidence: float
root_cause: str
recommendations: List[TriageRecommendation]
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import ipaddress
import socket
from functools import lru_cache
from typing import Iterable
from urllib.parse import urlparse
from .config import settings
_METADATA_HOSTS = {
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.internal",
}
def _normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _split_csv(value: object) -> list[str]:
raw = _normalize_text(value)
if not raw:
return []
return [part.strip() for part in raw.split(",") if part.strip()]
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
return bool(
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_multicast
or ip_obj.is_unspecified
or ip_obj.is_reserved
or ip_obj.is_private
)
@lru_cache(maxsize=256)
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
resolved: list[ipaddress._BaseAddress] = []
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
if family == socket.AF_INET:
resolved.append(ipaddress.ip_address(sockaddr[0]))
elif family == socket.AF_INET6:
resolved.append(ipaddress.ip_address(sockaddr[0]))
return tuple(resolved)
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
candidate = _normalize_text(host)
if not candidate:
return False
try:
host_ip = ipaddress.ip_address(candidate)
except ValueError:
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
for entry in trusted_proxies:
raw = _normalize_text(entry)
if not raw:
continue
try:
if "/" in raw:
if host_ip in ipaddress.ip_network(raw, strict=False):
return True
elif host_ip == ipaddress.ip_address(raw):
return True
except ValueError:
continue
return False
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
return False
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
if not trusted:
return False
return _is_trusted_proxy_host(client_host or "", trusted)
def validate_notification_target_url(
url: str,
*,
allow_private: bool | None = None,
) -> str:
raw = _normalize_text(url)
if not raw:
raise ValueError("URL cannot be empty.")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if parsed.username or parsed.password:
raise ValueError("URL must not embed credentials.")
hostname = _normalize_text(parsed.hostname).lower()
if not hostname:
raise ValueError("URL must include a valid host.")
allow_private_targets = (
settings.magent_allow_private_notification_targets
if allow_private is None
else bool(allow_private)
)
if hostname in _METADATA_HOSTS:
raise ValueError("Metadata service targets are not allowed.")
if hostname == "localhost" and not allow_private_targets:
raise ValueError("Local notification targets are not allowed.")
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
if host_ip is not None:
if _ip_is_sensitive(host_ip) and not allow_private_targets:
raise ValueError("Private or local notification targets are not allowed.")
return raw
try:
resolved_ips = _resolve_host_ips(hostname)
except socket.gaierror as exc:
raise ValueError("Host could not be resolved.") from exc
if not resolved_ips:
raise ValueError("Host could not be resolved.")
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
raise ValueError("Private or local notification targets are not allowed.")
return raw
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
import os
from io import BytesIO
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from PIL import Image, ImageDraw, ImageFont
router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
def _ensure_branding_dir() -> None:
os.makedirs(_BRANDING_DIR, exist_ok=True)
def _resize_image(image: Image.Image, max_size: int = 300) -> Image.Image:
image = image.convert("RGBA")
image.thumbnail((max_size, max_size))
return image
def _load_font(size: int) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
try:
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
try:
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
font = _load_font(160)
text = "M"
box = draw.textbbox((0, 0), text, font=font)
text_w = box[2] - box[0]
text_h = box[3] - box[1]
draw.text(
((300 - text_w) / 2, (300 - text_h) / 2 - 6),
text,
font=font,
fill=(255, 255, 255, 255),
)
image.save(_LOGO_PATH, format="PNG")
if not os.path.exists(_FAVICON_PATH):
favicon = Image.open(_LOGO_PATH).copy()
favicon.thumbnail((64, 64))
try:
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
except OSError:
favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png")
async def branding_logo() -> FileResponse:
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "no-store"}
return FileResponse(logo_path, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
_, favicon_path = _resolve_branding_paths()
if not os.path.exists(favicon_path):
raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "no-store"}
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Please upload an image file.")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
try:
image = Image.open(BytesIO(content))
except OSError as exc:
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
_ensure_branding_dir()
image = _resize_image(image, 300)
image.save(_LOGO_PATH, format="PNG")
favicon = image.copy()
favicon.thumbnail((64, 64))
try:
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
except OSError:
favicon.save(_FAVICON_PATH, format="ICO")
return {"status": "ok", "width": image.width, "height": image.height}
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"])
def _sse_json(payload: Dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
def _jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
try:
return value.model_dump(mode="json")
except TypeError:
return value.model_dump()
if hasattr(value, "dict"):
try:
return value.dict()
except TypeError:
return value
return value
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
items: list[dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
items.append(
{
"request_id": entry.get("request_id"),
"state": entry.get("state"),
"state_reason": entry.get("state_reason"),
"created_at": entry.get("created_at"),
}
)
return items
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
items: list[dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
items.append(
{
"request_id": entry.get("request_id"),
"action_id": entry.get("action_id"),
"label": entry.get("label"),
"status": entry.get("status"),
"message": entry.get("message"),
"created_at": entry.get("created_at"),
}
)
return items
@router.get("/stream")
async def events_stream(
request: Request,
recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_recent_at:
next_recent_at = now + 15.0
try:
recent_payload = await requests_router.recent_requests(
take=recent_take,
skip=0,
days=recent_days,
stage=recent_stage,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"stage": recent_stage,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"stage": recent_stage,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_recent_signature:
last_recent_signature = signature
yield _sse_json(payload)
sent_any = True
if now >= next_services_at:
next_services_at = now + 30.0
try:
status_payload = await services_status()
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"status": status_payload,
}
except Exception as exc:
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_services_signature:
last_services_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.get("/requests/{request_id}/stream")
async def request_events_stream(
request_id: str,
request: Request,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
request_id = str(request_id).strip()
if not request_id:
raise HTTPException(status_code=400, detail="Missing request id")
async def event_generator():
yield "retry: 2000\n\n"
last_signature: Optional[str] = None
next_refresh_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_refresh_at:
next_refresh_at = now + 2.0
try:
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
history_payload = await requests_router.request_history(
request_id=request_id, limit=5, user=user
)
actions_payload = await requests_router.request_actions(
request_id=request_id, limit=5, user=user
)
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"snapshot": _jsonable(snapshot),
"history": _request_history_brief(
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
),
"actions": _request_actions_brief(
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
),
}
except HTTPException as exc:
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc.detail),
"status_code": int(exc.status_code),
}
except Exception as exc:
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_signature:
last_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+46
View File
@@ -0,0 +1,46 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")
try:
webhook_url = validate_notification_target_url(webhook_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
feedback_type = str(payload.get("type") or "").strip().lower()
if feedback_type not in {"bug", "feature"}:
raise HTTPException(status_code=400, detail="Invalid feedback type")
message = str(payload.get("message") or "").strip()
if not message:
raise HTTPException(status_code=400, detail="Message is required")
if len(message) > 2000:
raise HTTPException(status_code=400, detail="Message is too long")
username = user.get("username") or "unknown"
content = f"**{feedback_type.title()}** from **{username}**\n{message}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(webhook_url, json={"content": content})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"status": "ok"}
+100
View File
@@ -0,0 +1,100 @@
import os
import re
import mimetypes
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, RedirectResponse
import httpx
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/images", tags=["images"])
_TMDB_BASE = "https://image.tmdb.org/t/p"
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
logger = logging.getLogger(__name__)
def _safe_filename(path: str) -> str:
trimmed = path.strip("/")
trimmed = trimmed.replace("/", "_")
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
return safe or "image"
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
if not path or "://" in path or ".." in path:
return None
if not path.startswith("/"):
path = f"/{path}"
if size not in _ALLOWED_SIZES:
return None
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
return os.path.join(cache_dir, _safe_filename(path))
def is_tmdb_cached(path: str, size: str) -> bool:
file_path = tmdb_cache_path(path, size)
return bool(file_path and os.path.exists(file_path))
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if not path or "://" in path or ".." in path:
return False
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
if cache_mode != "cache":
return False
file_path = tmdb_cache_path(path, size)
if not file_path:
return False
os.makedirs(os.path.dirname(file_path), exist_ok=True)
if os.path.exists(file_path):
return True
url = f"{_TMDB_BASE}/{size}{path}"
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(url)
response.raise_for_status()
content = response.content
with open(file_path, "wb") as handle:
handle.write(content)
return True
@router.get("/tmdb")
async def tmdb_image(path: str, size: str = "w342"):
if not path or "://" in path or ".." in path:
raise HTTPException(status_code=400, detail="Invalid image path")
if not path.startswith("/"):
path = f"/{path}"
if size not in _ALLOWED_SIZES:
raise HTTPException(status_code=400, detail="Invalid size")
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
url = f"{_TMDB_BASE}/{size}{path}"
if cache_mode != "cache":
return RedirectResponse(url=url)
file_path = tmdb_cache_path(path, size)
if not file_path:
raise HTTPException(status_code=400, detail="Invalid image path")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers)
try:
await cache_tmdb_image(path, size)
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers)
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
except (httpx.HTTPError, OSError) as exc:
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
return RedirectResponse(url=url)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
_BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
"banner": {
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
"tone": tone,
},
"login": {
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
"showSignupLink": bool(runtime.site_login_show_signup_link),
},
"navigation": {
"showRequests": bool(runtime.site_nav_show_requests),
},
}
if include_changelog:
info["changelog"] = (CHANGELOG or "").strip()
return info
@router.get("/public")
async def site_public() -> Dict[str, Any]:
return _build_site_info(False)
@router.get("/info")
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
return _build_site_info(True)
+164
View File
@@ -0,0 +1,164 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..runtime import get_runtime_settings
from ..clients.jellyseerr import JellyseerrClient
from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
if not configured:
return {"name": name, "status": "not_configured"}
try:
result = await func()
return {"name": name, "status": "up", "detail": result}
except httpx.HTTPError as exc:
return {"name": name, "status": "down", "message": str(exc)}
except Exception as exc:
return {"name": name, "status": "down", "message": str(exc)}
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
if not qbittorrent.base_url:
return {"name": "qBittorrent", "status": "not_configured"}
if not qbittorrent.username or not qbittorrent.password:
reachable = await qbittorrent.is_webui_reachable()
return {
"name": "qBittorrent",
"status": "degraded" if reachable else "not_configured",
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
}
try:
result = await qbittorrent.get_app_version()
return {"name": "qBittorrent", "status": "up", "detail": result}
except RuntimeError as exc:
if "login failed" in str(exc).lower():
reachable = await qbittorrent.is_webui_reachable()
if reachable:
return {
"name": "qBittorrent",
"status": "degraded",
"message": "qBittorrent is reachable but the saved credentials were rejected",
}
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except httpx.HTTPError as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except Exception as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
@router.get("/services")
async def services_status() -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
services = []
services.append(
await _check(
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
)
)
services.append(
await _check(
"Sonarr",
sonarr.configured(),
sonarr.get_system_status,
)
)
services.append(
await _check(
"Radarr",
radarr.configured(),
radarr.get_system_status,
)
)
prowlarr_status = await _check(
"Prowlarr",
prowlarr.configured(),
prowlarr.get_health,
)
if prowlarr_status.get("status") == "up":
health = prowlarr_status.get("detail")
if isinstance(health, list) and health:
prowlarr_status["status"] = "degraded"
prowlarr_status["message"] = "Health warnings"
services.append(prowlarr_status)
services.append(await _check_qbittorrent(qbittorrent))
services.append(
await _check(
"Jellyfin",
jellyfin.configured(),
jellyfin.get_system_info,
)
)
overall = "up"
if any(s.get("status") == "down" for s in services):
overall = "down"
elif any(s.get("status") in {"degraded", "not_configured"} for s in services):
overall = "degraded"
return {"overall": overall, "services": services}
@router.post("/services/{service}/test")
async def test_service(service: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower()
checks = {
"seerr": (
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"jellyseerr": (
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
if service_key == "qbittorrent":
return await _check_qbittorrent(qbittorrent)
if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service")
name, configured, func = checks[service_key]
result = await _check(name, configured, func)
if name == "Prowlarr" and result.get("status") == "up":
health = result.get("detail")
if isinstance(health, list) and health:
result["status"] = "degraded"
result["message"] = "Health warnings"
return result
+67
View File
@@ -0,0 +1,67 @@
from .config import settings
from .db import get_settings_overrides
_INT_FIELDS = {
"magent_application_port",
"magent_api_port",
"auth_rate_limit_window_seconds",
"auth_rate_limit_max_attempts_ip",
"auth_rate_limit_max_attempts_user",
"password_reset_rate_limit_window_seconds",
"password_reset_rate_limit_max_attempts_ip",
"password_reset_rate_limit_max_attempts_identifier",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"jwt_exp_minutes",
"log_file_max_bytes",
"log_file_backup_count",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"magent_notify_email_smtp_port",
}
_BOOL_FIELDS = {
"magent_proxy_enabled",
"magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_telegram_enabled",
"magent_notify_push_enabled",
"magent_notify_webhook_enabled",
"jellyfin_sync_to_arr",
"site_banner_enabled",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
"site_nav_show_requests",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
overrides = get_settings_overrides()
update = {}
for key, value in overrides.items():
if value is None:
continue
if key in _SKIP_OVERRIDE_FIELDS:
continue
if key in _INT_FIELDS:
try:
update[key] = int(value)
except (TypeError, ValueError):
continue
elif key in _BOOL_FIELDS:
if isinstance(value, bool):
update[key] = value
else:
update[key] = str(value).strip().lower() in {"1", "true", "yes", "on"}
else:
update[key] = value
return settings.model_copy(update=update)
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from passlib.context import CryptContext
import jwt
from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str:
return _pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password)
def validate_password_policy(password: str) -> str:
candidate = password.strip()
if len(candidate) < MIN_PASSWORD_LENGTH:
raise ValueError(PASSWORD_POLICY_MESSAGE)
return candidate
def _create_token(
subject: str,
role: str,
*,
expires_at: datetime,
token_type: str = "access",
) -> str:
payload: Dict[str, Any] = {
"sub": subject,
"role": role,
"typ": token_type,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return _create_token(subject, role, expires_at=expires, token_type="access")
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
return _create_token(subject, role, expires_at=expires, token_type="sse")
def decode_token(token: str) -> Dict[str, Any]:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
class TokenError(Exception):
pass
def safe_decode_token(token: str) -> Dict[str, Any]:
try:
return decode_token(token)
except InvalidTokenError as exc:
raise TokenError("Invalid token") from exc
+735
View File
@@ -0,0 +1,735 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from time import perf_counter
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
from urllib.parse import urlparse
import httpx
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import get_database_diagnostics
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
@dataclass(frozen=True)
class DiagnosticCheck:
key: str
label: str
category: str
description: str
live_safe: bool
configured: bool
config_detail: str
target: Optional[str]
runner: DiagnosticRunner
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _url_target(url: Optional[str]) -> Optional[str]:
raw = _clean_text(url)
if not raw:
return None
try:
parsed = urlparse(raw)
except Exception:
return raw
host = parsed.hostname or parsed.netloc or raw
if parsed.port:
host = f"{host}:{parsed.port}"
return host
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
resolved_host = _clean_text(host)
if not resolved_host:
return None
if port is None:
return resolved_host
return f"{resolved_host}:{port}"
def _http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
body = ""
try:
body = response.text.strip()
except Exception:
body = ""
if body:
return f"HTTP {response.status_code}: {body}"
return f"HTTP {response.status_code}"
return str(exc)
def _config_status(detail: str) -> str:
lowered = detail.lower()
if "disabled" in lowered:
return "disabled"
return "not_configured"
def _discord_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
return False, "Discord notifications are disabled."
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Discord webhook URL is required."
def _telegram_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
return False, "Telegram notifications are disabled."
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
return True, "ok"
return False, "Telegram bot token and chat ID are required."
def _webhook_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
return False, "Generic webhook notifications are disabled."
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Generic webhook URL is required."
def _push_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
return False, "Push notifications are disabled."
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
if provider == "ntfy":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_topic):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "ntfy requires a base URL and topic."
if provider == "gotify":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_token):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Gotify requires a base URL and app token."
if provider == "pushover":
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
return True, "ok"
return False, "Pushover requires an application token and user key."
if provider == "webhook":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url:
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Webhook relay requires a target URL."
if provider == "telegram":
return _telegram_config_ready(runtime)
if provider == "discord":
return _discord_config_ready(runtime)
return False, f"Unsupported push provider: {provider or 'unknown'}"
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
summary = {
"total": len(results),
"up": 0,
"down": 0,
"degraded": 0,
"not_configured": 0,
"disabled": 0,
}
for result in results:
status = str(result.get("status") or "").strip().lower()
if status in summary:
summary[status] += 1
return summary
async def _run_http_json_get(
url: str,
*,
headers: Optional[Dict[str, str]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
payload = response.json()
return {"response": payload}
async def _run_http_text_get(url: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
body = response.text
return {"response": body, "message": f"HTTP {response.status_code}"}
async def _run_http_post(
url: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
data_payload: Any = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
response.raise_for_status()
if not response.content:
return {"message": f"HTTP {response.status_code}"}
content_type = response.headers.get("content-type", "")
if "application/json" in content_type.lower():
try:
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
except Exception:
pass
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
async def _run_database_check() -> Dict[str, Any]:
detail = await asyncio.to_thread(get_database_diagnostics)
integrity = _clean_text(detail.get("integrity_check"), "unknown")
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
status = "up" if integrity == "ok" else "degraded"
return {
"status": status,
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
"detail": detail,
}
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
payload = result.get("response")
build_number = payload.get("build") if isinstance(payload, dict) else None
message = "Health endpoint responded"
if build_number:
message = f"Health endpoint responded (build {build_number})"
return {"message": message, "detail": payload}
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
result = await _run_http_text_get(base_url.rstrip("/"))
body = result.get("response")
if isinstance(body, str) and "<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")
warning = _clean_text(result.get("warning"))
if warning:
return {
"status": "degraded",
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
"detail": result,
}
return {"message": f"Test email sent to {recipient}", "detail": result}
async def _run_discord_check(runtime) -> Dict[str, Any]:
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
payload = {
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
}
result = await _run_http_post(webhook_url, json_payload=payload)
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
async def _run_telegram_check(runtime) -> Dict[str, Any]:
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
}
result = await _run_http_post(url, json_payload=payload)
return {"message": "Telegram ping accepted", "detail": result.get("response")}
async def _run_webhook_check(runtime) -> Dict[str, Any]:
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
payload = {
"type": "diagnostics.ping",
"application": env_settings.app_name,
"build": env_settings.site_build_number,
"checked_at": _now_iso(),
}
result = await _run_http_post(webhook_url, json_payload=payload)
return {"message": "Webhook accepted ping", "detail": result.get("response")}
async def _run_push_check(runtime) -> Dict[str, Any]:
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
message = f"{env_settings.app_name} diagnostics ping"
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
if provider == "ntfy":
base_url = _clean_text(runtime.magent_notify_push_base_url)
topic = _clean_text(runtime.magent_notify_push_topic)
result = await _run_http_post(
f"{base_url.rstrip('/')}/{topic}",
data_payload=f"{message}\n{build_suffix}",
headers={"Content-Type": "text/plain; charset=utf-8"},
)
return {"message": "ntfy push accepted", "detail": result.get("response")}
if provider == "gotify":
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
result = await _run_http_post(
f"{base_url.rstrip('/')}/message",
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
params={"token": token},
)
return {"message": "Gotify push accepted", "detail": result.get("response")}
if provider == "pushover":
token = _clean_text(runtime.magent_notify_push_token)
user_key = _clean_text(runtime.magent_notify_push_user_key)
device = _clean_text(runtime.magent_notify_push_device)
payload = {
"token": token,
"user": user_key,
"message": f"{message}\n{build_suffix}",
"title": env_settings.app_name,
}
if device:
payload["device"] = device
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
return {"message": "Pushover push accepted", "detail": result.get("response")}
if provider == "webhook":
base_url = _clean_text(runtime.magent_notify_push_base_url)
payload = {
"type": "diagnostics.push",
"application": env_settings.app_name,
"build": env_settings.site_build_number,
"checked_at": _now_iso(),
}
result = await _run_http_post(base_url, json_payload=payload)
return {"message": "Push webhook accepted", "detail": result.get("response")}
if provider == "telegram":
return await _run_telegram_check(runtime)
if provider == "discord":
return await _run_discord_check(runtime)
raise RuntimeError(f"Unsupported push provider: {provider}")
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
runtime = get_runtime_settings()
seerr_target = _url_target(runtime.jellyseerr_base_url)
jellyfin_target = _url_target(runtime.jellyfin_base_url)
sonarr_target = _url_target(runtime.sonarr_base_url)
radarr_target = _url_target(runtime.radarr_base_url)
prowlarr_target = _url_target(runtime.prowlarr_base_url)
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
webhook_target = _url_target(runtime.magent_notify_webhook_url)
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
push_target = None
if push_provider == "pushover":
push_target = "api.pushover.net"
elif push_provider == "telegram":
push_target = telegram_target or "api.telegram.org"
elif push_provider == "discord":
push_target = discord_target or "discord.com"
else:
push_target = _url_target(runtime.magent_notify_push_base_url)
email_ready, email_detail = smtp_email_config_ready()
email_warning = smtp_email_delivery_warning()
discord_ready, discord_detail = _discord_config_ready(runtime)
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
push_ready, push_detail = _push_config_ready(runtime)
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
checks = [
DiagnosticCheck(
key="magent-web",
label="Magent application",
category="Application",
description="Checks that the frontend application URL is responding.",
live_safe=True,
configured=True,
config_detail="ok",
target=application_target,
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
),
DiagnosticCheck(
key="magent-api",
label="Magent API",
category="Application",
description="Checks the Magent API health endpoint.",
live_safe=True,
configured=True,
config_detail="ok",
target=api_target,
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
),
DiagnosticCheck(
key="database",
label="SQLite database",
category="Application",
description="Runs SQLite integrity_check against the current Magent database.",
live_safe=True,
configured=True,
config_detail="ok",
target="sqlite",
runner=_run_database_check,
),
DiagnosticCheck(
key="seerr",
label="Seerr",
category="Media services",
description="Checks Seerr API reachability and version.",
live_safe=True,
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
config_detail="Seerr URL and API key are required.",
target=seerr_target,
runner=lambda runtime=runtime: _run_seerr_check(runtime),
),
DiagnosticCheck(
key="jellyfin",
label="Jellyfin",
category="Media services",
description="Checks Jellyfin system info with the configured API key.",
live_safe=True,
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
config_detail="Jellyfin URL and API key are required.",
target=jellyfin_target,
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
),
DiagnosticCheck(
key="sonarr",
label="Sonarr",
category="Media services",
description="Checks Sonarr system status with the configured API key.",
live_safe=True,
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
config_detail="Sonarr URL and API key are required.",
target=sonarr_target,
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
),
DiagnosticCheck(
key="radarr",
label="Radarr",
category="Media services",
description="Checks Radarr system status with the configured API key.",
live_safe=True,
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
config_detail="Radarr URL and API key are required.",
target=radarr_target,
runner=lambda runtime=runtime: _run_radarr_check(runtime),
),
DiagnosticCheck(
key="prowlarr",
label="Prowlarr",
category="Media services",
description="Checks Prowlarr health and flags warnings as degraded.",
live_safe=True,
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
config_detail="Prowlarr URL and API key are required.",
target=prowlarr_target,
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
),
DiagnosticCheck(
key="qbittorrent",
label="qBittorrent",
category="Media services",
description="Checks qBittorrent login and app version.",
live_safe=True,
configured=bool(
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
),
config_detail="qBittorrent URL, username, and password are required.",
target=qbittorrent_target,
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
),
DiagnosticCheck(
key="email",
label="SMTP email",
category="Notifications",
description="Sends a live test email using the configured SMTP provider.",
live_safe=False,
configured=email_ready,
config_detail=email_warning or email_detail,
target=smtp_target,
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
),
DiagnosticCheck(
key="discord",
label="Discord webhook",
category="Notifications",
description="Posts a live test message to the configured Discord webhook.",
live_safe=False,
configured=discord_ready,
config_detail=discord_detail,
target=discord_target,
runner=lambda runtime=runtime: _run_discord_check(runtime),
),
DiagnosticCheck(
key="telegram",
label="Telegram",
category="Notifications",
description="Sends a live test message to the configured Telegram chat.",
live_safe=False,
configured=telegram_ready,
config_detail=telegram_detail,
target=telegram_target,
runner=lambda runtime=runtime: _run_telegram_check(runtime),
),
DiagnosticCheck(
key="push",
label="Push/mobile provider",
category="Notifications",
description="Sends a live test message through the configured push provider.",
live_safe=False,
configured=push_ready,
config_detail=push_detail,
target=push_target,
runner=lambda runtime=runtime: _run_push_check(runtime),
),
DiagnosticCheck(
key="webhook",
label="Generic webhook",
category="Notifications",
description="Posts a live test payload to the configured generic webhook.",
live_safe=False,
configured=webhook_ready,
config_detail=webhook_detail,
target=webhook_target,
runner=lambda runtime=runtime: _run_webhook_check(runtime),
),
]
return checks
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
if not check.configured:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": False,
"status": _config_status(check.config_detail),
"message": check.config_detail,
"checked_at": _now_iso(),
"duration_ms": 0,
}
started = perf_counter()
checked_at = _now_iso()
try:
payload = await check.runner()
status = _clean_text(payload.get("status"), "up")
message = _clean_text(payload.get("message"), "Check passed")
detail = payload.get("detail")
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": status,
"message": message,
"detail": detail,
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
except httpx.HTTPError as exc:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": "down",
"message": _http_error_detail(exc),
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
except Exception as exc:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": "down",
"message": str(exc),
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
def get_diagnostics_catalog() -> Dict[str, Any]:
checks = _build_diagnostic_checks()
items = []
for check in checks:
items.append(
{
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"live_safe": check.live_safe,
"target": check.target,
"configured": check.configured,
"config_status": "configured" if check.configured else _config_status(check.config_detail),
"config_detail": "Ready to test." if check.configured else check.config_detail,
}
)
categories = sorted({item["category"] for item in items})
return {
"checks": items,
"categories": categories,
"generated_at": _now_iso(),
}
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
checks = _build_diagnostic_checks(recipient_email=recipient_email)
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
if selected:
checks = [check for check in checks if check.key.lower() in selected]
results = await asyncio.gather(*(_execute_check(check) for check in checks))
return {
"results": results,
"summary": _summary_from_results(results),
"checked_at": _now_iso(),
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
import logging
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_email,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
logger = logging.getLogger(__name__)
async def sync_jellyfin_users() -> int:
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyfin not configured")
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Seerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0
for user in users:
if not isinstance(user, dict):
continue
name = user.get("Name")
if not name:
continue
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
created = create_user_if_missing(
name,
"jellyfin-user",
role="user",
email=matched_email,
auth_provider="jellyfin",
jellyseerr_user_id=matched_id,
)
if created:
imported += 1
else:
existing = get_user_by_username(name)
if (
existing
and str(existing.get("role") or "user").strip().lower() != "admin"
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
):
set_user_auth_provider(name, "jellyfin")
if matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
if matched_email:
set_user_email(name, matched_email)
return imported
async def run_daily_jellyfin_sync() -> None:
while True:
delay = _seconds_until_midnight()
await _sleep_seconds(delay)
try:
imported = await sync_jellyfin_users()
logger.info("Jellyfin daily sync complete: imported=%s", imported)
except HTTPException as exc:
logger.warning("Jellyfin daily sync skipped: %s", exc.detail)
except Exception:
logger.exception("Jellyfin daily sync failed")
def _seconds_until_midnight() -> float:
from datetime import datetime, timedelta
now = datetime.now()
next_midnight = (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
return max((next_midnight - now).total_seconds(), 0.0)
async def _sleep_seconds(delay: float) -> None:
import asyncio
await asyncio.sleep(delay)
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
logger = logging.getLogger(__name__)
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _split_emails(value: str) -> list[str]:
if not value:
return []
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
return [entry for entry in parts if entry and "@" in entry]
def _resolve_app_url() -> str:
runtime = get_runtime_settings()
for candidate in (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
):
normalized = _clean_text(candidate)
if normalized:
return normalized.rstrip("/")
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
return f"http://localhost:{port}"
def _portal_item_url(item_id: int) -> str:
return f"{_resolve_app_url()}/portal?item={item_id}"
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = response.text
return {"status_code": response.status_code, "body": body}
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
runtime.discord_webhook_url
)
if not webhook:
return {"status": "skipped", "detail": "Discord webhook not configured."}
data = {
"content": f"**{title}**\n{message}",
"embeds": [
{
"title": title,
"description": message,
"fields": [
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
],
"url": _clean_text(payload.get("item_url")),
}
],
}
result = await _http_post_json(webhook, data)
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
if not bot_token or not chat_id:
return {"status": "skipped", "detail": "Telegram is not configured."}
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
result = await _http_post_json(url, payload)
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_webhook_url)
if not webhook:
return {"status": "skipped", "detail": "Generic webhook is not configured."}
result = await _http_post_json(webhook, payload)
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
topic = _clean_text(runtime.magent_notify_push_topic)
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
response.raise_for_status()
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
if provider == "pushover":
user_key = _clean_text(runtime.magent_notify_push_user_key)
if not token or not user_key:
return {"status": "skipped", "detail": "Pushover needs token and user key."}
form = {"token": token, "user": user_key, "title": title, "message": message}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
response.raise_for_status()
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
if provider == "discord":
return await _send_discord(title, message, payload)
if provider == "telegram":
return await _send_telegram(title, message)
if provider == "webhook":
return await _send_webhook(payload)
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
fallback = _clean_text(runtime.magent_notify_email_from_address)
if fallback and fallback not in recipients:
recipients.append(fallback)
if not recipients:
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
body_text = (
f"{title}\n\n"
f"{message}\n\n"
f"Kind: {_clean_text(payload.get('kind'))}\n"
f"Status: {_clean_text(payload.get('status'))}\n"
f"Priority: {_clean_text(payload.get('priority'))}\n"
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
f"Open: {_clean_text(payload.get('item_url'))}\n"
)
body_html = (
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
"</table>"
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
"</div>"
)
deliveries: list[Dict[str, Any]] = []
for recipient in recipients:
try:
result = await send_generic_email(
recipient_email=recipient,
subject=title,
body_text=body_text,
body_html=body_html,
)
deliveries.append({"recipient": recipient, "status": "ok", **result})
except Exception as exc:
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
if successful:
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
async def send_portal_notification(
*,
event_type: str,
item: Dict[str, Any],
actor_username: str,
actor_role: str,
note: Optional[str] = None,
) -> Dict[str, Any]:
runtime = get_runtime_settings()
if not runtime.magent_notify_enabled:
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
item_id = int(item.get("id") or 0)
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
message_lines = [
f"Event: {event_type}",
f"Actor: {actor_username} ({actor_role})",
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
]
if note:
message_lines.append(f"Note: {note}")
message_lines.append(f"Open: {_portal_item_url(item_id)}")
message = "\n".join(message_lines)
payload = {
"type": "portal.notification",
"event": event_type,
"item_id": item_id,
"item_url": _portal_item_url(item_id),
"kind": _clean_text(item.get("kind")),
"status": _clean_text(item.get("status")),
"priority": _clean_text(item.get("priority")),
"requested_by": _clean_text(item.get("created_by_username")),
"actor_username": actor_username,
"actor_role": actor_role,
"note": note or "",
}
channels: Dict[str, Dict[str, Any]] = {}
if runtime.magent_notify_discord_enabled:
try:
channels["discord"] = await _send_discord(title, message, payload)
except Exception as exc:
channels["discord"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_telegram_enabled:
try:
channels["telegram"] = await _send_telegram(title, message)
except Exception as exc:
channels["telegram"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_webhook_enabled:
try:
channels["webhook"] = await _send_webhook(payload)
except Exception as exc:
channels["webhook"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_push_enabled:
try:
channels["push"] = await _send_push(title, message, payload)
except Exception as exc:
channels["push"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_email_enabled:
try:
channels["email"] = await _send_email(title, message, payload)
except Exception as exc:
channels["email"] = {"status": "error", "detail": str(exc)}
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
failed = [name for name, value in channels.items() if value.get("status") == "error"]
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
logger.info(
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
event_type,
item_id,
successful,
failed,
skipped,
)
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
if not channels:
overall = "skipped"
return {"status": overall, "channels": channels}
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..db import (
create_password_reset_token,
delete_expired_password_reset_tokens,
get_password_reset_token,
get_user_by_jellyseerr_id,
get_user_by_username,
get_users_by_username_ci,
mark_password_reset_token_used,
set_user_auth_provider,
set_user_password,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from .invite_email import send_password_reset_email
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
logger = logging.getLogger(__name__)
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
class PasswordResetUnavailableError(RuntimeError):
pass
def _normalize_handles(value: object) -> list[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
if not users:
return None
requested = str(requested_identifier or "").strip().lower()
def _rank(user: dict) -> tuple[int, int, int, int]:
provider = str(user.get("auth_provider") or "local").strip().lower()
role = str(user.get("role") or "user").strip().lower()
username = str(user.get("username") or "").strip().lower()
return (
0 if role == "admin" else 1,
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
0 if username == requested else 1,
)
return sorted(users, key=_rank)[0]
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
target_handles = set(_normalize_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email"):
value = user.get(key)
if target_handles.intersection(_normalize_handles(value)):
return user
return None
async def _fetch_all_seerr_users() -> list[dict]:
cached = get_cached_jellyseerr_users()
if cached is not None:
return cached
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return []
users: list[dict] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
if users:
return save_jellyseerr_users_cache(users)
return users
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
if isinstance(local_user, dict):
stored_email = str(local_user.get("email") or "").strip()
if "@" in stored_email:
return stored_email
username = str(local_user.get("username") or "").strip()
if "@" in username:
return username
if isinstance(seerr_user, dict):
email = str(seerr_user.get("email") or "").strip()
if "@" in email:
return email
return None
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
normalized_identifier = str(identifier or "").strip()
if not normalized_identifier:
return None
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
)
seerr_users: list[dict] | None = None
seerr_user: dict | None = None
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_users = await _fetch_all_seerr_users()
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not local_user:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
if seerr_user:
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
try:
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
except (TypeError, ValueError):
seerr_user_id = None
if seerr_user_id is not None:
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
if not local_user:
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
if not isinstance(candidate, str) or not candidate.strip():
continue
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
)
if local_user:
break
if not local_user:
return None
auth_provider = resolve_user_auth_provider(local_user)
username = str(local_user.get("username") or "").strip()
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
if isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not seerr_user:
seerr_user = _find_matching_seerr_user(username, seerr_users)
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
return None
if auth_provider == "jellyseerr":
runtime = get_runtime_settings()
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
try:
jellyfin_user = await jellyfin_client.find_user_by_name(username)
except Exception:
jellyfin_user = None
if isinstance(jellyfin_user, dict):
auth_provider = "jellyfin"
if auth_provider not in {"local", "jellyfin"}:
return None
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
}
def _token_record_is_usable(record: Optional[dict]) -> bool:
if not isinstance(record, dict):
return False
if record.get("is_used"):
return False
if record.get("is_expired"):
return False
return True
def _mask_email(email: str) -> str:
candidate = str(email or "").strip()
if "@" not in candidate:
return "valid reset link"
local_part, domain = candidate.split("@", 1)
if not local_part:
return f"***@{domain}"
if len(local_part) == 1:
return f"{local_part}***@{domain}"
return f"{local_part[0]}***{local_part[-1]}@{domain}"
async def request_password_reset(
identifier: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
target = await _resolve_reset_target(identifier)
if not target:
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
create_password_reset_token(
token,
target["username"],
target["recipient_email"],
target["auth_provider"],
expires_at,
requested_by_ip=requested_by_ip,
requested_user_agent=requested_user_agent,
)
await send_password_reset_email(
recipient_email=target["recipient_email"],
username=target["username"],
token=token,
expires_at=expires_at,
auth_provider=target["auth_provider"],
)
return {
"status": "ok",
"issued": True,
"username": target["username"],
"recipient_email": target["recipient_email"],
"auth_provider": target["auth_provider"],
"expires_at": expires_at,
}
def verify_password_reset_token(token: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
return {
"status": "ok",
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
"auth_provider": record.get("auth_provider"),
"expires_at": record.get("expires_at"),
}
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
username = str(record.get("username") or "").strip()
if not username:
raise ValueError("Password reset link is invalid or has expired.")
stored_user = normalize_user_auth_provider(get_user_by_username(username))
if not stored_user:
raise ValueError("Password reset link is invalid or has expired.")
auth_provider = resolve_user_auth_provider(stored_user)
if auth_provider == "jellyseerr":
auth_provider = "jellyfin"
if auth_provider == "local":
set_user_password(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
set_user_auth_provider(username, "local")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=local", username)
return {"status": "ok", "provider": "local", "username": username}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
jellyfin_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jellyfin_user)
if not user_id:
raise ValueError("Password reset link is invalid or has expired.")
await client.set_user_password(user_id, new_password)
sync_jellyfin_password_state(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin", "username": username}
raise ValueError("Password reset is not available for this sign-in provider.")
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
import json
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting, delete_setting
logger = logging.getLogger(__name__)
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
parsed = _parse_iso(cached_at)
if not parsed:
return False
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(minutes=max_age_minutes)
def _load_cached_users(
cache_key: str, cache_at_key: str, max_age_minutes: int
) -> Optional[List[Dict[str, Any]]]:
cached_at = get_setting(cache_at_key)
if not _cache_is_fresh(cached_at, max_age_minutes):
return None
raw = get_setting(cache_key)
if not raw:
return None
try:
data = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return None
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return None
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
payload = json.dumps(users, ensure_ascii=True)
set_setting(cache_key, payload)
set_setting(cache_at_key, _now_iso())
def _normalized_handles(value: Any) -> List[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
candidate_to_id: Dict[str, int] = {}
for user in users:
if not isinstance(user, dict):
continue
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
for key in ("username", "email", "displayName", "name"):
for handle in _normalized_handles(user.get(key)):
candidate_to_id.setdefault(handle, user_id)
return candidate_to_id
def find_matching_jellyseerr_user(
identifier: str, users: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
target_handles = set(_normalized_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email", "displayName", "name"):
if target_handles.intersection(_normalized_handles(user.get(key))):
return user
return None
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(user, dict):
return None
value = user.get("email")
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def match_jellyseerr_user_id(
username: str, candidate_map: Dict[str, int]
) -> Optional[int]:
for handle in _normalized_handles(username):
matched = candidate_map.get(handle)
if matched is not None:
return matched
return None
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("id") or user.get("userId") or user.get("Id"),
"email": user.get("email"),
"username": user.get("username"),
"displayName": user.get("displayName"),
"name": user.get("name"),
}
)
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
logger.debug("Cached Seerr users: %s", len(normalized))
return normalized
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
logger.debug("Cached Jellyfin users: %s", len(normalized))
return normalized
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.134.0
uvicorn==0.41.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.14.2
PyJWT==2.13.0
passlib==1.7.4
python-multipart==0.0.31
Pillow==12.3.0
+385
View File
@@ -0,0 +1,385 @@
import os
from types import SimpleNamespace
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import HTTPException
from starlette.requests import Request
from backend.app import db
from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop
from backend.app.routers import auth as auth_router
from backend.app.routers import portal as portal_router
from backend.app.routers import requests as requests_router
from backend.app.routers import site as site_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset
from backend.app.services.snapshot import _build_presentation, _episode_availability
def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/auth/password/forgot",
"raw_path": b"/auth/password/forgot",
"query_string": b"",
"headers": [(b"user-agent", user_agent.encode("utf-8"))],
"client": (ip, 12345),
"server": ("testserver", 8000),
}
async def receive() -> dict:
return {"type": "http.request", "body": b"", "more_body": False}
return Request(scope, receive)
class TempDatabaseMixin:
def setUp(self) -> None:
super_method = getattr(super(), "setUp", None)
if callable(super_method):
super_method()
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self._original_sqlite_path = settings.sqlite_path
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
settings.sqlite_journal_mode = "DELETE"
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
db.init_db()
def tearDown(self) -> None:
settings.sqlite_path = self._original_sqlite_path
settings.sqlite_journal_mode = self._original_journal_mode
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
self._tempdir.cleanup()
super_method = getattr(super(), "tearDown", None)
if callable(super_method):
super_method()
class PasswordPolicyTests(unittest.TestCase):
def test_validate_password_policy_rejects_short_passwords(self) -> None:
with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE):
validate_password_policy("short")
def test_validate_password_policy_trims_whitespace(self) -> None:
self.assertEqual(validate_password_policy(" password123 "), "password123")
class NetworkSecurityTests(unittest.TestCase):
def test_notification_targets_reject_loopback(self) -> None:
with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
validate_notification_target_url("http://127.0.0.1:8080/webhook")
def test_forwarded_headers_require_trusted_proxy(self) -> None:
original_enabled = settings.magent_proxy_enabled
original_trust = settings.magent_proxy_trust_forwarded_headers
original_proxies = settings.magent_proxy_trusted_proxies
settings.magent_proxy_enabled = True
settings.magent_proxy_trust_forwarded_headers = True
settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
try:
self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
finally:
settings.magent_proxy_enabled = original_enabled
settings.magent_proxy_trust_forwarded_headers = original_trust
settings.magent_proxy_trusted_proxies = original_proxies
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
class FakeClient:
def __init__(self) -> None:
self.cookies = httpx.Cookies()
async def post(self, *_args, **_kwargs) -> httpx.Response:
self.cookies.set("QBT_SID_8080", "session")
return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login"))
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
await client._login(FakeClient())
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
with patch.object(
client,
"get_app_version",
new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
class SiteInfoTests(unittest.TestCase):
def test_site_public_exposes_requests_navigation_toggle(self) -> None:
runtime = SimpleNamespace(
site_build_number="test-build",
site_banner_enabled=False,
site_banner_message="",
site_banner_tone="info",
site_login_show_jellyfin_login=True,
site_login_show_local_login=True,
site_login_show_forgot_password=True,
site_login_show_signup_link=True,
site_nav_show_requests=False,
)
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
info = site_router._build_site_info(False)
self.assertEqual(info["navigation"], {"showRequests": False})
class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None:
requests_router._detail_cache.clear()
requests_router._failed_detail_cache.clear()
def test_successful_detail_cache_write_clears_prior_failure(self) -> None:
key = "request:123"
requests_router._failure_cache_set(key)
self.assertTrue(requests_router._failure_cache_has(key))
requests_router._cache_set(key, {"id": 123})
self.assertFalse(requests_router._failure_cache_has(key))
self.assertEqual(requests_router._cache_get(key), {"id": 123})
class RequestPresentationTests(unittest.TestCase):
def test_episode_availability_counts_only_aired_monitored_episodes(self) -> None:
episodes = [
{"seasonNumber": 1, "episodeNumber": 1, "monitored": True, "hasFile": True},
{"seasonNumber": 1, "episodeNumber": 2, "monitored": True, "hasFile": False},
{"seasonNumber": 1, "episodeNumber": 3, "monitored": False, "hasFile": False},
{
"seasonNumber": 1,
"episodeNumber": 4,
"monitored": True,
"hasFile": False,
"airDateUtc": "2999-01-01T00:00:00Z",
},
]
availability = _episode_availability(episodes)
self.assertEqual(availability["available"], 1)
self.assertEqual(availability["missing"], 1)
self.assertEqual(availability["total"], 2)
def test_presentation_hides_download_without_download_evidence(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example",
request_type=RequestType.tv,
state=NormalizedState.added_to_arr,
actions=[],
)
presentation = _build_presentation(
snapshot,
approved=True,
arr_state="added",
arr_details={
"availability": {"available": 0, "missing": 6, "total": 6, "seasons": []}
},
prowlarr_state="ok",
download={
"visible": False,
"state": "not_started",
"summary": "No download attempt has been observed.",
"torrents": [],
},
jellyfin_found=False,
jellyfin_link=None,
)
self.assertFalse(presentation["download"]["visible"])
self.assertIn("waiting for 6 episodes", presentation["status"]["label"])
download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
self.assertEqual(download_stage["summary"], "No download attempt yet")
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing(
"MixedCaseUser",
"password123",
email=None,
auth_provider="local",
)
self.assertTrue(created)
updated = db.set_user_email("mixedcaseuser", "mixed@example.com")
self.assertTrue(updated)
stored = db.get_user_by_username("MIXEDCASEUSER")
self.assertIsNotNone(stored)
self.assertEqual(stored.get("email"), "mixed@example.com")
class SnapshotHistoryTests(TempDatabaseMixin, unittest.TestCase):
def test_duplicate_snapshots_are_not_saved_and_download_evidence_is_retained(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example",
request_type=RequestType.tv,
state=NormalizedState.downloading,
state_reason="Downloading one episode.",
timeline=[
TimelineHop(
service="qBittorrent",
status="downloading",
details={
"summary": "Downloading one item.",
"torrents": [{"hash": "abc", "progress": 0.5}],
},
)
],
)
db.save_snapshot(snapshot)
db.save_snapshot(snapshot)
history = db.get_recent_snapshots("3909", 10)
evidence = db.get_request_download_evidence("3909")
self.assertEqual(len(history), 1)
self.assertTrue(evidence["observed"])
self.assertEqual(evidence["state"], "downloading")
class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_forgot_password_is_rate_limited(self) -> None:
request = _build_request(ip="10.1.2.3")
payload = {"identifier": "resetuser@example.com"}
with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object(
auth_router,
"request_password_reset",
new=AsyncMock(return_value={"status": "ok", "issued": False}),
):
for _ in range(3):
result = await auth_router.forgot_password(payload, request)
self.assertEqual(result["status"], "ok")
with self.assertRaises(HTTPException) as context:
await auth_router.forgot_password(payload, request)
self.assertEqual(context.exception.status_code, 429)
self.assertEqual(
context.exception.detail,
"Too many password reset attempts. Try again shortly.",
)
async def test_request_password_reset_prefers_local_user_email(self) -> None:
db.create_user_if_missing(
"ResetUser",
"password123",
email="local@example.com",
auth_provider="local",
)
with patch.object(
password_reset,
"send_password_reset_email",
new=AsyncMock(return_value={"status": "ok"}),
) as send_email:
result = await password_reset.request_password_reset("ResetUser")
self.assertTrue(result["issued"])
self.assertEqual(result["recipient_email"], "local@example.com")
send_email.assert_awaited_once()
self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com")
async def test_profile_invite_requires_recipient_email(self) -> None:
current_user = {
"username": "invite-owner",
"role": "user",
"invite_management_enabled": True,
"profile_id": None,
}
with self.assertRaises(HTTPException) as context:
await auth_router.create_profile_invite({"label": "Missing email"}, current_user)
self.assertEqual(context.exception.status_code, 400)
self.assertEqual(
context.exception.detail,
"recipient_email is required and must be a valid email address.",
)
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
def test_legacy_request_status_maps_to_workflow(self) -> None:
item = {"kind": "request", "status": "in_progress"}
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
workflow = serialized.get("workflow") or {}
self.assertEqual(workflow.get("request_status"), "approved")
self.assertEqual(workflow.get("media_status"), "processing")
def test_invalid_pipeline_transition_is_rejected(self) -> None:
with self.assertRaises(HTTPException) as context:
portal_router._validate_pipeline_transition(
"approved",
"processing",
"pending",
"pending",
)
self.assertEqual(context.exception.status_code, 400)
def test_portal_workflow_filters(self) -> None:
db.create_portal_item(
kind="request",
title="Request A",
description="A",
created_by_username="alpha",
created_by_id=None,
status="processing",
workflow_request_status="approved",
workflow_media_status="processing",
)
db.create_portal_item(
kind="request",
title="Request B",
description="B",
created_by_username="bravo",
created_by_id=None,
status="pending",
workflow_request_status="pending",
workflow_media_status="pending",
)
processing = db.list_portal_items(
kind="request",
workflow_request_status="approved",
workflow_media_status="processing",
limit=10,
offset=0,
)
pending_count = db.count_portal_items(
kind="request",
workflow_request_status="pending",
workflow_media_status="pending",
)
self.assertEqual(len(processing), 1)
self.assertEqual(pending_count, 1)
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+28
View File
@@ -0,0 +1,28 @@
name: magent-beta
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_NAME: Magent Beta
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true"
SITE_BANNER_MESSAGE: "Beta environment"
SITE_BANNER_TONE: warning
ports:
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
- "127.0.0.1:8100:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
+10
View File
@@ -0,0 +1,10 @@
services:
magent:
image: rephl3xnz/magent:latest
env_file:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
+12
View File
@@ -0,0 +1,12 @@
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
+28
View File
@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[program:backend]
directory=/app
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
[program:frontend]
directory=/app/frontend
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.next/
.env
+33
View File
@@ -0,0 +1,33 @@
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json ./
RUN npm install
COPY app ./app
COPY public ./public
COPY next-env.d.ts ./next-env.d.ts
COPY next.config.js ./next.config.js
COPY tsconfig.json ./tsconfig.json
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js
EXPOSE 3000
CMD ["npm", "run", "start"]
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
import { notFound } from 'next/navigation'
import SettingsPage from '../SettingsPage'
const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr',
'jellyfin',
'artwork',
'sonarr',
'radarr',
'prowlarr',
'qbittorrent',
'requests',
'cache',
'logs',
'maintenance',
'magent',
'general',
'notifications',
'site',
])
type PageProps = {
params: Promise<{ section: string }>
}
export default async function AdminSectionPage({ params }: PageProps) {
const { section } = await params
if (!ALLOWED_SECTIONS.has(section)) {
notFound()
}
return <SettingsPage section={section} />
}
+27
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>
)
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from '../../portal/PortalClient'
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
}
+260
View File
@@ -0,0 +1,260 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type ServiceState = {
name: string
status: string
message?: string
}
type RecentRequest = {
id: number
title?: string | null
year?: number | null
statusLabel?: string | null
requestedBy?: string | null
createdAt?: string | null
}
type PortalOverview = {
overview?: {
total_items?: number
total_comments?: number
by_kind?: Record<string, number>
by_status?: Record<string, number>
}
my_items?: number
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const normalizeRecent = (items: any[]): RecentRequest[] =>
items
.filter((item) => item?.id)
.map((item) => ({
id: Number(item.id),
title: item.title ?? null,
year: item.year ?? null,
statusLabel: item.statusLabel ?? null,
requestedBy: item.requestedBy ?? null,
createdAt: item.createdAt ?? null,
}))
export default function AdminLandingPage() {
const router = useRouter()
const [services, setServices] = useState<ServiceState[]>([])
const [serviceOverall, setServiceOverall] = useState('unknown')
const [recent, setRecent] = useState<RecentRequest[]>([])
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
authFetch(`${baseUrl}/status/services`),
authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
authFetch(`${baseUrl}/portal/overview`),
])
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
if (meResponse.status === 403) {
router.push('/')
return
}
const me = await meResponse.json()
if (me?.role !== 'admin') {
router.push('/')
return
}
if (serviceResponse.ok) {
const data = await serviceResponse.json()
setServiceOverall(data?.overall ?? 'unknown')
setServices(Array.isArray(data?.services) ? data.services : [])
}
if (recentResponse.ok) {
const data = await recentResponse.json()
setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
}
if (overviewResponse.ok) {
const data = await overviewResponse.json()
setPortalOverview(data)
}
} catch (err) {
console.error(err)
setError('Unable to load the operations dashboard.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
const serviceCounts = useMemo(() => {
const up = services.filter((service) => service.status === 'up').length
const down = services.filter((service) => service.status === 'down').length
const degraded = services.filter((service) => service.status === 'degraded').length
const notConfigured = services.filter((service) => service.status === 'not_configured').length
return { up, down, degraded, notConfigured, total: services.length }
}, [services])
const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Service ecosystem</span>
<div className="service-ecosystem">
{services.length === 0 ? (
<div className="status-banner">Service status is not available yet.</div>
) : (
services.map((service) => (
<a
key={service.name}
className="service-row"
href={`/admin/${service.name.toLowerCase().replace(/[^a-z0-9]/g, '')}`}
>
<span className={`system-dot system-dot-${service.status}`} />
<span>
<strong>{service.name}</strong>
<small>{service.message ?? 'No message reported'}</small>
</span>
<span className={`small-pill system-pill-${service.status}`}>{service.status}</span>
</a>
))
)}
</div>
</div>
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Quick actions</span>
<div className="quick-action-grid">
<a href="/admin/requests-all">Review requests</a>
<a href="/admin/issues">Manage issues</a>
<a href="/users">User directory</a>
<a href="/admin/logs">Activity log</a>
</div>
</div>
</div>
)
return (
<AdminShell
title="Operations Center"
subtitle="Live Magent controls, request movement, issue intake, and service health."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/')}>
View health
</button>
}
>
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
{error ? <div className="error-banner">{error}</div> : null}
<section className="ops-metric-grid">
<div className="ops-metric-card">
<span className="section-kicker">Services online</span>
<strong>
{serviceCounts.up}/{serviceCounts.total || 0}
</strong>
<p>{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Recent requests</span>
<strong>{recent.length}</strong>
<p>Loaded from the live request cache.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Open issue items</span>
<strong>{issueCount}</strong>
<p>{commentCount} portal comments recorded.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Portal requests</span>
<strong>{requestItemCount}</strong>
<p>Tracked in the dedicated request workflow.</p>
</div>
</section>
<section className="admin-zone">
<div className="section-header">
<div>
<h2>Recent activity</h2>
<p className="section-subtitle">Live request cache entries, newest first.</p>
</div>
</div>
{recent.length === 0 ? (
<div className="status-banner">No recent requests were returned.</div>
) : (
<div className="admin-table dashboard-activity-table">
<div className="admin-table-head">
<span>Request</span>
<span>Status</span>
<span>User</span>
<span>Created</span>
</div>
{recent.map((row) => (
<button
key={row.id}
type="button"
className="admin-table-row"
onClick={() => router.push(`/requests/${row.id}`)}
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''}
</span>
<span>{row.statusLabel || 'Unknown'}</span>
<span>{row.requestedBy || 'Unknown'}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
</div>
)}
</section>
<section className="admin-zone">
<div className="section-header">
<div>
<h2>Attention states</h2>
<p className="section-subtitle">Service states that affect request processing.</p>
</div>
</div>
<div className="ops-status-strip">
<span>{serviceCounts.down} down</span>
<span>{serviceCounts.degraded} degraded</span>
<span>{serviceCounts.notConfigured} not configured</span>
</div>
</section>
</AdminShell>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function AdminProfilesRedirectPage() {
redirect('/admin/invites')
}
+205
View File
@@ -0,0 +1,205 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type RequestRow = {
id: number
title?: string | null
year?: number | null
type?: string | null
statusLabel?: string | null
requestedBy?: string | null
createdAt?: string | null
}
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting for approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working on it' },
{ value: 'partial', label: 'Partially ready' },
{ value: 'ready', label: 'Ready to watch' },
{ value: 'declined', label: 'Declined' },
]
const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function AdminRequestsAllPage() {
const router = useRouter()
const [rows, setRows] = useState<RequestRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pageSize, setPageSize] = useState(50)
const [page, setPage] = useState(1)
const [stage, setStage] = useState('all')
const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1
return Math.max(1, Math.ceil(total / pageSize))
}, [total, pageSize])
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const skip = (page - 1) * pageSize
const params = new URLSearchParams({
take: String(pageSize),
skip: String(skip),
})
if (stage !== 'all') {
params.set('stage', stage)
}
const response = await authFetch(
`${baseUrl}/admin/requests/all?${params.toString()}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error(`Load failed: ${response.status}`)
}
const data = await response.json()
setRows(Array.isArray(data?.results) ? data.results : [])
setTotal(Number(data?.total ?? 0))
} catch (err) {
console.error(err)
setError('Unable to load requests.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [page, pageSize, stage])
useEffect(() => {
if (page > pageCount) {
setPage(pageCount)
}
}, [pageCount, page])
useEffect(() => {
setPage(1)
}, [stage])
return (
<AdminShell
title="All requests"
subtitle="Paginated view of every cached request."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
<span>{total.toLocaleString()} total</span>
</div>
<div className="admin-toolbar-actions">
<label className="admin-select">
<span>Stage</span>
<select value={stage} onChange={(e) => setStage(e.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-select">
<span>Per page</span>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
</div>
</div>
{loading ? (
<div className="status-banner">Loading requests</div>
) : error ? (
<div className="error-banner">{error}</div>
) : rows.length === 0 ? (
<div className="status-banner">No requests found.</div>
) : (
<div className="admin-table">
<div className="admin-table-head">
<span>Request</span>
<span>Status</span>
<span>Requested by</span>
<span>Created</span>
</div>
{rows.map((row) => (
<button
key={row.id}
type="button"
className="admin-table-row"
onClick={() => router.push(`/requests/${row.id}`)}
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''}
</span>
<span>{row.statusLabel || 'Unknown'}</span>
<span>{row.requestedBy || 'Unknown'}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
</div>
)}
<div className="admin-pagination">
<button type="button" onClick={() => setPage(1)} disabled={page <= 1}>
First
</button>
<button type="button" onClick={() => setPage(page - 1)} disabled={page <= 1}>
Previous
</button>
<span>
Page {page} of {pageCount}
</span>
<button
type="button"
onClick={() => setPage(page + 1)}
disabled={page >= pageCount}
>
Next
</button>
<button
type="button"
onClick={() => setPage(pageCount)}
disabled={page >= pageCount}
>
Last
</button>
</div>
</section>
</AdminShell>
)
}
+304
View File
@@ -0,0 +1,304 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type FlowStage = {
title: string
input: string
action: string
output: string
}
const REQUEST_FLOW: FlowStage[] = [
{
title: 'Identity + access',
input: 'Jellyfin/local login',
action: 'Magent validates credentials and role',
output: 'JWT token + user scope',
},
{
title: 'Request intake',
input: 'Seerr request ID',
action: 'Magent snapshots request + media metadata',
output: 'Unified request state',
},
{
title: 'Queue orchestration',
input: 'Approved request',
action: 'Sonarr/Radarr add/search operations',
output: 'Grab decision',
},
{
title: 'Download execution',
input: 'Selected release',
action: 'qBittorrent downloads + reports progress',
output: 'Import-ready payload',
},
{
title: 'Library import',
input: 'Completed download',
action: 'Sonarr/Radarr import and finalize',
output: 'Available media object',
},
{
title: 'Playback availability',
input: 'Imported media',
action: 'Jellyfin refresh + link resolution',
output: 'Ready-to-watch state',
},
]
export default function AdminSystemGuidePage() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
router.push('/')
return
}
const me = await response.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
}
setAuthorized(true)
} catch (error) {
console.error(error)
router.push('/')
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
if (loading) {
return <main className="card">Loading system guide...</main>
}
if (!authorized) {
return null
}
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">How it works</span>
<h2>Admin flow map</h2>
<p>Identity Request intake Queue orchestration Download Import Playback.</p>
<span className="small-pill">Admin only</span>
</div>
</div>
)
return (
<AdminShell
title="How it works"
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section system-guide">
<div className="admin-panel">
<h2>End-to-end system flow</h2>
<p className="lede">
This is the runtime path the platform follows from authentication through to playback
availability.
</p>
<div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment">
<article className="system-flow-card">
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
<div className="system-flow-card-row">
<span>Input</span>
<strong>{stage.input}</strong>
</div>
<div className="system-flow-card-row">
<span>Action</span>
<strong>{stage.action}</strong>
</div>
<div className="system-flow-card-row">
<span>Output</span>
<strong>{stage.output}</strong>
</div>
</article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>}
</div>
))}
</div>
</div>
<div className="admin-panel">
<h2>What each service is responsible for</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Magent</h3>
<p>
Handles authentication, request pages, live event updates, invite workflows,
diagnostics, notifications, and admin operations.
</p>
</article>
<article className="system-guide-card">
<h3>Seerr</h3>
<p>
Stores the request itself and remains the request-state source for approval and
media request metadata.
</p>
</article>
<article className="system-guide-card">
<h3>Jellyfin</h3>
<p>
Provides user sign-in identity and the final playback destination once content is
available.
</p>
</article>
<article className="system-guide-card">
<h3>Sonarr / Radarr</h3>
<p>
Control queue placement, quality-profile decisions, import handling, and release
monitoring.
</p>
</article>
<article className="system-guide-card">
<h3>Prowlarr</h3>
<p>Provides search/indexer coverage for Arr-side release searches.</p>
</article>
<article className="system-guide-card">
<h3>qBittorrent</h3>
<p>
Executes the download and exposes live progress, paused states, and queue
visibility.
</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>Operational controls by area</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>General</h3>
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
</article>
<article className="system-guide-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
</article>
<article className="system-guide-card">
<h3>Users</h3>
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
</article>
<article className="system-guide-card">
<h3>Invite management</h3>
<p>
Master template, profile assignment, invite access policy, invite emails, and trace
map lineage.
</p>
</article>
<article className="system-guide-card">
<h3>Requests + cache</h3>
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
</article>
<article className="system-guide-card">
<h3>Maintenance + diagnostics</h3>
<p>
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
nuclear flush/resync operations.
</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>User and invite model</h2>
<ol className="system-decision-list">
<li>
Jellyfin is used for sign-in identity and user presence across the platform.
</li>
<li>
Seerr provides request ownership and request-state data for Magent request pages.
</li>
<li>
Invite links, invite profiles, blanket rules, and invite-access controls are managed
inside Magent.
</li>
<li>
If invite tracing is enabled, the lineage view shows who invited whom and how the
chain branches.
</li>
<li>
Cross-system removal and ban flows are initiated from Magent admin controls.
</li>
</ol>
</div>
<div className="admin-panel">
<h2>Stall recovery path (decision flow)</h2>
<ol className="system-decision-list">
<li>
Request approved but not in Arr queue <span></span> run <strong>Re-add to Arr</strong>.
</li>
<li>
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li>
<li>
Release exists and user should not pick manually <span></span> run <strong>Search + auto-download</strong>.
</li>
<li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
</li>
<li>
Imported but not visible to user <span></span> validate Jellyfin visibility/link from request page.
</li>
</ol>
</div>
<div className="admin-panel">
<h2>Live update surfaces</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Landing page</h3>
<p>Recent requests and service summaries refresh live for signed-in users.</p>
</article>
<article className="system-guide-card">
<h3>Request pages</h3>
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
</article>
<article className="system-guide-card">
<h3>Admin views</h3>
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
</article>
</div>
</div>
</section>
</AdminShell>
)
}
+119
View File
@@ -0,0 +1,119 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type SiteInfo = {
changelog?: string
}
type ChangelogGroup = {
date: string
entries: string[]
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const parseChangelog = (raw: string): ChangelogGroup[] => {
const groups: ChangelogGroup[] = []
for (const rawLine of raw.split('\n')) {
const line = rawLine.trim()
if (!line) continue
const [candidateDate, ...messageParts] = line.split('|')
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
const message = messageParts.join('|').trim()
if (!message) continue
const currentGroup = groups[groups.length - 1]
if (currentGroup?.date === candidateDate) {
currentGroup.entries.push(message)
} else {
groups.push({ date: candidateDate, entries: [message] })
}
continue
}
if (groups.length === 0) {
groups.push({ date: 'Updates', entries: [line] })
} else {
groups[groups.length - 1].entries.push(line)
}
}
return groups
}
export default function ChangelogPage() {
const router = useRouter()
const [groups, setGroups] = useState<ChangelogGroup[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
router.push('/login')
return
}
let active = true
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/site/info`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Failed to load changelog')
}
const data: SiteInfo = await response.json()
if (!active) return
setGroups(parseChangelog(data?.changelog ?? ''))
} catch (err) {
console.error(err)
if (!active) return
setGroups([])
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
const content = useMemo(() => {
if (loading) {
return <div className="loading-text">Loading changelog...</div>
}
if (groups.length === 0) {
return <div className="meta">No updates posted yet.</div>
}
return (
<div className="changelog-groups">
{groups.map((group) => (
<section key={group.date} className="changelog-group">
<h2>{group.date}</h2>
<ul className="changelog-list">
{group.entries.map((entry, index) => (
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
))}
</ul>
</section>
))}
</div>
)
}, [groups, loading])
return (
<div className="page">
<section className="card changelog-card">
<div className="changelog-header">
<h1>Changelog</h1>
<p className="lede">Latest updates and release notes.</p>
</div>
{content}
</section>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
type Profile = {
username?: string
}
export default function FeedbackPage() {
const router = useRouter()
const [profile, setProfile] = useState<Profile | null>(null)
const [category, setCategory] = useState('bug')
const [message, setMessage] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) {
throw new Error('Could not load profile.')
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
}
}
void load()
}, [router])
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
if (!message.trim()) {
setStatus('Please write a short message before sending.')
return
}
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: category,
message: message.trim(),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setMessage('')
setStatus('Thanks! Your message has been sent.')
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
setStatus('That did not send. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<main className="card">
<header className="how-hero">
<p className="eyebrow">Send feedback</p>
<h1>Help us improve Magent</h1>
<p className="lede">
Found a problem or have an idea? Send it here and we will see it right away.
</p>
</header>
<form className="auth-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
<label htmlFor="feedback-type">What is this about?</label>
<select
id="feedback-type"
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="bug">Bug (something is broken)</option>
<option value="feature">Feature idea (new option)</option>
</select>
<label htmlFor="feedback-message">Tell us what happened</label>
<textarea
id="feedback-message"
rows={6}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the details here..."
/>
{status && <div className="status-banner">{status}</div>}
<button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send feedback'}
</button>
</form>
</main>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { getApiBase } from '../lib/auth'
export default function ForgotPasswordPage() {
const router = useRouter()
const [identifier, setIdentifier] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (!identifier.trim()) {
setError('Enter your username or email.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/password/forgot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: identifier.trim() }),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.')
}
setStatus(
typeof data?.message === 'string'
? data.message
: 'If an account exists for that username or email, a password reset link has been sent.',
)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to send reset link.')
} finally {
setLoading(false)
}
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Forgot password</h1>
<p className="lede">
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
will be emailed to you.
</p>
<form className="auth-form" onSubmit={submit}>
<label>
Username or email
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
autoComplete="username"
placeholder="you@example.com"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
{loading ? 'Sending reset link…' : 'Send reset link'}
</button>
</div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
Back to sign in
</button>
</form>
</main>
)
}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
'use client'
export default function HowItWorksPage() {
return (
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How it works</p>
<h1>How Magent works for users</h1>
<p className="lede">
Use Magent to find a request, watch it move through the pipeline, and know when it is
ready without constantly refreshing the page.
</p>
</header>
<section className="how-flow">
<h2>What Magent is for</h2>
<div className="how-grid">
<article className="how-card">
<h3>Track requests</h3>
<p>
Search by title, year, or request number to open the request page and see where an
item is up to.
</p>
</article>
<article className="how-card">
<h3>See live progress</h3>
<p>
Request status, timeline events, and download progress update live while you are
viewing the page.
</p>
</article>
<article className="how-card">
<h3>Know when it is ready</h3>
<p>
When the request is fully imported and available, Magent shows it as ready and links
you through to Jellyfin.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>The request pipeline</h2>
<ol className="how-steps">
<li>
<strong>You request a movie or show</strong> through Seerr.
</li>
<li>
<strong>Magent picks up the request</strong> and shows its current state.
</li>
<li>
<strong>The automation stack searches and downloads it</strong> if it can find a valid
release.
</li>
<li>
<strong>The file is imported into the library</strong>.
</li>
<li>
<strong>Jellyfin serves it</strong> once it is ready to watch.
</li>
</ol>
</section>
<section className="how-flow">
<h2>What the statuses usually mean</h2>
<div className="how-grid">
<article className="how-card">
<h3>Pending</h3>
<p>The request exists, but it is still waiting for approval or the next step.</p>
</article>
<article className="how-card">
<h3>Approved / Processing</h3>
<p>The request has been accepted and the automation tools are working on it.</p>
</article>
<article className="how-card">
<h3>Downloading</h3>
<p>Magent can show live progress while the content is still being downloaded.</p>
</article>
<article className="how-card">
<h3>Ready</h3>
<p>The item has been imported and should now be available in Jellyfin.</p>
</article>
<article className="how-card">
<h3>Partial / Waiting</h3>
<p>
Part of the workflow completed, but the request is still waiting on another service or
on content becoming available.
</p>
</article>
<article className="how-card">
<h3>Declined</h3>
<p>The request was rejected or cannot proceed in its current form.</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Live updates you can expect</h2>
<div className="how-step-grid">
<article className="how-step-card step-seerr">
<div className="step-badge">1</div>
<h3>Recent requests refresh automatically</h3>
<p className="step-note">
Your request list and landing-page activity update automatically while you are signed
in.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Request pages update in real time</h3>
<p className="step-note">
State changes, timeline steps, and downloader progress are pushed to the page live.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as the import completes</h3>
<p className="step-note">
Once the content is actually available, Magent updates the request page without a hard
refresh.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>User actions you may see</h2>
<div className="how-grid">
<article className="how-card">
<h3>Open request</h3>
<p>Jump into the full request page to inspect the current state and activity.</p>
</article>
<article className="how-card">
<h3>Open in Jellyfin</h3>
<p>Appears when the request is ready and Magent can link you through for playback.</p>
</article>
<article className="how-card">
<h3>Search + auto-download</h3>
<p>
Only appears for accounts that have been granted self-service download access by the
admin team.
</p>
</article>
<article className="how-card">
<h3>My invites</h3>
<p>
If your account is allowed to invite others, you can create and manage invite links
from your profile.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invites and signup</h2>
<ol className="how-steps">
<li>
<strong>You receive an invite link</strong> by email or directly from the person who
invited you.
</li>
<li>
<strong>You sign up through Magent</strong> and your account is linked into the media
stack.
</li>
<li>
<strong>Your account defaults apply</strong> based on the invite or your assigned
profile.
</li>
<li>
<strong>You sign in and track requests</strong> from the landing page and your request
pages.
</li>
</ol>
</section>
<section className="how-callout">
<h2>If a request looks stuck</h2>
<p>
A waiting request usually means no usable release has been found yet, the download is
still in progress, or the import has not completed. Magent will keep updating as the
underlying services move forward.
</p>
</section>
</main>
)
}
+45
View File
@@ -0,0 +1,45 @@
import './globals.css'
import './ops-redesign.css'
import type { ReactNode } from 'react'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus'
export const metadata = {
title: 'Magent',
description: 'Request timeline and AI triage for media requests',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" data-theme="dark">
<body>
<BrandingFavicon />
<div className="page">
<header className="header">
<div className="header-left">
<a className="brand-link" href="/">
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">GrizzlyFlix media operations</div>
</div>
</a>
</div>
<div className="header-right">
<span className="beta-chip" title="Beta environment">Beta</span>
<HeaderIdentity />
</div>
<div className="header-nav">
<HeaderActions />
</div>
</header>
<SiteStatus />
{children}
</div>
</body>
</html>
)
}
+100
View File
@@ -0,0 +1,100 @@
const AUTH_STATE_COOKIE = 'magent_logged_in'
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
}
const clearCookie = (name: string) => {
if (typeof document === 'undefined') return
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
}
export const getToken = () => {
if (typeof document === 'undefined') return null
const cookies = document.cookie.split(';').map((entry) => entry.trim())
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
if (!marker) return null
const [, value] = marker.split('=', 2)
return value || null
}
export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
}
export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE)
if (typeof window === 'undefined') return
const baseUrl = getApiBase()
void fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
keepalive: true,
}).catch(() => undefined)
}
export const logout = async () => {
const baseUrl = getApiBase()
clearCookie(AUTH_STATE_COOKIE)
await fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
}
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {})
return fetch(input, { ...init, headers, credentials: 'include' })
}
export const getEventStreamToken = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/stream-token`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Stream token request failed: ${response.status}`)
}
const data = await response.json()
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
if (!token) {
throw new Error('Stream token not returned')
}
return token
}
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized')
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends Error {
constructor() {
super('Forbidden')
this.name = 'ForbiddenError'
}
}
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init)
if (response.status === 401) {
clearToken()
throw new UnauthorizedError()
}
if (response.status === 403) {
throw new ForbiddenError()
}
return response
}
export const readResponseText = async (response: Response) => {
try {
return (await response.text()).trim()
} catch {
return ''
}
}
+190
View File
@@ -0,0 +1,190 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { getApiBase, setToken, clearToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
const DEFAULT_LOGIN_OPTIONS = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
}
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
? 'jellyfin'
: loginOptions.showLocalLogin
? 'local'
: null
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
event.preventDefault()
if (!primaryMode) {
setError('Login is currently disabled. Contact an administrator.')
return
}
setError(null)
setLoading(true)
try {
clearToken()
const baseUrl = getApiBase()
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
const body = new URLSearchParams({ username, password })
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
credentials: 'include',
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
if (data?.authenticated) {
setToken('cookie')
if (typeof window !== 'undefined') {
window.location.href = '/'
return
}
router.push('/')
return
}
throw new Error('Login failed')
} catch (err) {
console.error(err)
setError('Invalid username or password.')
} finally {
setLoading(false)
}
}
useEffect(() => {
let active = true
const loadLoginOptions = async () => {
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/site/public`)
if (!response.ok) {
return
}
const data = await response.json()
const login = data?.login ?? {}
if (!active) return
setLoginOptions({
showJellyfinLogin: login.showJellyfinLogin !== false,
showLocalLogin: login.showLocalLogin !== false,
showForgotPassword: login.showForgotPassword !== false,
showSignupLink: login.showSignupLink !== false,
})
} catch (err) {
console.error(err)
}
}
void loadLoginOptions()
return () => {
active = false
}
}, [])
const loginHelpText = (() => {
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
}
if (loginOptions.showJellyfinLogin) {
return 'Use your Jellyfin account to sign in.'
}
if (loginOptions.showLocalLogin) {
return 'Use your local Magent admin account to sign in.'
}
return 'No sign-in methods are currently available. Contact an administrator.'
})()
return (
<main className="auth-screen">
<section className="auth-hero">
<div className="auth-mark">
<BrandingLogo className="brand-logo brand-logo--login" />
</div>
<div className="auth-title-block">
<span className="section-kicker">Secure access</span>
<h1>Magent operational gateway</h1>
<p>{loginHelpText}</p>
</div>
</section>
<form
onSubmit={(event) => {
if (!primaryMode) {
event.preventDefault()
setError('Login is currently disabled. Contact an administrator.')
return
}
void submit(event, primaryMode)
}}
className="auth-form auth-panel"
>
<label>
<span>Username</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="Enter your username"
/>
</label>
<label>
<span>Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
placeholder="Enter your password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
<div className="auth-actions">
{loginOptions.showJellyfinLogin ? (
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button>
) : null}
</div>
{loginOptions.showLocalLogin ? (
<button
type="button"
className="ghost-button"
disabled={loading}
onClick={(event) => submit(event, 'local')}
>
Sign in with Magent account
</button>
) : null}
{loginOptions.showForgotPassword ? (
<a className="ghost-button" href="/forgot-password">
Forgot password?
</a>
) : null}
{loginOptions.showSignupLink ? (
<a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent)
</a>
) : null}
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
) : null}
<div className="auth-footnote">
<span className="live-dot" aria-hidden="true" />
Beta environment
</div>
</form>
</main>
)
}
File diff suppressed because it is too large Load Diff
+632
View File
@@ -0,0 +1,632 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
const [recent, setRecent] = useState<
{
id: number
title: string
year?: number
statusLabel?: string
artwork?: { poster_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
>(null)
const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
}
void runSearch(trimmed)
}
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setRecentLoading(true)
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
const userRole = me?.role ?? null
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
setRecentLoading(false)
}
}
load()
}, [recentDays, recentStage])
useEffect(() => {
if (!authReady) {
return
}
const load = async () => {
setServicesLoading(true)
setServicesError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Service status failed: ${response.status}`)
}
const data = await response.json()
setServicesStatus(data)
} catch (error) {
console.error(error)
setServicesError('Service status is not available right now.')
} finally {
setServicesLoading(false)
}
}
void load()
if (liveStreamConnected) {
return
}
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
if (!getToken()) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
if (payload.type === 'home_services') {
if (payload.status && typeof payload.status === 'object') {
setServicesStatus(payload.status)
setServicesError(null)
setServicesLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setServicesError('Service status is not available right now.')
setServicesLoading(false)
}
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (error) {
if (closed) return
console.error(error)
setLiveStreamConnected(false)
}
}
void connect()
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
}
}
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
const formatRequestTime = (value?: string | null) => {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const serviceItems = servicesStatus?.services ?? []
const serviceUpCount = serviceItems.filter((service) => service.status === 'up').length
const serviceAttentionCount = serviceItems.filter((service) =>
['down', 'degraded', 'not_configured'].includes(service.status)
).length
const serviceOverall = servicesStatus?.overall ?? 'unknown'
const serviceStatusLabel = servicesLoading
? 'Checking services...'
: servicesError
? 'Status not available yet'
: serviceOverall === 'up'
? 'Services are up and running'
: serviceOverall === 'down'
? 'Something is down'
: 'Some services need attention'
const serviceSummary = servicesError
? 'Unable to load service status'
: serviceItems.length === 0
? 'No services reported yet'
: serviceAttentionCount > 0
? `${serviceAttentionCount} of ${serviceItems.length} need attention`
: `${serviceUpCount} of ${serviceItems.length} online`
const orderedServices = ['Seerr', 'Sonarr', 'Radarr', 'Prowlarr', 'qBittorrent', 'Jellyfin'].map(
(name) => {
const item = serviceItems.find((entry) => entry.name === name)
return { name, status: item?.status ?? 'unknown', message: item?.message }
}
)
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
return (
<main className="card">
<section className="ops-metric-grid">
<div className="ops-metric-card">
<span className="section-kicker">Service mesh</span>
<strong>
{serviceUpCount}/{serviceItems.length || 0}
</strong>
<p>{servicesLoading ? 'Checking services now.' : 'Configured services online.'}</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Attention</span>
<strong>{serviceAttentionCount}</strong>
<p>Services reporting down, degraded, or not configured.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Loaded requests</span>
<strong>{recent.length}</strong>
<p>Returned by the live request cache.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Active queue</span>
<strong>{activeRecentCount}</strong>
<p>Loaded requests still moving through the pipeline.</p>
</div>
</section>
<div className="layout-grid">
<section className="recent centerpiece">
<details className="system-status system-status-dropdown">
<summary className="system-summary">
<span className="system-summary-copy">
<span className="section-kicker">System status</span>
<strong>{serviceSummary}</strong>
<span>{serviceStatusLabel}</span>
</span>
<span className="system-summary-actions">
<span className={`system-pill system-pill-${serviceOverall}`}>
{servicesLoading ? 'Checking' : serviceOverall.replaceAll('_', ' ')}
</span>
<span className="system-dropdown-cue" aria-hidden="true">Open</span>
</span>
</summary>
<div className="system-list">
{orderedServices.map(({ name, status, message }) => {
const testing = serviceTesting[name] ?? false
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span>
<span className="system-test-message">
{serviceTestResults[name] ?? message ?? 'No recent detail'}
</span>
</div>
<div className="system-actions">
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div>
)
})}
</div>
</details>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests</span>
</div>
) : recentError ? (
<button type="button" disabled>
{recentError}
</button>
) : recent.length === 0 ? (
<button type="button" disabled>
No recent requests found
</button>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card"
>
{item.artwork?.poster_url && (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
)}
<span className="recent-info">
<span className="recent-title">
{item.title || 'Untitled'}
{item.year ? ` (${item.year})` : ''}
</span>
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
</button>
))
)}
</div>
</section>
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Search all requests</h1>
<p className="lede">
Search any request by title + year or request number and see whether it already
exists in the system.
</p>
</div>
<div className="find-controls">
<form onSubmit={submit} className="search search-row">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="e.g. Dune 2021 or 1289"
/>
<button type="submit">Check status</button>
</form>
<div className="filters filters-compact">
<div className="filter">
<span>Type</span>
<div className="pill-group">
<button type="button">TV</button>
<button type="button">Movie</button>
</div>
</div>
<div className="filter">
<span>Status</span>
<div className="pill-group">
<button type="button">Pending</button>
<button type="button">Approved</button>
<button type="button">Processing</button>
<button type="button">Failed</button>
<button type="button">Available</button>
</div>
</div>
</div>
</div>
<section className="recent results-panel">
<h2>Search results</h2>
<div className="recent-grid">
{searchError ? (
<button type="button" disabled>
{searchError}
</button>
) : searchResults.length === 0 ? (
<button type="button" disabled>
No matches yet
</button>
) : (
searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
disabled={!item.requestId}
onClick={() =>
item.requestId && router.push(`/requests/${item.requestId}`)
}
>
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId
? '- not requested'
: item.statusLabel
? `- ${item.statusLabel}`
: '- already requested'}
</button>
))
)}
</div>
</section>
</section>
</aside>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function IssuePortalPage() {
return <PortalClient workspace="issue" />
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function PortalIndexPage() {
redirect('/portal/requests')
}
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function RequestPortalPage() {
return <PortalClient workspace="request" />
}
+638
View File
@@ -0,0 +1,638 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type ProfileInfo = {
username: string
role: string
auth_provider: string
invite_management_enabled?: boolean
}
type ProfileResponse = {
user: ProfileInfo
}
type OwnedInvite = {
id: number
code: string
label?: string | null
description?: string | null
recipient_email?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
enabled: boolean
expires_at?: string | null
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
updated_at?: string | null
}
type OwnedInvitesResponse = {
invites?: OwnedInvite[]
count?: number
invite_access?: {
enabled?: boolean
managed_by_master?: boolean
}
master_invite?: {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
enabled?: boolean
expires_at?: string | null
is_usable?: boolean
} | null
}
type OwnedInviteForm = {
code: string
label: string
description: string
recipient_email: string
max_uses: string
expires_at: string
enabled: boolean
send_email: boolean
message: string
}
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
recipient_email: '',
max_uses: '',
expires_at: '',
enabled: true,
send_email: false,
message: '',
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
export default function ProfileInvitesPage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true)
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
const loadPage = async () => {
const baseUrl = getApiBase()
const [profileResponse, invitesResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/profile`),
authFetch(`${baseUrl}/auth/profile/invites`),
])
if (!profileResponse.ok || !invitesResponse.ok) {
if (profileResponse.status === 401 || invitesResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Could not load invite tools.')
}
const [profileData, inviteData] = (await Promise.all([
profileResponse.json(),
invitesResponse.json(),
])) as [ProfileResponse, OwnedInvitesResponse]
const user = profileData?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
})
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(inviteData?.master_invite ?? null)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
await loadPage()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultOwnedInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setInviteEditingId(invite.id)
setInviteError(null)
setInviteStatus(null)
setInviteForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
send_email: false,
message: '',
})
}
const reloadInvites = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Invite refresh failed: ${response.status}`)
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data?.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(data?.master_invite ?? null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setInviteError('Recipient email is required.')
setInviteStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
setInviteError('Recipient email must be valid.')
setInviteStatus(null)
return
}
setInviteSaving(true)
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
inviteEditingId == null
? `${baseUrl}/auth/profile/invites`
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
{
method: inviteEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
recipient_email: recipientEmail,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
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()
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setInviteSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite delete failed')
}
if (inviteEditingId === invite.id) {
resetInviteEditor()
}
setInviteStatus(`Deleted invite ${invite.code}.`)
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setInviteStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
if (loading) {
return <main className="card">Loading invite tools...</main>
}
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My invites</h1>
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
Back to profile
</button>
</div>
</div>
{profile ? (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}).
</div>
) : null}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=activity')}
>
Activity
</button>
<button type="button" role="tab" aria-selected className="is-active">
My invites
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=security')}
>
Security
</button>
</div>
</div>
{inviteError && <div className="error-banner">{inviteError}</div>}
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
{!canManageInvites ? (
<section className="profile-section profile-tab-panel">
<h2>Invite access is disabled</h2>
<p className="lede">
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
</p>
<div className="admin-inline-actions">
<button type="button" onClick={() => router.push('/profile')}>
Return to profile
</button>
</div>
</section>
) : (
<section className="profile-section profile-invites-section profile-tab-panel">
<div className="user-directory-panel-header">
<div>
<h2>Invite workspace</h2>
<p className="lede">
{inviteManagedByMaster
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
</p>
</div>
</div>
<div className="profile-invites-layout">
<div className="profile-invite-form-card">
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
<p className="meta profile-invite-form-lede">
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
</p>
{inviteManagedByMaster && masterInviteTemplate ? (
<div className="status-banner profile-invite-master-banner">
Using master invite rule <code>{masterInviteTemplate.code}</code>
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
</div>
) : null}
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Optional code and label for easier tracking.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(event) =>
setInviteForm((current) => ({ ...current, code: event.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(event) =>
setInviteForm((current) => ({ ...current, label: event.target.value }))
}
placeholder="Family invite"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Recipient email is required. You can also send the invite immediately after saving.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email (required)</span>
<input
type="email"
required
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Required recipient email"
/>
</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-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
</div>
<div className="profile-invites-list">
{invites.length === 0 ? (
<div className="status-banner">You have not created any invites yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
<span className="small-pill is-muted">
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
</span>
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => copyInviteLink(invite)}
>
Copy link
</button>
<button
type="button"
className="ghost-button"
onClick={() => editInvite(invite)}
>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
)}
</main>
)
}
+461
View File
@@ -0,0 +1,461 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = {
username: string
role: string
auth_provider: string
invite_management_enabled?: boolean
password_change_supported?: boolean
password_provider?: 'local' | 'jellyfin' | null
}
type ProfileStats = {
total: number
ready: number
pending: number
in_progress: number
declined: number
working: number
partial: number
approved: number
last_request_at?: string | null
share: number
global_total: number
most_active_user?: { username: string; total: number } | null
}
type ActivityEntry = {
ip: string
user_agent: string
first_seen_at: string
last_seen_at: string
hit_count: number
}
type ProfileActivity = {
last_ip?: string | null
last_user_agent?: string | null
last_seen_at?: string | null
device_count: number
recent: ActivityEntry[]
}
type ProfileResponse = {
user: ProfileInfo
stats: ProfileStats
activity: ProfileActivity
}
type ProfileTab = 'overview' | 'activity' | 'security'
const normalizeProfileTab = (value?: string | null): ProfileTab => {
if (value === 'activity' || value === 'security') {
return value
}
return 'overview'
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const parseBrowser = (agent?: string | null) => {
if (!agent) return 'Unknown'
const value = agent.toLowerCase()
if (value.includes('edg/')) return 'Edge'
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
if (value.includes('firefox/')) return 'Firefox'
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
return 'Unknown'
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
const [loading, setLoading] = useState(true)
const inviteLink = useMemo(() => '/profile/invites', [])
useEffect(() => {
if (typeof window === 'undefined') return
const syncTabFromLocation = () => {
const params = new URLSearchParams(window.location.search)
setActiveTab(normalizeProfileTab(params.get('tab')))
}
syncTabFromLocation()
window.addEventListener('popstate', syncTabFromLocation)
return () => window.removeEventListener('popstate', syncTabFromLocation)
}, [])
const selectTab = (tab: ProfileTab) => {
setActiveTab(tab)
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
if (!profileResponse.ok) {
clearToken()
router.push('/login')
return
}
const data = (await profileResponse.json()) as ProfileResponse
const user = data?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
password_change_supported: Boolean(user?.password_change_supported ?? false),
password_provider:
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
? user.password_provider
: null,
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
} catch (err) {
console.error(err)
setStatus({ tone: 'error', message: 'Could not load your profile.' })
} finally {
setLoading(false)
}
}
void load()
}, [router])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
setStatus(null)
if (!currentPassword || !newPassword) {
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
return
}
if (newPassword !== confirmPassword) {
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
})
if (!response.ok) {
let detail = 'Update failed'
try {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
detail = payload.detail
}
} catch {
const text = await response.text().catch(() => '')
if (text?.trim()) detail = text
}
throw new Error(detail)
}
const data = await response.json().catch(() => ({}))
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setStatus({
tone: 'status',
message:
data?.provider === 'jellyfin'
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
: 'Password updated.',
})
} catch (err) {
console.error(err)
if (err instanceof Error && err.message) {
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
} else {
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
}
}
}
const authProvider = profile?.auth_provider ?? 'local'
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
const securityHelpText =
passwordProvider === 'jellyfin'
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
: passwordProvider === 'local'
? 'Change your Magent account password.'
: 'Password changes are not available for this sign-in provider.'
if (loading) {
return <main className="card">Loading profile...</main>
}
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My profile</h1>
<p className="lede">Review your account, activity, and security settings.</p>
</div>
{canManageInvites || canChangePassword ? (
<div className="admin-inline-actions">
{canManageInvites ? (
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
Open invite page
</button>
) : null}
{canChangePassword ? (
<button type="button" onClick={() => selectTab('security')}>
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
</button>
) : null}
</div>
) : null}
</div>
{profile && (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
{profile.auth_provider}.
</div>
)}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'overview'}
className={activeTab === 'overview' ? 'is-active' : ''}
onClick={() => selectTab('overview')}
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'activity'}
className={activeTab === 'activity' ? 'is-active' : ''}
onClick={() => selectTab('activity')}
>
Activity
</button>
{canManageInvites ? (
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
My invites
</button>
) : null}
<button
type="button"
role="tab"
aria-selected={activeTab === 'security'}
className={activeTab === 'security' ? 'is-active' : ''}
onClick={() => selectTab('security')}
>
Password
</button>
</div>
</div>
{activeTab === 'overview' && (
<section className="profile-section profile-tab-panel">
{canManageInvites ? (
<div className="profile-quick-link-card">
<div>
<h2>Invite tools</h2>
<p className="lede">
Create invite links, send them by email, and track who you have invited from a dedicated page.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" onClick={() => router.push(inviteLink)}>
Go to invites
</button>
</div>
</div>
) : null}
{canChangePassword ? (
<div className="profile-quick-link-card">
<div>
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
<p className="lede">
{passwordProvider === 'jellyfin'
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
: 'Update your Magent account password.'}
</p>
</div>
<div className="admin-inline-actions">
<button type="button" onClick={() => selectTab('security')}>
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
</button>
</div>
</div>
) : null}
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Requests submitted</div>
<div className="stat-value">{stats?.total ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Ready to watch</div>
<div className="stat-value">{stats?.ready ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">In progress</div>
<div className="stat-value">{stats?.in_progress ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Pending approval</div>
<div className="stat-value">{stats?.pending ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Working</div>
<div className="stat-value">{stats?.working ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Partial</div>
<div className="stat-value">{stats?.partial ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Approved</div>
<div className="stat-value">{stats?.approved ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Last request</div>
<div className="stat-value stat-value--small">
{formatDate(stats?.last_request_at)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Total requests (global)</div>
<div className="stat-value">{stats?.global_total ?? 0}</div>
</div>
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
) : null}
</div>
</section>
)}
{activeTab === 'activity' && (
<section className="profile-section profile-tab-panel">
<h2>Connection history</h2>
<div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
</div>
<div className="connection-list">
{(activity?.recent ?? []).map((entry, index) => (
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
<div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div>
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div>
<div className="connection-count">{entry.hit_count} visits</div>
</div>
))}
{activity && activity.recent.length === 0 ? (
<div className="status-banner">No connection history yet.</div>
) : null}
</div>
</section>
)}
{activeTab === 'security' && (
<section className="profile-section profile-tab-panel">
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
<div className="status-banner">{securityHelpText}</div>
{canChangePassword ? (
<form onSubmit={submit} className="auth-form profile-security-form">
<label>
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status ? (
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
{status.message}
</div>
) : null}
<div className="auth-actions">
<button type="submit">
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
</button>
</div>
</form>
) : (
<div className="status-banner">
Password changes are not available for {authProvider} sign-in accounts from Magent.
</div>
)}
</section>
)}
</main>
)
}
+598
View File
@@ -0,0 +1,598 @@
'use client'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
type TimelineHop = {
service: string
status: string
details?: Record<string, any>
}
type RequestAction = {
id: string
label: string
risk: string
description?: string
requires_confirmation: boolean
}
type PipelineStage = {
id: string
label: string
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
summary: string
available?: number
missing?: number
total?: number
seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>
missingEpisodes?: Record<string, number[]>
actionIds?: string[]
visible?: boolean
torrents?: Array<Record<string, any>>
link?: string | null
}
type Snapshot = {
request_id: string
title: string
year?: number
request_type: string
state: string
state_reason?: string
timeline: TimelineHop[]
actions: RequestAction[]
artwork?: { poster_url?: string; backdrop_url?: string }
presentation?: {
status?: { label?: string; meaning?: string }
download?: {
visible?: boolean
state?: string
summary?: string
torrents?: Array<Record<string, any>>
lastSeenAt?: string | null
}
nextStep?: { title?: string; description?: string; actionIds?: string[] }
pipeline?: PipelineStage[]
}
raw?: Record<string, any>
}
type ReleaseOption = {
title?: string
indexer?: string
indexerId?: number
guid?: string
size?: number
seeders?: number
leechers?: number
protocol?: string
publishDate?: string
infoUrl?: string
downloadUrl?: string
}
type SnapshotHistory = {
request_id: string
state: string
state_reason?: string
created_at: string
}
type ActionHistory = {
request_id: string
action_id: string
label: string
status: string
message?: string
created_at: string
}
const readApiError = async (response: Response, fallback: string) => {
try {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message
} else {
const text = await response.text()
if (text.trim()) return text.trim()
}
} catch (error) {
console.error(error)
}
return fallback
}
const isSnapshotPayload = (value: unknown): value is Snapshot => {
if (!value || typeof value !== 'object') return false
const snapshot = value as Partial<Snapshot>
return (
typeof snapshot.request_id === 'string' &&
typeof snapshot.title === 'string' &&
typeof snapshot.request_type === 'string' &&
typeof snapshot.state === 'string' &&
Array.isArray(snapshot.timeline) &&
Array.isArray(snapshot.actions)
)
}
const formatBytes = (value?: number) => {
if (!value || Number.isNaN(value)) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
const torrentProgress = (torrent: Record<string, any>) => {
const supplied = Number(torrent.progressPercent)
if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied)
const progress = Number(torrent.progress)
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 100)
return null
}
const fallbackStatusLabel = (state: string) => {
const labels: Record<string, string> = {
REQUESTED: 'Waiting for approval',
APPROVED: 'Approved — preparing collection',
NEEDS_ADD: 'Approved, but not yet in the library queue',
ADDED_TO_ARR: 'Added to library queue',
SEARCHING: 'Searching for a matching release',
GRABBED: 'Download queued',
DOWNLOADING: 'Download in progress',
IMPORTING: 'Preparing the collected media',
COMPLETED: 'Available to watch',
AVAILABLE: 'Available to watch',
FAILED: 'This request needs attention',
UNKNOWN: 'Checking request status',
}
return labels[state] ?? 'Checking request status'
}
const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
const approved = snapshot.state !== 'REQUESTED'
const complete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state)
return [
{ id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
{
id: 'approved',
label: 'Approved',
state: approved ? 'complete' : 'active',
summary: approved ? 'Approved for collection' : 'Waiting for approval',
},
{
id: 'library',
label: 'Library collection',
state: complete ? 'complete' : approved ? 'active' : 'waiting',
summary: complete ? 'Collection complete' : 'Waiting for collector information',
},
{ id: 'search', label: 'Release search', state: 'waiting', summary: 'Search state unavailable' },
{ id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
{
id: 'available',
label: 'Available',
state: complete ? 'complete' : 'waiting',
summary: complete ? 'Available to watch' : 'Not available on the media server yet',
link: snapshot.raw?.jellyfin?.link,
},
]
}
const formatWhen = (value?: string | null) => {
if (!value) return 'Time unavailable'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function RequestTimelinePage() {
const params = useParams<{ id: string | string[] }>()
const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [actionMessage, setActionMessage] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
useEffect(() => {
if (!requestId) return
const load = async () => {
setLoading(true)
setLoadError(null)
try {
if (!getToken()) {
router.push('/login')
return
}
const baseUrl = getApiBase()
const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
])
if ([snapshotResponse, historyResponse, actionsResponse].some((response) => response.status === 401)) {
clearToken()
router.push('/login')
return
}
if (!snapshotResponse.ok) {
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
}
const snapshotData = await snapshotResponse.json()
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
setSnapshot(snapshotData)
if (historyResponse.ok) {
const historyData = await historyResponse.json()
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json()
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
}
} catch (error) {
console.error(error)
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
} finally {
setLoading(false)
}
}
void load()
}, [requestId, router])
useEffect(() => {
if (!getToken() || !requestId) return
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
source = new EventSource(
`${baseUrl}/events/requests/${encodeURIComponent(requestId)}/stream?stream_token=${encodeURIComponent(streamToken)}`
)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (payload?.type !== 'request_live' || String(payload.request_id ?? '') !== String(requestId)) return
if (isSnapshotPayload(payload.snapshot)) setSnapshot(payload.snapshot)
if (Array.isArray(payload.history)) setHistorySnapshots(payload.history)
if (Array.isArray(payload.actions)) setHistoryActions(payload.actions)
} catch (error) {
console.error(error)
}
}
} catch (error) {
if (!closed) console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [requestId])
const actionsById = useMemo(
() => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
[snapshot?.actions]
)
if (loading) {
return (
<main className="card request-detail-page">
<div className="loading-center" role="status" aria-live="polite">
<div className="spinner" aria-hidden="true" />
<div className="loading-text">Building a clear request update</div>
</div>
</main>
)
}
if (loadError || !snapshot) {
return (
<main className="card request-detail-page">
<section className="request-error-state">
<span className="section-kicker">Request unavailable</span>
<h1>We could not load this request</h1>
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</section>
</main>
)
}
const presentation = snapshot.presentation ?? {}
const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot)
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
const download = presentation.download
const downloadVisible = Boolean(download?.visible)
const nextStep = presentation.nextStep ?? {
title: snapshot.actions[0]?.label ?? 'No action needed right now',
description: snapshot.actions.length ? 'Choose an option below to continue.' : 'Magent will keep checking automatically.',
actionIds: snapshot.actions.slice(0, 2).map((action) => action.id),
}
const recommendedActions = (nextStep.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action))
const posterUrl = snapshot.artwork?.poster_url
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
const runAction = async (action: RequestAction) => {
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
const actionPaths: Record<string, string> = {
search_releases: 'actions/search',
search_auto: 'actions/search_auto',
resume_torrent: 'actions/qbit/resume',
readd_to_arr: 'actions/readd',
}
const path = actionPaths[action.id]
if (!path) {
setActionError('This action is not connected yet.')
return
}
setBusyAction(action.id)
setActionError(null)
setActionMessage(null)
try {
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/${path}`, { method: 'POST' })
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, `${action.label} could not be completed.`))
const data = await response.json()
if (action.id === 'search_releases') {
const releases = Array.isArray(data.releases) ? data.releases : []
setReleaseOptions(releases)
setActionMessage(
releases.length
? `Found ${releases.length} possible release${releases.length === 1 ? '' : 's'}. Choose one below.`
: 'No matching releases were found. Magent will keep the request in the search stage.'
)
} else {
setActionMessage(data?.message ?? `${action.label} was started successfully.`)
}
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : `${action.label} could not be completed.`)
} finally {
setBusyAction(null)
}
}
const downloadRelease = async (release: ReleaseOption) => {
if (!release.guid || !release.indexerId) {
setActionError('This release is missing the details needed to start it.')
return
}
if (!window.confirm(`Download “${release.title ?? 'this release'}”?`)) return
setBusyAction(`grab:${release.guid}`)
setActionError(null)
try {
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(release),
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, 'The selected release could not be started.'))
const data = await response.json()
setActionMessage(data?.message ?? 'The selected release was queued for download.')
setReleaseOptions([])
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
} finally {
setBusyAction(null)
}
}
return (
<main className="card request-detail-page">
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
)}
<div>
<span className="section-kicker">Request #{snapshot.request_id}</span>
<h1>{snapshot.title}</h1>
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
</div>
</div>
</div>
<section className="request-overview" aria-labelledby="request-status-heading">
<div className="request-overview-block request-overview-status">
<span className="request-overview-label" id="request-status-heading">Status</span>
<strong>{statusLabel}</strong>
</div>
<div className="request-overview-block">
<span className="request-overview-label">What this means</span>
<p>{statusMeaning}</p>
</div>
{downloadVisible && (
<div className="request-overview-block">
<span className="request-overview-label">Current download state</span>
<strong>{download?.summary ?? 'A download attempt has been observed.'}</strong>
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
</div>
)}
<div className="request-overview-block request-next-step">
<span className="request-overview-label">Next step</span>
<strong>{nextStep.title}</strong>
<p>{nextStep.description}</p>
{recommendedActions.length > 0 && (
<div className="request-action-row">
{recommendedActions.map((action) => (
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
{busyAction === action.id ? 'Working…' : action.label}
</button>
))}
</div>
)}
</div>
{(actionMessage || actionError) && (
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
{actionError ?? actionMessage}
</div>
)}
</section>
<section className="request-journey" aria-labelledby="request-journey-heading">
<div className="request-journey-heading">
<div>
<span className="section-kicker">Live collection path</span>
<h2 id="request-journey-heading">Where your request is now</h2>
</div>
<span className="request-live-indicator"><i />Live status</span>
</div>
<div className="request-stage-grid">
{pipeline.map((stage, index) => {
const stageActions = (stage.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action))
const content = (
<>
<div className="request-stage-topline">
<span className="request-stage-number">{String(index + 1).padStart(2, '0')}</span>
<span className={`request-stage-state state-${stage.state}`}>{stage.state}</span>
</div>
<h3>{stage.label}</h3>
<p>{stage.summary}</p>
{stage.id === 'library' && Boolean(stage.total) && (
<div className="request-availability-meter">
<div className="request-meter-copy"><span>{stage.available ?? 0} collected</span><span>{stage.missing ?? 0} missing</span></div>
<div
className="request-meter-track"
role="progressbar"
aria-label="Collection progress"
aria-valuemin={0}
aria-valuemax={stage.total ?? 0}
aria-valuenow={stage.available ?? 0}
>
<span style={{ width: `${Math.round(((stage.available ?? 0) / Math.max(stage.total ?? 1, 1)) * 100)}%` }} />
</div>
{stage.seasons?.map((season) => (
<div className="request-season-row" key={season.seasonNumber}><span>Season {season.seasonNumber}</span><span>{season.available} collected · {season.missing} missing</span></div>
))}
</div>
)}
{stage.id === 'library' && stage.missingEpisodes && Object.keys(stage.missingEpisodes).length > 0 && (
<div className="request-missing-list">
{Object.entries(stage.missingEpisodes).map(([season, episodes]) => (
<div key={season}><span>Missing from season {season}</span><strong>{episodes.map((episode) => `E${episode}`).join(', ')}</strong></div>
))}
</div>
)}
{stage.id === 'download' && stage.visible && stage.torrents?.map((torrent) => {
const progress = torrentProgress(torrent)
return (
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : `${progress}% complete`}</span></div>
{progress !== null && <div className="request-meter-track"><span style={{ width: `${progress}%` }} /></div>}
</div>
)
})}
{stageActions.length > 0 && (
<div className="request-stage-actions">
{stageActions.map((action) => (
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>{busyAction === action.id ? 'Working…' : action.label}</button>
))}
</div>
)}
</>
)
return stage.id === 'available' && stage.link ? (
<a className={`request-stage stage-${stage.state} is-link`} href={stage.link} target="_blank" rel="noreferrer" key={stage.id}>{content}<span className="request-stage-link">Open on media server </span></a>
) : (
<article className={`request-stage stage-${stage.state}`} key={stage.id}>{content}</article>
)
})}
</div>
{releaseOptions.length > 0 && (
<div className="request-release-picker">
<div className="request-release-heading"><div><span className="section-kicker">Manual selection</span><h3>Choose a release</h3></div><button type="button" className="ghost-button" onClick={() => setReleaseOptions([])}>Close</button></div>
<div className="request-release-list">
{releaseOptions.map((release) => (
<div className="request-release" key={`${release.guid ?? release.title}`}>
<div><strong>{release.title ?? 'Unknown release'}</strong><span>{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}</span></div>
<button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Starting…' : 'Download this release'}</button>
</div>
))}
</div>
</div>
)}
</section>
<section className="request-advanced">
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
<span><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
<span>{showDetails ? 'Hide' : 'Show'}</span>
</button>
{showDetails && (
<div className="request-advanced-content">
<div className="request-diagnostics-grid">
{snapshot.timeline.map((hop, index) => (
<article className="request-diagnostic" key={`${hop.service}-${index}`}>
<div><strong>{hop.service}</strong><span>{hop.status}</span></div>
{hop.details && <pre>{JSON.stringify(hop.details, null, 2)}</pre>}
</article>
))}
</div>
<div className="history-grid">
<div className="summary-card">
<h3>Status changes</h3>
<ul>
{historySnapshots.length === 0 ? <li>No distinct status changes recorded yet.</li> : historySnapshots.map((entry) => (
<li key={`${entry.created_at}-${entry.state}`}><span>{fallbackStatusLabel(entry.state)}</span><small>{entry.state_reason ?? 'No additional detail.'} · {formatWhen(entry.created_at)}</small></li>
))}
</ul>
</div>
<div className="summary-card">
<h3>Recorded actions</h3>
<ul>
{historyActions.length === 0 ? <li>No actions have been run for this request.</li> : historyActions.map((entry) => (
<li key={`${entry.created_at}-${entry.action_id}`}><span>{entry.label}</span><small>{entry.message ?? entry.status} · {formatWhen(entry.created_at)}</small></li>
))}
</ul>
</div>
</div>
</div>
)}
</section>
</main>
)
}
+156
View File
@@ -0,0 +1,156 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { getApiBase } from '../lib/auth'
type ResetVerification = {
status: string
recipient_hint?: string
auth_provider?: string
expires_at?: string
}
function ResetPasswordPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token') ?? ''
const [verification, setVerification] = useState<ResetVerification | null>(null)
const [loading, setLoading] = useState(false)
const [verifying, setVerifying] = useState(true)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
useEffect(() => {
const verifyToken = async () => {
if (!token) {
setError('Password reset link is invalid or missing.')
setVerifying(false)
return
}
setVerifying(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(
`${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`,
)
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Password reset link is invalid.')
}
setVerification(data)
} catch (err) {
console.error(err)
setVerification(null)
setError(err instanceof Error ? err.message : 'Password reset link is invalid.')
} finally {
setVerifying(false)
}
}
void verifyToken()
}, [token])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (!token) {
setError('Password reset link is invalid or missing.')
return
}
if (password.trim().length < 8) {
setError('Password must be at least 8 characters.')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/password/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: password }),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to reset password.')
}
setStatus('Password updated. You can now sign in with the new password.')
setPassword('')
setConfirmPassword('')
window.setTimeout(() => router.push('/login'), 1200)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to reset password.')
} finally {
setLoading(false)
}
}
const providerLabel =
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Reset password</h1>
<p className="lede">Choose a new password for your account.</p>
<form className="auth-form" onSubmit={submit}>
{verifying && <div className="status-banner">Checking password reset link</div>}
{!verifying && verification && (
<div className="status-banner">
This reset link was sent to {verification.recipient_hint || 'your email'} and will update the password
used for {providerLabel}.
</div>
)}
<label>
New password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
disabled={!verification || loading}
/>
</label>
<label>
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
disabled={!verification || loading}
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading || verifying || !verification}>
{loading ? 'Updating password…' : 'Reset password'}
</button>
</div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
Back to sign in
</button>
</form>
</main>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading password reset</main>}>
<ResetPasswordPageContent />
</Suspense>
)
}
+224
View File
@@ -0,0 +1,224 @@
'use client'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
code: string
label?: string | null
description?: string | null
enabled: boolean
is_expired?: boolean
is_usable?: boolean
expires_at?: string | null
max_uses?: number | null
use_count?: number | null
remaining_uses?: number | null
profile?: {
id: number
name: string
description?: string | null
} | null
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
function SignupPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
const [invite, setInvite] = useState<InviteInfo | null>(null)
const [inviteLoading, setInviteLoading] = useState(false)
const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const canSubmit = useMemo(() => {
return Boolean(invite?.is_usable && username.trim() && password && !loading)
}, [invite, username, password, loading])
const lookupInvite = async (code: string) => {
const trimmed = code.trim()
if (!trimmed) {
setInvite(null)
return
}
setInviteLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite not found')
}
const data = await response.json()
setInvite(data?.invite ?? null)
setStatus('Invite loaded.')
} catch (err) {
console.error(err)
setInvite(null)
setError('Invite code not found or unavailable.')
} finally {
setInviteLoading(false)
}
}
useEffect(() => {
const initialCode = searchParams.get('code') ?? ''
if (initialCode) {
setInviteCode(initialCode)
void lookupInvite(initialCode)
}
}, [searchParams])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (!inviteCode.trim()) {
setError('Invite code is required.')
return
}
if (!invite?.is_usable) {
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
clearToken()
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
password,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sign-up failed')
}
const data = await response.json()
if (data?.authenticated) {
setToken('cookie')
window.location.href = '/'
return
}
throw new Error('Sign-up did not complete')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.')
} finally {
setLoading(false)
}
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form">
<label>
Invite code
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
<button
type="button"
className="ghost-button"
disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)}
>
{inviteLoading ? 'Checking…' : 'Check invite'}
</button>
</div>
</label>
{invite && (
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Profile: {invite.profile?.name || 'None'}</span>
</div>
</div>
)}
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</main>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading sign-up</main>}>
<SignupPageContent />
</Suspense>
)
}
+517
View File
@@ -0,0 +1,517 @@
'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
}
type DatabaseDiagnosticDetail = {
integrity_check?: string
database_path?: string
database_size_bytes?: number
wal_size_bytes?: number
shm_size_bytes?: number
page_size_bytes?: number
page_count?: number
freelist_pages?: number
allocated_bytes?: number
free_bytes?: number
row_counts?: Record<string, number>
timings_ms?: Record<string, number>
}
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
}
function formatBytes(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
return '0 B'
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${value} B`
}
function formatDetailLabel(value: string) {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
return null
}
return detail as DatabaseDiagnosticDetail
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null
}
return (
<div className="diagnostic-detail-group">
<h4>{title}</h4>
<div className="diagnostic-detail-grid">
{values.map(([label, value]) => (
<div key={`${title}-${label}`} className="diagnostic-detail-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
)
}
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>
{check.key === 'database'
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail)
if (!detail) {
return null
}
return (
<div className="diagnostic-detail-panel">
{renderDatabaseMetricGroup('Storage', [
['Database file', formatBytes(detail.database_size_bytes)],
['WAL file', formatBytes(detail.wal_size_bytes)],
['Shared memory', formatBytes(detail.shm_size_bytes)],
['Allocated bytes', formatBytes(detail.allocated_bytes)],
['Free bytes', formatBytes(detail.free_bytes)],
['Page size', formatBytes(detail.page_size_bytes)],
['Page count', `${detail.page_count?.toLocaleString() ?? 0}`],
['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
'Tables',
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
'Timings',
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</div>
)
})()
: null}
</article>
)
})}
</div>
</div>
)
})}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
'use client'
import type { ReactNode } from 'react'
import AdminSidebar from './AdminSidebar'
type AdminShellProps = {
title: string
subtitle?: string
actions?: ReactNode
rail?: ReactNode
children: ReactNode
}
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
const hasRail = Boolean(rail)
return (
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
<aside className="admin-shell-nav">
<AdminSidebar />
</aside>
<main className="card admin-card">
<div className="admin-header">
<div>
<span className="section-kicker">Beta stream</span>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
{actions}
</div>
{children}
</main>
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { usePathname } from 'next/navigation'
const NAV_GROUPS = [
{
title: 'Operations',
items: [
{ href: '/admin', label: 'Overview' },
{ href: '/', label: 'Health' },
{ href: '/portal/requests', label: 'Request portal' },
{ href: '/admin/issues', label: 'Issue tracking' },
],
},
{
title: 'Services',
items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/seerr', label: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
],
},
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
{ href: '/admin/artwork', label: 'Artwork cache' },
],
},
{
title: 'Admin',
items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'How it works' },
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
],
},
]
export default function AdminSidebar() {
const pathname = usePathname()
return (
<nav className="admin-sidebar">
<div className="admin-sidebar-title">Settings</div>
{NAV_GROUPS.map((group) => (
<div key={group.title} className="admin-nav-group">
<span className="admin-nav-title">{group.title}</span>
<div className="admin-nav-links">
{group.items.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
return (
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
{item.label}
</a>
)
})}
</div>
</div>
))}
</nav>
)
}
+18
View File
@@ -0,0 +1,18 @@
'use client'
import { useEffect } from 'react'
export default function BrandingFavicon() {
useEffect(() => {
const href = '/api/branding/favicon.ico'
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
}, [])
return null
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { useState } from 'react'
type BrandingLogoProps = {
className?: string
alt?: string
}
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
return (
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
{!failed ? (
<img
className={loaded ? 'is-loaded' : undefined}
src="/api/branding/logo.png"
alt=""
aria-hidden="true"
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : null}
{!loaded ? (
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
<defs>
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7ed7ff" />
<stop offset="100%" stopColor="#c6c1ff" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
fill="url(#magentLogoGlow)"
/>
</svg>
) : null}
</span>
)
}
+113
View File
@@ -0,0 +1,113 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
const [showRequestsNav, setShowRequestsNav] = useState(true)
const pathname = usePathname()
useEffect(() => {
const token = getToken()
setSignedIn(Boolean(token))
if (!token) {
setShowRequestsNav(true)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const [response, siteResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
fetch(`${baseUrl}/site/public`).catch(() => null),
])
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
const data = await response.json()
setRole(data?.role ?? null)
if (siteResponse?.ok) {
const siteData = await siteResponse.json()
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
} else {
setShowRequestsNav(true)
}
} catch (err) {
console.error(err)
setShowRequestsNav(true)
}
}
void load()
}, [])
if (!signedIn) {
return null
}
const roleItems =
role === null
? []
: role === 'admin'
? [
{
href: '/admin',
label: 'Config',
match: (path: string) => path.startsWith('/admin'),
},
]
: [
{
href: '/profile',
label: 'Profile',
match: (path: string) => path.startsWith('/profile') && !path.startsWith('/profile/invites'),
},
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
},
]
const commonItems = [
{ href: '/', label: 'Health', match: (path: string) => path === '/' },
...(showRequestsNav
? [
{
href: '/portal/requests',
label: 'Requests',
match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
},
]
: []),
{
href: '/portal/issues',
label: 'Issues',
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
},
]
const items = [
...commonItems,
...roleItems,
]
return (
<nav className="header-actions" aria-label="Primary">
{items.map((item, index) => {
const active = item.match(pathname)
return (
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
{item.label}
</a>
)
})}
</nav>
)
}
+96
View File
@@ -0,0 +1,96 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
const [buildNumber, setBuildNumber] = useState<string | null>(null)
const [open, setOpen] = useState(false)
useEffect(() => {
const token = getToken()
if (!token) {
setIdentity(null)
setBuildNumber(null)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setIdentity(null)
return
}
const data = await response.json()
if (data?.username) {
setIdentity({ username: data.username, role: data.role })
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
if (siteResponse.ok) {
const siteInfo = await siteResponse.json()
if (siteInfo?.buildNumber) {
setBuildNumber(siteInfo.buildNumber)
}
}
} catch (err) {
console.error(err)
setIdentity(null)
}
}
void load()
}, [])
if (!identity) {
return null
}
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = async () => {
await logout().catch(() => undefined)
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
return (
<div className="signed-in-menu">
<button
type="button"
className="avatar-button"
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
title={label}
>
{initial}
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">Signed in as {label}</div>
<div className="signed-in-actions">
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{identity.role === 'admin' ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
) : null}
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
Sign out
</button>
</div>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
</div>
)}
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type BannerInfo = {
enabled: boolean
message: string
tone?: string
}
type SiteInfo = {
buildNumber?: string
banner?: BannerInfo
}
const buildRequest = () => {
const token = getToken()
const baseUrl = getApiBase()
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
const fetcher = token ? authFetch : fetch
return { token, url, fetcher }
}
export default function SiteStatus() {
const [info, setInfo] = useState<SiteInfo | null>(null)
useEffect(() => {
let active = true
const load = async () => {
try {
const { token, url, fetcher } = buildRequest()
const response = await fetcher(url)
if (!response.ok) {
if (response.status === 401 && token) {
clearToken()
}
return
}
const data = await response.json()
if (!active) return
setInfo(data)
} catch (err) {
console.error(err)
}
}
void load()
return () => {
active = false
}
}, [])
const banner = info?.banner
const tone = banner?.tone || 'info'
return (
<>
{banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
) : null}
</>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'magent_theme'
const getPreferredTheme = () => {
if (typeof window === 'undefined') return 'dark'
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
return stored
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyTheme = (theme: string) => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const preferred = getPreferredTheme()
setTheme(preferred)
applyTheme(preferred)
}, [])
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
applyTheme(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a8.5 8.5 0 1 0 11.5 11.5z" />
</svg>
)}
</button>
)
}
+696
View File
@@ -0,0 +1,696 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
type AdminUser = {
id?: number
username: string
email?: string | null
role: string
auth_provider?: string | null
last_login_at?: string | null
is_blocked?: boolean
auto_search_enabled?: boolean
invite_management_enabled?: boolean
jellyseerr_user_id?: number | null
profile_id?: number | null
expires_at?: string | null
is_expired?: boolean
invited_by_code?: string | null
invited_at?: string | null
}
type UserLineage = {
invite_code?: string | null
invited_by?: string | null
invite?: {
id?: number
code?: string
label?: string | null
created_by?: string | null
created_at?: string | null
enabled?: boolean
is_usable?: boolean
} | null
} | null
type UserProfileOption = {
id: number
name: string
is_active?: boolean
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return ''
const offsetMs = date.getTimezoneOffset() * 60_000
const local = new Date(date.getTime() - offsetMs)
return local.toISOString().slice(0, 16)
}
const fromLocalDateTimeInput = (value: string) => {
if (!value.trim()) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return null
return date.toISOString()
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UserDetailPage() {
const params = useParams()
const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
const [user, setUser] = useState<AdminUser | null>(null)
const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [profileSelection, setProfileSelection] = useState('')
const [expiryInput, setExpiryInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false)
const [systemActionBusy, setSystemActionBusy] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null)
const [lineage, setLineage] = useState<UserLineage>(null)
const loadProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles`)
if (!response.ok) {
return
}
const data = await response.json()
if (!Array.isArray(data?.profiles)) {
setProfiles([])
return
}
setProfiles(
data.profiles.map((profile: any) => ({
id: Number(profile.id ?? 0),
name: String(profile.name ?? 'Unnamed profile'),
is_active: Boolean(profile.is_active ?? true),
}))
)
} catch (err) {
console.error(err)
}
}
const loadUser = async () => {
if (!idParam) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
if (response.status === 404) {
setError('User not found.')
return
}
throw new Error('Could not load user.')
}
const data = await response.json()
const nextUser = data?.user ?? null
setUser(nextUser)
setStats(normalizeStats(data?.stats))
setLineage((data?.lineage ?? null) as UserLineage)
setProfileSelection(
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
? ''
: String(nextUser.profile_id)
)
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
setError(null)
} catch (err) {
console.error(err)
setError('Could not load user.')
} finally {
setLoading(false)
}
}
const toggleUserBlock = async (blocked: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (role: string) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Role updated to ${role}.`)
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
} catch (err) {
console.error(err)
setError('Could not update auto search access.')
}
}
const updateInviteManagementEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
} catch (err) {
console.error(err)
setError('Could not update invite access.')
}
}
const applyProfileToUser = async (profileOverride?: string | null) => {
if (!user) return
const profileValue = profileOverride ?? profileSelection
setSavingProfile(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileValue || null }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile update failed')
}
await loadUser()
setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
} catch (err) {
console.error(err)
setError('Could not update user profile.')
} finally {
setSavingProfile(false)
}
}
const saveUserExpiry = async () => {
if (!user) return
const expiresAt = fromLocalDateTimeInput(expiryInput)
if (expiryInput.trim() && !expiresAt) {
setError('Invalid expiry date/time.')
return
}
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expires_at: expiresAt }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry update failed')
}
await loadUser()
setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not update user expiry.')
} finally {
setSavingExpiry(false)
}
}
const clearUserExpiry = async () => {
if (!user) return
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear: true }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry clear failed')
}
setExpiryInput('')
await loadUser()
setActionStatus('User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not clear user expiry.')
} finally {
setSavingExpiry(false)
}
}
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
)
if (!confirmed) return
}
setSystemActionBusy(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Cross-system action failed')
}
const state = data?.status === 'partial' ? 'partial' : 'complete'
if (action === 'remove') {
setActionStatus(`User removed (${state}).`)
router.push('/users')
return
}
await loadUser()
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
} finally {
setSystemActionBusy(false)
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUser()
void loadProfiles()
}, [router, idParam])
if (loading) {
return <main className="card">Loading user...</main>
}
return (
<AdminShell
title={user?.username || 'User'}
subtitle="User overview and request stats."
actions={
<button type="button" onClick={() => router.push('/users')}>
Back to users
</button>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{actionStatus && <div className="status-banner">{actionStatus}</div>}
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<div className="user-detail-page-grid">
<div className="user-detail-main-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<div className="user-detail-title-row">
<strong className="user-detail-name">{user.username}</strong>
<span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
{user.is_blocked ? 'Blocked' : 'Active'}
</span>
<span className={`user-grid-pill ${user.is_expired ? 'is-blocked' : ''}`}>
{user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
</span>
</div>
<p className="lede">
User identity, access state, and request history for this account.
</p>
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Email</span>
<strong>{user.email || 'Not set'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Role</span>
<strong>{user.role}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Login type</span>
<strong>{user.auth_provider || 'local'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Assigned profile</span>
<strong>{user.profile_id ?? 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invited by</span>
<strong>{lineage?.invited_by || 'Direct / unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invite code used</span>
<strong>{lineage?.invite_code || user.invited_by_code || 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Last login</span>
<strong>{formatDateTime(user.last_login_at)}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Account expiry</span>
<strong>{user.expires_at ? formatDateTime(user.expires_at) : 'Never'}</strong>
</div>
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Request statistics</h2>
<p className="lede">Snapshot of request states and recent activity for this user.</p>
</div>
<div className="user-detail-grid">
<div className="user-detail-stat">
<span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span>
</div>
<div className="user-detail-stat user-detail-stat--wide">
<span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div>
</div>
</div>
</div>
<div className="user-detail-side-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Access controls</h2>
<p className="lede">Role, login access, and auto-download behavior.</p>
</div>
<div className="user-detail-control-stack">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.auto_search_enabled ?? true)}
disabled={user.role === 'admin'}
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
/>
<span>Allow auto search/download</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.invite_management_enabled ?? false)}
disabled={user.role === 'admin'}
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
/>
<span>Allow self-service invites</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download and invite-management access.
</div>
)}
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Profile defaults</h2>
<p className="lede">Assign or clear an invite profile for this user.</p>
</div>
<div className="user-detail-actions user-detail-actions--stacked">
<label className="admin-select">
<span>Assigned profile</span>
<select
value={profileSelection}
onChange={(event) => setProfileSelection(event.target.value)}
disabled={savingProfile}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
{savingProfile ? 'Applying...' : 'Apply profile defaults'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setProfileSelection('')
void applyProfileToUser('')
}}
disabled={savingProfile}
>
Clear profile
</button>
</div>
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Account expiry</h2>
<p className="lede">Set a specific expiry date/time for this user account.</p>
</div>
<div className="user-detail-actions user-detail-actions--stacked">
<label>
<span className="user-bulk-label">Account expiry</span>
<input
type="datetime-local"
value={expiryInput}
onChange={(event) => setExpiryInput(event.target.value)}
disabled={savingExpiry}
/>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
{savingExpiry ? 'Saving...' : 'Save expiry'}
</button>
<button
type="button"
className="ghost-button"
onClick={clearUserExpiry}
disabled={savingExpiry}
>
Clear expiry
</button>
</div>
</div>
</div>
</div>
</div>
)}
</section>
</AdminShell>
)
}
+476
View File
@@ -0,0 +1,476 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminUser = {
id: number
username: string
email?: string | null
role: string
authProvider?: string | null
lastLoginAt?: string | null
isBlocked?: boolean
autoSearchEnabled?: boolean
profileId?: number | null
expiresAt?: string | null
isExpired?: boolean
stats?: UserStats
}
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
const formatLastLogin = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const formatLastRequest = (value?: string | null) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const formatExpiry = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const emptyStats: UserStats = {
total: 0,
ready: 0,
pending: 0,
approved: 0,
working: 0,
partial: 0,
declined: 0,
in_progress: 0,
last_request_at: null,
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UsersPage() {
const router = useRouter()
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const loadUsers = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/summary`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Could not load users.')
}
const data = await response.json()
if (Array.isArray(data?.users)) {
setUsers(
data.users.map((user: any) => ({
username: user.username ?? 'Unknown',
email: user.email ?? null,
role: user.role ?? 'user',
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked),
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
profileId:
user.profile_id == null || Number.isNaN(Number(user.profile_id))
? null
: Number(user.profile_id),
expiresAt: user.expires_at ?? null,
isExpired: Boolean(user.is_expired),
id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats),
}))
)
} else {
setUsers([])
}
setError(null)
} catch (err) {
console.error(err)
setError('Could not load user list.')
} finally {
setLoading(false)
}
}
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not sync Seerr users.')
} finally {
setJellyseerrSyncBusy(false)
}
}
const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Seerr. Continue?'
)
if (!confirmed) return
setJellyseerrSyncStatus(null)
setJellyseerrResyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Resync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not resync Seerr users.')
} finally {
setJellyseerrResyncBusy(false)
}
}
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk update failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update auto search/download for all users.')
} finally {
setBulkAutoSearchBusy(false)
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUsers()
}, [router])
if (loading) {
return <main className="card">Loading users...</main>
}
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length
const normalizedQuery = query.trim().toLowerCase()
const filteredUsers = normalizedQuery
? users.filter((user) => {
const fields = [
user.username,
user.email || '',
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
]
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
})
: users
const filteredCountLabel =
filteredUsers.length === users.length
? `${users.length} users`
: `${filteredUsers.length} of ${users.length} users`
const usersRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card users-rail-summary">
<div className="user-directory-panel-header">
<div>
<h2>Directory summary</h2>
<p className="lede">A quick view of user access and account state.</p>
</div>
</div>
<div className="users-summary-grid">
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Total users</span>
<strong className="users-summary-value">{users.length}</strong>
</div>
<p className="users-summary-meta">{adminCount} admin accounts</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Auto search</span>
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
</div>
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Blocked</span>
<strong className="users-summary-value">{blockedCount}</strong>
</div>
<p className="users-summary-meta">
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Expired</span>
<strong className="users-summary-value">{expiredCount}</strong>
</div>
<p className="users-summary-meta">
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
</p>
</div>
</div>
</div>
</div>
)
return (
<AdminShell
title="Users"
subtitle="Directory, access status, and request activity."
rail={usersRail}
>
<section className="admin-section">
<div className="admin-panel users-page-toolbar">
<div className="users-page-toolbar-grid">
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Directory actions</span>
<div className="users-page-toolbar-actions">
<button
type="button"
className="ghost-button"
onClick={() => router.push('/admin/invites')}
>
Invite management
</button>
<button type="button" onClick={loadUsers}>
Reload list
</button>
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Seerr sync</span>
<div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
</button>
<button
type="button"
onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy}
>
{jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
</button>
</div>
</div>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Bulk controls</h2>
<p className="lede">
Auto search/download can be enabled or disabled for all non-admin users.
</p>
</div>
</div>
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
<span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span>
</div>
<div className="user-bulk-actions">
<button
type="button"
onClick={() => bulkUpdateAutoSearch(true)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => bulkUpdateAutoSearch(false)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
</button>
</div>
</div>
</div>
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div>
{filteredUsers.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="user-directory-list">
<div className="user-directory-header">
<span>User</span>
<span>Access</span>
<span>Requests</span>
<span>Activity</span>
</div>
{filteredUsers.map((user) => (
<Link
key={user.username}
className="user-directory-row"
href={`/users/${user.id}`}
>
<div className="user-directory-cell user-directory-cell--identity">
<div className="user-directory-title-row">
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<div className="user-directory-subtext">
{user.email || 'No email on file'}
</div>
<div className="user-directory-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-pill-row">
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
{user.isBlocked ? 'Blocked' : 'Active'}
</span>
<span
className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}
>
Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span>
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
{user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
</span>
</div>
<div className="user-directory-subtext">
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-stats-inline">
<span><strong>{user.stats?.total ?? 0}</strong> total</span>
<span><strong>{user.stats?.ready ?? 0}</strong> ready</span>
<span><strong>{user.stats?.pending ?? 0}</strong> pending</span>
<span><strong>{user.stats?.in_progress ?? 0}</strong> in progress</span>
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-subtext">
Last login: {formatLastLogin(user.lastLoginAt)}
</div>
<div className="user-directory-subtext">
Last request: {formatLastRequest(user.stats?.last_request_at)}
</div>
</div>
<div className="user-directory-row-chevron" aria-hidden="true">
Open
</div>
</Link>
))}
</div>
)}
</section>
</AdminShell>
)
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
"files": {
"includes": [
"app/**/*.{ts,tsx}",
"next.config.js",
"!node_modules",
"!.next"
]
},
"formatter": {
"enabled": false
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useExhaustiveDependencies": "off"
},
"performance": {
"noImgElement": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
"noDocumentCookie": "off",
"noExplicitAny": "off"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+15
View File
@@ -0,0 +1,15 @@
const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000'
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${backendUrl}/:path*`,
},
]
},
}
module.exports = nextConfig
+1252
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "magent-frontend",
"private": true,
"version": "0803262237",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome lint ."
},
"dependencies": {
"next": "16.2.12",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@biomejs/biome": "2.5.6",
"@types/node": "24.11.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
},
"overrides": {
"nanoid": "3.3.18",
"postcss": "8.5.25",
"sharp": "0.35.3"
}
}

Some files were not shown because too many files have changed in this diff Show More