Compare commits

..
31 Commits
Author SHA1 Message Date
Rephl3x 57a4883931 Seed branding logo from bundled assets 2026-01-25 18:01:54 +13:00
Rephl3x 6ba41b854b Tidy request sync controls 2026-01-25 17:52:33 +13:00
Rephl3x 580b335268 Add Jellyfin login cache and admin-only stats 2026-01-25 17:47:03 +13:00
Rephl3x 23549f1e45 Add user stats and activity tracking 2026-01-25 17:04:24 +13:00
Rephl3x 2c45dd0065 Move account actions into avatar menu 2026-01-25 16:48:38 +13:00
Rephl3x 92959d80ab Improve mobile header layout 2026-01-25 16:36:29 +13:00
Rephl3x 615c4c1c29 Automate build number tagging and sync 2026-01-25 14:52:38 +13:00
Rephl3x 38eee2407b Add site banner, build number, and changelog 2026-01-25 14:28:16 +13:00
Rephl3x cf4277d10c Improve request handling and qBittorrent categories 2026-01-24 21:48:55 +13:00
Rephl3x 030480410b Map Prowlarr releases to Arr indexers for manual grab 2026-01-24 19:21:40 +13:00
Rephl3x 3d414b4aeb Clarify how-it-works steps and fixes 2026-01-24 19:15:43 +13:00
Rephl3x 18bbcbf660 Document fix buttons in how-it-works 2026-01-24 19:09:05 +13:00
Rephl3x 5fa3aa6665 Route grabs through Sonarr/Radarr only 2026-01-24 19:06:40 +13:00
Rephl3x 52e3d680f7 Use backend branding assets for logo and favicon 2026-01-23 20:27:12 +13:00
Rephl3x 00bccfa8b6 Copy public assets into frontend image 2026-01-23 20:22:50 +13:00
Rephl3x aa3532dd83 Fix backend Dockerfile paths for root context 2026-01-23 20:17:42 +13:00
Rephl3x 4ec2351241 Add Docker Hub compose override 2026-01-23 20:16:59 +13:00
Rephl3x 6480478167 Remove password fields from users page 2026-01-23 20:16:19 +13:00
Rephl3x 3739e11016 Use bundled branding assets 2026-01-23 20:07:17 +13:00
Rephl3x 132e02e06e Add default branding assets when missing 2026-01-23 20:02:36 +13:00
Rephl3x cc79685eaf Show available status on landing when in Jellyfin 2026-01-23 19:39:26 +13:00
Rephl3x b20cf0a9d2 Fix cache titles and move feedback link 2026-01-23 19:31:31 +13:00
Rephl3x eab212ea8d Add feedback form and webhook 2026-01-23 19:24:45 +13:00
Rephl3x 24685a5371 Hide header actions when signed out 2026-01-23 19:16:17 +13:00
Rephl3x 49e9ee771f Fallback manual grab to qBittorrent 2026-01-23 18:15:36 +13:00
Rephl3x 69dc7febe2 Split search actions and improve download options 2026-01-23 18:05:17 +13:00
Rephl3x 7b8fc1d99b Fix cache titles via Jellyseerr media lookup 2026-01-23 11:35:59 +13:00
Rephl3x 7a7d570852 Update README with Docker-first guide 2026-01-22 22:55:35 +13:00
Rephl3x 3eb4b3f09f Update README 2026-01-22 22:54:13 +13:00
Rephl3x 6425345c69 Ignore build artifacts 2026-01-22 22:50:27 +13:00
Rephl3x fe43a81175 Initial commit 2026-01-22 22:49:57 +13:00
125 changed files with 3123 additions and 45870 deletions
+1 -1
View File
@@ -1 +1 @@
0803262237
251260501
-17
View File
@@ -1,17 +0,0 @@
* 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
@@ -1,104 +0,0 @@
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
-53
View File
@@ -1,53 +0,0 @@
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"]
+10 -30
View File
@@ -1,10 +1,10 @@
# 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.
Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
## How it works
1) Requests are pulled from Seerr and stored locally.
1) Requests are pulled from Jellyseerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
- Request search by title/year or request ID.
- Recent requests list with posters and status.
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Timeline view across Jellyseerr, 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.
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="replace-with-a-long-random-secret"
JWT_SECRET="change-me"
JWT_EXP_MINUTES="720"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="adminadmin"
```
## Screenshots
@@ -112,10 +112,10 @@ $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_SECRET="change-me"
$env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
$env:ADMIN_USERNAME="admin"
$env:ADMIN_PASSWORD="adminadmin"
```
### Frontend (Next.js)
@@ -141,26 +141,6 @@ The frontend proxies `/api/*` to the backend container. Set:
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
@@ -180,7 +160,7 @@ Configure these Gitea Actions secrets before enabling the deploy job:
### No recent requests
- Confirm Seerr credentials in Settings.
- Confirm Jellyseerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
+4 -1
View File
@@ -2,8 +2,11 @@ FROM python:3.12-slim
WORKDIR /app
ARG BUILD_NUMBER=dev
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
SITE_BUILD_NUMBER=${BUILD_NUMBER}
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+2 -2
View File
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
if snapshot.state == NormalizedState.requested:
root_cause = "approval"
summary = "The request is waiting for approval in Seerr."
summary = "The request is waiting for approval in Jellyseerr."
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.",
reason="Jellyseerr has not marked this request as approved.",
risk="low",
)
)
+16 -186
View File
@@ -1,152 +1,32 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Dict, Any
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi import Depends, HTTPException, status, Request
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)
from .db import get_user_by_username, upsert_user_activity
from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
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
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 request.client and request.client.host:
return request.client.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]:
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = 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:
@@ -157,10 +37,6 @@ def _load_current_user_from_token(
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)
@@ -169,58 +45,12 @@ def _load_current_user_from_token(
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
+12 -393
View File
@@ -1,261 +1,11 @@
from typing import Any, Dict, Optional
import logging
import time
import httpx
from ..logging_config import sanitize_headers, sanitize_value
from ..services.operation_progress import finish_remote_call, start_remote_call
_SERVICE_NAMES = {
"JellyseerrClient": "Seerr",
"SonarrClient": "Sonarr",
"RadarrClient": "Radarr",
"BazarrClient": "Bazarr",
"ProwlarrClient": "Prowlarr",
"JellyfinClient": "Jellyfin",
"QBittorrentClient": "qBittorrent",
}
def _result_items(result: Any, *keys: str) -> list[Any]:
if isinstance(result, list):
return result
if not isinstance(result, dict):
return []
for key in keys:
value = result.get(key)
if isinstance(value, list):
return value
return []
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
candidates = result if isinstance(result, list) else [result]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
title = str(candidate.get("title") or candidate.get("name") or "").strip()
if title:
return title
if isinstance(payload, dict):
title = str(payload.get("title") or payload.get("name") or "").strip()
if title:
return title
return None
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
noun = singular if count == 1 else (plural or f"{singular}s")
return f"{count} {noun}"
def _queue_result_message(service: str, result: Any) -> str:
records = _result_items(result, "records", "items")
total = result.get("totalRecords") if isinstance(result, dict) else None
count = int(total) if isinstance(total, int) else len(records)
if count == 0:
return f"{service} has no matching downloads in its queue."
first = next((item for item in records if isinstance(item, dict)), None)
progress_text = ""
if first:
size = first.get("size")
size_left = first.get("sizeleft")
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
progress_text = f" The first is {progress}% complete."
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
raw_name = str((payload or {}).get("name") or "").strip()
names = {
"MoviesSearch": "movie search",
"SeriesSearch": "series search",
"EpisodeSearch": "episode search",
"DownloadRelease": "release download",
"RefreshMovie": "movie refresh",
"RescanMovie": "movie rescan",
"RefreshSeries": "series refresh",
"RescanSeries": "series rescan",
}
return names.get(raw_name, "command")
def _operation_result_message(
service: str,
method: str,
path: str,
result: Any,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> str:
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
normalized_method = method.upper()
title = _result_title(result, payload)
title_text = f' "{title}"' if title else ""
if service == "Seerr":
if normalized_path.endswith("/request") and normalized_method == "POST":
request_id = result.get("id") if isinstance(result, dict) else None
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
return f"Seerr created the request{suffix} and passed it into the collection workflow."
if "/request/" in normalized_path and normalized_method == "GET":
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
status = result.get("status") if isinstance(result, dict) else None
status_text = status_names.get(status)
return (
f"Seerr found the request; it is currently {status_text}."
if status_text
else "Seerr found the request and returned its current status."
)
if service in {"Radarr", "Sonarr"}:
media_name = "movie" if service == "Radarr" else "series"
media_path = "/movie" if service == "Radarr" else "/series"
if "/queue" in normalized_path and normalized_method == "GET":
return _queue_result_message(service, result)
if "/command" in normalized_path and normalized_method == "POST":
return f"{service} accepted the {_command_name(payload)} and queued it for processing."
if "/release" in normalized_path:
if normalized_method == "GET":
count = len(_result_items(result, "records", "items"))
return (
f"{service} found {_count_message(count, 'download option')}."
if count
else f"{service} could not find a suitable download option."
)
return f"{service} accepted the selected release and sent it to the download client."
if "/qualityprofile" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'available quality profile')}."
if "/rootfolder" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'configured library location')}."
if "/indexer" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} reports {_count_message(count, 'configured search source')}."
if service == "Sonarr" and "/episodefile" in normalized_path:
if normalized_method == "DELETE":
return "Sonarr removed the existing episode file so it can be replaced."
count = len(_result_items(result))
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT":
return "Sonarr marked the selected episodes as wanted."
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
episodes = _result_items(result)
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
return "Radarr removed the existing movie file so it can be replaced."
is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
if is_media_endpoint:
if normalized_method == "GET":
found = bool(result) if not isinstance(result, list) else len(result) > 0
return (
f"{service} found{title_text} in its library list."
if found
else f"This {media_name} is not currently in {service}."
)
if normalized_method == "POST":
search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
search_text = " and started looking for a download" if search_requested else ""
subject = title_text or f" the {media_name}"
return f"{service} added{subject}{search_text}."
if normalized_method == "PUT":
return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
if "/system/status" in normalized_path:
version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
return f"Connected to {service}{f' version {version}' if version else ''}."
if service == "Prowlarr":
if "/health" in normalized_path:
issues = _result_items(result)
if not issues:
return "Prowlarr reports that all configured indexers are healthy."
first = next((item for item in issues if isinstance(item, dict)), {})
detail = str(first.get("message") or first.get("source") or "").strip()
suffix = f" First issue: {detail}" if detail else ""
return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
if "/search" in normalized_path:
results = _result_items(result, "results", "records")
return (
f"Prowlarr found {_count_message(len(results), 'possible release')}."
if results
else "Prowlarr did not find any possible releases."
)
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
target = "movie" if "/movies/" in normalized_path else "selected episode"
language = str((params or {}).get("language") or "the requested language").upper()
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
if normalized_method == "GET":
return f"{service} completed the check successfully."
if normalized_method == "POST":
return f"{service} accepted the request and started processing it."
if normalized_method == "PUT":
return f"{service} saved the requested changes."
if normalized_method == "DELETE":
return f"{service} confirmed the item was removed."
return f"{service} completed the request successfully."
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
explanations = {
400: "rejected the request because some details were invalid",
401: "rejected Magent's login details",
403: "refused permission for this action",
404: "could not find the requested item",
409: "reported a conflict, usually because the item already exists",
422: "could not use the details Magent supplied",
429: "is busy and asked Magent to try again later",
500: "encountered an internal error while processing the request",
502: "could not reach one of its own dependent services",
503: "is temporarily unavailable",
504: "did not finish before the request timed out",
}
explanation = explanations.get(status_code)
if explanation:
return f"{service} {explanation}."
if status_code:
return f"{service} could not complete the request (response code {status_code})."
return f"Magent could not get a usable response from {service}."
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
normalized_path = path.lower()
normalized_method = method.upper()
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
return "Reading the request from Seerr…", "Seerr returned the current request record"
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
return "Checking Radarr for the movie…", "Radarr returned the movie record"
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
return "Checking Sonarr for the series…", "Sonarr returned the series record"
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
if service == "Sonarr" and "/episode" in normalized_path:
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
return f"Checking releases through {service}", f"{service} returned release information"
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
if normalized_method == "GET":
return f"Checking {service}'s search activity…", f"{service} returned its current activity"
return f"Sending a command to {service}", f"{service} accepted the command"
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
if service == "Prowlarr" and "/health" in normalized_path:
return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded"
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)
@@ -263,151 +13,20 @@ class ApiClient:
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 _send_request(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: Dict[str, str],
params: Optional[Dict[str, Any]],
payload: Optional[Dict[str, Any]],
) -> httpx.Response:
return await client.request(
method,
url,
headers=headers,
params=params,
json=payload,
)
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
async def get(self, path: str, params: 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()
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
active_message, _ = _operation_messages(service_name, method, path)
operation_event_id = start_remote_call(service_name, active_message)
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=timeout_seconds) as client:
response = await self._send_request(
client,
method,
url,
headers=self.headers(),
params=params,
payload=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,
)
result = response.json() if response.content else None
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_operation_result_message(
service_name,
method,
path,
result,
params=params,
payload=payload,
),
)
return result
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),
)
finish_remote_call(
operation_event_id,
success=False,
status_code=status if isinstance(status, int) else None,
message=_operation_error_message(
service_name,
status if isinstance(status, int) else None,
),
)
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,
)
finish_remote_call(
operation_event_id,
success=False,
message=_operation_error_message(service_name, None),
)
raise
async def get(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
return await self._request(
"GET", path, params=params, timeout_seconds=timeout_seconds
)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=self.headers(), params=params)
response.raise_for_status()
return response.json()
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,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
return await self._request("DELETE", path, params=params)
if not self.base_url:
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=self.headers(), json=payload)
response.raise_for_status()
return response.json()
-48
View File
@@ -1,48 +0,0 @@
from typing import Any, Optional
from .base import ApiClient
class BazarrClient(ApiClient):
async def get_system_status(self) -> Optional[Any]:
return await self._request("GET", "/api/system/status")
async def search_movie_subtitles(
self,
radarr_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/movies/subtitles",
params={
"radarrid": radarr_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
async def search_episode_subtitles(
self,
series_id: int,
episode_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/episodes/subtitles",
params={
"seriesid": series_id,
"episodeid": episode_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
+12 -234
View File
@@ -1,24 +1,6 @@
from typing import Any, Dict, Optional
import httpx
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _availability_message(result: Any) -> str:
if not isinstance(result, dict):
return "Jellyfin did not return any matching library items."
total = result.get("TotalRecordCount")
items = result.get("Items")
available = (
(isinstance(total, int) and total > 0)
or (isinstance(items, list) and len(items) > 0)
)
return (
"The title is available to watch in Jellyfin."
if available
else "The title is not currently available in Jellyfin."
)
from .base import ApiClient
class JellyfinClient(ApiClient):
@@ -28,265 +10,61 @@ class JellyfinClient(ApiClient):
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
def _emby_headers(self) -> Dict[str, str]:
return {"X-Emby-Token": self.api_key} if self.api_key else {}
@staticmethod
def _extract_user_id(payload: Any) -> Optional[str]:
if not isinstance(payload, dict):
return None
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
if not isinstance(candidate, dict):
return None
for key in ("Id", "id", "UserId", "userId"):
value = candidate.get(key)
if value is None:
continue
if isinstance(value, (str, int)):
text = str(value).strip()
if text:
return text
return None
async def get_users(self) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users"
headers = self._emby_headers()
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
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()
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
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
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
url = f"{self.base_url}/Items"
params = {
"SearchTerm": term,
"IncludeItemTypes": ",".join(item_types or []),
"Recursive": "true",
"Fields": "Path,MediaSources",
"Limit": limit,
}
headers = self._emby_headers()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_availability_message(result),
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("Jellyfin", status_code),
)
raise
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
if not self.base_url or not self.api_key or not str(series_id).strip():
return []
url = f"{self.base_url}/Items"
params = {
"ParentId": str(series_id).strip(),
"IncludeItemTypes": "Episode",
"Recursive": "true",
"Fields": "Path,ProviderIds,MediaSources",
"Limit": 10000,
}
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.get(url, headers=self._emby_headers(), params=params)
headers = {"X-Emby-Token": self.api_key}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
return []
items = payload.get("Items") or payload.get("items") or []
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
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()
headers = {"X-Emby-Token": self.api_key}
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_sessions(self) -> Optional[list[Dict[str, Any]]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Sessions"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, list) else []
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers()
headers = {"X-Emby-Token": self.api_key}
params = {"Recursive": "true" if recursive else "false"}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message="Jellyfin accepted the library refresh and is scanning for new media.",
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("Jellyfin", status_code),
)
raise
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
+6 -94
View File
@@ -1,44 +1,8 @@
from typing import Any, Dict, Optional
from urllib.parse import quote, unquote, urlsplit
import httpx
from .base import ApiClient
class JellyseerrClient(ApiClient):
async def _send_request(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: Dict[str, str],
params: Optional[Dict[str, Any]],
payload: Optional[Dict[str, Any]],
) -> httpx.Response:
request_headers = dict(headers)
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
# Seerr's optional CSRF protection also applies to API-key writes.
# Seed its secret/token cookie pair, then echo the readable token in
# the header Seerr's own web client uses.
csrf_response = await client.get(
f"{self.base_url}/api/v1/auth/me",
headers=self.headers(),
)
csrf_response.raise_for_status()
csrf_token = client.cookies.get("XSRF-TOKEN")
if csrf_token:
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
parsed_base = urlsplit(self.base_url)
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
return await super()._send_request(
client,
method,
url,
headers=request_headers,
params=params,
payload=payload,
)
async def get_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v1/status")
@@ -54,6 +18,9 @@ class JellyseerrClient(ApiClient):
},
)
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/media/{media_id}")
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/movie/{tmdb_id}")
@@ -61,65 +28,10 @@ class JellyseerrClient(ApiClient):
return await self.get(f"/api/v1/tv/{tmdb_id}")
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
# Seerr rejects the `+` encoding that standard query builders use for
# spaces. Build this query explicitly so multi-word titles are sent as
# percent-encoded values.
encoded_query = quote(query, safe="")
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
async def get_service_settings(self, media_type: str) -> Optional[Any]:
service = "sonarr" if media_type == "tv" else "radarr"
return await self.get(f"/api/v1/settings/{service}")
async def create_request(
self,
*,
media_type: str,
media_id: int,
seasons: Optional[list[int]] = None,
is_4k: Optional[bool] = None,
server_id: Optional[int] = None,
profile_id: Optional[int] = None,
root_folder: Optional[str] = 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
if isinstance(server_id, int):
payload["serverId"] = server_id
if isinstance(profile_id, int):
payload["profileId"] = profile_id
if isinstance(root_folder, str) and root_folder.strip():
payload["rootFolder"] = root_folder.strip()
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",
"/api/v1/search",
params={
"take": take,
"skip": skip,
"query": query,
"page": page,
},
)
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
+17 -151
View File
@@ -1,59 +1,7 @@
from typing import Any, Dict, Optional
import httpx
import logging
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _torrent_state_text(state: Any) -> str:
normalized = str(state or "").strip().lower()
if "pause" in normalized:
return "paused"
if "stall" in normalized:
return "stalled"
if normalized.startswith("queued"):
return "waiting in the queue"
if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}:
return "downloading"
if "upload" in normalized or normalized in {"stalledup", "forcedup"}:
return "finished and seeding"
if normalized in {"completed", "missingfiles"}:
return "finished" if normalized == "completed" else "missing files"
if "error" in normalized:
return "in an error state"
return "present"
def _torrent_result_message(result: Any) -> str:
torrents = result if isinstance(result, list) else []
if not torrents:
return "qBittorrent found no matching downloads."
first = next((item for item in torrents if isinstance(item, dict)), {})
if len(torrents) == 1:
name = str(first.get("name") or "the matching download").strip()
progress = first.get("progress")
progress_text = (
f" and {max(0, min(100, round(progress * 100)))}% complete"
if isinstance(progress, (int, float))
else ""
)
return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.'
active = sum(
1
for item in torrents
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
)
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
def _torrent_action_message(path: str) -> str:
normalized_path = path.lower()
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
return "qBittorrent accepted the request to resume the download."
if normalized_path.endswith("/add"):
return "qBittorrent accepted the release and added it to the download queue."
return "qBittorrent accepted the requested download action."
from .base import ApiClient
class QBittorrentClient(ApiClient):
@@ -75,109 +23,34 @@ class QBittorrentClient(ApiClient):
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):
if response.text.strip().lower() != "ok.":
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
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
try:
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()
result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_torrent_result_message(result),
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
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
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
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()
result = response.text.strip()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
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
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
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()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_torrent_action_message(path),
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
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 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 get_torrents(self) -> Optional[Any]:
return await self._get("/api/v2/torrents/info")
@@ -188,9 +61,6 @@ class QBittorrentClient(ApiClient):
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")
@@ -203,9 +73,7 @@ class QBittorrentClient(ApiClient):
return
raise
async def add_torrent_url(
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
) -> None:
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
url_host = None
if isinstance(url, str) and "://" in url:
url_host = url.split("://", 1)[-1].split("/", 1)[0]
@@ -217,6 +85,4 @@ class QBittorrentClient(ApiClient):
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)
-36
View File
@@ -9,13 +9,6 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
return result if isinstance(result, dict) else None
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")
@@ -28,32 +21,12 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def search_releases(self, movie_id: int) -> Optional[Any]:
return await self.get(
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
)
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 monitor_movie(
self, movie_id: int, monitored: bool = True
) -> Optional[Dict[str, Any]]:
movie = await self.get_movie(movie_id)
if not isinstance(movie, dict):
raise ValueError("Radarr did not return the movie before updating its monitored state")
movie["monitored"] = monitored
return await self.update_movie(movie)
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/moviefile/{movie_file_id}",
params={"deleteFromClient": "true"},
)
async def add_movie(
self,
tmdb_id: int,
@@ -61,15 +34,9 @@ class RadarrClient(ApiClient):
root_folder: str,
monitored: bool = True,
search_for_movie: bool = True,
title: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
if not resolved_title:
raise ValueError("Radarr could not resolve a title for this TMDB ID")
payload = {
"tmdbId": tmdb_id,
"title": resolved_title,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
@@ -77,9 +44,6 @@ class RadarrClient(ApiClient):
}
return await self.post("/api/v3/movie", payload=payload)
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/movie", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
+2 -49
View File
@@ -9,23 +9,6 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
if not isinstance(result, list):
return None
for item in result:
if not isinstance(item, dict):
continue
try:
if int(item.get("tvdbId")) == tvdb_id:
return item
except (TypeError, ValueError):
continue
return next((item for item in result if isinstance(item, dict)), None)
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")
@@ -41,36 +24,12 @@ class SonarrClient(ApiClient):
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 get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
return await self.get(
"/api/v3/release",
params={"seriesId": series_id, "seasonNumber": season_number},
timeout_seconds=90.0,
)
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 monitor_episodes(
self, episode_ids: list[int], monitored: bool = True
) -> Optional[Dict[str, Any]]:
return await self.put(
"/api/v3/episode/monitor",
payload={"episodeIds": episode_ids, "monitored": monitored},
)
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/episodefile/{episode_file_id}",
params={"deleteFromClient": "true"},
)
async def add_series(
self,
tvdb_id: int,
@@ -80,24 +39,18 @@ class SonarrClient(ApiClient):
title: Optional[str] = None,
search_missing: bool = True,
) -> Optional[Dict[str, Any]]:
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
if not resolved_title:
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
payload = {
"tvdbId": tvdb_id,
"title": resolved_title,
"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})
+8 -216
View File
@@ -2,68 +2,18 @@ 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_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
)
auth_rate_limit_max_attempts_ip: int = Field(
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
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")
)
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
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")
)
@@ -85,19 +35,12 @@ class Settings(BaseSettings):
requests_data_source: str = Field(
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
)
issue_confirmation_contact_attempts: int = Field(
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
)
issue_confirmation_interval_value: int = Field(
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
)
issue_confirmation_interval_unit: str = Field(
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
)
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
@@ -107,149 +50,8 @@ class Settings(BaseSettings):
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"),
site_changelog: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
)
jellyseerr_base_url: Optional[str] = Field(
@@ -305,16 +107,6 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
bazarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
)
bazarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
)
bazarr_default_language: str = Field(
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
)
@@ -333,7 +125,7 @@ class Settings(BaseSettings):
)
discord_webhook_url: Optional[str] = Field(
default=None,
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
+157 -3015
View File
File diff suppressed because it is too large Load Diff
+5 -154
View File
@@ -1,148 +1,10 @@
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
from typing import Optional
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:
def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
level_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
@@ -156,20 +18,15 @@ def configure_logging(
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",
log_path, maxBytes=2_000_000, backupCount=3, 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",
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers:
handler.addFilter(context_filter)
handler.setFormatter(formatter)
root = logging.getLogger()
@@ -181,10 +38,4 @@ def configure_logging(
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)
logging.getLogger("uvicorn.access").setLevel(level)
+13 -225
View File
@@ -1,14 +1,10 @@
import asyncio
import logging
import time
import uuid
from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .db import has_admin_user, init_db
from .db import init_db, set_setting
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
@@ -17,42 +13,17 @@ from .routers.requests import (
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.admin import router as admin_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 .routers.operations import router as operations_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import (
begin_operation,
finish_operation,
normalize_operation_id,
reset_operation,
)
from .logging_config import (
bind_request_id,
configure_logging,
reset_request_id,
sanitize_headers,
sanitize_value,
summarize_http_body,
)
from .logging_config import configure_logging
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 = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
@@ -63,212 +34,29 @@ app.add_middleware(
)
@app.middleware("http")
async def log_requests_and_add_security_headers(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = bind_request_id(request_id)
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
operation_token = None
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
operation_token = begin_operation(
operation_id,
label=request.headers.get("X-Magent-Operation-Label"),
path=request.url.path,
)
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,
)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=500)
reset_operation(operation_token)
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"}
}
),
)
if operation_id and operation_token is not None:
finish_operation(
operation_id,
success=response.status_code < 400,
status_code=response.status_code,
)
reset_operation(operation_token)
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()
if settings.site_build_number and settings.site_build_number.strip():
set_setting("site_build_number", settings.site_build_number.strip())
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)
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
logger.info("startup complete")
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
asyncio.create_task(startup_warmup_requests_cache())
asyncio.create_task(run_requests_delta_loop())
asyncio.create_task(run_daily_requests_full_sync())
asyncio.create_task(run_daily_db_cleanup())
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)
app.include_router(operations_router)
-2
View File
@@ -35,7 +35,6 @@ class ActionOption(BaseModel):
id: str
label: str
risk: str
description: Optional[str] = None
requires_confirmation: bool = True
@@ -49,7 +48,6 @@ class Snapshot(BaseModel):
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)
-132
View File
@@ -1,132 +0,0 @@
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
+10 -19
View File
@@ -14,7 +14,6 @@ _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:
@@ -81,32 +80,24 @@ def _ensure_default_branding() -> None:
favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png")
async def branding_logo() -> FileResponse:
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
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)
headers = {"Cache-Control": "public, max-age=300"}
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):
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
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)
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
-229
View File
@@ -1,229 +0,0 @@
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
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
next_recent_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 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)
+1 -9
View File
@@ -3,7 +3,6 @@ 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)])
@@ -12,16 +11,9 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
webhook_url = 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"}:
+13 -31
View File
@@ -1,8 +1,6 @@
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
@@ -13,7 +11,6 @@ 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:
@@ -22,35 +19,23 @@ def _safe_filename(path: str) -> str:
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
if not path.startswith("/"):
path = f"/{path}"
if size not in _ALLOWED_SIZES:
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)
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
os.makedirs(cache_dir, exist_ok=True)
file_path = os.path.join(cache_dir, _safe_filename(path))
if os.path.exists(file_path):
return True
@@ -79,10 +64,9 @@ async def tmdb_image(path: str, size: str = "w342"):
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)
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
os.makedirs(cache_dir, exist_ok=True)
file_path = os.path.join(cache_dir, _safe_filename(path))
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
@@ -93,8 +77,6 @@ async def tmdb_image(path: str, size: str = "w342"):
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers)
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)
raise HTTPException(status_code=502, detail="Image cache failed")
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
-19
View File
@@ -1,19 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..services.operation_progress import get_operation
router = APIRouter(
prefix="/operations",
tags=["operations"],
dependencies=[Depends(get_current_user)],
)
@router.get("/{operation_id}")
async def operation_status(operation_id: str) -> dict:
operation = get_operation(operation_id)
if not operation:
raise HTTPException(status_code=404, detail="Operation not found")
return operation
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -3,7 +3,6 @@ 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"])
@@ -18,24 +17,15 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
"buildNumber": (runtime.site_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()
info["changelog"] = (runtime.site_changelog or "").strip()
return info
+11 -95
View File
@@ -1,18 +1,17 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from ..auth import require_admin
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.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
@@ -27,42 +26,12 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
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)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
@@ -72,7 +41,7 @@ async def services_status() -> Dict[str, Any]:
services = []
services.append(
await _check(
"Seerr",
"Jellyseerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
)
@@ -91,13 +60,6 @@ async def services_status() -> Dict[str, Any]:
radarr.get_system_status,
)
)
services.append(
await _check(
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
)
)
prowlarr_status = await _check(
"Prowlarr",
prowlarr.configured(),
@@ -109,7 +71,13 @@ async def services_status() -> Dict[str, Any]:
prowlarr_status["status"] = "degraded"
prowlarr_status["message"] = "Health warnings"
services.append(prowlarr_status)
services.append(await _check_qbittorrent(qbittorrent))
services.append(
await _check(
"qBittorrent",
qbittorrent.configured(),
qbittorrent.get_app_version,
)
)
services.append(
await _check(
"Jellyfin",
@@ -125,55 +93,3 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded"
return {"overall": overall, "services": services}
@router.post("/services/{service}/test")
async def test_service(service: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_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),
"bazarr": (
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.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
-32
View File
@@ -2,48 +2,18 @@ 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",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"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():
@@ -52,8 +22,6 @@ def get_runtime_settings():
for key, value in overrides.items():
if value is None:
continue
if key in _SKIP_OVERRIDE_FIELDS:
continue
if key in _INT_FIELDS:
try:
update[key] = int(value)
+4 -37
View File
@@ -1,16 +1,13 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import jwt
from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str:
@@ -21,44 +18,14 @@ 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")
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
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])
@@ -69,5 +36,5 @@ class TokenError(Exception):
def safe_decode_token(token: str) -> Dict[str, Any]:
try:
return decode_token(token)
except InvalidTokenError as exc:
except JWTError as exc:
raise TokenError("Invalid token") from exc
-61
View File
@@ -1,61 +0,0 @@
"""Read title-specific search activity without starting a search or changing monitoring."""
from typing import Any
from ..clients.base import ApiClient
from ..models import RequestType
def _ids(values: Any) -> set[int]:
if not isinstance(values, list):
return set()
return {value for value in values if type(value) is int and value > 0}
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
"""Only a matching queued/started search is evidence of current activity.
Completed commands, RSS syncs and library-wide jobs do not establish that this
title is being searched. Episode searches are matched using Sonarr episode IDs.
"""
if not isinstance(commands, list):
return "unavailable"
episode_ids = _ids([
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
])
queued = False
for command in commands:
if not isinstance(command, dict):
continue
body = command.get("body")
if not isinstance(body, dict):
continue
name = str(command.get("name") or body.get("name") or "").lower()
if request_type == RequestType.movie:
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
else:
matches = (
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
) or (
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
)
if not matches or command.get("ended"):
continue
status = str(command.get("status", "")).lower()
if status in {"started", "1"}:
return "searching"
if status in {"queued", "0"}:
queued = True
return "queued" if queued else "idle"
async def read_search_status(
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
) -> str:
try:
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
except Exception:
# Search telemetry must not turn a healthy library record into an error.
return "unavailable"
return search_status(commands, request_type, item_id, episodes)
-735
View File
@@ -1,735 +0,0 @@
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
-585
View File
@@ -1,585 +0,0 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from html import escape
import json
import logging
from typing import Any, Dict, Optional
from ..config import settings as env_settings
from ..db import (
add_portal_item_activity,
get_portal_item,
get_user_by_username,
list_portal_item_activity,
list_portal_items,
update_portal_item,
)
from ..clients.jellyfin import JellyfinClient
from ..clients.sonarr import SonarrClient
from ..runtime import get_runtime_settings
from .invite_email import resolve_user_delivery_email, send_generic_email
from .snapshot import build_snapshot
logger = logging.getLogger(__name__)
_SYSTEM_USER = "Magent"
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_datetime(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
raw = item.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
state = _metadata(item).get("resolutionConfirmation")
return dict(state) if isinstance(state, dict) else {}
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
metadata = _metadata(item)
metadata["resolutionConfirmation"] = state
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
def _interval_delta(value: int, unit: str) -> timedelta:
safe_value = max(1, min(int(value), 365))
normalized_unit = str(unit or "days").strip().lower()
if normalized_unit == "weeks":
return timedelta(weeks=safe_value)
if normalized_unit == "months":
return timedelta(days=30 * safe_value)
return timedelta(days=safe_value)
def _workflow_settings() -> tuple[int, int, str]:
runtime = get_runtime_settings()
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
if interval_unit not in {"days", "weeks", "months"}:
interval_unit = "days"
return attempts, interval_value, interval_unit
def _app_url() -> str:
runtime = get_runtime_settings()
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
candidate = str(value or "").strip()
if candidate:
return candidate.rstrip("/")
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
def _issue_url(item_id: int) -> str:
return f"{_app_url()}/portal/issues?item={item_id}"
def _activity(
item_id: int,
event_type: str,
message: str,
*,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=actor_username,
actor_role=actor_role,
message=message,
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
)
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
raw = entry.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
activity = list_portal_item_activity(item_id, limit=500)
for entry in reversed(activity):
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
continue
tracking = _activity_metadata(entry).get("repairTracking")
if isinstance(tracking, dict):
return dict(tracking), activity
return {}, activity
def _positive_ints(value: Any) -> list[int]:
if not isinstance(value, list):
return []
return [
int(item)
for item in value
if isinstance(item, int) and not isinstance(item, bool) and item > 0
]
def _media_signature(item: Any) -> Dict[str, str]:
if not isinstance(item, dict):
return {}
result: Dict[str, str] = {}
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
value = item.get(key)
if isinstance(value, (dict, list)):
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
elif value is not None and str(value).strip():
result[key] = str(value).strip()
return result
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
previous = _media_signature(baseline)
if not previous:
return True
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
request_id = str(tracking.get("requestId") or "").strip()
action_id = str(tracking.get("actionId") or "").strip()
media_type = str(tracking.get("mediaType") or "").strip().lower()
collector_id = tracking.get("collectorId")
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
snapshot = await build_snapshot(request_id)
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
arr = raw.get("arr") if isinstance(raw.get("arr"), dict) else {}
arr_item = arr.get("item") if isinstance(arr, dict) else None
jellyfin = raw.get("jellyfin") if isinstance(raw.get("jellyfin"), dict) else {}
jellyfin_item = jellyfin.get("item") if isinstance(jellyfin, dict) else None
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
baselines = tracking.get("jellyfinBaseline")
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
found_at_start = tracking.get("jellyfinFoundAtStart") is True
if media_type == "movie":
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
imported = isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
current_signature = _media_signature(jellyfin_item)
if action_id == "replace_media" and found_at_start:
if not baselines or not _signature_changed(current_signature, baselines[0]):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
target_rows = tracking.get("episodes")
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
target_ids = {
int(item["id"])
for item in targets
if isinstance(item.get("id"), int) and int(item["id"]) > 0
}
target_pairs = {
(int(item["seasonNumber"]), int(item["episodeNumber"]))
for item in targets
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if not target_ids or not target_pairs:
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
runtime = get_runtime_settings()
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
episodes = await sonarr.get_episodes(collector_id)
episode_map = {
int(item["id"]): item
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
} if isinstance(episodes, list) else {}
imported = all(
episode_id in episode_map
and (
episode_map[episode_id].get("hasFile") is True
or (
isinstance(episode_map[episode_id].get("episodeFileId"), int)
and episode_map[episode_id]["episodeFileId"] > 0
)
)
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
for episode_id in target_ids
)
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
current_by_pair = {
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
for item in jellyfin_episodes
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
}
if not all(pair in current_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
if action_id == "replace_media" and found_at_start:
baseline_by_pair = {
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
for item in baselines
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if any(pair not in baseline_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
if not all(
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
for pair in target_pairs
):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
def _close_issue(
item: Dict[str, Any],
*,
reason: str,
confirmed: bool,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
) -> Dict[str, Any]:
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "confirmed" if confirmed else "auto_closed",
"confirmedAt": now if confirmed else state.get("confirmedAt"),
"closedAt": now,
"nextContactAt": None,
"closedReason": reason,
}
)
updated = update_portal_item(
int(item["id"]),
status="closed",
issue_resolved_at=now,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be closed")
_activity(
int(item["id"]),
"resolution_confirmed" if confirmed else "issue_auto_closed",
reason,
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
maximum, interval_value, interval_unit = _workflow_settings()
state = issue_resolution_state(item)
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0:
return _close_issue(
item,
reason="Issue closed automatically because reporter confirmation emails are disabled.",
confirmed=False,
)
if attempts >= maximum:
return _close_issue(
item,
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
confirmed=False,
)
attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter)
issue_url = _issue_url(int(item["id"]))
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
body_text = (
f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n"
f"Issue: {item.get('title') or 'Untitled issue'}\n"
f"Confirmation request: {attempt_number} of {maximum}\n\n"
f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n"
"If you do not respond, Magent will close the issue automatically after the configured confirmation period."
)
body_html = (
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
f'Confirmation request {attempt_number} of {maximum}</p>'
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
'</div>'
)
try:
await send_generic_email(
recipient_email=recipient,
subject=subject,
body_text=body_text,
body_html=body_html,
)
sent = True
except Exception as exc:
delivery_error = str(exc)
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
else:
delivery_error = "No email address is stored for the reporter."
now = _now()
state.update(
{
"status": "awaiting_confirmation",
"attemptsSent": attempt_number,
"maximumAttempts": maximum,
"lastContactAt": now.isoformat(),
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"lastDeliverySucceeded": sent,
"lastDeliveryError": delivery_error,
}
)
updated = update_portal_item(
int(item["id"]),
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation schedule could not be saved")
if sent:
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
else:
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
_activity(
int(item["id"]),
"confirmation_email_sent" if sent else "confirmation_email_failed",
message,
metadata={
"attempt": attempt_number,
"maximum": maximum,
"nextContactAt": state["nextContactAt"],
"deliveryError": delivery_error,
},
)
return updated
async def begin_issue_confirmation(
item_id: int,
*,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
now = _now().isoformat()
maximum, interval_value, interval_unit = _workflow_settings()
state = {
"status": "awaiting_confirmation",
"startedAt": now,
"attemptsSent": 0,
"maximumAttempts": maximum,
"lastContactAt": None,
"nextContactAt": now,
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"confirmedAt": None,
"closedAt": None,
}
updated = update_portal_item(
item_id,
status="awaiting_confirmation",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation workflow could not be started")
_activity(
item_id,
"resolution_proposed",
"The issue was marked fixed and sent to the reporter for confirmation.",
actor_username=actor_username,
actor_role=actor_role,
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
)
return await _contact_reporter(updated)
def respond_to_issue_confirmation(
item_id: int,
*,
resolved: bool,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
if str(item.get("status") or "").lower() != "awaiting_confirmation":
raise ValueError("This issue is not waiting for resolution confirmation")
if resolved:
return _close_issue(
item,
reason="The reporter confirmed that the issue is fixed.",
confirmed=True,
actor_username=actor_username,
actor_role=actor_role,
)
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "reported_still_broken",
"reporterResponseAt": now,
"nextContactAt": None,
"closedAt": None,
}
)
updated = update_portal_item(
item_id,
status="in_progress",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be reopened")
_activity(
item_id,
"resolution_rejected",
"The reporter said the issue is still happening. The issue was returned to In progress.",
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def process_active_media_repairs() -> Dict[str, int]:
items = list_portal_items(kind="issue", status="in_progress", limit=500)
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
for item in items:
tracking, activity = _repair_tracking(int(item["id"]))
if not tracking:
continue
result["checked"] += 1
try:
evidence = await _media_repair_evidence(tracking)
if evidence.get("complete"):
_activity(
int(item["id"]),
"repair_verified",
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
metadata={
"requestId": tracking.get("requestId"),
"actionId": tracking.get("actionId"),
},
)
await begin_issue_confirmation(
int(item["id"]),
actor_username=_SYSTEM_USER,
actor_role="system",
)
result["completed"] += 1
continue
result["waiting"] += 1
if evidence.get("phase") == "indexing" and not any(
str(entry.get("event_type") or "") == "repair_imported"
for entry in activity
):
_activity(
int(item["id"]),
"repair_imported",
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
metadata={
"requestId": tracking.get("requestId"),
"actionId": tracking.get("actionId"),
},
)
except Exception:
result["failed"] += 1
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
return result
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
current = (now or _now()).astimezone(timezone.utc)
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
maximum, _, _ = _workflow_settings()
for item in items:
state = issue_resolution_state(item)
due_at = _parse_datetime(state.get("nextContactAt"))
if due_at and due_at > current:
continue
try:
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0 or attempts >= maximum:
_close_issue(
item,
reason=(
"Issue closed automatically because reporter confirmation emails are disabled."
if maximum <= 0
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
),
confirmed=False,
)
result["closed"] += 1
else:
await _contact_reporter(item)
result["contacted"] += 1
except Exception:
result["failed"] += 1
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
return result
async def run_issue_confirmation_loop() -> None:
while True:
try:
repair_result = await process_active_media_repairs()
if repair_result["completed"] or repair_result["failed"]:
logger.info("automatic media repair sweep complete result=%s", repair_result)
result = await process_due_issue_confirmations()
if result["contacted"] or result["closed"] or result["failed"]:
logger.info("issue confirmation sweep complete result=%s", result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("issue confirmation sweep failed")
await asyncio.sleep(60)
+2 -44
View File
@@ -3,22 +3,8 @@ 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 ..db import create_user_if_missing
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__)
@@ -31,11 +17,6 @@ async def sync_jellyfin_users() -> int:
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Seerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0
for user in users:
if not isinstance(user, dict):
@@ -43,31 +24,8 @@ async def sync_jellyfin_users() -> int:
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:
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
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
-280
View File
@@ -1,280 +0,0 @@
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}
-206
View File
@@ -1,206 +0,0 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from copy import deepcopy
from datetime import datetime, timezone
import re
import threading
import time
import uuid
from typing import Any, Dict, Optional
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
_OPERATION_TTL_SECONDS = 15 * 60
_MAX_OPERATIONS = 500
_MAX_EVENTS = 60
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
"magent_operation_id", default=None
)
_operations: Dict[str, Dict[str, Any]] = {}
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
def _prune_locked(now_monotonic: float) -> None:
expired = [
operation_id
for operation_id, operation in _operations.items()
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
]
for operation_id in expired:
_operations.pop(operation_id, None)
if len(_operations) <= _MAX_OPERATIONS:
return
oldest = sorted(
_operations,
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
)
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
_operations.pop(operation_id, None)
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
now_monotonic = time.monotonic()
now_iso = _now_iso()
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
with _lock:
_prune_locked(now_monotonic)
_operations[operation_id] = {
"id": operation_id,
"label": normalized_label,
"path": path,
"status": "running",
"started_at": now_iso,
"updated_at": now_iso,
"updated_monotonic": now_monotonic,
"duration_ms": None,
"events": [
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete",
"message": "Magent received the action.",
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": None,
}
],
}
return _current_operation_id.set(operation_id)
def reset_operation(token: Token) -> None:
_current_operation_id.reset(token)
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
operation_id = _current_operation_id.get()
if not operation_id:
return None
event_id = uuid.uuid4().hex
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return None
operation["events"].append(
{
"id": event_id,
"service": service,
"state": "active",
"message": message or f"Contacting {service}",
"started_at": now_iso,
"finished_at": None,
"duration_ms": None,
"status_code": None,
"started_monotonic": now_monotonic,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
return event_id
def finish_remote_call(
event_id: Optional[str],
*,
success: bool,
status_code: Optional[int] = None,
message: Optional[str] = None,
) -> None:
operation_id = _current_operation_id.get()
if not operation_id or not event_id:
return
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
event = next(
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
None,
)
if not event:
return
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "complete" if success else "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["status_code"] = status_code
event["message"] = message or (
f"{event['service']} responded successfully."
if success
else f"{event['service']} returned an error."
)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
for event in operation["events"]:
if event.get("state") == "active":
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
started = datetime.fromisoformat(str(operation["started_at"]))
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
operation["status"] = "complete" if success else "error"
operation["status_code"] = status_code
operation["duration_ms"] = round(duration_ms, 1)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
operation["events"].append(
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete" if success else "error",
"message": (
"Magent finished processing the action."
if success
else "Magent could not complete the action."
),
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": status_code,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
normalized = normalize_operation_id(operation_id)
if not normalized:
return None
with _lock:
operation = _operations.get(normalized)
if not operation:
return None
result = deepcopy(operation)
result.pop("updated_monotonic", None)
for event in result.get("events", []):
event.pop("started_monotonic", None)
return result
-333
View File
@@ -1,333 +0,0 @@
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
@@ -1,185 +0,0 @@
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}
+8 -8
View File
@@ -1,9 +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
fastapi==0.115.0
uvicorn==0.30.6
httpx==0.27.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
passlib==1.7.4
python-multipart==0.0.31
Pillow==12.3.0
python-multipart==0.0.9
Pillow==10.4.0
File diff suppressed because it is too large Load Diff
-158
View File
@@ -1,158 +0,0 @@
from contextlib import ExitStack
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
from backend.app.config import settings
from backend.app.models import NormalizedState, RequestType, Snapshot
from backend.app.services import snapshot as snapshot_service
from backend.app.services.collector_search import read_search_status, search_status
def command(name="MoviesSearch", status="started", **body):
return {"name": name, "status": status, "body": body}
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
def test_movie_search_is_scoped_to_the_movie(self):
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
def test_queued_search_and_running_search_priority(self):
queued = command(status="queued", movieIds=[12])
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
def test_terminal_commands_are_not_searching(self):
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
with self.subTest(state=state):
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
def test_numeric_statuses(self):
for state, expected in [(0, "queued"), (1, "searching")]:
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
def test_series_and_season_searches(self):
for name in ["SeriesSearch", "SeasonSearch"]:
with self.subTest(name=name):
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
def test_episode_search_uses_episode_ids_not_numbers(self):
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
with self.subTest(name=name):
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
for payload in [None, {}, {"error": "unavailable"}]:
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
async def test_check_is_read_only_with_a_short_timeout(self):
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
async def test_service_failure_is_unknown_not_idle(self):
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
class LibrarySearchPresentationTests(unittest.TestCase):
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
arr_state="added", download_state="not_started", jellyfin=False):
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
state=NormalizedState.added_to_arr)
return snapshot_service._build_presentation(
snapshot, approved=True, arr_state=arr_state,
arr_details={"search": {"state": search}, "availability": {
"available": available, "missing": missing, "total": available + missing,
}}, prowlarr_state="ok",
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
jellyfin_found=jellyfin, jellyfin_link=None,
)
def stage(self, presentation, stage_id="library"):
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
def test_card_uses_actual_search_state(self):
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
with self.subTest(state=state):
presentation = self.presentation(state)
library = self.stage(presentation)
self.assertEqual(library["stateLabel"], badge)
self.assertEqual(library["state"], style)
self.assertEqual(library["searchStatus"], state)
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
self.assertEqual(library["available"], 0)
self.assertEqual(library["missing"], 1)
def test_partial_tv_retains_counts_and_search_activity(self):
for state in ["idle", "searching", "queued", "unavailable"]:
with self.subTest(state=state):
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
library = self.stage(presentation)
self.assertEqual(library["state"], "partial")
self.assertEqual(library["searchStatus"], state)
self.assertIn("22 of 24 episodes collected", library["summary"])
self.assertNotIn("is still looking", presentation["status"]["meaning"])
def test_collected_titles_dont_look_stuck_searching(self):
for jellyfin in [True, False]:
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
self.assertEqual(library["state"], "complete")
self.assertIn("no search needed", library["summary"])
def test_download_has_its_own_state_without_claiming_searching(self):
library = self.stage(self.presentation("idle", download_state="downloading"))
self.assertEqual(library["stateLabel"], "Downloading")
self.assertIn("Not currently searching", library["summary"])
def test_an_old_missing_download_does_not_mark_search_complete(self):
search = self.stage(self.presentation("idle", download_state="missing"), "search")
self.assertEqual(search["state"], "waiting")
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
for media_type in [RequestType.movie, RequestType.tv]:
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
collector = SimpleNamespace(
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
get_queue=AsyncMock(return_value={"records": []}),
get=AsyncMock(return_value=commands),
)
mocks = {
"get_runtime_settings": runtime,
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
"get_request_cache_by_id": None,
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
"JellyfinClient": SimpleNamespace(configured=lambda: False),
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
"SonarrClient": collector, "RadarrClient": collector,
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
"get_request_download_evidence": {}, "_latest_repair_action": None, "save_snapshot": None,
}
for name, value in mocks.items():
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
snapshot = await snapshot_service.build_snapshot("12")
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
self.assertEqual(library["searchStatus"], expected)
-28
View File
@@ -1,28 +0,0 @@
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
+12 -3
View File
@@ -1,10 +1,19 @@
services:
magent:
image: rephl3xnz/magent:latest
backend:
image: rephl3xnz/magent-backend:latest
env_file:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
frontend:
image: rephl3xnz/magent-frontend:latest
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend
+16 -3
View File
@@ -1,12 +1,25 @@
services:
magent:
backend:
build:
context: .
dockerfile: Dockerfile
dockerfile: backend/Dockerfile
args:
BUILD_NUMBER: ${BUILD_NUMBER}
env_file:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend
-28
View File
@@ -1,28 +0,0 @@
[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
-20
View File
@@ -1,20 +0,0 @@
# Shared workspace layout
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
## Browser checks
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
-101
View File
@@ -1,101 +0,0 @@
/* Top-navigation workspace and streamlined account screens. */
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
.settings-top-navigation label { display: flex; align-items: center; gap: 12px; margin: 0; padding: 0; }
.settings-top-navigation label span { color: var(--ops-faint); font: 12px Inter, sans-serif; text-transform: none; }
.settings-top-navigation select { width: 260px; min-height: 42px; font: 13px Inter, sans-serif; padding: 10px 12px; border-radius: 8px; }
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
.account-identity > div > span { font-size: 12px; color: var(--ops-faint); }
.account-avatar { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; border: 1px solid #45434f; border-radius: 14px; background: #25242c; color: #dedaff; font: 600 20px "DM Sans", sans-serif; }
.account-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--ops-line-soft); margin-bottom: 24px; }
.account-tabs button { min-height: 46px; padding: 0 2px; border: 0; border-radius: 0 !important; border-bottom: 2px solid transparent; border-color: transparent !important; background: transparent !important; color: var(--ops-muted); font: 500 14px "DM Sans", sans-serif; box-shadow: none; text-transform: none; }
.account-tabs button[aria-selected=true] { color: #dedaff; border-bottom-color: #bcb3ff !important; }
.account-page [hidden] { display: none !important; }
.account-panel { border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); padding: 32px; animation: account-appear .18s ease-out; }
.account-section-intro { margin-bottom: 26px; }
.account-section-intro h2 { margin: 0 0 8px; font-size: 21px; }
.account-section-intro p { margin: 0; font-size: 13px; color: var(--ops-muted); line-height: 1.6; }
.account-form { display: grid; gap: 10px; max-width: 500px; }
.account-form fieldset { display: grid; gap: 10px; min-width: 0; padding: 0; margin: 0; border: 0; }
.account-form label { display: block; padding: 0; margin: 0; border: 0; background: none; color: var(--ops-text); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
.account-form input { display: block; width: 100%; min-width: 0; min-height: 46px; padding: 11px 13px; margin: 0; border: 1px solid var(--ops-line); border-radius: 8px; font: 14px Inter, sans-serif; }
.account-form fieldset label:not(:first-child) { margin-top: 10px; }
.account-form .account-hint { margin: 0; color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
.account-form-actions { display: flex; gap: 10px; margin-top: 14px; }
button.account-primary { min-height: 44px; padding: 11px 20px; border: 1px solid #c7bdff !important; border-radius: 8px; background: #c7bdff !important; color: #1c172c !important; font: 700 13px "DM Sans", sans-serif; text-transform: none; transition: background .15s, opacity .15s; }
button.account-primary:hover:not(:disabled) { background: #d8d1ff !important; }
button.account-primary:disabled { opacity: .4; cursor: not-allowed; }
button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px solid var(--ops-line); background: transparent !important; color: var(--ops-muted); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
.account-request-summary > div { display: grid; gap: 5px; }
.account-request-summary strong { font: 600 25px "DM Sans", sans-serif; }
.account-request-summary span { color: var(--ops-muted); font-size: 12px; }
.account-request-summary a { margin-left: auto; font-size: 13px; color: #d0c8ff; text-decoration: none; }
.account-list-heading { font-size: 14px; margin: 0 0 8px; }
.account-access-list { list-style: none; padding: 0; margin: 0; }
.account-access-list > li { padding: 18px 0; border-bottom: 1px solid var(--ops-line-soft); }
.account-access-list > li:last-child { border: 0; }
.account-access-summary { display: flex; justify-content: space-between; gap: 16px; }
.account-access-summary strong { font-size: 13px; font-weight: 500; }
.account-access-summary time { font-size: 12px; color: var(--ops-muted); text-align: right; }
.account-access-list details { margin-top: 8px; font-size: 12px; color: var(--ops-faint); }
.account-access-list summary { cursor: pointer; }
.account-access-list dl { display: grid; gap: 8px; margin-bottom: 0; }
.account-access-list dl > div { display: flex; gap: 12px; flex-wrap: wrap; }
.account-access-list dd { margin: 0; color: var(--ops-muted); overflow-wrap: anywhere; }
.account-empty { color: var(--ops-muted); font-size: 14px; line-height: 1.6; padding: 16px 0; }
.account-empty button { margin-top: 8px; }
.page:has(> .login-page) { padding: 0; background: #121214; }
.page > main.login-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; width: 100%; max-width: none; margin: 0; padding: 40px 20px; background: radial-gradient(ellipse at 50% 15%, #24202e 0, transparent 60%); }
.login-card { width: 100%; max-width: 430px; border: 1px solid #3a3841; border-radius: 20px; padding: 32px; background: #1c1b1f; box-shadow: 0 22px 90px #0003; }
.login-brand { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 36px; }
.login-brand > a { display: flex; align-items: center; gap: 10px; color: #eeeaf5; text-decoration: none; font: 600 23px "DM Sans", sans-serif; }
.magent-mark { width: 38px; height: 38px; }
.login-beta { padding: 4px 8px; border: 1px solid #44404d; border-radius: 6px; font: 10px "JetBrains Mono", monospace; color: #bdb5cf; text-transform: uppercase; }
.login-card header { margin-bottom: 24px; }
.login-card h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.2; color: #f1edf6; }
.login-card header p { margin: 0; color: #aba5b7; font-size: 13px; }
.login-methods { display: flex; gap: 4px; padding: 4px; margin: 0 0 16px; background: #151417; border: 1px solid #3b3842; border-radius: 9px; }
.login-methods button { flex: 1; min-height: 36px; padding: 8px; border: 0; border-radius: 6px !important; background: transparent !important; color: #b3adbf; font: 500 13px "DM Sans", sans-serif; text-transform: none; }
.login-methods button[aria-pressed=true] { background: #36313f !important; color: #eee7ff; }
.login-method-help { font-size: 12px; line-height: 1.5; margin: 0 0 10px; color: #aba5b7; }
.login-form input { background: #151417 !important; border-color: #46414e !important; }
.login-password-label { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; }
.login-password-label a { color: #c4b9e5; font-size: 12px; text-decoration: none; }
.login-password-field { position: relative; }
.login-password-field input { padding-right: 48px; }
.login-password-field .password-visibility { position: absolute; top: 1px; right: 1px; width: 44px; height: calc(100% - 2px); min-height: 42px; padding: 12px; border: 0; background: transparent !important; color: #aba5b7; box-shadow: none; }
.password-visibility svg { width: 19px; height: 19px; display: block; }
.login-form .login-submit { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; min-height: 46px; }
.login-card footer { border-top: 1px solid #36323c; margin-top: 28px; padding-top: 22px; text-align: center; color: #aaa3b5; font-size: 12px; }
.login-card footer a { margin-left: 4px; color: #d4c7ff; text-decoration: none; font-weight: 500; }
.login-credit { color: #87818f; font-size: 11px; margin: 24px 0 0; }
.account-page :focus-visible, .login-page :focus-visible, .settings-top-navigation :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
@media (max-width: 680px) {
.account-identity strong { max-width: 130px; }
.account-avatar { display: none; }
.account-panel { padding: 22px 20px; }
.account-request-summary { gap: 24px; flex-wrap: wrap; }
.account-request-summary a { width: 100%; margin: 0; }
.account-access-summary { flex-direction: column; gap: 6px; }
.account-access-summary time { text-align: left; }
.settings-top-navigation { gap: 14px; flex-wrap: wrap; }
.settings-top-navigation label { flex: 1; min-width: 220px; }
.settings-top-navigation select { flex: 1; width: 100%; }
.login-card { padding: 26px 24px; }
.page > main.login-page { padding: 24px 16px; }
}
-72
View File
@@ -1,72 +0,0 @@
'use client'
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
type Option = { value: string; label: string }
type Props = {
setting: AdminSetting
label: string
value: string
help?: string
placeholder?: string
boolean?: boolean
numeric?: boolean
multiline?: boolean
options?: Option[]
optionsUnavailable?: boolean
onChange: (value: string) => void
}
const SELECTS: Record<string, Option[]> = {
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })),
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({ value: String(index), label: index === 0 ? 'None — close when fixed' : String(index) })),
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }],
site_banner_tone: ['info', 'warning', 'error', 'maintenance'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
magent_notify_push_provider: ['ntfy', 'gotify', 'pushover', 'webhook', 'telegram', 'discord'].map((value) => ({ value, label: value })),
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
}
export default function SettingField(props: Props) {
const { setting, label, value, help, placeholder, onChange } = props
const id = `setting-${setting.key}`
const options = props.options ?? SELECTS[setting.key] ?? (setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ? SELECTS.log_level : undefined)
const selectedOptions = options && value && !options.some((option) => option.value === value)
? [{ value, label: `Current selection (${value})` }, ...options] : options
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time'
const zeroAllowed = setting.key === 'log_file_backup_count'
const minimum = zeroAllowed ? 0 : 1
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
if (props.boolean) {
return (
<div className="setting-field setting-switch">
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
</div>
)
}
return (
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}>
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
{props.optionsUnavailable ? (
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
) : selectedOptions ? (
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
{!value && <option value="">Choose an option</option>}
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : props.multiline ? (
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
) : (
<input {...aria} type={setting.sensitive ? 'password' : props.numeric ? 'number' : isTime ? 'time' : 'text'}
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined}
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false}
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder}
onChange={(event) => onChange(event.target.value)} />
)}
{help && <p id={`${id}-help`}>{help}</p>}
</div>
)
}
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
'use client'
import { useState, type ReactNode } from 'react'
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) {
const [open, setOpen] = useState(!collapsed)
return (
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}>
{collapsed && <button type="button" className="config-region-toggle" aria-expanded={open} aria-controls={`${id}-content`} onClick={() => setOpen(!open)}><strong>{title}</strong><span>{open ? 'Hide' : 'Configure'} <b aria-hidden="true">{open ? '' : '+'}</b></span></button>}
<div id={`${id}-content`} hidden={!open}>{children}</div>
</section>
)
}
+4 -11
View File
@@ -2,34 +2,27 @@ import { notFound } from 'next/navigation'
import SettingsPage from '../SettingsPage'
const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr',
'jellyfin',
'artwork',
'sonarr',
'radarr',
'bazarr',
'prowlarr',
'qbittorrent',
'requests',
'issue-workflow',
'cache',
'logs',
'maintenance',
'magent',
'general',
'notifications',
'site',
])
type PageProps = {
params: Promise<{ section: string }>
params: { section: string }
}
export default async function AdminSectionPage({ params }: PageProps) {
const { section } = await params
if (!ALLOWED_SECTIONS.has(section)) {
export default function AdminSectionPage({ params }: PageProps) {
if (!ALLOWED_SECTIONS.has(params.section)) {
notFound()
}
return <SettingsPage section={section} />
return <SettingsPage section={params.section} />
}
-106
View File
@@ -1,106 +0,0 @@
/* Settings workspace. Shared Stitch tokens, compact controls and clear regions. */
.config-directory { display: grid; gap: 32px; max-width: 1120px; }
.config-directory-region { display: grid; gap: 16px; }
.config-directory-region header h2 { margin: 0 0 4px; font-size: 18px; }
.config-directory-region header p { margin: 0; color: var(--ops-muted); font-size: 13px; }
.config-directory-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.config-directory-link { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); color: var(--ops-text); text-decoration: none; min-width: 0; transition: border-color .15s, background .15s; }
.config-directory-link:hover { border-color: var(--ops-primary-2); background: var(--ops-panel-2); }
.config-link-icon { flex: 0 0 34px; display: grid; place-items: center; height: 34px; border-radius: 8px; background: var(--ops-primary); color: var(--ops-primary-2); font: 11px "JetBrains Mono", monospace; }
.config-link-copy { display: grid; flex: 1; min-width: 0; gap: 4px; }
.config-link-copy strong { font-size: 14px; }
.config-link-copy small { font-size: 12px; font-weight: 400; color: var(--ops-muted); line-height: 1.5; }
.config-link-arrow { color: var(--ops-faint); }
.config-connection-badge { flex-shrink: 0; font: 11px "JetBrains Mono", monospace; color: var(--ops-muted); }
.config-connection-badge::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
.config-connection-badge.is-up { color: var(--ops-green); }
.config-connection-badge.is-down { color: var(--ops-red); }
.config-connection-badge.is-degraded { color: var(--ops-warn); }
.config-advanced-directory { border: 1px solid var(--ops-line); border-radius: 10px; padding: 18px; }
.config-advanced-directory > summary { cursor: pointer; color: var(--ops-text); }
.config-advanced-directory > summary > span { margin-left: 12px; color: var(--ops-muted); font-size: 12px; }
.config-advanced-directory[open] > summary { margin-bottom: 18px; }
.config-sidebar-home { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px 20px; font: 600 20px "DM Sans", sans-serif; text-decoration: none; color: var(--ops-primary-2); }
.config-sidebar-back { display: block; margin-top: 20px; padding: 12px 10px; color: var(--ops-muted); font-size: 12px; }
.config-desktop-navigation { display: grid; gap: 18px; }
.admin-sidebar .admin-nav-links a { font: 13px Inter, sans-serif; padding: 8px 10px; min-height: 34px; }
.admin-sidebar .admin-nav-title { font-size: 10px; }
.config-nav-advanced > summary { cursor: pointer; padding: 8px 10px; font-size: 12px; color: var(--ops-muted); }
.config-mobile-picker { display: none; }
.admin-card { min-width: 0; }
.admin-shell { grid-template-areas: "nav main"; }
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main rail"; }
.admin-shell--no-rail > .admin-card { width: 100%; max-width: 1280px; }
.admin-card .admin-header { margin-bottom: 24px; }
.admin-card .admin-header .lede { max-width: 720px; margin: 8px 0 0; font-size: 14px; }
.admin-card .admin-header .section-kicker { font-size: 10px; }
.config-service-status { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 20px; font-size: 12px; color: var(--ops-muted); }
.config-service-status button { margin-left: auto; }
.admin-form.admin-zone-stack { gap: 16px; }
.admin-form .config-subsection { padding: 22px !important; }
.config-subsection form { display: grid; gap: 18px; min-width: 0; }
.config-subsection .section-header { margin: 0; padding: 0; border: 0; align-items: center; }
.config-subsection .section-header h2 { font-size: 18px; padding: 0; }
.config-subsection .section-header h2::after { display: none; }
.config-subsection .section-subtitle { margin: -10px 0 0; font-size: 12px; line-height: 1.6; }
.config-subsection .admin-grid { gap: 20px 24px; }
.config-subsection .setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.config-subsection .setting-field > label, .config-subsection .setting-switch label { display: flex; align-items: center; gap: 10px; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0; background: none; color: var(--ops-text); font: 500 13px Inter, sans-serif; text-transform: none; letter-spacing: 0; }
.config-subsection .setting-field label small { color: var(--ops-green); font-size: 11px; font-weight: 400; }
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
.setting-switch > div { display: grid; gap: 6px; }
.config-subsection .setting-switch input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: 0 0 38px; width: 38px; height: 22px; min-height: 22px; padding: 2px; margin: 0; background: var(--ops-panel-3) !important; border: 1px solid var(--ops-line); border-radius: 20px !important; cursor: pointer; }
.config-subsection .setting-switch input[type=checkbox]::before { content: ''; display: block; width: 16px; height: 16px; background: var(--ops-muted); border-radius: 50%; transition: transform .15s; }
.config-subsection .setting-switch input[type=checkbox]:checked { background: var(--ops-primary-2) !important; border-color: var(--ops-primary-2) !important; }
.config-subsection .setting-switch input[type=checkbox]:checked::before { background: var(--ops-primary); transform: translateX(16px); }
.config-subsection .settings-section-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding-top: 16px; border-top: 1px solid var(--ops-line); }
.config-subsection .settings-inline-field { padding: 0; border: 0; background: none; min-height: 0; flex: 1 1 230px; max-width: 330px; }
.config-subsection .settings-inline-field span { font: 500 12px Inter, sans-serif; text-transform: none; }
.config-subsection button { font: 600 12px "DM Sans", "Segoe UI", sans-serif; min-height: 38px; }
.config-subsection .config-unsaved { margin-right: auto; font-size: 12px; color: var(--ops-warn); }
.config-subsection .config-region-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; background: transparent !important; padding: 0; color: var(--ops-text); text-align: left; box-shadow: none; }
.config-region-toggle strong { font-size: 15px; }
.config-region-toggle > span { font-size: 12px; color: var(--ops-muted); }
.config-region-toggle + div:not([hidden]) { margin-top: 18px; }
.config-subsection [hidden] { display: none !important; }
.config-region-toggle + div .config-subsection-heading { display: none; }
.config-inline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.config-inline-controls label { display: flex; align-items: center; gap: 8px; }
.config-inline-controls select { width: auto; }
.admin-card .maintenance-layout { grid-template-columns: 1fr; }
.admin-card .cache-table, .admin-card .log-viewer { overflow-x: auto; max-width: 100%; }
.admin-card .cache-row { min-width: 650px; }
.config-tool-link { padding: 16px 0; color: var(--ops-primary-2); font-size: 13px; }
@media (max-width: 1250px) {
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main"; }
}
@media (max-width: 1180px) {
.config-directory-links { grid-template-columns: 1fr; }
}
@media (max-width: 980px) {
.admin-shell-nav .admin-sidebar { display: block; padding: 12px 18px; }
.config-desktop-navigation { display: none; }
.config-mobile-picker { display: flex; align-items: center; gap: 16px; margin: 0; }
.config-mobile-picker > span { font: 500 12px Inter, sans-serif; color: var(--ops-muted); }
.config-mobile-picker select { flex: 1; width: 100%; min-width: 0; padding: 10px; font: 13px Inter, sans-serif; }
}
@media (max-width: 680px) {
.admin-form .admin-grid { grid-template-columns: 1fr; }
.admin-form .config-subsection { padding: 16px !important; }
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
.config-link-copy { flex-basis: calc(100% - 62px); }
.config-directory-link .config-connection-badge { margin-left: 46px; }
.config-link-arrow { display: none; }
.config-advanced-directory > summary > span { display: block; margin: 8px 0 0; }
.admin-card .admin-header { align-items: flex-start; gap: 14px; flex-direction: column; }
.config-subsection .section-header { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.config-subsection .section-subtitle { margin-top: 0; }
.config-subsection .settings-section-actions > button { flex-grow: 1; }
.config-subsection .settings-section-actions .config-unsaved { flex-basis: 100%; }
.config-service-status { gap: 10px; }
}
-34
View File
@@ -1,34 +0,0 @@
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
export const CONFIG_GROUPS: ConfigGroup[] = [
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr', description: 'Search sources', symbol: 'PR', service: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' },
]},
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options', symbol: '01' },
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates', symbol: '02' },
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
]},
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' },
{ href: '/admin/diagnostics', label: 'System health', description: 'Service checks and diagnostics' },
{ href: '/admin/logs', label: 'Logs', description: 'Recent activity and log settings' },
{ href: '/admin/cache', label: 'Request cache', description: 'Inspect saved request records' },
{ href: '/admin/artwork', label: 'Artwork cache', description: 'Poster storage and missing artwork' },
{ href: '/admin/maintenance', label: 'Recovery & cleanup', description: 'Database repair and history cleanup' },
]},
]
export const serviceStatusLabel = (status?: string) => ({
up: 'Connected', down: 'Unavailable', degraded: 'Needs attention', not_configured: 'Not set up',
}[status ?? ''] ?? 'Not checked')
-15
View File
@@ -1,15 +0,0 @@
'use client'
import AdminShell from '../../ui/AdminShell'
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
export default function AdminDiagnosticsPage() {
return (
<AdminShell
title="Diagnostics"
subtitle="Check connections and investigate service problems."
>
<AdminDiagnosticsPanel />
</AdminShell>
)
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
import PortalClient from '../../portal/PortalClient'
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
}
+13 -64
View File
@@ -1,77 +1,26 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
type ServiceState = { name: string; status: string }
export default function AdminLandingPage() {
const router = useRouter()
const [services, setServices] = useState<ServiceState[]>([])
const [ready, setReady] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) { router.replace('/login'); return }
try {
const response = await authFetch(`${getApiBase()}/auth/me`)
if (!response.ok) { router.replace('/login'); return }
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
if (!active) return
setReady(true)
const status = await authFetch(`${getApiBase()}/status/services`)
if (!status.ok) throw new Error('Status unavailable')
const data = await status.json()
if (active) setServices(Array.isArray(data.services) ? data.services : [])
} catch {
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
}
}
void load()
return () => { active = false }
}, [router])
return (
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings</p> : (
<div className="config-directory">
{error && <p className="error-banner" role="alert">{error}</p>}
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
<section className="config-directory-region" key={group.title}>
<header><h2>{group.title}</h2><p>{group.description}</p></header>
<div className="config-directory-links">
{group.items.map((item) => {
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
return (
<a href={item.href} key={item.href} className="config-directory-link">
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
<span className="config-link-arrow" aria-hidden="true"></span>
</a>
)
})}
</div>
</section>
))}
<details className="config-advanced-directory">
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary>
<div className="config-directory-links">
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => (
<a href={item.href} key={item.href} className="config-directory-link">
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
<span className="config-link-arrow" aria-hidden="true"></span>
</a>
))}
</div>
</details>
<AdminShell
title="Settings"
subtitle="Choose what you want to manage."
actions={
<button type="button" onClick={() => router.push('/')}>
Back to requests
</button>
}
>
<section className="admin-section">
<div className="status-banner">
Pick a section from the left. Each page explains what it does and how it helps.
</div>
)}
</section>
</AdminShell>
)
}
-6
View File
@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation'
export default function AdminProfilesRedirectPage() {
redirect('/admin/invites')
}
-200
View File
@@ -1,200 +0,0 @@
'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."
>
<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>
)
}
-299
View File
@@ -1,299 +0,0 @@
'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="System guide"
subtitle="Service connections, controls, and recovery paths."
rail={rail}
>
<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 request activity refreshes 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>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
</article>
</div>
</div>
</section>
</AdminShell>
)
}
+23 -54
View File
@@ -1,7 +1,5 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -10,42 +8,15 @@ 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
}
const parseChangelog = (raw: string) =>
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
export default function ChangelogPage() {
const router = useRouter()
const [groups, setGroups] = useState<ChangelogGroup[]>([])
const [entries, setEntries] = useState<string[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -69,11 +40,11 @@ export default function ChangelogPage() {
}
const data: SiteInfo = await response.json()
if (!active) return
setGroups(parseChangelog(data?.changelog ?? ''))
setEntries(parseChangelog(data?.changelog ?? ''))
} catch (err) {
console.error(err)
if (!active) return
setGroups([])
setEntries([])
} finally {
if (active) setLoading(false)
}
@@ -88,29 +59,27 @@ export default function ChangelogPage() {
if (loading) {
return <div className="loading-text">Loading changelog...</div>
}
if (groups.length === 0) {
if (entries.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>
<ul className="changelog-list">
{entries.map((entry, index) => (
<li key={`${entry}-${index}`}>{entry}</li>
))}
</div>
</ul>
)
}, [groups, loading])
}, [entries, loading])
return (
<main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content}
</main>
<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>
)
}
+20 -17
View File
@@ -1,10 +1,8 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type Profile = {
username?: string
@@ -26,17 +24,15 @@ export default function FeedbackPage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
throw new Error('Could not load profile.')
clearToken()
router.push('/login')
return
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
}
}
@@ -53,7 +49,7 @@ export default function FeedbackPage() {
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
const response = await authFetch(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -62,16 +58,17 @@ export default function FeedbackPage() {
}),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
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 {
@@ -80,10 +77,16 @@ export default function FeedbackPage() {
}
return (
<main className="card feedback-page">
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
<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="account-panel account-form feedback-form" onSubmit={submit}>
<form className="auth-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
-73
View File
@@ -1,73 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import AuthLayout from '../ui/AuthLayout'
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 (
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
<form className="account-form login-form auth-flow-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="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" className="account-primary" 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>
</AuthLayout>
)
}
+12 -5733
View File
File diff suppressed because it is too large Load Diff
+102 -140
View File
@@ -1,179 +1,141 @@
'use client'
import PageHeading from '../ui/PageHeading'
export default function HowItWorksPage() {
return (
<main className="card how-page">
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
<header className="how-hero">
<p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1>
<p className="lede">
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
your request is and what you can safely do next.
</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 className="how-grid">
<article className="how-card">
<h2>Jellyseerr</h2>
<p className="how-title">The request box</p>
<p>
This is where you ask for a movie or show. It keeps the request and whether it is
approved.
</p>
</article>
<article className="how-card">
<h2>Sonarr / Radarr</h2>
<p className="how-title">The library manager</p>
<p>
These add the request to the library list and decide what quality to look for.
</p>
</article>
<article className="how-card">
<h2>Prowlarr</h2>
<p className="how-title">The search helper</p>
<p>
This checks your search sources and reports back what it finds.
</p>
</article>
<article className="how-card">
<h2>qBittorrent</h2>
<p className="how-title">The downloader</p>
<p>
This downloads the file. Magent can tell if it is downloading, paused, or finished.
</p>
</article>
<article className="how-card">
<h2>Jellyfin</h2>
<p className="how-title">The place you watch</p>
<p>
When the file is ready, Jellyfin shows it in your library so you can watch it.
</p>
</article>
</section>
<section className="how-flow">
<h2>The request pipeline</h2>
<h2>The pipeline in plain English</h2>
<ol className="how-steps">
<li>
<strong>You request a movie or show</strong> through Seerr.
<strong>You request a title</strong> in Jellyseerr.
</li>
<li>
<strong>Magent picks up the request</strong> and shows its current state.
<strong>Sonarr/Radarr adds it</strong> to the library list.
</li>
<li>
<strong>The automation stack searches and downloads it</strong> if it can find a valid
release.
<strong>Prowlarr looks for sources</strong> and sends results back.
</li>
<li>
<strong>The file is imported into the library</strong>.
<strong>qBittorrent downloads</strong> the match.
</li>
<li>
<strong>Jellyfin serves it</strong> once it is ready to watch.
<strong>Sonarr/Radarr imports</strong> it into your library.
</li>
<li>
<strong>Jellyfin shows it</strong> when 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>
<h2>Steps and fixes (simple and visual)</h2>
<div className="how-step-grid">
<article className="how-step-card step-seerr">
<article className="how-step-card step-jellyseerr">
<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>
<h3>Request sent</h3>
<p className="step-note">Jellyseerr holds your request and approval.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Add to library queue (if it was approved but never added)</li>
</ul>
</article>
<article className="how-step-card step-qbit">
<article className="how-step-card step-arr">
<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>
<h3>Added to the library list</h3>
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Search for releases (see options)</li>
<li>Search and auto-download (let it pick for you)</li>
</ul>
</article>
<article className="how-step-card step-jellyfin">
<article className="how-step-card step-prowlarr">
<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>
<h3>Searching for sources</h3>
<p className="step-note">Prowlarr checks your torrent providers.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Search for releases (show a list to choose)</li>
</ul>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">4</div>
<h3>Downloading the file</h3>
<p className="step-note">qBittorrent downloads the selected match.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Resume download (only if it already exists there)</li>
</ul>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">5</div>
<h3>Ready to watch</h3>
<p className="step-note">Jellyfin shows it in your library.</p>
<div className="step-fix-title">What to do next</div>
<ul className="step-fix-list">
<li>Open in Jellyfin (watch it)</li>
</ul>
</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>
<h2>Why Magent sometimes says &quot;waiting&quot;</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.
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
That does not mean it is broken. It usually means the release is not available yet.
</p>
</section>
</main>
+24 -6
View File
@@ -1,11 +1,11 @@
import './globals.css'
import './ops-redesign.css'
import './admin/config.css'
import './account.css'
import './workspace.css'
import type { ReactNode } from 'react'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon'
import ApplicationChrome from './ui/ApplicationChrome'
import BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus'
export const metadata = {
title: 'Magent',
@@ -18,7 +18,25 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body>
<BrandingFavicon />
<div className="page">
<ApplicationChrome />
<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">Find and fix media requests fast.</div>
</div>
</a>
</div>
<div className="header-right">
<ThemeToggle />
<HeaderIdentity />
</div>
<div className="header-nav">
<HeaderActions />
</div>
</header>
<SiteStatus />
{children}
</div>
</body>
+10 -85
View File
@@ -1,100 +1,25 @@
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
if (typeof window === 'undefined') return null
return window.localStorage.getItem('magent_token')
}
export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
export const setToken = (token: string) => {
if (typeof window === 'undefined') return
window.localStorage.setItem('magent_token', token)
}
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',
})
window.localStorage.removeItem('magent_token')
}
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const token = getToken()
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}`)
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
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 ''
}
return fetch(input, { ...init, headers })
}
-48
View File
@@ -1,48 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
const USER_VIEW_EVENT = 'magent:user-view-change'
const readUserViewPreview = () => {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
}
const applyDocumentMode = (enabled: boolean) => {
if (typeof document === 'undefined') return
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
}
export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === 'undefined') return
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
}
applyDocumentMode(enabled)
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
}
export const useUserViewPreview = () => {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
const sync = () => {
const nextValue = readUserViewPreview()
applyDocumentMode(nextValue)
setEnabled(nextValue)
}
sync()
window.addEventListener(USER_VIEW_EVENT, sync)
window.addEventListener('storage', sync)
return () => {
window.removeEventListener(USER_VIEW_EVENT, sync)
window.removeEventListener('storage', sync)
}
}, [])
return enabled
}
+69 -84
View File
@@ -1,106 +1,91 @@
'use client'
import { useEffect, useState, type FormEvent } from 'react'
import { getApiBase, setToken } from '../lib/auth'
import AuthLayout from '../ui/AuthLayout'
type LoginMode = 'jellyfin' | 'local'
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
const DEFAULT_OPTIONS: LoginOptions = { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { getApiBase, setToken, clearToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [mode, setMode] = useState<LoginMode>('jellyfin')
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
const [optionsReady, setOptionsReady] = useState(false)
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
const [error, setError] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin'
useEffect(() => {
const controller = new AbortController()
const load = async () => {
try {
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal })
if (!response.ok) throw new Error('Options unavailable')
const data = await response.json()
if (controller.signal.aborted) return
setOptions({
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
showLocalLogin: data?.login?.showLocalLogin !== false,
showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== false,
})
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
}
} catch {
// Keep the normal sign-in methods available during a settings outage.
} finally {
if (!controller.signal.aborted) setOptionsReady(true)
}
}
void load()
return () => controller.abort()
}, [])
const submit = async (event: FormEvent<HTMLFormElement>) => {
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
event.preventDefault()
if (loading || !canSignIn || !optionsReady) return
setError('')
setError(null)
setLoading(true)
try {
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, {
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: new URLSearchParams({ username: username.trim(), password }),
credentials: 'include',
body,
})
if (!response.ok) {
setError(response.status === 429 ? 'Too many attempts. Please wait a moment and try again.'
: response.status >= 500 ? 'Sign-in is temporarily unavailable. Please try again shortly.'
: response.status === 403 ? 'This account cannot sign in. Please contact an administrator.'
: 'Check your username and password, then try again.')
return
throw new Error('Login failed')
}
const data = await response.json()
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie')
window.location.assign('/')
} catch {
setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) }
if (data?.access_token) {
setToken(data.access_token)
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)
}
}
return (
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></>
}>
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
</div>}
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
<form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
<label htmlFor="login-username">Username</label>
<input id="login-username" name="username" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" autoCapitalize="none" spellCheck={false} required disabled={loading} />
<div className="login-password-label"><label htmlFor="login-password">Password</label>{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}</div>
<div className="login-password-field">
<input id="login-password" name="password" type={showPassword ? 'text' : 'password'} value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required disabled={loading} />
<button type="button" className="password-visibility" aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} onClick={() => setShowPassword(!showPassword)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />{showPassword && <path d="m3 3 18 18" />}</svg>
</button>
</div>
{error && <p className="account-notice is-error" role="alert">{error}</p>}
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true"></span></button>
</form>
)}
</AuthLayout>
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
<label>
Username
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button>
</div>
<button
type="button"
className="ghost-button"
disabled={loading}
onClick={(event) => submit(event, 'local')}
>
Sign in with Magent account
</button>
</form>
</main>
)
}
@@ -1,519 +0,0 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type MediaType = 'movie' | 'tv'
type DiscoveryResult = {
title: string
year?: number | null
type: MediaType
tmdbId: number
requestId?: number | null
statusLabel?: string | null
overview?: string | null
posterPath?: string | null
backdropPath?: string | null
}
type RequestOptions = {
media: DiscoveryResult & {
seasons: Array<{
seasonNumber: number
name: string
episodeCount: number
airDate?: string | null
}>
existingRequestId?: number | null
}
destination: {
collector: 'Sonarr' | 'Radarr'
serverName: string
defaultProfileId: number
profiles: Array<{ id: number; name: string }>
}
}
type OperationEvent = {
id: string
service: string
state: 'active' | 'complete' | 'error'
message: string
duration_ms?: number | null
status_code?: number | null
}
type OperationProgress = {
status: 'running' | 'complete' | 'error'
duration_ms?: number | null
events: OperationEvent[]
}
const mediaChoices: Array<{
type: MediaType
eyebrow: string
title: string
description: string
collector: 'Radarr' | 'Sonarr'
icon: string
}> = [
{
type: 'movie',
eyebrow: 'Film',
title: 'Movie',
description: 'Find a film and send it through Seerr to Radarr.',
collector: 'Radarr',
icon: '/service-icons/radarr.svg',
},
{
type: 'tv',
eyebrow: 'Series',
title: 'TV show',
description: 'Choose a series, the seasons you want, and send it to Sonarr.',
collector: 'Sonarr',
icon: '/service-icons/sonarr.svg',
},
]
const artworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w342') => {
if (!path) return null
return `https://image.tmdb.org/t/p/${size}${path.startsWith('/') ? path : `/${path}`}`
}
const apiError = async (response: Response, fallback: string) => {
try {
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
} catch {
// The upstream response was not JSON. Use the friendly fallback below.
}
return fallback
}
const formatDuration = (milliseconds?: number | null) => {
if (milliseconds == null) return null
if (milliseconds < 1000) return `${Math.round(milliseconds)} ms`
return `${(milliseconds / 1000).toFixed(1)} s`
}
export default function NewRequestClient() {
const router = useRouter()
const searchSectionRef = useRef<HTMLElement | null>(null)
const resultsSectionRef = useRef<HTMLElement | null>(null)
const configureSectionRef = useRef<HTMLElement | null>(null)
const [mediaType, setMediaType] = useState<MediaType | null>(null)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [searchAttempted, setSearchAttempted] = useState(false)
const [results, setResults] = useState<DiscoveryResult[]>([])
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
const [options, setOptions] = useState<RequestOptions | null>(null)
const [loadingOptions, setLoadingOptions] = useState(false)
const [profileId, setProfileId] = useState<number | null>(null)
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
const [submitting, setSubmitting] = useState(false)
const [operation, setOperation] = useState<OperationProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => {
if (!getToken()) router.push('/login')
}, [router])
const resetAfterType = (nextType: MediaType) => {
setMediaType(nextType)
setQuery('')
setResults([])
setSearchAttempted(false)
setSelected(null)
setOptions(null)
setProfileId(null)
setSelectedSeasons([])
setOperation(null)
setError(null)
setSuccess(null)
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80)
}
const runSearch = async (event: React.FormEvent) => {
event.preventDefault()
if (!mediaType) return
const term = query.trim()
if (!term) {
setError('Enter a title to search for.')
return
}
setSearching(true)
setSearchAttempted(true)
setSelected(null)
setOptions(null)
setOperation(null)
setError(null)
setSuccess(null)
try {
const baseUrl = getApiBase()
const params = new URLSearchParams({ query: term, media_type: mediaType })
const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`))
const payload = await response.json()
const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
? payload.results
.filter((item: any) => item?.type === mediaType && Number(item?.tmdbId) > 0)
.map((item: any) => ({
title: String(item?.title || 'Untitled'),
year: typeof item?.year === 'number' ? item.year : null,
type: mediaType,
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
overview: typeof item?.overview === 'string' ? item.overview : null,
posterPath: item?.posterPath ?? null,
backdropPath: item?.backdropPath ?? null,
}))
: []
setResults(mapped)
window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
} catch (caught) {
setResults([])
setError(caught instanceof Error ? caught.message : 'Search is unavailable right now.')
} finally {
setSearching(false)
}
}
const selectResult = async (item: DiscoveryResult) => {
setSelected(item)
setOptions(null)
setProfileId(null)
setSelectedSeasons([])
setOperation(null)
setError(null)
setSuccess(null)
if (item.requestId) {
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
return
}
setLoadingOptions(true)
try {
const baseUrl = getApiBase()
const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) })
const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await apiError(response, `Could not load request options (${response.status}).`))
const payload = (await response.json()) as RequestOptions
const refreshedSelection: DiscoveryResult = {
...item,
title: payload.media.title || item.title,
year: payload.media.year ?? item.year,
overview: payload.media.overview || item.overview,
posterPath: payload.media.posterPath || item.posterPath,
backdropPath: payload.media.backdropPath || item.backdropPath,
requestId: payload.media.existingRequestId || item.requestId,
statusLabel: payload.media.existingRequestId ? 'Already requested' : item.statusLabel,
}
setSelected(refreshedSelection)
if (payload.media.existingRequestId) {
setResults((current) => current.map((result) => result.tmdbId === item.tmdbId && result.type === item.type
? refreshedSelection
: result))
return
}
setOptions(payload)
setProfileId(payload.destination.defaultProfileId)
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
} finally {
setLoadingOptions(false)
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
}
}
const pollOperation = async (operationId: string) => {
try {
const response = await authFetch(`${getApiBase()}/operations/${operationId}`)
if (response.ok) setOperation((await response.json()) as OperationProgress)
} catch {
// The request response remains authoritative if a progress poll is interrupted.
}
}
const submitRequest = async () => {
if (!selected || !options || !profileId) return
if (selected.type === 'tv' && selectedSeasons.length === 0) {
setError('Select at least one season.')
return
}
setSubmitting(true)
setError(null)
setSuccess(null)
const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`
setOperation({ status: 'running', events: [] })
const interval = window.setInterval(() => void pollOperation(operationId), 500)
try {
const response = await authFetch(`${getApiBase()}/requests/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Magent-Operation-ID': operationId,
'X-Magent-Operation-Label': `Requesting ${selected.title}`,
},
body: JSON.stringify({
mediaType: selected.type,
tmdbId: selected.tmdbId,
profileId,
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
}),
})
await pollOperation(operationId)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`))
const payload = await response.json()
const requestId = typeof payload?.requestId === 'number' ? payload.requestId : null
setSelected((current) => current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current)
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
: item))
setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.')
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
} finally {
window.clearInterval(interval)
await pollOperation(operationId)
setSubmitting(false)
}
}
const setEverySeason = (checked: boolean) => {
setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : [])
}
const selectedPoster = artworkUrl(selected?.posterPath, 'w185')
const currentFlowStep = success
? 5
: selected
? 4
: searchAttempted
? 3
: mediaType
? 2
: 1
return (
<main className="card request-portal-page">
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
<ol className="request-master-stepper" aria-label="New request progress">
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
const step = index + 1
return (
<li key={label} className={step === currentFlowStep ? 'is-active' : step < currentFlowStep ? 'is-complete' : ''}>
<span>{step < currentFlowStep ? '✓' : step}</span>
<strong>{label}</strong>
</li>
)
})}
</ol>
{error && <div className="error-banner request-flow-alert">{error}</div>}
{success && <div className="status-banner request-flow-alert">{success}</div>}
<section className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">01</span>
<div><span>Start here</span><h2>What are you looking for?</h2></div>
</div>
<div className="request-type-grid">
{mediaChoices.map((choice) => (
<button
key={choice.type}
type="button"
className={`request-type-card ${mediaType === choice.type ? 'is-selected' : ''}`}
onClick={() => resetAfterType(choice.type)}
aria-pressed={mediaType === choice.type}
>
<span className="request-type-card-body">
<span className="request-service-icon">
<img src={choice.icon} alt={`${choice.collector} logo`} />
</span>
<span className="request-type-card-copy">
<span>{choice.eyebrow}</span>
<strong>{choice.title}</strong>
<span className="request-type-description">{choice.description}</span>
<b>{mediaType === choice.type ? 'Selected' : `Choose ${choice.title.toLowerCase()}`}</b>
</span>
</span>
</button>
))}
</div>
</section>
{mediaType && (
<section ref={searchSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">02</span>
<div><span>{mediaType === 'tv' ? 'TV show selected' : 'Movie selected'}</span><h2>Search for the title</h2></div>
</div>
<form className="request-flow-search" onSubmit={runSearch}>
<label htmlFor="request-title-search">Title</label>
<div>
<input
id="request-title-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mediaType === 'tv' ? 'Search TV shows' : 'Search movies'}
autoComplete="off"
/>
<button type="submit" disabled={searching}>{searching ? 'Searching…' : 'Search Seerr'}</button>
</div>
</form>
</section>
)}
{mediaType && searchAttempted && !searching && (
<section ref={resultsSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">03</span>
<div><span>Search results</span><h2>{results.length ? 'Select the right title' : 'No matches found'}</h2></div>
</div>
{results.length === 0 ? (
<div className="request-flow-empty">
<strong>Nothing matched {query.trim()}.</strong>
<p>Check the spelling or try a shorter title.</p>
</div>
) : (
<div className="request-result-grid">
{results.map((item) => {
const poster = artworkUrl(item.posterPath)
const isSelected = selected?.tmdbId === item.tmdbId && selected.type === item.type
return (
<button
key={`${item.type}:${item.tmdbId}`}
type="button"
className={`request-result-card ${isSelected ? 'is-selected' : ''}`}
onClick={() => void selectResult(item)}
>
<span className="request-result-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span className="request-result-copy">
<small>{item.type === 'tv' ? 'TV show' : 'Movie'}{item.year ? ` · ${item.year}` : ''}</small>
<strong>{item.title}</strong>
<p>{item.overview || 'Select this title to view the available request options.'}</p>
<b>{item.requestId ? item.statusLabel || 'Already requested' : isSelected ? 'Selected' : 'Select title'}</b>
</span>
</button>
)
})}
</div>
)}
</section>
)}
{selected && (
<section ref={configureSectionRef} className="request-flow-stage is-current request-configure-stage">
<div className="request-flow-heading">
<span className="request-flow-number">04</span>
<div><span>Final step</span><h2>{selected.requestId ? 'This title is already in the pipeline' : 'Configure your request'}</h2></div>
</div>
<div className="request-selection-summary">
<span className="request-selection-poster">
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
</span>
<div>
<small>{selected.type === 'tv' ? 'TV show' : 'Movie'}{selected.year ? ` · ${selected.year}` : ''}</small>
<h3>{selected.title}</h3>
<p>{selected.overview || 'Ready to configure.'}</p>
</div>
</div>
{selected.requestId ? (
<div className="request-existing-state">
<div><span>Current status</span><strong>{selected.statusLabel || 'Already requested'}</strong><p>Request #{selected.requestId} is already being tracked by Magent.</p></div>
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Open request</button>
</div>
) : loadingOptions ? (
<div className="request-flow-empty"><strong>Checking Seerr and {selected.type === 'tv' ? 'Sonarr' : 'Radarr'}</strong><p>Loading valid profiles and request choices.</p></div>
) : options ? (
<div className="request-options-layout">
{selected.type === 'tv' && (
<fieldset className="request-season-picker">
<legend>Which seasons?</legend>
<div className="request-season-actions">
<button type="button" onClick={() => setEverySeason(true)}>Select all</button>
<button type="button" onClick={() => setEverySeason(false)}>Clear</button>
</div>
<div className="request-season-grid">
{options.media.seasons.map((season) => (
<label key={season.seasonNumber} className={selectedSeasons.includes(season.seasonNumber) ? 'is-selected' : ''}>
<input
type="checkbox"
checked={selectedSeasons.includes(season.seasonNumber)}
onChange={(event) => setSelectedSeasons((current) => event.target.checked
? [...current, season.seasonNumber].sort((a, b) => a - b)
: current.filter((value) => value !== season.seasonNumber))}
/>
<span><strong>{season.name}</strong><small>{season.episodeCount} episode{season.episodeCount === 1 ? '' : 's'}</small></span>
</label>
))}
</div>
</fieldset>
)}
<label className="request-profile-field">
<span>Quality profile</span>
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
</label>
<div className="request-submit-bar">
<div><span>Delivery route</span><strong>Seerr {options.destination.collector} Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (selected.type === 'tv' && selectedSeasons.length === 0)}>
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
</button>
</div>
</div>
) : null}
{operation && (
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
<div>
{operation.events.map((event) => (
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
))}
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request</span></p>}
</div>
</div>
)}
{success && selected.requestId && (
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
)}
</section>
)}
</main>
)
}
-9
View File
@@ -1,9 +0,0 @@
import NewRequestClient from './NewRequestClient'
export const metadata = {
title: 'New Requests | Magent',
}
export default function NewRequestsPage() {
return <NewRequestClient />
}
-11
View File
@@ -1,11 +0,0 @@
import Link from 'next/link'
import PageHeading from './ui/PageHeading'
export default function NotFound() {
return (
<main className="card">
<PageHeading title="Page not found" description="This link may have moved or no longer be available." />
<p><Link href="/"> Back to my requests</Link></p>
</main>
)
}
File diff suppressed because it is too large Load Diff
+243 -278
View File
@@ -1,40 +1,8 @@
'use client'
import PageHeading from './ui/PageHeading'
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,
type: item.type,
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' },
]
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
export default function HomePage() {
const router = useRouter()
@@ -44,30 +12,24 @@ export default function HomePage() {
id: number
title: string
year?: number
type?: string
statusLabel?: string
artwork?: { poster_url?: string; backdrop_url?: string }
createdAt?: string | null
artwork?: { poster_url?: string }
}[]
>([])
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
}[]
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[]
>([])
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 submit = (event: React.FormEvent) => {
event.preventDefault()
@@ -104,14 +66,9 @@ export default function HomePage() {
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()}`)
const response = await authFetch(
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
@@ -122,7 +79,24 @@ export default function HomePage() {
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
setRecent(
data.results
.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,
}
})
)
}
} catch (error) {
console.error(error)
@@ -133,69 +107,40 @@ export default function HomePage() {
}
load()
}, [recentDays, recentStage])
}, [recentDays])
useEffect(() => {
if (!authReady) {
return
}
if (!getToken()) {
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
const load = async () => {
setServicesLoading(true)
setServicesError(null)
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.onmessage = (event) => {
if (closed) return
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
}
} catch (error) {
console.error(error)
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) {
if (closed) return
console.error(error)
setServicesError('Service status is not available right now.')
} finally {
setServicesLoading(false)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [authReady, recentDays, recentStage])
load()
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, router])
const runSearch = async (term: string) => {
try {
@@ -212,16 +157,14 @@ export default function HomePage() {
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),
}))
)
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
}))
)
setSearchError(null)
}
} catch (error) {
@@ -236,172 +179,194 @@ export default function HomePage() {
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 activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
const requestCardState = (value?: string) => {
const label = String(value ?? '').toLowerCase()
if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 }
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
}
return (
<main className="card home-page">
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
<main className="card">
<div className="layout-grid">
<section className="recent centerpiece">
<div className="system-status">
<div className="system-header">
<h2>System status</h2>
<span
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</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>
</div>
)}
</div>
{authReady && (
<div className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
<button
type="button"
key={option.value}
className={recentStage === option.value ? 'is-active' : undefined}
onClick={() => setRecentStage(option.value)}
>
{option.value === 'working' ? <i aria-hidden="true" /> : null}
{option.value === 'all' ? 'All' : option.label}
</button>
))}
</div>
)}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-meta">
{item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
{servicesLoading
? 'Checking services...'
: servicesError
? 'Status not available yet'
: servicesStatus?.overall === 'up'
? 'Services are up and running'
: servicesStatus?.overall === 'down'
? 'Something is down'
: 'Some services need attention'}
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
</div>
<div className="system-list">
{(() => {
const order = [
'Jellyseerr',
'Sonarr',
'Radarr',
'Prowlarr',
'qBittorrent',
'Jellyfin',
]
const items = servicesStatus?.services ?? []
return order.map((name) => {
const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown'
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<span className="system-name">{name}</span>
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
</div>
)
})
})()}
</div>
</div>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<label className="recent-filter">
<span>Show last</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<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>
)}
</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}
</span>
</span>
</button>
))
)}
</div>
</section>
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Find my request</h1>
<p className="lede">
Search by title + year, paste a request number, or pick from your recent requests.
</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}`
: ''}
</button>
))
)}
</div>
</section>
</section>
</aside>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
import PortalClient from '../PortalClient'
export default function IssuePortalPage() {
return <PortalClient workspace="issue" />
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function PortalIndexPage() {
redirect('/new-requests')
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function RequestPortalPage() {
redirect('/new-requests')
}
-304
View File
@@ -1,304 +0,0 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
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_usable?: boolean; created_at?: string | null
}
type OwnedInvitesResponse = {
invites?: OwnedInvite[]
invite_access?: { enabled?: boolean; managed_by_master?: boolean }
master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null
}
type InviteForm = {
code: string; label: string; description: string; recipient_email: string
enabled: boolean; message: string
}
type DeliveryMethod = '' | 'manual' | 'email'
const defaultInviteForm = (): InviteForm => ({
code: '', label: '', description: '', recipient_email: '', enabled: true, message: '',
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
return Number.isNaN(date.valueOf()) ? value : 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 [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [flowStep, setFlowStep] = useState(1)
const [useCustomCode, setUseCustomCode] = useState(false)
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>('')
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null)
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
const loadInvites = async () => {
const response = await authFetch(`${getApiBase()}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Could not load your invite workspace.')
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data.invite_access?.enabled))
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master))
setMasterInvite(data.master_invite ?? null)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`)
if (!profileResponse.ok) {
if (profileResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Could not load your profile.')
}
const profileData = await profileResponse.json()
setProfile(profileData?.user ?? null)
await loadInvites()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
const resetFlow = () => {
setEditingId(null)
setFlowStep(1)
setUseCustomCode(false)
setDeliveryMethod('')
setInviteForm(defaultInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setEditingId(invite.id)
setCreatedInvite(null)
setFlowStep(4)
setUseCustomCode(true)
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
setInviteForm({
code: invite.code,
label: invite.label ?? '',
description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
enabled: invite.enabled !== false,
message: '',
})
setError(null)
setStatus(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
const inviteName = inviteForm.label.trim()
const recipientEmail = inviteForm.recipient_email.trim()
if (!inviteName) {
setError('Give this invite a name so you can recognise it later.')
return
}
if (!deliveryMethod) {
setError('Choose how you want to deliver the invite.')
return
}
if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
setError('Enter a valid recipient email address.')
return
}
setSaving(true)
setError(null)
setStatus(null)
try {
const response = await authFetch(
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`,
{
method: editingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: useCustomCode ? inviteForm.code || null : null,
label: inviteName,
description: inviteForm.description || null,
recipient_email: deliveryMethod === 'email' ? recipientEmail : null,
enabled: inviteForm.enabled,
send_email: editingId == null && deliveryMethod === 'email',
message: inviteForm.message || null,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error((await response.text()) || 'Could not save the invite.')
}
const data = await response.json()
const savedInvite = data?.invite as OwnedInvite | undefined
setStatus(
data?.email?.status === 'ok'
? `Invite created and emailed to ${data.email.recipient_email}.`
: data?.email?.status === 'error'
? `Invite created, but the email could not be sent: ${data.email.detail}`
: editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.'
)
resetFlow()
if (editingId == null && savedInvite) setCreatedInvite(savedInvite)
await loadInvites()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save the invite.')
} finally {
setSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return
setError(null)
try {
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' })
if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.')
if (editingId === invite.id) resetFlow()
setStatus(`Deleted ${invite.label || invite.code}.`)
await loadInvites()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not delete the invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
await navigator.clipboard.writeText(url)
setStatus(`Copied the link for ${invite.label || invite.code}.`)
} catch {
window.prompt('Copy invite link', url)
}
}
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6))
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : ''
if (loading) return <main className="card">Loading invite workspace</main>
return (
<main className="card invites-page">
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{!canManageInvites ? (
<section className="profile-section profile-tab-panel">
<h2>Invites are not enabled for your account</h2>
<p className="lede">Ask an administrator if you need permission to invite someone.</p>
</section>
) : (
<section className="profile-section profile-invites-section profile-tab-panel">
<div className="invite-flow-heading">
<div><span className="eyebrow">Invite flow</span><h2>{editingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2><p className="lede">Set up the invite one decision at a time.</p></div>
{editingId != null && <button type="button" className="ghost-button" onClick={resetFlow}>Cancel edit</button>}
</div>
{createdInvite && editingId == null ? (
<div className="invite-created-card" role="status">
<span className="eyebrow">Invite ready</span><h3>{createdInvite.label || 'Your invite'}</h3>
<p>{createdInvite.recipient_email ? `The invite was emailed to ${createdInvite.recipient_email}.` : 'Copy this link and send it to the person you are inviting.'}</p>
<div className="invite-created-link"><input value={createdInviteUrl} readOnly aria-label="Created invite link" /><button type="button" onClick={() => void copyInviteLink(createdInvite)}>Copy link</button></div>
<button type="button" className="ghost-button" onClick={() => { setCreatedInvite(null); resetFlow() }}>Create another invite</button>
</div>
) : (
<form onSubmit={saveInvite} className="invite-flow-form">
<ol className="invite-flow-route" aria-label="Invite creation progress">
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
const step = index + 1
return <li key={label} className={step === flowStep ? 'is-active' : step < flowStep ? 'is-complete' : ''}><span>{String(step).padStart(2, '0')}</span><strong>{label}</strong></li>
})}
</ol>
<section className={`invite-flow-step ${flowStep > 1 ? 'is-complete' : 'is-active'}`}>
<header><span className="invite-flow-number">01</span><div><span className="eyebrow">Identity</span><h3>Who is this invite for?</h3><p>Give it a name that will make sense when you return later.</p></div></header>
<div className="invite-flow-fields">
<label><span>Invite name</span><input value={inviteForm.label} onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))} placeholder="Family, that guy from work, the neighbour" /></label>
<label className="invite-flow-choice-line"><input type="checkbox" checked={useCustomCode} disabled={editingId != null} onChange={(event) => { setUseCustomCode(event.target.checked); if (!event.target.checked) setInviteForm((current) => ({ ...current, code: '' })) }} /><span><strong>Choose a custom invite code</strong><small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small></span></label>
{useCustomCode && <label><span>Custom code</span><input value={inviteForm.code} disabled={editingId != null} onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))} placeholder="At least 6 letters or numbers" /><small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small></label>}
{flowStep === 1 && <div className="invite-flow-actions"><button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>Continue to description</button></div>}
</div>
</section>
{flowStep >= 2 && <section className={`invite-flow-step ${flowStep > 2 ? 'is-complete' : 'is-active'}`}>
<header><span className="invite-flow-number">02</span><div><span className="eyebrow">Description</span><h3>Add a welcome note</h3><p>This optional message is shown on the sign-up page.</p></div></header>
<div className="invite-flow-fields"><label><span>Welcome note (optional)</span><textarea rows={3} value={inviteForm.description} onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))} placeholder="Welcome to Grizzlyflix. Use this link to create your account." /></label>{flowStep === 2 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(1)}>Back</button><button type="button" className="ghost-button" onClick={() => { setInviteForm((current) => ({ ...current, description: '' })); setFlowStep(3) }}>Skip</button><button type="button" onClick={() => setFlowStep(3)}>Continue</button></div>}</div>
</section>}
{flowStep >= 3 && <section className={`invite-flow-step ${flowStep > 3 ? 'is-complete' : 'is-active'}`}>
<header><span className="invite-flow-number">03</span><div><span className="eyebrow">Access</span><h3>Account access is applied automatically</h3><p>Magent uses the safe invite policy configured by an administrator.</p></div></header>
<div className="invite-flow-fields"><div className="invite-policy-note"><strong>Standard user access</strong><span>{inviteManagedByMaster && masterInvite ? `Using the “${masterInvite.label || masterInvite.code}” invite policy. Usage and expiry limits are automatic.` : 'This invite creates a standard user account using your configured defaults.'}</span></div>{flowStep === 3 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(2)}>Back</button><button type="button" onClick={() => setFlowStep(4)}>Continue to delivery</button></div>}</div>
</section>}
{flowStep >= 4 && <section className="invite-flow-step is-active">
<header><span className="invite-flow-number">04</span><div><span className="eyebrow">Delivery</span><h3>How will they receive it?</h3><p>Copy the link yourself, or let Magent email it directly.</p></div></header>
<div className="invite-flow-fields">
<div className="invite-delivery-grid"><button type="button" className={deliveryMethod === 'manual' ? 'is-selected' : ''} onClick={() => { setDeliveryMethod('manual'); setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }}><span className="eyebrow">Manual</span><strong>Give me a link</strong><small>Magent creates the URL. You copy and share it yourself.</small></button><button type="button" className={deliveryMethod === 'email' ? 'is-selected' : ''} onClick={() => setDeliveryMethod('email')}><span className="eyebrow">Email</span><strong>Send it for me</strong><small>Magent emails the invitation and still gives you a copyable URL.</small></button></div>
{deliveryMethod === 'manual' && <div className="invite-delivery-summary"><strong>Your link will appear as soon as the invite is created.</strong><span>No email address is required and Magent will not send a message.</span></div>}
{deliveryMethod === 'email' && <div className="invite-flow-field-grid"><label><span>Recipient email</span><input type="email" value={inviteForm.recipient_email} onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))} placeholder="person@example.com" /></label><label><span>Email note (optional)</span><textarea rows={3} value={inviteForm.message} onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))} placeholder="A short personal message" /></label></div>}
{editingId != null && <label className="invite-status-control"><input type="checkbox" checked={inviteForm.enabled} onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))} /><span><strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong><small>Disable this existing invite to stop its link from accepting sign-ups.</small></span></label>}
<div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(3)}>Back</button><button type="submit" disabled={saving || !deliveryMethod || (deliveryMethod === 'email' && !isValidEmail(inviteForm.recipient_email))}>{saving ? 'Saving…' : editingId != null ? 'Save invite' : deliveryMethod === 'email' ? 'Create and email invite' : 'Create invite link'}</button></div>
</div>
</section>}
</form>
)}
<div className="profile-invites-list">
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
{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"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</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={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
</div>
</section>
)}
</main>
)
}
+210 -187
View File
@@ -1,234 +1,257 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = {
username: string
email?: string | null
role: string
auth_provider: string
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?: { total: number; ready: number; in_progress: number }
activity?: { recent: ActivityEntry[] }
stats: ProfileStats
activity: ProfileActivity
}
type Notice = { tone: 'status' | 'error'; message: string } | null
type ProfileTab = 'overview' | 'security' | 'activity'
const TABS: { key: ProfileTab; label: string }[] = [
{ key: 'overview', label: 'Account' },
{ key: 'security', label: 'Security' },
{ key: 'activity', label: 'Activity' },
]
const normalizeTab = (value: string | null): ProfileTab => value === 'security' || value === 'activity' ? value : 'overview'
const formatDate = (value?: string) => {
if (!value) return 'Not recorded'
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const deviceName = (agent: string) => {
const value = (agent || '').toLowerCase()
const browser = value.includes('edg/') ? 'Edge' : value.includes('firefox/') || value.includes('fxios/') ? 'Firefox'
: value.includes('chrome/') || value.includes('crios/') ? 'Chrome' : value.includes('safari/') ? 'Safari' : 'Browser'
const device = /iphone|ipad/.test(value) ? 'iOS' : value.includes('android') ? 'Android'
: value.includes('windows') ? 'Windows' : value.includes('macintosh') ? 'Mac' : value.includes('linux') ? 'Linux' : ''
return device ? `${browser} on ${device}` : browser
}
const responseMessage = async (response: Response, fallback: string) => {
const data = await response.json().catch(() => null)
return typeof data?.detail === 'string' && data.detail.trim() ? data.detail : fallback
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 [data, setData] = useState<ProfileResponse | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
const [email, setEmail] = useState('')
const [emailSaving, setEmailSaving] = useState(false)
const [emailNotice, setEmailNotice] = useState<Notice>(null)
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 [passwordSaving, setPasswordSaving] = useState(false)
const [passwordNotice, setPasswordNotice] = useState<Notice>(null)
const [showAllActivity, setShowAllActivity] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const loadProfile = useCallback(async () => {
if (!getToken()) { router.replace('/login'); return }
setLoading(true)
setLoadError('')
try {
const response = await authFetch(`${getApiBase()}/auth/profile`)
if (response.status === 401) { clearToken(); router.replace('/login'); return }
if (!response.ok) throw new Error('Could not load your profile. Please try again.')
const profile = await response.json() as ProfileResponse
setData(profile)
setEmail(profile.user.email ?? '')
} catch {
setLoadError('Could not load your profile. Please try again.')
} finally {
setLoading(false)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
const user = data?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
useEffect(() => { void loadProfile() }, [loadProfile])
useEffect(() => {
const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get('tab')))
syncTab()
window.addEventListener('popstate', syncTab)
return () => window.removeEventListener('popstate', syncTab)
}, [])
const selectTab = (tab: ProfileTab) => {
setActiveTab(tab)
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`, { scroll: false })
}
const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index
if (event.key === 'ArrowRight') next = (index + 1) % TABS.length
else if (event.key === 'ArrowLeft') next = (index + TABS.length - 1) % TABS.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = TABS.length - 1
else return
const submit = async (event: React.FormEvent) => {
event.preventDefault()
selectTab(TABS[next].key)
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus()
}
const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (emailSaving) return
setEmailSaving(true)
setEmailNotice(null)
try {
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() || null }),
})
if (response.status === 401) { clearToken(); router.replace('/login'); return }
if (!response.ok) throw new Error(await responseMessage(response, 'Could not save your email. Please try again.'))
const result = await response.json()
const saved = typeof result.email === 'string' ? result.email : ''
setData((current) => current ? { ...current, user: { ...current.user, email: saved || null } } : current)
setEmail(saved)
setEmailNotice({ tone: 'status', message: saved ? 'Email saved.' : 'Email removed.' })
} catch (error) {
setEmailNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not save your email.' })
} finally { setEmailSaving(false) }
}
const savePassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (passwordSaving) return
setPasswordNotice(null)
if (newPassword.trim().length < 8) {
setPasswordNotice({ tone: 'error', message: 'Use at least 8 characters for your new password.' })
setStatus(null)
if (!currentPassword || !newPassword) {
setStatus('Enter your current password and a new password.')
return
}
if (newPassword !== confirmPassword) {
setPasswordNotice({ tone: 'error', message: 'The new passwords do not match.' })
return
}
setPasswordSaving(true)
try {
const response = await authFetch(`${getApiBase()}/auth/password`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
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) throw new Error(await responseMessage(response, 'Could not change your password. Please try again.'))
const result = await response.json()
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
setPasswordNotice({ tone: 'status', message: result.provider === 'jellyfin'
? 'Password updated for Grizzlyflix and Magent. Seerr uses the same password.'
: 'Password updated.' })
} catch (error) {
setPasswordNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not change your password.' })
} finally { setPasswordSaving(false) }
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setCurrentPassword('')
setNewPassword('')
setStatus('Password updated.')
} catch (err) {
console.error(err)
setStatus('Could not update password. Check your current password.')
}
}
const user = data?.user
const passwordProvider = user?.password_provider ?? (user?.auth_provider === 'jellyfin' ? 'jellyfin' : 'local')
const canChangePassword = user?.password_change_supported ?? ['local', 'jellyfin'].includes(user?.auth_provider ?? '')
const emailChanged = email.trim() !== (user?.email ?? '')
const recent = data?.activity?.recent ?? []
const notice = (value: Notice) => value && <p className={`account-notice is-${value.tone}`} role={value.tone === 'error' ? 'alert' : 'status'}>{value.message}</p>
if (loading) {
return <main className="card">Loading profile...</main>
}
return (
<main className="account-page">
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={
user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>
} />
{loading ? <p className="account-empty" role="status">Loading your profile</p> : loadError ? (
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
) : user && <>
<div className="account-tabs" role="tablist" aria-label="Profile sections">
{TABS.map((tab, index) => <button key={tab.key} id={`profile-tab-${tab.key}`} type="button" role="tab"
aria-selected={activeTab === tab.key} aria-controls={`profile-panel-${tab.key}`} tabIndex={activeTab === tab.key ? 0 : -1}
onKeyDown={(event) => tabKeyDown(event, index)} onClick={() => selectTab(tab.key)}>{tab.label}</button>)}
<main className="card">
<h1>My profile</h1>
{profile && (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
{profile.auth_provider}.
</div>
<section className="account-panel" id="profile-panel-overview" role="tabpanel" aria-labelledby="profile-tab-overview" hidden={activeTab !== 'overview'}>
<div className="account-section-intro"><h2>Contact email</h2><p>For password recovery and updates on your reported issues.</p></div>
<form className="account-form" onSubmit={saveEmail}>
<label htmlFor="profile-email">Email address</label>
<input id="profile-email" name="email" type="email" autoComplete="email" placeholder="you@example.com" value={email} disabled={emailSaving}
onChange={(event) => { setEmail(event.target.value); setEmailNotice(null) }} />
{!user.email && <p className="account-hint">Add an email so we can let you know when a fix is ready.</p>}
{user.email && !email.trim() && <p className="account-hint">Saving without an email stops account and issue emails.</p>}
{notice(emailNotice)}
<div className="account-form-actions">
<button type="submit" className="account-primary" disabled={emailSaving || !emailChanged}>{emailSaving ? 'Saving…' : 'Save email'}</button>
{emailChanged && <button type="button" className="account-secondary" disabled={emailSaving} onClick={() => { setEmail(user.email ?? ''); setEmailNotice(null) }}>Discard</button>}
)}
<div className="profile-grid">
<section className="profile-section">
<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>
</form>
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></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">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>
{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>
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
<div className="account-section-intro"><h2>Change password</h2><p>{passwordProvider === 'jellyfin' ? 'One password for Grizzlyflix, Seerr and Magent.' : 'Keep your Magent account secure.'}</p></div>
{canChangePassword ? <form className="account-form" onSubmit={savePassword}>
<fieldset disabled={passwordSaving}>
<label htmlFor="profile-current-password">Current password</label>
<input id="profile-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required />
<label htmlFor="profile-new-password">New password</label>
<input id="profile-new-password" type="password" autoComplete="new-password" aria-describedby="password-length" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={8} required />
<p id="password-length" className="account-hint">At least 8 characters.</p>
<label htmlFor="profile-confirm-password">Confirm new password</label>
<input id="profile-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} minLength={8} required />
</fieldset>
{notice(passwordNotice)}
<div className="account-form-actions"><button type="submit" className="account-primary" disabled={passwordSaving}>{passwordSaving ? 'Updating…' : 'Update password'}</button></div>
</form> : <p className="account-empty">Password changes are managed by your sign-in provider. Contact an administrator for help.</p>}
<section className="profile-section">
<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">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>
<section className="account-panel" id="profile-panel-activity" role="tabpanel" aria-labelledby="profile-tab-activity" hidden={activeTab !== 'activity'}>
<div className="account-section-intro"><h2>Your activity</h2><p>Your requests and recent account access.</p></div>
{data?.stats && <div className="account-request-summary"><div><strong>{data.stats.total}</strong><span>Requests</span></div><div><strong>{data.stats.ready}</strong><span>Ready to watch</span></div><a href="/">View my requests <span aria-hidden="true"></span></a></div>}
<h3 className="account-list-heading">Recent account access</h3>
{recent.length ? <ul className="account-access-list">
{(showAllActivity ? recent : recent.slice(0, 5)).map((entry, index) => <li key={`${entry.ip}-${entry.last_seen_at}-${index}`}>
<div className="account-access-summary"><strong>{deviceName(entry.user_agent)}</strong><time dateTime={entry.last_seen_at}>{formatDate(entry.last_seen_at)}</time></div>
<details><summary>Connection details</summary><dl><div><dt>IP address</dt><dd>{entry.ip || 'Not recorded'}</dd></div><div><dt>First seen</dt><dd>{formatDate(entry.first_seen_at)}</dd></div></dl></details>
</li>)}
</ul> : <p className="account-empty">No recent activity yet.</p>}
{recent.length > 5 && <button className="account-secondary" type="button" onClick={() => setShowAllActivity(!showAllActivity)}>{showAllActivity ? 'Show less' : 'Show all activity'}</button>}
</section>
</>}
</div>
{profile?.auth_provider !== 'local' ? (
<div className="status-banner">
Password changes are only available for local Magent accounts.
</div>
) : (
<form onSubmit={submit} className="auth-form">
<label>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Update password</button>
</div>
</form>
)}
</main>
)
}
File diff suppressed because it is too large Load Diff
-153
View File
@@ -1,153 +0,0 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import AuthLayout from '../ui/AuthLayout'
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 (
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
<form className="account-form login-form auth-flow-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="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" className="account-primary" 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>
</AuthLayout>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<AuthLayout title="Reset password" description="Choose a new password for your account."><p role="status">Checking your reset link</p></AuthLayout>}>
<ResetPasswordPageContent />
</Suspense>
)
}
-221
View File
@@ -1,221 +0,0 @@
'use client'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import AuthLayout from '../ui/AuthLayout'
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 (
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<form onSubmit={submit} className="account-form login-form auth-flow-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 ? 'Ready' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<details className="auth-invite-details"><summary>Invite details</summary><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></details>
</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="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" className="account-primary" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</AuthLayout>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."><p role="status">Loading sign-up</p></AuthLayout>}>
<SignupPageContent />
</Suspense>
)
}
-517
View File
@@ -1,517 +0,0 @@
'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>
)
}
+13 -8
View File
@@ -1,25 +1,30 @@
'use client'
import type { ReactNode } from 'react'
import SettingsNavigation from './SettingsNavigation'
import PageHeading from './PageHeading'
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) {
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
return (
<div className="admin-shell admin-shell--top-nav">
<SettingsNavigation />
<div className="admin-shell">
<aside className="admin-shell-nav">
<AdminSidebar />
</aside>
<main className="card admin-card">
<PageHeading title={title} description={subtitle} actions={actions} />
<div className="admin-header">
<div>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
{actions}
</div>
{children}
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
</main>
</div>
)
+60
View File
@@ -0,0 +1,60 @@
'use client'
import { usePathname } from 'next/navigation'
const NAV_GROUPS = [
{
title: 'Services',
items: [
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ 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 syncing' },
{ href: '/admin/artwork', label: 'Artwork' },
{ href: '/admin/cache', label: 'Cache' },
],
},
{
title: 'Admin',
items: [
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ 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>
)
}
-24
View File
@@ -1,24 +0,0 @@
'use client'
import { usePathname } from 'next/navigation'
import BrandingLogo from './BrandingLogo'
import HeaderActions from './HeaderActions'
import HeaderIdentity from './HeaderIdentity'
import SiteStatus from './SiteStatus'
import UserViewBanner from './UserViewBanner'
import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() {
const pathname = usePathname()
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
return <>
<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>
<WorkspaceNavigation />
<UserViewBanner />
<SiteStatus />
</>
}
-21
View File
@@ -1,21 +0,0 @@
import type { ReactNode } from 'react'
import MagentMark from './MagentMark'
export default function AuthLayout({ title, description, children, footer }: {
title: string
description: string
children: ReactNode
footer?: ReactNode
}) {
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
<header><h1 id="login-title">{title}</h1><p>{description}</p></header>
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
)
}
+5 -35
View File
@@ -1,44 +1,14 @@
'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>
<img
className={className}
src="/api/branding/logo.png"
alt={alt}
/>
)
}
+7 -77
View File
@@ -1,29 +1,22 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
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),
])
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setSignedIn(false)
@@ -32,15 +25,8 @@ export default function HeaderActions() {
}
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()
@@ -50,68 +36,12 @@ export default function HeaderActions() {
return null
}
const roleItems =
role === null
? []
: role === 'admin'
? [
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
},
{
href: '/admin',
label: 'Config',
match: (path: string) => path.startsWith('/admin') || path.startsWith('/users'),
},
]
: [
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
},
]
const commonItems = [
{
href: '/',
label: 'My Requests',
match: (path: string) => path === '/' || path.startsWith('/requests/'),
},
...(showRequestsNav
? [
{
href: '/new-requests',
label: 'New Requests',
match: (path: string) => path === '/new-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>
<div className="header-actions">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a>
<a href="/how-it-works">How it works</a>
{role === 'admin' && <a href="/admin">Settings</a>}
</div>
)
}
+29 -56
View File
@@ -1,14 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
import { authFetch, clearToken, getApiBase, getToken } 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)
const viewAsUser = useUserViewPreview()
useEffect(() => {
const token = getToken()
@@ -29,9 +27,6 @@ export default function HeaderIdentity() {
const data = await response.json()
if (data?.username) {
setIdentity({ username: data.username, role: data.role })
if (data.role !== 'admin') {
setUserViewPreview(false)
}
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
if (siteResponse.ok) {
@@ -54,9 +49,7 @@ export default function HeaderIdentity() {
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = async () => {
setUserViewPreview(false)
await logout().catch(() => undefined)
const signOut = () => {
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
@@ -64,54 +57,34 @@ export default function HeaderIdentity() {
}
return (
<div className="signed-in-context">
{identity.role === 'admin' ? (
<button
type="button"
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
aria-pressed={viewAsUser}
onClick={() => setUserViewPreview(!viewAsUser)}
>
{viewAsUser ? 'Exit user view' : 'View as user'}
</button>
) : null}
<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}
{viewAsUser ? <span>Previewing user view</span> : null}
</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 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>
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={signOut}>
Sign out
</button>
</div>
)}
</div>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
</div>
)}
</div>
)
}
-3
View File
@@ -1,3 +0,0 @@
export default function MagentMark() {
return <svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true"><rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" /><path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" /></svg>
}
-26
View File
@@ -1,26 +0,0 @@
import type { ReactNode } from 'react'
type PageHeadingProps = {
title: string
description?: string
eyebrow?: string
leading?: ReactNode
actions?: ReactNode
}
/** A flat, shared page title. Panels belong to the content below it. */
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
return (
<header className="page-heading">
<div className="page-heading-main">
{leading && <div className="page-heading-leading">{leading}</div>}
<div className="page-heading-copy">
{eyebrow && <span className="page-heading-eyebrow">{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
</div>
{actions && <div className="page-heading-actions">{actions}</div>}
</header>
)
}

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