Compare commits

..
51 Commits
Author SHA1 Message Date
Assclaw 4d67567d4c Simplify navigation and modernize profile and sign-in
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m58s
2026-09-06 18:24:18 +12:00
Assclaw b5e4c57e93 Simplify settings workspace and fix responsive admin controls
Magent CI/CD / verify (push) Successful in 11m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-06 17:42:28 +12:00
Assclaw f65e1b114c Apply Stitch media operations redesign
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-09-01 22:58:48 +12:00
Assclaw 32bfa20ab7 Add wrong download repair option
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 22:42:07 +12:00
Assclaw 0b59289a2e Automate repair completion confirmation
Magent CI/CD / verify (push) Canceled after 4m31s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:37:23 +12:00
Assclaw ded794a819 Add self-service profile email management
Magent CI/CD / verify (push) Canceled after 10m22s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:26:53 +12:00
Assclaw c6d449dc17 Move invite flow to client navigation 2026-09-01 22:20:48 +12:00
Assclaw 06d944c9d9 Add admin user email management
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 20s
2026-09-01 22:09:20 +12:00
Assclaw 0ac53b7f59 Add quality-aware release picker modal
Magent CI/CD / verify (push) Canceled after 7m6s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:02:22 +12:00
Assclaw 2dbe11e6bc Rework invite creation as guided flow
Magent CI/CD / verify (push) Canceled after 8m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:53:27 +12:00
Assclaw 3aac40ba0f Keep issue dialog above site navigation
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 21:33:02 +12:00
Assclaw b6c48a0be7 Clarify completed repair handoff
Magent CI/CD / verify (push) Canceled after 9m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:22:55 +12:00
Assclaw 3fc52f70c7 Show live repair progress on requests
Magent CI/CD / verify (push) Canceled after 4m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:17:50 +12:00
Assclaw 6391fbfd81 Monitor media before issue repair searches
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 20:50:16 +12:00
Assclaw 0ed22dd315 Separate invites from profile navigation
Magent CI/CD / verify (push) Successful in 10m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 19:51:02 +12:00
Assclaw 7ed0f4b103 Add global admin user-view preview
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 17:04:25 +12:00
Assclaw b3c41f6dea Add safe user preview for invites
Magent CI/CD / verify (push) Successful in 10m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 16:51:32 +12:00
Assclaw 976d24217b Fix top-level invites navigation
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 15:28:28 +12:00
Assclaw c49a149cfd Refresh invite operations workspace
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 15:12:37 +12:00
Assclaw 5de14b1cb7 Add admin issue deletion workflow
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 14:58:58 +12:00
Assclaw 16876e1cf0 Advance issue status from repair actions
Magent CI/CD / verify (push) Successful in 10m30s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Canceled after 5s
2026-09-01 14:48:08 +12:00
Assclaw c7a56f2525 Add issue workflow progress tracker
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 14:44:17 +12:00
Assclaw 87a4aae246 Move issue history into sidebar modal
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 14:28:27 +12:00
Assclaw e58614305e Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 13:44:13 +12:00
Assclaw 2adbed7259 Add issue resolution confirmation workflow
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 12s
2026-09-01 11:40:43 +12:00
Assclaw 393b8c2a88 Add prominent watch button to ready requests
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 11:16:43 +12:00
Assclaw b0eff9ffcf Clarify and live-refresh media indexing state
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 26s
2026-09-01 09:41:15 +12:00
Assclaw ae6cee5d0b Explain remote service responses in plain English
Magent CI/CD / verify (push) Successful in 10m20s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-31 22:28:56 +12:00
Assclaw 906a777b95 Fix issue search result card layout
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-31 22:14:33 +12:00
Assclaw ec8145a58a Add exact media replacement from issues
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 22:05:11 +12:00
Assclaw f8770cb44a Build guided media issue workflow
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 21:46:43 +12:00
Assclaw 0e04d219a0 Reorganize admin configuration sections
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-31 16:32:24 +12:00
Assclaw 3402e53c31 Protect advanced request diagnostics for non-admins
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 16:09:17 +12:00
Assclaw ecf9b230c1 Separate new requests from request tracking
Magent CI/CD / verify (push) Canceled after 7m35s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 16:02:00 +12:00
Assclaw 8f810e0f36 Isolate sparse request recheck regression test
Magent CI/CD / verify (push) Successful in 10m11s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-31 15:23:49 +12:00
Assclaw 82d87d968e Fix sparse request metadata hydration 2026-08-31 15:21:47 +12:00
Assclaw 372f4a1bfc Fix Seerr transport payload forwarding
Magent CI/CD / verify (push) Canceled after 9m15s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:14:32 +12:00
Assclaw 547ed754e6 Handle Seerr CSRF on request creation
Magent CI/CD / verify (push) Canceled after 2m30s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:11:57 +12:00
Assclaw a55369190b Move request service icons beside card content
Magent CI/CD / verify (push) Canceled after 6m44s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:05:01 +12:00
Assclaw ee81749b43 Add Sonarr and Radarr request icons
Magent CI/CD / verify (push) Canceled after 5m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:59:23 +12:00
Assclaw 9dfea25d56 Redesign the request portal flow
Magent CI/CD / verify (push) Canceled after 5m59s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:53:15 +12:00
Assclaw 963506d098 Show live feedback for remote request actions
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-30 21:23:20 +12:00
Assclaw 02245d365e Add live request pipeline recheck
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m56s
2026-08-30 19:50:09 +12:00
Assclaw 2cbd9fe73f Hide landing page live update indicator
Magent CI/CD / verify (push) Successful in 11m5s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-08-30 19:24:56 +12:00
Assclaw 9db32481bd Resolve Arr metadata before adding media
Magent CI/CD / verify (push) Successful in 11m12s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-30 16:32:14 +12:00
Assclaw 3815dfea60 Move fleet health into admin settings and tidy landing page
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 18s
2026-08-29 22:53:48 +12:00
Assclaw c073581639 Mark available downloads complete
Magent CI/CD / verify (push) Successful in 10m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-29 22:41:50 +12:00
Assclaw 06a000bb06 Balance manual results across missing seasons
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-29 22:17:39 +12:00
Assclaw 391cd41d71 Route manual downloads through Arr collectors
Magent CI/CD / verify (push) Canceled after 3m43s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-29 22:13:46 +12:00
Assclaw 96fc43365f Add live download progress updates
Magent CI/CD / verify (push) Successful in 12m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-29 20:52:49 +12:00
Assclaw 655e2f8158 Start Magent beta overhaul
Magent CI/CD / verify (push) Successful in 13m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-29 20:27:17 +12:00
97 changed files with 24677 additions and 4070 deletions
+1 -1
View File
@@ -1 +1 @@
0303261611
0803262237
+104
View File
@@ -0,0 +1,104 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- prod
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Build frontend
working-directory: frontend
run: npm run build
deploy-prod:
if: github.ref_name == 'prod'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_ams_dev01.sh
deploy-beta:
if: github.ref_name == 'beta'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy beta to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_beta_ams_dev01.sh
+26 -6
View File
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="change-me"
JWT_SECRET="replace-with-a-long-random-secret"
JWT_EXP_MINUTES="720"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="adminadmin"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
## 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="change-me"
$env:JWT_SECRET="replace-with-a-long-random-secret"
$env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="admin"
$env:ADMIN_PASSWORD="adminadmin"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
### Frontend (Next.js)
@@ -141,6 +141,26 @@ 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
+94 -28
View File
@@ -1,13 +1,15 @@
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .security import safe_decode_token, TokenError, verify_password
from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
def _is_expired(expires_at: str | None) -> bool:
@@ -24,20 +26,79 @@ def _is_expired(expires_at: str | None) -> bool:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str:
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
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if direct_host:
return direct_host
return "unknown"
def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
return {
"secure": bool(settings.auth_cookie_secure),
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
"path": "/",
}
def _state_cookie_settings() -> dict[str, Any]:
cookie = _cookie_settings()
cookie["httponly"] = False
return cookie
def set_auth_cookies(response: Response, token: str) -> None:
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
response.set_cookie(
settings.auth_cookie_name,
token,
max_age=max_age,
**_cookie_settings(),
)
response.set_cookie(
settings.auth_state_cookie_name,
"1",
max_age=max_age,
**_state_cookie_settings(),
)
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
response.delete_cookie(
settings.auth_state_cookie_name,
path="/",
domain=settings.auth_cookie_domain or None,
)
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header.split(" ", 1)[1].strip()
if oauth_token:
return oauth_token
cookie_token = request.cookies.get(settings.auth_cookie_name)
if isinstance(cookie_token, str) and cookie_token.strip():
return cookie_token.strip()
return None
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict):
return "local"
@@ -108,6 +169,7 @@ 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"),
@@ -121,24 +183,28 @@ def _load_current_user_from_token(
}
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
return _load_current_user_from_token(token, request)
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) -> Dict[str, Any]:
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."""
token = None
stream_query_token = None
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
stream_query_token = request.query_params.get("stream_token")
if not token and not stream_query_token:
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")
if token:
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
return _load_current_user_from_token(token, None)
return _load_current_user_from_token(
str(stream_query_token),
None,
File diff suppressed because one or more lines are too long
+313 -10
View File
@@ -4,6 +4,249 @@ 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:
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:
@@ -29,6 +272,24 @@ class ApiClient:
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,
@@ -36,12 +297,16 @@ class ApiClient:
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> 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,
@@ -51,13 +316,14 @@ class ApiClient:
sanitize_headers(self.headers()),
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.request(
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await self._send_request(
client,
method,
url,
headers=self.headers(),
params=params,
json=payload,
payload=payload,
)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
@@ -68,9 +334,21 @@ class ApiClient:
response.status_code,
duration_ms,
)
if not response.content:
return None
return response.json()
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
@@ -84,6 +362,15 @@ class ApiClient:
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)
@@ -93,10 +380,22 @@ class ApiClient:
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) -> Optional[Any]:
return await self._request("GET", path, params=params)
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 def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("POST", path, payload=payload)
@@ -104,5 +403,9 @@ class ApiClient:
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("PUT", path, payload=payload)
async def delete(self, path: str) -> Optional[Any]:
return await self._request("DELETE", path)
async def delete(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
return await self._request("DELETE", path, params=params)
+48
View File
@@ -0,0 +1,48 @@
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,
)
+98 -7
View File
@@ -1,6 +1,24 @@
from typing import Any, Dict, Optional
import httpx
from .base import ApiClient
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."
)
class JellyfinClient(ApiClient):
@@ -167,18 +185,60 @@ class JellyfinClient(ApiClient):
) -> 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()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
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)
response.raise_for_status()
return response.json()
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 []
async def get_system_info(self) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
@@ -190,12 +250,43 @@ class JellyfinClient(ApiClient):
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()
params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
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
+71 -7
View File
@@ -1,9 +1,44 @@
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")
@@ -26,13 +61,42 @@ 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]]:
return await self.get(
"/api/v1/search",
params={
"query": query,
"page": page,
},
)
# 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(
+151 -17
View File
@@ -1,7 +1,59 @@
from typing import Any, Dict, Optional
import httpx
import logging
from .base import ApiClient
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."
class QBittorrentClient(ApiClient):
@@ -23,34 +75,109 @@ class QBittorrentClient(ApiClient):
headers={"Referer": self.base_url},
)
response.raise_for_status()
if response.text.strip().lower() != "ok.":
text = response.text.strip().lower()
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
raise RuntimeError("qBittorrent login failed")
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.json()
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 def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.text.strip()
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 def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
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 def get_torrents(self) -> Optional[Any]:
return await self._get("/api/v2/torrents/info")
@@ -61,6 +188,9 @@ 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")
@@ -73,7 +203,9 @@ class QBittorrentClient(ApiClient):
return
raise
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
async def add_torrent_url(
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
) -> None:
url_host = None
if isinstance(url, str) and "://" in url:
url_host = url.split("://", 1)[-1].split("/", 1)[0]
@@ -85,4 +217,6 @@ 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)
+30
View File
@@ -9,6 +9,10 @@ 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}")
@@ -24,12 +28,32 @@ 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,
@@ -37,9 +61,15 @@ 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,
+43 -2
View File
@@ -9,6 +9,20 @@ 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}")
@@ -27,12 +41,36 @@ 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,
@@ -42,16 +80,19 @@ 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]]:
+60 -3
View File
@@ -9,7 +9,10 @@ class Settings(BaseSettings):
app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000"
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
sqlite_journal_mode: str = Field(
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
)
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
@@ -21,8 +24,32 @@ class Settings(BaseSettings):
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
password_reset_rate_limit_window_seconds: int = Field(
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
)
password_reset_rate_limit_max_attempts_ip: int = Field(
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
password_reset_rate_limit_max_attempts_identifier: int = Field(
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
auth_cookie_secure: bool = Field(
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
)
auth_cookie_samesite: str = Field(
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
)
auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
)
auth_state_cookie_name: str = Field(
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field(
@@ -58,6 +85,15 @@ 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")
)
@@ -83,6 +119,9 @@ class Settings(BaseSettings):
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(
@@ -109,6 +148,10 @@ class Settings(BaseSettings):
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")
)
@@ -204,6 +247,10 @@ class Settings(BaseSettings):
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
magent_allow_private_notification_targets: bool = Field(
default=False,
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -258,6 +305,16 @@ 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")
)
@@ -276,7 +333,7 @@ class Settings(BaseSettings):
)
discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
+1027 -99
View File
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .db import init_db
from .db import has_admin_user, init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
@@ -24,7 +24,16 @@ 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,
@@ -58,6 +67,14 @@ app.add_middleware(
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()
@@ -100,6 +117,9 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
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
@@ -129,6 +149,13 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
}
),
)
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
@@ -163,6 +190,34 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
_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(
@@ -174,7 +229,9 @@ async def startup() -> None:
log_background_sync_level=settings.log_background_sync_level,
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
configure_logging(
runtime.log_level,
@@ -199,6 +256,7 @@ async def startup() -> None:
_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")
@@ -212,3 +270,5 @@ 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,6 +35,7 @@ class ActionOption(BaseModel):
id: str
label: str
risk: str
description: Optional[str] = None
requires_confirmation: bool = True
@@ -48,6 +49,7 @@ 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
@@ -0,0 +1,132 @@
from __future__ import annotations
import ipaddress
import socket
from functools import lru_cache
from typing import Iterable
from urllib.parse import urlparse
from .config import settings
_METADATA_HOSTS = {
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.internal",
}
def _normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _split_csv(value: object) -> list[str]:
raw = _normalize_text(value)
if not raw:
return []
return [part.strip() for part in raw.split(",") if part.strip()]
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
return bool(
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_multicast
or ip_obj.is_unspecified
or ip_obj.is_reserved
or ip_obj.is_private
)
@lru_cache(maxsize=256)
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
resolved: list[ipaddress._BaseAddress] = []
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
if family == socket.AF_INET:
resolved.append(ipaddress.ip_address(sockaddr[0]))
elif family == socket.AF_INET6:
resolved.append(ipaddress.ip_address(sockaddr[0]))
return tuple(resolved)
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
candidate = _normalize_text(host)
if not candidate:
return False
try:
host_ip = ipaddress.ip_address(candidate)
except ValueError:
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
for entry in trusted_proxies:
raw = _normalize_text(entry)
if not raw:
continue
try:
if "/" in raw:
if host_ip in ipaddress.ip_network(raw, strict=False):
return True
elif host_ip == ipaddress.ip_address(raw):
return True
except ValueError:
continue
return False
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
return False
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
if not trusted:
return False
return _is_trusted_proxy_host(client_host or "", trusted)
def validate_notification_target_url(
url: str,
*,
allow_private: bool | None = None,
) -> str:
raw = _normalize_text(url)
if not raw:
raise ValueError("URL cannot be empty.")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if parsed.username or parsed.password:
raise ValueError("URL must not embed credentials.")
hostname = _normalize_text(parsed.hostname).lower()
if not hostname:
raise ValueError("URL must include a valid host.")
allow_private_targets = (
settings.magent_allow_private_notification_targets
if allow_private is None
else bool(allow_private)
)
if hostname in _METADATA_HOSTS:
raise ValueError("Metadata service targets are not allowed.")
if hostname == "localhost" and not allow_private_targets:
raise ValueError("Local notification targets are not allowed.")
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
if host_ip is not None:
if _ip_is_sensitive(host_ip) and not allow_private_targets:
raise ValueError("Private or local notification targets are not allowed.")
return raw
try:
resolved_ips = _resolve_host_ips(hostname)
except socket.gaierror as exc:
raise ValueError("Host could not be resolved.") from exc
if not resolved_ips:
raise ValueError("Host could not be resolved.")
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
raise ValueError("Private or local notification targets are not allowed.")
return raw
+151 -7
View File
@@ -20,6 +20,7 @@ from ..auth import (
resolve_user_auth_provider,
)
from ..config import settings as env_settings
from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
get_all_users,
@@ -41,6 +42,7 @@ from ..db import (
delete_user_activity_by_username,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_email,
set_user_invite_management_enabled,
set_invite_management_enabled_for_non_admin_users,
set_user_profile_id,
@@ -78,6 +80,8 @@ from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
@@ -85,9 +89,11 @@ from ..services.user_cache import (
save_jellyseerr_users_cache,
clear_user_import_caches,
)
from ..security import validate_password_policy
from ..services.invite_email import (
TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
get_invite_email_templates,
normalize_delivery_email,
reset_invite_email_template,
save_invite_email_template,
send_test_email,
@@ -106,6 +112,25 @@ events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(
status_code=400,
detail="recipient_email is required and must be a valid email address",
)
def _optional_recipient_email(value: object) -> Optional[str]:
if value is None or (isinstance(value, str) and not value.strip()):
return None
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
SENSITIVE_KEYS = {
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
@@ -119,6 +144,7 @@ SENSITIVE_KEYS = {
"jellyfin_api_key",
"sonarr_api_key",
"radarr_api_key",
"bazarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
}
@@ -134,10 +160,17 @@ URL_SETTING_KEYS = {
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"bazarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
}
NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_discord_webhook_url",
"magent_notify_push_base_url",
"magent_notify_webhook_url",
}
SETTING_KEYS: List[str] = [
"magent_application_url",
"magent_application_port",
@@ -194,6 +227,9 @@ SETTING_KEYS: List[str] = [
"radarr_quality_profile_id",
"radarr_root_folder",
"radarr_qbittorrent_category",
"bazarr_base_url",
"bazarr_api_key",
"bazarr_default_language",
"prowlarr_base_url",
"prowlarr_api_key",
"qbittorrent_base_url",
@@ -212,6 +248,9 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"issue_confirmation_interval_unit",
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
@@ -219,6 +258,7 @@ SETTING_KEYS: List[str] = [
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
"site_nav_show_requests",
]
@@ -638,12 +678,38 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
changed_keys.append(key)
continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key == "issue_confirmation_contact_attempts":
try:
attempts = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
if attempts < 0 or attempts > 10:
raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
value_to_store = str(attempts)
if key == "issue_confirmation_interval_value":
try:
interval_value = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
if interval_value < 1 or interval_value > 365:
raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
value_to_store = str(interval_value)
if key == "issue_confirmation_interval_unit":
value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
try:
value_to_store = validate_notification_target_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1
changed_keys.append(key)
@@ -820,8 +886,12 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
continue
username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
if matched_email:
set_user_email(username, matched_email)
updated += 1
else:
skipped += 1
@@ -858,10 +928,12 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
username = _pick_jellyseerr_username(user)
if not username:
continue
email = extract_jellyseerr_user_email(user)
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
email=email,
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
@@ -869,6 +941,8 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
if email:
set_user_email(username, email)
return {"status": "ok", "imported": imported, "cleared": cleared}
@router.post("/requests/sync")
@@ -1284,6 +1358,35 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
return {"status": "ok", "username": username, "role": role}
@router.post("/users/{username}/email")
async def update_user_email(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
email = _optional_recipient_email(payload.get("email"))
if email:
duplicate = next(
(
candidate
for candidate in get_all_users()
if str(candidate.get("username") or "").casefold() != username.casefold()
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
),
None,
)
if duplicate:
raise HTTPException(status_code=409, detail="That email address is already assigned to another user")
if not set_user_email(username, email):
raise HTTPException(status_code=404, detail="User not found")
refreshed = get_user_by_username(username)
logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email))
return {"status": "ok", "user": refreshed, "email": email}
@router.post("/users/{username}/auto-search")
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
@@ -1458,12 +1561,15 @@ async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
@router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
if not isinstance(new_password, str):
raise HTTPException(status_code=400, detail="Invalid payload")
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_password_clean = new_password.strip()
user = normalize_user_auth_provider(user)
auth_provider = resolve_user_auth_provider(user)
if auth_provider == "local":
@@ -1627,9 +1733,34 @@ async def get_invites() -> Dict[str, Any]:
results = []
for invite in invites:
profile = profiles.get(invite.get("profile_id"))
if not invite.get("enabled"):
operational_state = "disabled"
state_label = "Disabled"
attention_reason = "This invite has been switched off."
elif invite.get("is_expired"):
operational_state = "expired"
state_label = "Expired"
attention_reason = "The invite has passed its expiry date."
elif invite.get("remaining_uses") == 0:
operational_state = "exhausted"
state_label = "Fully used"
attention_reason = "Every permitted sign-up has been used."
elif invite.get("profile_id") is not None and (
profile is None or profile.get("is_active") is False
):
operational_state = "profile_unavailable"
state_label = "Profile unavailable"
attention_reason = "The assigned profile is missing or disabled."
else:
operational_state = "ready"
state_label = "Ready to use"
attention_reason = None
results.append(
{
**invite,
"operational_state": operational_state,
"state_label": state_label,
"attention_reason": attention_reason,
"profile": (
{
"id": profile.get("id"),
@@ -1640,7 +1771,16 @@ async def get_invites() -> Dict[str, Any]:
),
}
)
return {"invites": results}
return {
"invites": results,
"summary": {
"total": len(results),
"ready": sum(1 for invite in results if invite["operational_state"] == "ready"),
"attention": sum(1 for invite in results if invite["operational_state"] != "ready"),
"used_signups": sum(int(invite.get("use_count") or 0) for invite in results),
"with_recipient": sum(1 for invite in results if invite.get("recipient_email")),
},
}
@router.get("/invites/policy")
@@ -1775,7 +1915,7 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
if invite is None:
invite = _resolve_user_invite(user)
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
recipient_email = _require_recipient_email(payload.get("recipient_email"))
message = _normalize_optional_text(payload.get("message"))
reason = _normalize_optional_text(payload.get("reason"))
@@ -1825,8 +1965,10 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
send_email = bool(payload.get("send_email"))
if send_email and not recipient_email:
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
delivery_message = _normalize_optional_text(payload.get("message"))
try:
invite = create_signup_invite(
@@ -1896,8 +2038,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
send_email = bool(payload.get("send_email"))
if send_email and not recipient_email:
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
delivery_message = _normalize_optional_text(payload.get("message"))
try:
invite = update_signup_invite(
+264 -68
View File
@@ -7,7 +7,7 @@ import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
@@ -17,8 +17,10 @@ from ..db import (
set_last_login,
get_user_by_username,
get_users_by_username_ci,
get_all_users,
set_user_password,
set_user_jellyseerr_id,
set_user_email,
set_user_auth_provider,
get_signup_invite_by_code,
get_signup_invite_by_id,
@@ -39,17 +41,35 @@ from ..db import (
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..security import (
PASSWORD_POLICY_MESSAGE,
create_access_token,
validate_password_policy,
verify_password,
)
from ..security import create_stream_token
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
from ..auth import (
clear_auth_cookies,
get_current_user,
normalize_user_auth_provider,
resolve_user_auth_provider,
set_auth_cookies,
)
from ..config import settings
from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
from ..services.invite_email import send_templated_email, smtp_email_config_ready
from ..services.invite_email import (
normalize_delivery_email,
send_templated_email,
smtp_email_config_ready,
)
from ..services.password_reset import (
PasswordResetUnavailableError,
apply_password_reset,
@@ -68,15 +88,48 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
_RESET_RATE_LOCK = Lock()
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="recipient_email is required and must be a valid email address.",
)
def _optional_recipient_email(value: object) -> str | None:
if value is None or not str(value).strip():
return None
return _require_recipient_email(value)
def _optional_account_email(value: object) -> str | None:
if value is None or not str(value).strip():
return None
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Enter a valid email address.",
)
def _auth_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
if request.client and request.client.host:
return str(request.client.host)
return "unknown"
@@ -86,6 +139,10 @@ def _login_rate_key_user(username: str) -> str:
return (username or "").strip().lower()[:256] or "<empty>"
def _password_reset_rate_key_identifier(identifier: str) -> str:
return (identifier or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
@@ -171,6 +228,57 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
)
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
ip_bucket.append(now)
identifier_bucket.append(now)
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if identifier_bucket:
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded:
logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
identifier_key,
ip_key,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many password reset attempts. Try again shortly.",
headers={"Retry-After": str(retry_after)},
)
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
@@ -219,6 +327,13 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None
def _extract_jellyseerr_response_email(response: dict) -> str | None:
if not isinstance(response, dict):
return None
user_payload = response.get("user") if isinstance(response.get("user"), dict) else response
return extract_jellyseerr_user_email(user_payload)
def _extract_http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
@@ -271,6 +386,15 @@ def _assert_user_can_login(user: dict | None) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
set_auth_cookies(response, token)
return {
"authenticated": True,
"token_type": "cookie",
"user": user_payload,
}
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
@@ -493,7 +617,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=local username=%s client=%s",
@@ -542,15 +670,19 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
user["role"],
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": user["username"], "role": user["role"]},
}
return _auth_success_response(
response,
token,
{"username": user["username"], "role": user["role"]},
)
@router.post("/jellyfin/login")
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def jellyfin_login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=jellyfin username=%s client=%s",
@@ -569,6 +701,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
user = preferred_match or get_user_by_username(username)
matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user")
@@ -579,13 +713,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
canonical_username,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
return _auth_success_response(
response,
token,
{"username": canonical_username, "role": "user"},
)
try:
response = await client.authenticate_by_name(username, password)
auth_response = await client.authenticate_by_name(username, password)
except Exception as exc:
logger.exception(
"login upstream error provider=jellyfin username=%s client=%s",
@@ -593,11 +727,17 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
_auth_client_ip(request),
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
if not isinstance(auth_response, dict) or not auth_response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
if not preferred_match:
create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin")
create_user_if_missing(
canonical_username,
"jellyfin-user",
role="user",
email=matched_email,
auth_provider="jellyfin",
)
elif (
user
and str(user.get("role") or "user").strip().lower() != "admin"
@@ -605,6 +745,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
):
set_user_auth_provider(canonical_username, "jellyfin")
user = get_user_by_username(canonical_username)
if matched_email:
set_user_email(canonical_username, matched_email)
user = get_user_by_username(canonical_username)
_assert_user_can_login(user)
try:
@@ -627,16 +769,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
return _auth_success_response(
response,
token,
{"username": canonical_username, "role": "user"},
)
@router.post("/seerr/login")
@router.post("/jellyseerr/login")
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def jellyseerr_login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=seerr username=%s client=%s",
@@ -648,7 +794,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
try:
response = await client.login_local(form_data.username, form_data.password)
auth_response = await client.login_local(form_data.username, form_data.password)
except Exception as exc:
logger.exception(
"login upstream error provider=seerr username=%s client=%s",
@@ -656,10 +802,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
_auth_client_ip(request),
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
if not isinstance(auth_response, dict):
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
@@ -668,13 +815,22 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
canonical_username,
"jellyseerr-user",
role="user",
email=jellyseerr_email,
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
elif (
preferred_match
and str(preferred_match.get("role") or "user").strip().lower() != "admin"
and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"}
):
set_user_auth_provider(canonical_username, "jellyseerr")
user = get_user_by_username(canonical_username)
_assert_user_can_login(user)
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
if jellyseerr_email:
set_user_email(canonical_username, jellyseerr_email)
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
@@ -684,11 +840,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
jellyseerr_user_id,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
return _auth_success_response(
response,
token,
{"username": canonical_username, "role": "user"},
)
@router.get("/me")
@@ -696,6 +852,12 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.post("/logout")
async def logout(response: Response) -> dict:
clear_auth_cookies(response)
return {"status": "ok"}
@router.get("/stream-token")
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
token = create_stream_token(
@@ -725,7 +887,7 @@ async def invite_details(code: str) -> dict:
@router.post("/signup")
async def signup(payload: dict) -> dict:
async def signup(payload: dict, response: Response) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
@@ -735,11 +897,10 @@ async def signup(payload: dict) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
try:
password_value = validate_password_policy(password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info(
@@ -786,7 +947,6 @@ async def signup(payload: dict) -> dict:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
runtime = get_runtime_settings()
password_value = password.strip()
auth_provider = "local"
local_password_value = password_value
matched_jellyseerr_user_id: int | None = None
@@ -803,14 +963,14 @@ async def signup(payload: dict) -> dict:
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
response = await jellyfin_client.authenticate_by_name(username, password_value)
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc
if not isinstance(response, dict) or not response.get("User"):
if not isinstance(auth_response, dict) or not auth_response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
@@ -839,6 +999,7 @@ async def signup(payload: dict) -> dict:
username,
local_password_value,
role=role,
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled,
@@ -881,17 +1042,17 @@ async def signup(payload: dict) -> dict:
created_user.get("profile_id") if created_user else None,
invite.get("code"),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {
return _auth_success_response(
response,
token,
{
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
}
)
@router.post("/password/forgot")
@@ -901,6 +1062,8 @@ async def forgot_password(payload: dict, request: Request) -> dict:
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
if not isinstance(identifier, str) or not identifier.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
_enforce_password_reset_rate_limit(request, identifier)
_record_password_reset_attempt(request, identifier)
ready, detail = smtp_email_config_ready()
if not ready:
@@ -960,14 +1123,15 @@ async def password_reset(payload: dict) -> dict:
new_password = payload.get("new_password")
if not isinstance(token, str) or not token.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
if not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE)
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
try:
result = await apply_password_reset(token.strip(), new_password.strip())
result = await apply_password_reset(token.strip(), new_password_clean)
except PasswordResetUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
except ValueError as exc:
@@ -1014,6 +1178,37 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
}
@router.put("/profile/email")
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
username = str(current_user.get("username") or "").strip()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
email = _optional_account_email(payload.get("email"))
if email:
duplicate = next(
(
candidate
for candidate in get_all_users()
if str(candidate.get("username") or "").casefold() != username.casefold()
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
),
None,
)
if duplicate:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That email address is already assigned to another account.",
)
if not set_user_email(username, email):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
logger.info("User updated profile contact email: username=%s email_set=%s", username, bool(email))
return {"status": "ok", "email": email}
@router.get("/profile/invites")
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip()
@@ -1065,9 +1260,10 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
label = str(label).strip() or None
if description is not None:
description = str(description).strip() or None
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
send_email = bool(payload.get("send_email"))
recipient_email = _optional_recipient_email(recipient_email)
if send_email and not recipient_email:
recipient_email = _require_recipient_email(recipient_email)
delivery_message = str(payload.get("message") or "").strip() or None
master_invite = _get_self_service_master_invite()
@@ -1156,9 +1352,10 @@ async def update_profile_invite(
label = str(label).strip() or None
if description is not None:
description = str(description).strip() or None
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
send_email = bool(payload.get("send_email"))
recipient_email = _optional_recipient_email(recipient_email)
if send_email and not recipient_email:
recipient_email = _require_recipient_email(recipient_email)
delivery_message = str(payload.get("message") or "").strip() or None
master_invite = _get_self_service_master_invite()
@@ -1232,14 +1429,13 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
username = str(current_user.get("username") or "").strip()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
new_password_clean = new_password.strip()
stored_user = normalize_user_auth_provider(get_user_by_username(username))
auth_provider = resolve_user_auth_provider(stored_user or current_user)
logger.info("password change requested username=%s provider=%s", username, auth_provider)
-24
View File
@@ -11,7 +11,6 @@ from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"])
@@ -85,9 +84,7 @@ async def events_stream(
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0
while True:
@@ -129,27 +126,6 @@ async def events_stream(
yield _sse_json(payload)
sent_any = True
if now >= next_services_at:
next_services_at = now + 30.0
try:
status_payload = await services_status()
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"status": status_payload,
}
except Exception as exc:
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_services_signature:
last_services_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
+5
View File
@@ -3,6 +3,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@@ -17,6 +18,10 @@ async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(
)
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"}:
+19
View File
@@ -0,0 +1,19 @@
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
+3
View File
@@ -30,6 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
"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()
+50 -10
View File
@@ -2,16 +2,17 @@ from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..auth import require_admin
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(get_current_user)])
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
@@ -26,12 +27,42 @@ 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
@@ -60,6 +91,13 @@ 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(),
@@ -71,13 +109,7 @@ 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.configured(),
qbittorrent.get_app_version,
)
)
services.append(await _check_qbittorrent(qbittorrent))
services.append(
await _check(
"Jellyfin",
@@ -101,6 +133,7 @@ async def test_service(service: str) -> Dict[str, Any]:
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
@@ -121,11 +154,18 @@ async def test_service(service: str) -> Dict[str, Any]:
),
"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),
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
"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")
+9
View File
@@ -4,6 +4,12 @@ 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",
@@ -13,6 +19,8 @@ _INT_FIELDS = {
"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 = {
@@ -33,6 +41,7 @@ _BOOL_FIELDS = {
"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"}
+16 -2
View File
@@ -1,13 +1,16 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import jwt
from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str:
@@ -18,6 +21,13 @@ 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,
@@ -34,6 +44,8 @@ def _create_token(
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")
@@ -45,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
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])
@@ -55,5 +69,5 @@ class TokenError(Exception):
def safe_decode_token(token: str) -> Dict[str, Any]:
try:
return decode_token(token)
except JWTError as exc:
except InvalidTokenError as exc:
raise TokenError("Invalid token") from exc
+32 -5
View File
@@ -17,6 +17,7 @@ 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
@@ -97,7 +98,12 @@ def _config_status(detail: str) -> str:
def _discord_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
return False, "Discord notifications are disabled."
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
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."
@@ -113,7 +119,12 @@ def _telegram_config_ready(runtime) -> tuple[bool, str]:
def _webhook_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
return False, "Generic webhook notifications are disabled."
if _clean_text(runtime.magent_notify_webhook_url):
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."
@@ -123,11 +134,21 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
return False, "Push notifications are disabled."
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
if provider == "ntfy":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
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":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
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":
@@ -135,7 +156,12 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
return True, "ok"
return False, "Pushover requires an application token and user key."
if provider == "webhook":
if _clean_text(runtime.magent_notify_push_base_url):
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":
@@ -190,6 +216,7 @@ async def _run_http_post(
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()
+764 -52
View File
@@ -6,11 +6,15 @@ import json
import logging
import re
import smtplib
from functools import lru_cache
from pathlib import Path
from email.generator import BytesGenerator
from email.message import EmailMessage
from email.utils import formataddr
from email.policy import SMTP as SMTP_POLICY
from email.utils import formataddr, formatdate, make_msgid
from io import BytesIO
from typing import Any, Dict, Optional
from urllib.parse import urlparse
from ..build_info import BUILD_NUMBER
from ..config import settings as env_settings
@@ -25,6 +29,7 @@ EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
EXCHANGE_MESSAGE_ID_PATTERN = re.compile(r"<([^>]+)>")
EXCHANGE_INTERNAL_ID_PATTERN = re.compile(r"\[InternalId=([^\],]+)")
EMAIL_LOGO_CID = "magent-logo"
TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = {
"invited": {
@@ -64,6 +69,183 @@ TEMPLATE_PLACEHOLDERS = [
"username",
]
EMAIL_TAGLINE = "Find and fix media requests fast."
EMAIL_TONE_STYLES: Dict[str, Dict[str, str]] = {
"brand": {
"chip_bg": "rgba(255, 107, 43, 0.16)",
"chip_border": "rgba(255, 107, 43, 0.38)",
"chip_text": "#ffd2bf",
"accent_a": "#ff6b2b",
"accent_b": "#1c6bff",
},
"success": {
"chip_bg": "rgba(34, 197, 94, 0.16)",
"chip_border": "rgba(34, 197, 94, 0.38)",
"chip_text": "#c7f9d7",
"accent_a": "#22c55e",
"accent_b": "#1c6bff",
},
"warning": {
"chip_bg": "rgba(251, 146, 60, 0.16)",
"chip_border": "rgba(251, 146, 60, 0.38)",
"chip_text": "#ffe0ba",
"accent_a": "#fb923c",
"accent_b": "#ff6b2b",
},
"danger": {
"chip_bg": "rgba(248, 113, 113, 0.16)",
"chip_border": "rgba(248, 113, 113, 0.38)",
"chip_text": "#ffd0d0",
"accent_a": "#ef4444",
"accent_b": "#ff6b2b",
},
}
TEMPLATE_PRESENTATION: Dict[str, Dict[str, str]] = {
"invited": {
"tone": "brand",
"title": "You have been invited",
"subtitle": "A new account invitation is ready for you.",
"primary_label": "Accept invite",
"primary_url_key": "invite_link",
"secondary_label": "How it works",
"secondary_url_key": "how_it_works_url",
},
"welcome": {
"tone": "success",
"title": "Welcome to Magent",
"subtitle": "Your account is ready and synced.",
"primary_label": "Open Magent",
"primary_url_key": "app_url",
"secondary_label": "How it works",
"secondary_url_key": "how_it_works_url",
},
"warning": {
"tone": "warning",
"title": "Account warning",
"subtitle": "Please review the note below.",
"primary_label": "Open Magent",
"primary_url_key": "app_url",
"secondary_label": "How it works",
"secondary_url_key": "how_it_works_url",
},
"banned": {
"tone": "danger",
"title": "Account status changed",
"subtitle": "Your account has been restricted or removed.",
"primary_label": "How it works",
"primary_url_key": "how_it_works_url",
"secondary_label": "",
"secondary_url_key": "",
},
}
def _build_email_stat_card(label: str, value: str, detail: str = "") -> str:
detail_html = (
f"<div style=\"margin-top:8px; font-size:13px; line-height:1.6; color:#5c687d; word-break:break-word;\">"
f"{html.escape(detail)}</div>"
if detail
else ""
)
return (
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"border-collapse:separate; background:#f8fafc; border:1px solid #d9e2ef; border-radius:16px;\">"
"<tr><td style=\"padding:16px; font-family:Segoe UI, Arial, sans-serif; color:#132033;\">"
f"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#6b778c; margin-bottom:8px;\">"
f"{html.escape(label)}</div>"
f"<div style=\"font-size:20px; font-weight:800; line-height:1.45; word-break:break-word; color:#132033;\">"
f"{html.escape(value)}</div>"
f"{detail_html}"
"</td></tr></table>"
)
def _build_email_stat_grid(cards: list[str]) -> str:
if not cards:
return ""
rows: list[str] = []
for index in range(0, len(cards), 2):
left = cards[index]
right = cards[index + 1] if index + 1 < len(cards) else ""
rows.append(
"<tr>"
f"<td width=\"50%\" style=\"vertical-align:top; padding:0 5px 10px 0;\">{left}</td>"
f"<td width=\"50%\" style=\"vertical-align:top; padding:0 0 10px 5px;\">{right}</td>"
"</tr>"
)
return (
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"border-collapse:collapse; margin:0 0 18px;\">"
f"{''.join(rows)}"
"</table>"
)
def _build_email_list(items: list[str], *, ordered: bool = False) -> str:
tag = "ol" if ordered else "ul"
marker = "padding-left:20px;" if ordered else "padding-left:18px;"
rendered_items = "".join(
f"<li style=\"margin:0 0 8px;\">{html.escape(item)}</li>" for item in items if item
)
return (
f"<{tag} style=\"margin:0; {marker} color:#132033; line-height:1.8; font-size:14px;\">"
f"{rendered_items}"
f"</{tag}>"
)
def _build_email_panel(title: str, body_html: str, *, variant: str = "neutral") -> str:
styles = {
"neutral": {
"background": "#f8fafc",
"border": "#d9e2ef",
"eyebrow": "#6b778c",
"text": "#132033",
},
"brand": {
"background": "#eef4ff",
"border": "#bfd2ff",
"eyebrow": "#2754b6",
"text": "#132033",
},
"success": {
"background": "#edf9f0",
"border": "#bfe4c6",
"eyebrow": "#1f7a3f",
"text": "#132033",
},
"warning": {
"background": "#fff5ea",
"border": "#ffd5a8",
"eyebrow": "#c46a10",
"text": "#132033",
},
"danger": {
"background": "#fff0f0",
"border": "#f3c1c1",
"eyebrow": "#bb2d2d",
"text": "#132033",
},
}.get(variant, {
"background": "#f8fafc",
"border": "#d9e2ef",
"eyebrow": "#6b778c",
"text": "#132033",
})
return (
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
f"style=\"border-collapse:separate; margin:0 0 18px; background:{styles['background']}; "
f"border:1px solid {styles['border']}; border-radius:18px;\">"
f"<tr><td style=\"padding:18px; font-family:Segoe UI, Arial, sans-serif; color:{styles['text']};\">"
f"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:{styles['eyebrow']}; margin-bottom:10px;\">"
f"{html.escape(title)}</div>"
f"<div style=\"font-size:14px; line-height:1.8; color:{styles['text']};\">{body_html}</div>"
"</td></tr></table>"
)
DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
"invited": {
"subject": "{{app_name}} invite for {{recipient_email}}",
@@ -81,18 +263,43 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
"Build: {{build_number}}\n"
),
"body_html": (
"<h1>You have been invited</h1>"
"<p>You have been invited to <strong>{{app_name}}</strong>.</p>"
"<p><strong>Invite code:</strong> {{invite_code}}<br />"
"<strong>Invited by:</strong> {{inviter_username}}<br />"
"<strong>Invite label:</strong> {{invite_label}}<br />"
"<strong>Expires:</strong> {{invite_expires_at}}<br />"
"<strong>Remaining uses:</strong> {{invite_remaining_uses}}</p>"
"<p>{{invite_description}}</p>"
"<p>{{message}}</p>"
"<p><a href=\"{{invite_link}}\">Accept invite and create account</a></p>"
"<p><a href=\"{{how_it_works_url}}\">How it works</a></p>"
"<p class=\"meta\">Build {{build_number}}</p>"
"<div style=\"margin:0 0 20px; color:#132033; font-size:15px; line-height:1.7;\">"
"A new invitation has been prepared for <strong>{{recipient_email}}</strong>. Use the details below to sign up."
"</div>"
+ _build_email_stat_grid(
[
_build_email_stat_card("Invite code", "{{invite_code}}"),
_build_email_stat_card("Invited by", "{{inviter_username}}"),
_build_email_stat_card("Invite label", "{{invite_label}}"),
_build_email_stat_card(
"Access window",
"{{invite_expires_at}}",
"Remaining uses: {{invite_remaining_uses}}",
),
]
)
+ _build_email_panel(
"Invitation details",
"<div style=\"white-space:pre-line;\">{{invite_description}}</div>",
variant="brand",
)
+ _build_email_panel(
"Message from admin",
"<div style=\"white-space:pre-line;\">{{message}}</div>",
variant="neutral",
)
+ _build_email_panel(
"What happens next",
_build_email_list(
[
"Open the invite link and complete the signup flow.",
"Sign in using the shared credentials for Magent and Seerr.",
"Use the How it works page if you want a quick overview first.",
],
ordered=True,
),
variant="neutral",
)
),
},
"welcome": {
@@ -106,12 +313,34 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
"{{message}}\n"
),
"body_html": (
"<h1>Welcome</h1>"
"<p>Your {{app_name}} account is ready, <strong>{{username}}</strong>.</p>"
"<p><strong>Role:</strong> {{role}}</p>"
"<p><a href=\"{{app_url}}\">Open {{app_name}}</a><br />"
"<a href=\"{{how_it_works_url}}\">Read how it works</a></p>"
"<p>{{message}}</p>"
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
"Your account is live and ready to use. Everything below mirrors the current site behavior."
"</div>"
+ _build_email_stat_grid(
[
_build_email_stat_card("Username", "{{username}}"),
_build_email_stat_card("Role", "{{role}}"),
_build_email_stat_card("Magent", "{{app_url}}"),
_build_email_stat_card("Guides", "{{how_it_works_url}}"),
]
)
+ _build_email_panel(
"What to do next",
_build_email_list(
[
"Open Magent and sign in using your shared credentials.",
"Search all requests or review your own activity without refreshing the page.",
"Use the invite tools in your profile if your account allows it.",
],
ordered=True,
),
variant="success",
)
+ _build_email_panel(
"Additional notes",
"<div style=\"white-space:pre-line;\">{{message}}</div>",
variant="neutral",
)
),
},
"warning": {
@@ -124,12 +353,38 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
"If you need help, contact the admin.\n"
),
"body_html": (
"<h1>Account warning</h1>"
"<p>Hello <strong>{{username}}</strong>,</p>"
"<p>This is a warning regarding your {{app_name}} account.</p>"
"<p><strong>Reason:</strong> {{reason}}</p>"
"<p>{{message}}</p>"
"<p>If you need help, contact the admin.</p>"
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
"Please review this account notice carefully. This message was sent by an administrator."
"</div>"
+ _build_email_stat_grid(
[
_build_email_stat_card("Account", "{{username}}"),
_build_email_stat_card("Role", "{{role}}"),
_build_email_stat_card("Application", "{{app_name}}"),
_build_email_stat_card("Support", "{{how_it_works_url}}"),
]
)
+ _build_email_panel(
"Reason",
"<div style=\"font-size:18px; font-weight:800; line-height:1.6; white-space:pre-line;\">{{reason}}</div>",
variant="warning",
)
+ _build_email_panel(
"Administrator note",
"<div style=\"white-space:pre-line;\">{{message}}</div>",
variant="neutral",
)
+ _build_email_panel(
"What to do next",
_build_email_list(
[
"Review the note above and confirm you understand what needs to change.",
"If you need help, reply through your usual support path or contact an administrator.",
"Keep this email for reference until the matter is resolved.",
]
),
variant="neutral",
)
),
},
"banned": {
@@ -141,11 +396,38 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
"{{message}}\n"
),
"body_html": (
"<h1>Account status changed</h1>"
"<p>Hello <strong>{{username}}</strong>,</p>"
"<p>Your {{app_name}} account has been banned or removed.</p>"
"<p><strong>Reason:</strong> {{reason}}</p>"
"<p>{{message}}</p>"
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
"Your account access has changed. Review the details below."
"</div>"
+ _build_email_stat_grid(
[
_build_email_stat_card("Account", "{{username}}"),
_build_email_stat_card("Status", "Restricted"),
_build_email_stat_card("Application", "{{app_name}}"),
_build_email_stat_card("Guidance", "{{how_it_works_url}}"),
]
)
+ _build_email_panel(
"Reason",
"<div style=\"font-size:18px; font-weight:800; line-height:1.6; white-space:pre-line;\">{{reason}}</div>",
variant="danger",
)
+ _build_email_panel(
"Administrator note",
"<div style=\"white-space:pre-line;\">{{message}}</div>",
variant="neutral",
)
+ _build_email_panel(
"What this means",
_build_email_list(
[
"Your access has been removed or restricted across the linked services.",
"If you believe this is incorrect, contact the site administrator directly.",
"Do not rely on old links or cached sessions after this change.",
]
),
variant="neutral",
)
),
},
}
@@ -170,6 +452,10 @@ def _normalize_email(value: object) -> Optional[str]:
return str(value).strip()
def normalize_delivery_email(value: object) -> Optional[str]:
return _normalize_email(value)
def _normalize_display_text(value: object, fallback: str = "") -> str:
if value is None:
return fallback
@@ -227,6 +513,252 @@ def _build_default_base_url() -> str:
return f"http://localhost:{port}"
def _derive_mail_hostname(*, from_address: str) -> str:
runtime = get_runtime_settings()
candidates = (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
)
for candidate in candidates:
normalized = _normalize_display_text(candidate)
if not normalized:
continue
parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
hostname = _normalize_display_text(parsed.hostname)
if hostname and "." in hostname:
return hostname
domain = _normalize_display_text(from_address.split("@", 1)[1] if "@" in from_address else None)
if domain and "." in domain:
return domain
return "localhost"
def _add_transactional_headers(
message: EmailMessage,
*,
from_name: str,
from_address: str,
) -> None:
message["Reply-To"] = formataddr((from_name, from_address))
message["Organization"] = env_settings.app_name
message["X-Mailer"] = f"{env_settings.app_name}/{BUILD_NUMBER}"
message["Auto-Submitted"] = "auto-generated"
message["X-Auto-Response-Suppress"] = "All"
def _looks_like_full_html_document(value: str) -> bool:
probe = value.lstrip().lower()
return probe.startswith("<!doctype") or probe.startswith("<html") or "<body" in probe[:300]
def _build_email_action_button(label: str, url: str, *, primary: bool) -> str:
background = "linear-gradient(135deg, #ff6b2b 0%, #1c6bff 100%)" if primary else "#ffffff"
fallback = "#1c6bff" if primary else "#ffffff"
border = "1px solid rgba(28, 107, 255, 0.28)" if primary else "1px solid #d5deed"
color = "#ffffff" if primary else "#132033"
return (
f"<a href=\"{html.escape(url)}\" "
f"style=\"display:inline-block; padding:12px 20px; margin:0 12px 12px 0; border-radius:999px; "
f"background-color:{fallback}; background:{background}; border:{border}; color:{color}; text-decoration:none; font-size:14px; "
f"font-weight:800; letter-spacing:0.01em;\">{html.escape(label)}</a>"
)
@lru_cache(maxsize=1)
def _get_email_logo_bytes() -> bytes:
logo_path = Path(__file__).resolve().parents[1] / "assets" / "branding" / "logo.png"
try:
return logo_path.read_bytes()
except OSError:
return b""
def _build_email_logo_block(app_name: str) -> str:
if _get_email_logo_bytes():
return (
f"<img src=\"cid:{EMAIL_LOGO_CID}\" alt=\"{html.escape(app_name)}\" width=\"52\" height=\"52\" "
"style=\"display:block; width:52px; height:52px; border-radius:14px; border:1px solid rgba(255,255,255,0.08); "
"background:#0f1522; padding:6px;\" />"
)
return (
"<div style=\"width:52px; height:52px; border-radius:14px; border:1px solid rgba(255,255,255,0.08); "
"background:linear-gradient(135deg, #ff6b2b 0%, #1c6bff 100%); color:#ffffff; font-size:24px; "
"font-weight:900; text-align:center; line-height:52px;\">M</div>"
)
def _build_outlook_safe_test_email_html(
*,
app_name: str,
application_url: str,
build_number: str,
smtp_target: str,
security_mode: str,
auth_mode: str,
warning: str,
primary_url: str = "",
) -> str:
action_html = (
_build_email_action_button("Open Magent", primary_url, primary=True) if primary_url else ""
)
logo_block = _build_email_logo_block(app_name)
warning_block = (
"<tr>"
"<td style=\"padding:0 32px 24px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"border-collapse:separate; background:#fff5ea; border:1px solid #ffd5a8; border-radius:14px;\">"
"<tr><td style=\"padding:16px; color:#132033; font-size:14px; line-height:1.7;\">"
"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#c46a10; margin-bottom:8px;\">"
"Delivery notes</div>"
f"{html.escape(warning)}"
"</td></tr></table>"
"</td>"
"</tr>"
) if warning else ""
return (
"<!doctype html>"
"<html>"
"<body style=\"margin:0; padding:0; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"border-collapse:collapse; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
"<tr><td align=\"center\" style=\"padding:32px 16px;\">"
"<table role=\"presentation\" width=\"680\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"width:680px; max-width:680px; border-collapse:collapse; background:#ffffff; border:1px solid #d5deed;\">"
"<tr><td style=\"padding:24px 32px; background:#0f172a;\" bgcolor=\"#0f172a\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
"<tr>"
f"<td width=\"56\" valign=\"middle\">{logo_block}</td>"
"<td valign=\"middle\" style=\"padding-left:16px; font-family:Segoe UI, Arial, sans-serif; color:#ffffff;\">"
f"<div style=\"font-size:28px; line-height:1.1; font-weight:800; color:#ffffff;\">{html.escape(app_name)} email test</div>"
"<div style=\"margin-top:6px; font-size:15px; line-height:1.5; color:#d5deed;\">This confirms Magent can generate and hand off branded mail.</div>"
"</td>"
"</tr>"
"</table>"
"</td></tr>"
"<tr><td height=\"6\" style=\"background:#ff6b2b; font-size:0; line-height:0;\">&nbsp;</td></tr>"
"<tr><td style=\"padding:28px 32px 18px 32px; font-family:Segoe UI, Arial, sans-serif; color:#132033;\">"
"<div style=\"font-size:18px; line-height:1.6; color:#132033;\">This is a test email from <strong>Magent</strong>.</div>"
"</td></tr>"
"<tr>"
"<td style=\"padding:0 32px 18px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
"<tr>"
"<td width=\"50%\" valign=\"top\" style=\"padding:0 8px 12px 0;\">"
f"{_build_email_stat_card('Build', build_number)}"
"</td>"
"<td width=\"50%\" valign=\"top\" style=\"padding:0 0 12px 8px;\">"
f"{_build_email_stat_card('Application URL', application_url)}"
"</td>"
"</tr>"
"<tr>"
"<td width=\"50%\" valign=\"top\" style=\"padding:0 8px 12px 0;\">"
f"{_build_email_stat_card('SMTP target', smtp_target)}"
"</td>"
"<td width=\"50%\" valign=\"top\" style=\"padding:0 0 12px 8px;\">"
f"{_build_email_stat_card('Security', security_mode, auth_mode)}"
"</td>"
"</tr>"
"</table>"
"</td>"
"</tr>"
"<tr>"
"<td style=\"padding:0 32px 24px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"border-collapse:separate; background:#eef4ff; border:1px solid #bfd2ff; border-radius:14px;\">"
"<tr><td style=\"padding:16px; color:#132033; font-size:14px; line-height:1.8;\">"
"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#2754b6; margin-bottom:8px;\">"
"What this verifies</div>"
"<div>Magent can build the HTML template shell correctly.</div>"
"<div>The configured SMTP route accepts and relays the message.</div>"
"<div>Branding, links, and build metadata are rendering consistently.</div>"
"</td></tr></table>"
"</td>"
"</tr>"
f"{warning_block}"
"<tr>"
"<td style=\"padding:0 32px 32px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
f"{action_html}"
"</td>"
"</tr>"
"</table>"
"</td></tr></table>"
"</body>"
"</html>"
)
def _wrap_email_html(
*,
app_name: str,
app_url: str,
build_number: str,
title: str,
subtitle: str,
tone: str,
body_html: str,
primary_label: str = "",
primary_url: str = "",
secondary_label: str = "",
secondary_url: str = "",
footer_note: str = "",
) -> str:
styles = EMAIL_TONE_STYLES.get(tone, EMAIL_TONE_STYLES["brand"])
actions = []
if primary_label and primary_url:
actions.append(_build_email_action_button(primary_label, primary_url, primary=True))
if secondary_label and secondary_url:
actions.append(_build_email_action_button(secondary_label, secondary_url, primary=False))
actions_html = "".join(actions)
footer = footer_note or "This email was generated automatically by Magent."
logo_block = _build_email_logo_block(app_name)
return (
"<!doctype html>"
"<html><body style=\"margin:0; padding:0; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
"<div style=\"display:none; max-height:0; overflow:hidden; opacity:0;\">"
f"{html.escape(title)} - {html.escape(subtitle)}"
"</div>"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"width:100%; border-collapse:collapse; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
"<tr><td style=\"padding:32px 18px;\" bgcolor=\"#eef2f7\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
"style=\"max-width:680px; margin:0 auto; border-collapse:collapse;\">"
"<tr><td style=\"padding:0 0 18px;\">"
f"<div style=\"padding:24px 28px; background:#ffffff; border:1px solid #d5deed; border-radius:28px; box-shadow:0 18px 48px rgba(15,23,42,0.08);\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
"<tr>"
f"<td style=\"vertical-align:middle; width:64px; padding:0 18px 0 0;\">{logo_block}</td>"
"<td style=\"vertical-align:middle;\">"
f"<div style=\"font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:#6b778c; margin-bottom:6px;\">{html.escape(app_name)}</div>"
f"<div style=\"font-size:30px; line-height:1.1; font-weight:900; color:#132033; margin:0 0 6px;\">{html.escape(title)}</div>"
f"<div style=\"font-size:15px; line-height:1.6; color:#5c687d;\">{html.escape(subtitle or EMAIL_TAGLINE)}</div>"
"</td>"
"</tr>"
"</table>"
f"<div style=\"height:6px; margin:22px 0 22px; border-radius:999px; background-color:{styles['accent_b']}; background:linear-gradient(90deg, {styles['accent_a']} 0%, {styles['accent_b']} 100%);\"></div>"
f"<div style=\"display:inline-block; padding:7px 12px; margin:0 0 16px; background:{styles['chip_bg']}; "
f"border:1px solid {styles['chip_border']}; border-radius:999px; color:{styles['chip_text']}; "
"font-size:11px; font-weight:800; letter-spacing:0.14em; text-transform:uppercase;\">"
f"{html.escape(EMAIL_TAGLINE)}</div>"
f"<div style=\"color:#132033;\">{body_html}</div>"
f"<div style=\"margin:24px 0 0;\">{actions_html}</div>"
"<div style=\"margin:28px 0 0; padding:18px 0 0; border-top:1px solid #e2e8f0;\">"
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
"<tr>"
f"<td style=\"font-size:12px; line-height:1.7; color:#6b778c;\">{html.escape(footer)}</td>"
f"<td style=\"font-size:12px; line-height:1.7; color:#6b778c; text-align:right;\">Build {html.escape(build_number)}</td>"
"</tr>"
"</table>"
"</div>"
"</div>"
"</td></tr></table>"
"</td></tr></table>"
"</body></html>"
)
def build_invite_email_context(
*,
invite: Optional[Dict[str, Any]] = None,
@@ -263,7 +795,7 @@ def build_invite_email_context(
invite.get("created_by") if invite else (user.get("username") if user else None),
"Admin",
),
"message": _normalize_display_text(message, ""),
"message": _normalize_display_text(message, "No additional note."),
"reason": _normalize_display_text(reason, "Not specified"),
"recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"),
"role": _normalize_display_text(user.get("role") if user else None, "user"),
@@ -348,11 +880,35 @@ def render_invite_email_template(
reason=reason,
overrides=overrides,
)
body_html = _render_template_string(template["body_html"], context, escape_html=True)
raw_body_html = _render_template_string(template["body_html"], context, escape_html=True)
body_text = _render_template_string(template["body_text"], context, escape_html=False)
if not body_text.strip() and body_html.strip():
body_text = _strip_html_for_text(body_html)
if not body_text.strip() and raw_body_html.strip():
body_text = _strip_html_for_text(raw_body_html)
subject = _render_template_string(template["subject"], context, escape_html=False)
presentation = TEMPLATE_PRESENTATION.get(template_key, TEMPLATE_PRESENTATION["invited"])
primary_url = _normalize_display_text(context.get(presentation["primary_url_key"], ""))
secondary_url = _normalize_display_text(context.get(presentation["secondary_url_key"], ""))
if _looks_like_full_html_document(raw_body_html):
body_html = raw_body_html.strip()
else:
body_html = _wrap_email_html(
app_name=_normalize_display_text(context.get("app_name"), env_settings.app_name),
app_url=_normalize_display_text(context.get("app_url"), _build_default_base_url()),
build_number=_normalize_display_text(context.get("build_number"), BUILD_NUMBER),
title=_normalize_display_text(context.get("title"), presentation["title"]),
subtitle=_normalize_display_text(context.get("subtitle"), presentation["subtitle"]),
tone=_normalize_display_text(context.get("tone"), presentation["tone"]),
body_html=raw_body_html.strip(),
primary_label=_normalize_display_text(
context.get("primary_label"), presentation["primary_label"]
),
primary_url=primary_url,
secondary_label=_normalize_display_text(
context.get("secondary_label"), presentation["secondary_label"]
),
secondary_url=secondary_url,
footer_note=_normalize_display_text(context.get("footer_note"), ""),
).strip()
return {
"subject": subject.strip(),
"body_text": body_text.strip(),
@@ -363,6 +919,9 @@ def render_invite_email_template(
def resolve_user_delivery_email(user: Optional[Dict[str, Any]], invite: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not isinstance(user, dict):
return _normalize_email(invite.get("recipient_email") if isinstance(invite, dict) else None)
stored_email = _normalize_email(user.get("email"))
if stored_email:
return stored_email
username_email = _normalize_email(user.get("username"))
if username_email:
return username_email
@@ -394,15 +953,17 @@ def smtp_email_delivery_warning() -> Optional[str]:
if host.endswith(".mail.protection.outlook.com") and not (username and password):
return (
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
"confirm mailbox delivery. For reliable delivery, use smtp.office365.com:587 with "
"SMTP credentials or configure a verified Exchange relay connector."
"confirm mailbox delivery, and suspicious messages can still be filtered. For reliable "
"delivery, use smtp.office365.com:587 with SMTP credentials or configure a verified "
"Exchange relay connector and make sure SPF, DKIM, and DMARC are healthy for the "
"sender domain."
)
return None
def _flatten_message(message: EmailMessage) -> bytes:
buffer = BytesIO()
BytesGenerator(buffer).flatten(message)
BytesGenerator(buffer, policy=SMTP_POLICY).flatten(message)
return buffer.getvalue()
@@ -462,8 +1023,9 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
delivery_warning = smtp_email_delivery_warning()
if not host or not from_address:
raise RuntimeError("SMTP email settings are incomplete.")
local_hostname = _derive_mail_hostname(from_address=from_address)
logger.info(
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s",
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
recipient_email,
from_address,
host,
@@ -472,6 +1034,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
use_ssl,
bool(username and password),
subject,
local_hostname,
)
if delivery_warning:
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
@@ -480,12 +1043,35 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
message["Subject"] = subject
message["From"] = formataddr((from_name, from_address))
message["To"] = recipient_email
message["Date"] = formatdate(localtime=True)
if "@" in from_address:
message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
else:
message["Message-ID"] = make_msgid()
_add_transactional_headers(
message,
from_name=from_name,
from_address=from_address,
)
message.set_content(body_text or _strip_html_for_text(body_html))
if body_html.strip():
message.add_alternative(body_html, subtype="html")
if f"cid:{EMAIL_LOGO_CID}" in body_html:
logo_bytes = _get_email_logo_bytes()
if logo_bytes:
html_part = message.get_body(preferencelist=("html",))
if html_part is not None:
html_part.add_related(
logo_bytes,
maintype="image",
subtype="png",
cid=f"<{EMAIL_LOGO_CID}>",
filename="logo.png",
disposition="inline",
)
if use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
with smtplib.SMTP_SSL(host, port, timeout=20, local_hostname=local_hostname) as smtp:
logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
if username and password:
smtp.login(username, password)
@@ -505,7 +1091,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
)
return receipt
with smtplib.SMTP(host, port, timeout=20) as smtp:
with smtplib.SMTP(host, port, timeout=20, local_hostname=local_hostname) as smtp:
logger.debug("smtp connection opened host=%s port=%s", host, port)
smtp.ehlo()
if use_tls:
@@ -579,6 +1165,38 @@ async def send_templated_email(
}
async def send_generic_email(
*,
recipient_email: str,
subject: str,
body_text: str,
body_html: str = "",
) -> Dict[str, str]:
ready, detail = smtp_email_config_ready()
if not ready:
raise RuntimeError(detail)
resolved_email = _normalize_email(recipient_email)
if not resolved_email:
raise RuntimeError("A valid recipient email is required.")
receipt = await asyncio.to_thread(
_send_email_sync,
recipient_email=resolved_email,
subject=subject.strip() or f"{env_settings.app_name} notification",
body_text=body_text.strip(),
body_html=body_html.strip(),
)
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
return {
"recipient_email": resolved_email,
"subject": subject.strip() or f"{env_settings.app_name} notification",
**{
key: value
for key, value in receipt.items()
if key in {"provider_message_id", "provider_internal_id", "data_response"}
},
}
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
ready, detail = smtp_email_config_ready()
if not ready:
@@ -592,17 +1210,71 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
raise RuntimeError("No valid recipient email is configured for the test message.")
application_url = _normalize_display_text(runtime.magent_application_url, "Not configured")
primary_url = application_url if application_url.lower().startswith(("http://", "https://")) else ""
smtp_target = f"{_normalize_display_text(runtime.magent_notify_email_smtp_host, 'Not configured')}:{int(runtime.magent_notify_email_smtp_port or 587)}"
security_mode = "SSL" if runtime.magent_notify_email_use_ssl else ("STARTTLS" if runtime.magent_notify_email_use_tls else "Plain SMTP")
auth_mode = "Authenticated" if (
_normalize_display_text(runtime.magent_notify_email_smtp_username)
and _normalize_display_text(runtime.magent_notify_email_smtp_password)
) else "No SMTP auth"
delivery_warning = smtp_email_delivery_warning()
subject = f"{env_settings.app_name} email test"
body_text = (
f"This is a test email from {env_settings.app_name}.\n\n"
f"Build: {BUILD_NUMBER}\n"
f"Application URL: {application_url}\n"
f"SMTP target: {smtp_target}\n"
f"Security: {security_mode} ({auth_mode})\n\n"
"What this verifies:\n"
"- Magent can build the HTML template shell correctly.\n"
"- The configured SMTP route accepts and relays the message.\n"
"- Branding, links, and build metadata are rendering consistently.\n"
)
body_html = (
f"<h1>{html.escape(env_settings.app_name)} email test</h1>"
f"<p>This is a test email from <strong>{html.escape(env_settings.app_name)}</strong>.</p>"
f"<p><strong>Build:</strong> {html.escape(BUILD_NUMBER)}<br />"
f"<strong>Application URL:</strong> {html.escape(application_url)}</p>"
body_html = _wrap_email_html(
app_name=env_settings.app_name,
app_url=_build_default_base_url(),
build_number=BUILD_NUMBER,
title="Email delivery test",
subtitle="This confirms Magent can generate and hand off branded mail.",
tone="brand",
body_html=(
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
"This is a live test email from Magent. If this renders correctly, the HTML template shell and SMTP handoff are both working."
"</div>"
+ _build_email_stat_grid(
[
_build_email_stat_card("Recipient", resolved_email),
_build_email_stat_card("Build", BUILD_NUMBER),
_build_email_stat_card("SMTP target", smtp_target),
_build_email_stat_card("Security", security_mode, auth_mode),
_build_email_stat_card("Application URL", application_url),
_build_email_stat_card("Template shell", "Branded HTML", "Logo, gradient, action buttons"),
]
)
+ _build_email_panel(
"What this verifies",
_build_email_list(
[
"Magent can build the HTML template shell correctly.",
"The configured SMTP route accepts and relays the message.",
"Branding, links, and build metadata are rendering consistently.",
]
),
variant="brand",
)
+ _build_email_panel(
"Delivery notes",
(
f"<div style=\"white-space:pre-line;\">{html.escape(delivery_warning)}</div>"
if delivery_warning
else "Use this test when changing SMTP settings, relay targets, or branding."
),
variant="warning" if delivery_warning else "neutral",
)
),
primary_label="Open Magent" if primary_url else "",
primary_url=primary_url,
footer_note="SMTP test email generated by Magent.",
)
receipt = await asyncio.to_thread(
@@ -654,13 +1326,53 @@ async def send_password_reset_email(
f"Expires: {expires_at}\n\n"
"If you did not request this reset, you can ignore this email.\n"
)
body_html = (
f"<h1>{html.escape(env_settings.app_name)} password reset</h1>"
f"<p>A password reset was requested for <strong>{html.escape(username)}</strong>.</p>"
f"<p>This link will reset the password used for <strong>{html.escape(provider_label)}</strong>.</p>"
f"<p><a href=\"{html.escape(reset_url)}\">Reset password</a></p>"
f"<p><strong>Expires:</strong> {html.escape(expires_at)}</p>"
"<p>If you did not request this reset, you can ignore this email.</p>"
body_html = _wrap_email_html(
app_name=env_settings.app_name,
app_url=app_url,
build_number=BUILD_NUMBER,
title="Reset your password",
subtitle=f"This will update the credentials used for {provider_label}.",
tone="brand",
body_html=(
f"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
f"A password reset was requested for <strong>{html.escape(username)}</strong>."
"</div>"
+ _build_email_stat_grid(
[
_build_email_stat_card("Account", username),
_build_email_stat_card("Expires", expires_at),
_build_email_stat_card("Credentials updated", provider_label),
_build_email_stat_card("Delivery target", resolved_email),
]
)
+ _build_email_panel(
"What will be updated",
f"This reset will update the password used for <strong>{html.escape(provider_label)}</strong>.",
variant="brand",
)
+ _build_email_panel(
"What happens next",
_build_email_list(
[
"Open the reset link and choose a new password.",
"Complete the form before the expiry time shown above.",
"Use the new password the next time you sign in.",
],
ordered=True,
),
variant="neutral",
)
+ _build_email_panel(
"Safety note",
"If you did not request this reset, ignore this email. No changes will be applied until the reset link is opened and completed.",
variant="warning",
)
),
primary_label="Reset password",
primary_url=reset_url,
secondary_label="Open Magent",
secondary_url=app_url,
footer_note="Password reset email generated by Magent.",
)
receipt = await asyncio.to_thread(
+585
View File
@@ -0,0 +1,585 @@
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)
+8
View File
@@ -6,12 +6,15 @@ from ..clients.jellyfin import JellyfinClient
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_email,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
@@ -41,10 +44,13 @@ async def sync_jellyfin_users() -> int:
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,
)
@@ -60,6 +66,8 @@ async def sync_jellyfin_users() -> int:
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
@@ -0,0 +1,280 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
logger = logging.getLogger(__name__)
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _split_emails(value: str) -> list[str]:
if not value:
return []
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
return [entry for entry in parts if entry and "@" in entry]
def _resolve_app_url() -> str:
runtime = get_runtime_settings()
for candidate in (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
):
normalized = _clean_text(candidate)
if normalized:
return normalized.rstrip("/")
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
return f"http://localhost:{port}"
def _portal_item_url(item_id: int) -> str:
return f"{_resolve_app_url()}/portal?item={item_id}"
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = response.text
return {"status_code": response.status_code, "body": body}
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
runtime.discord_webhook_url
)
if not webhook:
return {"status": "skipped", "detail": "Discord webhook not configured."}
data = {
"content": f"**{title}**\n{message}",
"embeds": [
{
"title": title,
"description": message,
"fields": [
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
],
"url": _clean_text(payload.get("item_url")),
}
],
}
result = await _http_post_json(webhook, data)
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
if not bot_token or not chat_id:
return {"status": "skipped", "detail": "Telegram is not configured."}
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
result = await _http_post_json(url, payload)
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_webhook_url)
if not webhook:
return {"status": "skipped", "detail": "Generic webhook is not configured."}
result = await _http_post_json(webhook, payload)
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
topic = _clean_text(runtime.magent_notify_push_topic)
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
response.raise_for_status()
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
if provider == "pushover":
user_key = _clean_text(runtime.magent_notify_push_user_key)
if not token or not user_key:
return {"status": "skipped", "detail": "Pushover needs token and user key."}
form = {"token": token, "user": user_key, "title": title, "message": message}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
response.raise_for_status()
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
if provider == "discord":
return await _send_discord(title, message, payload)
if provider == "telegram":
return await _send_telegram(title, message)
if provider == "webhook":
return await _send_webhook(payload)
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
fallback = _clean_text(runtime.magent_notify_email_from_address)
if fallback and fallback not in recipients:
recipients.append(fallback)
if not recipients:
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
body_text = (
f"{title}\n\n"
f"{message}\n\n"
f"Kind: {_clean_text(payload.get('kind'))}\n"
f"Status: {_clean_text(payload.get('status'))}\n"
f"Priority: {_clean_text(payload.get('priority'))}\n"
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
f"Open: {_clean_text(payload.get('item_url'))}\n"
)
body_html = (
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
"</table>"
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
"</div>"
)
deliveries: list[Dict[str, Any]] = []
for recipient in recipients:
try:
result = await send_generic_email(
recipient_email=recipient,
subject=title,
body_text=body_text,
body_html=body_html,
)
deliveries.append({"recipient": recipient, "status": "ok", **result})
except Exception as exc:
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
if successful:
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
async def send_portal_notification(
*,
event_type: str,
item: Dict[str, Any],
actor_username: str,
actor_role: str,
note: Optional[str] = None,
) -> Dict[str, Any]:
runtime = get_runtime_settings()
if not runtime.magent_notify_enabled:
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
item_id = int(item.get("id") or 0)
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
message_lines = [
f"Event: {event_type}",
f"Actor: {actor_username} ({actor_role})",
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
]
if note:
message_lines.append(f"Note: {note}")
message_lines.append(f"Open: {_portal_item_url(item_id)}")
message = "\n".join(message_lines)
payload = {
"type": "portal.notification",
"event": event_type,
"item_id": item_id,
"item_url": _portal_item_url(item_id),
"kind": _clean_text(item.get("kind")),
"status": _clean_text(item.get("status")),
"priority": _clean_text(item.get("priority")),
"requested_by": _clean_text(item.get("created_by_username")),
"actor_username": actor_username,
"actor_role": actor_role,
"note": note or "",
}
channels: Dict[str, Dict[str, Any]] = {}
if runtime.magent_notify_discord_enabled:
try:
channels["discord"] = await _send_discord(title, message, payload)
except Exception as exc:
channels["discord"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_telegram_enabled:
try:
channels["telegram"] = await _send_telegram(title, message)
except Exception as exc:
channels["telegram"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_webhook_enabled:
try:
channels["webhook"] = await _send_webhook(payload)
except Exception as exc:
channels["webhook"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_push_enabled:
try:
channels["push"] = await _send_push(title, message, payload)
except Exception as exc:
channels["push"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_email_enabled:
try:
channels["email"] = await _send_email(title, message, payload)
except Exception as exc:
channels["email"] = {"status": "error", "detail": str(exc)}
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
failed = [name for name, value in channels.items() if value.get("status") == "error"]
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
logger.info(
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
event_type,
item_id,
successful,
failed,
skipped,
)
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
if not channels:
overall = "skipped"
return {"status": overall, "channels": channels}
+206
View File
@@ -0,0 +1,206 @@
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
+3
View File
@@ -112,6 +112,9 @@ async def _fetch_all_seerr_users() -> list[dict]:
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
+744 -38
View File
@@ -15,8 +15,10 @@ from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings
from ..db import (
save_snapshot,
get_recent_actions,
get_request_cache_payload,
get_request_cache_by_id,
get_request_download_evidence,
get_recent_snapshots,
get_setting,
set_setting,
@@ -30,6 +32,8 @@ logger = logging.getLogger(__name__)
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
_jellyfin_scan_key = "jellyfin_scan_last_at"
REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
STATUS_LABELS = {
@@ -58,6 +62,22 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None:
"""Use the collector's authoritative identity when cached Seerr metadata is sparse."""
if not isinstance(arr_item, dict):
return
if snapshot.title in {None, "", "Unknown"}:
title = arr_item.get("title") or arr_item.get("seriesTitle")
if isinstance(title, str) and title.strip():
snapshot.title = title.strip()
if not snapshot.year:
year = arr_item.get("year")
try:
snapshot.year = int(year) if year else snapshot.year
except (TypeError, ValueError):
pass
def _normalize_media_title(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
@@ -206,7 +226,20 @@ async def _get_seerr_media_details(
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
collector_has_file = bool(
isinstance(collector_item, dict)
and (
collector_item.get("hasFile")
or snapshot.request_type == RequestType.tv
and isinstance(collector_stats, dict)
and collector_stats.get("episodeFileCount")
)
)
if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
snapshot.state == NormalizedState.importing and collector_has_file
):
return
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
@@ -222,8 +255,9 @@ async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
pass
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
if previous:
prev_state = previous[0].get("state")
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
previous_payload = previous[0].get("payload") or {}
previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
if previous_jellyfin.get("found"):
return
try:
await client.refresh_library()
@@ -300,6 +334,43 @@ def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]:
return grouped
def _episode_availability(episodes: Any) -> Dict[str, Any]:
if not isinstance(episodes, list):
return {"available": 0, "missing": 0, "total": 0, "seasons": []}
now = datetime.now(timezone.utc)
season_rows: Dict[int, Dict[str, Any]] = {}
for episode in episodes:
if not isinstance(episode, dict) or not episode.get("monitored", True):
continue
air_date = episode.get("airDateUtc")
if isinstance(air_date, str):
try:
aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
except ValueError:
aired_at = None
if aired_at and aired_at > now:
continue
season_number = episode.get("seasonNumber")
if not isinstance(season_number, int):
continue
row = season_rows.setdefault(
season_number,
{"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0},
)
row["total"] += 1
if episode.get("hasFile"):
row["available"] += 1
else:
row["missing"] += 1
seasons = [season_rows[key] for key in sorted(season_rows)]
return {
"available": sum(int(row["available"]) for row in seasons),
"missing": sum(int(row["missing"]) for row in seasons),
"total": sum(int(row["total"]) for row in seasons),
"seasons": seasons,
}
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
if not torrents:
return {"state": "idle", "message": "0 active downloads."}
@@ -344,6 +415,546 @@ def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[st
return f"https://image.tmdb.org/t/p/{size}{path}"
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
progress = torrent.get("progress")
try:
numeric = float(progress)
except (TypeError, ValueError):
numeric = -1
if 0 <= numeric <= 1:
return round(numeric * 100, 1)
try:
size = float(torrent.get("size"))
amount_left = float(torrent.get("amount_left"))
except (TypeError, ValueError):
return None
if size <= 0:
return None
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
def _parse_action_time(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
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
for action in get_recent_actions(request_id, 25):
if action.get("action_id") not in REPAIR_ACTION_IDS:
continue
created_at = _parse_action_time(action.get("created_at"))
if created_at is None:
continue
age_seconds = (current_time - created_at).total_seconds()
if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
return action
return None
def _build_repair_activity(
snapshot: Snapshot,
*,
action: Optional[Dict[str, Any]],
arr_state: str,
arr_details: Dict[str, Any],
download: Dict[str, Any],
jellyfin_found: bool,
) -> Optional[Dict[str, Any]]:
if not action:
return None
action_id = str(action.get("action_id") or "")
collector = (
"Bazarr"
if action_id == "repair_subtitles"
else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
)
action_ok = str(action.get("status") or "").lower() == "ok"
action_message = str(action.get("message") or "The repair action was recorded.")
download_state = str(download.get("state") or "not_started")
download_visible = bool(download.get("visible"))
availability = arr_details.get("availability")
if not isinstance(availability, dict):
availability = {}
missing = int(availability.get("missing") or 0)
total = int(availability.get("total") or 0)
collection_complete = arr_state == "available" and (
snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
)
submitted_step = {
"id": "submitted",
"label": "Repair requested",
"state": "complete",
"detail": "Magent recorded the issue and started the selected repair.",
}
if not action_ok:
return {
"visible": True,
"actionId": action_id,
"state": "attention",
"headline": "Repair needs attention",
"message": action_message,
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": f"{collector} hand-off",
"state": "attention",
"detail": action_message,
},
],
}
if action_id == "repair_subtitles":
return {
"visible": True,
"actionId": action_id,
"state": "searching",
"headline": "Subtitle repair is running",
"message": (
f"{action_message} Bazarr is checking the configured subtitle providers; "
"the issue can be confirmed once the replacement track is available."
),
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": "Bazarr accepted the search",
"state": "complete",
"detail": action_message,
},
{
"id": "result",
"label": "Subtitle result",
"state": "active",
"detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
},
],
}
if collection_complete:
headline = "Repair collected"
message = (
f"{collector} now reports the replacement file as collected. "
+ (
"It is also available in Grizzlyflix."
if jellyfin_found
else "Grizzlyflix is indexing the updated file now."
)
)
state = "complete" if jellyfin_found else "indexing"
download_step_state = "complete"
download_step_detail = f"{collector} reports the replacement file as collected and imported."
available_step_state = "complete" if jellyfin_found else "active"
elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
state = {
"downloading": "downloading",
"completed": "importing",
"paused": "attention",
"error": "attention",
"missing": "attention",
}[download_state]
headline = {
"downloading": "Replacement download in progress",
"completed": "Replacement downloaded — waiting for import",
"paused": "Replacement download paused",
"error": "Replacement download cannot be checked",
"missing": "Replacement hand-off needs checking",
}[download_state]
message = {
"downloading": "The replacement is downloading now.",
"paused": "The replacement download is paused and needs attention.",
"completed": f"The download has finished and is waiting for {collector} to import it.",
"error": "Magent cannot currently read the replacement download from qBittorrent.",
"missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
}[download_state]
download_step_state = "active" if download_state == "downloading" else (
"complete" if download_state == "completed" else "attention"
)
download_step_detail = str(
download.get("summary") or "Magent found the replacement download in qBittorrent."
)
available_step_state = "waiting"
else:
state = "searching"
headline = "Replacement search in progress"
message = (
f"{action_message} {collector} has accepted the search, but no replacement download "
"has been selected yet. Magent will keep checking."
)
download_step_state = "waiting"
download_step_detail = "Waiting for a suitable release to be selected."
available_step_state = "waiting"
return {
"visible": True,
"actionId": action_id,
"state": state,
"headline": headline,
"message": message,
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": f"{collector} accepted the search",
"state": "complete",
"detail": action_message,
},
{
"id": "download",
"label": "Replacement download",
"state": download_step_state,
"detail": download_step_detail,
},
{
"id": "available",
"label": "Updated media available",
"state": available_step_state,
"detail": (
"The repaired title is available in Grizzlyflix."
if jellyfin_found and collection_complete
else (
"The media server is indexing the replacement."
if collection_complete
else "Waiting for download and import to finish."
)
),
},
],
}
def _build_presentation(
snapshot: Snapshot,
*,
approved: bool,
arr_state: str,
arr_details: Dict[str, Any],
prowlarr_state: str,
download: Dict[str, Any],
jellyfin_found: bool,
jellyfin_link: Optional[str],
) -> Dict[str, Any]:
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
noun = "episode" if snapshot.request_type == RequestType.tv else "movie"
availability = arr_details.get("availability")
if not isinstance(availability, dict):
availability = {"available": 0, "missing": 0, "total": 0, "seasons": []}
available = int(availability.get("available") or 0)
missing = int(availability.get("missing") or 0)
total = int(availability.get("total") or 0)
partial = available > 0 and missing > 0
jellyfin_partial = bool(
jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0
)
fully_available = bool(jellyfin_found and not jellyfin_partial)
download_visible = bool(download.get("visible"))
download_state = str(download.get("state") or "not_started")
if snapshot.state == NormalizedState.requested:
status_label = "Waiting for approval"
meaning = "This request has been received, but it must be approved before collection can begin."
elif snapshot.state == NormalizedState.needs_add:
status_label = "Approved, but not yet in the library queue"
meaning = (
f"The request was approved, but it has not reached the {collector} collector yet. "
"Adding it to the library queue is the next step."
)
elif jellyfin_partial:
status_label = f"Partially available — {available} of {total} episodes collected"
meaning = (
f"Some of this request is ready to watch. {collector} is still looking for "
f"{missing} missing episode{'s' if missing != 1 else ''}."
)
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
status_label = "Available to watch"
meaning = "Collection is complete and the title is available on the media server."
elif download_visible and download_state == "paused":
status_label = "Download paused"
meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed."
elif download_visible and download_state == "missing":
status_label = "Download attempt is no longer visible"
meaning = (
"A download was previously queued for this request, but qBittorrent no longer reports it. "
"A fresh release search may be required."
)
elif download_visible and download_state == "error":
status_label = "Unable to read the current download"
meaning = (
"A download attempt exists, but Magent cannot currently read its progress from qBittorrent."
)
elif snapshot.state == NormalizedState.downloading:
status_label = "Download in progress"
meaning = "A release has been collected and is currently downloading."
elif snapshot.state == NormalizedState.importing:
if arr_state == "available" and not jellyfin_found:
status_label = "Collected — waiting for the media server"
meaning = (
f"{collector} has collected and imported this title, but it is not visible on "
"the media server yet."
)
else:
status_label = "Downloaded — waiting for library import"
meaning = f"The download has finished and {collector} is preparing it for the media server."
elif arr_state == "error":
status_label = "Unable to read the library queue"
meaning = (
f"The request is approved, but Magent could not read its current state from {collector}. "
"The service may be temporarily unavailable."
)
elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total:
if partial:
status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing"
meaning = (
f"The request was approved and sent to {collector}. {available} of {total} aired "
f"episodes have been collected; {missing} still need a matching release."
)
elif missing:
status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}"
meaning = (
f"The request was approved and sent to the {collector} collector, but none of the "
f"{total} aired episodes have been collected yet."
)
else:
status_label = "Added to library queue"
meaning = f"The request was approved and sent to the {collector} collector."
elif arr_state in {"added", "searching"}:
status_label = "Added to library queue — waiting for a matching release"
meaning = (
f"The request was approved and sent to the {collector} collector, but a usable release "
"has not been collected yet."
)
elif snapshot.state == NormalizedState.failed:
status_label = "This request needs attention"
meaning = snapshot.state_reason or "Magent could not determine the next stage for this request."
else:
status_label = "Approved — preparing collection" if approved else "Request received"
meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process."
action_ids = [action.id for action in snapshot.actions]
if fully_available:
next_title = "Ready to watch"
next_description = "Collection is complete. Open the title on the media server when you are ready."
recommended = []
elif "resume_torrent" in action_ids:
next_title = "Resume the interrupted download"
next_description = "The download exists but is not currently progressing. Resume it to continue collection."
recommended = ["resume_torrent"]
elif "readd_to_arr" in action_ids:
next_title = "Add this request to the library queue"
next_description = f"Send the approved request to {collector} so collection can begin."
recommended = ["readd_to_arr"]
elif "search_auto" in action_ids or "search_releases" in action_ids:
if snapshot.request_type == RequestType.tv and missing:
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
else:
target = f"a matching {noun} release"
next_title = f"Search for {target}"
next_description = (
"Run an automatic search, or review the available releases and choose one manually."
)
recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids]
elif download_state == "downloading":
next_title = "Let the current download finish"
next_description = "Magent is tracking the active download; no action is needed right now."
recommended = []
elif snapshot.state == NormalizedState.importing and arr_state == "available":
next_title = "Wait for the media server to index this title"
next_description = (
f"{collector} has completed its work. Use Recheck request to see whether the title "
"has appeared on the media server."
)
recommended = []
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
next_title = "Ready to watch"
next_description = "Collection is complete. Open the title on the media server when you are ready."
recommended = []
elif snapshot.state == NormalizedState.requested:
next_title = "Wait for approval"
next_description = "An administrator must approve this request before collection can start."
recommended = []
else:
next_title = "Magent is checking the next step"
next_description = "No safe action is available until the current service state is known."
recommended = []
requested_stage = {
"id": "requested",
"label": "Requested",
"state": "complete",
"summary": "Request received",
}
approved_stage = {
"id": "approved",
"label": "Approved",
"state": "complete" if approved else "active",
"summary": "Approved for collection" if approved else "Waiting for approval",
}
if arr_state == "missing":
library_state, library_summary = "attention", "Not yet added to the collector"
elif arr_state == "error":
library_state, library_summary = "attention", f"Unable to read {collector}"
elif partial:
library_state, library_summary = "partial", f"{available} of {total} episodes collected"
elif arr_state == "available":
library_state, library_summary = "complete", "Collection complete"
elif arr_state in {"added", "searching"}:
library_state = "active" if missing or not available else "complete"
library_summary = (
f"{missing} episode{'s' if missing != 1 else ''} still missing"
if snapshot.request_type == RequestType.tv and missing
else "In the library queue"
)
else:
library_state, library_summary = "waiting", "Waiting for collector information"
if fully_available:
search_state, search_summary = "complete", "No further search needed"
elif arr_state == "available":
search_state, search_summary = "complete", "A release was collected"
elif download_visible:
search_state = "complete"
search_summary = "A release was found"
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
search_state = "active" if prowlarr_state == "ok" else "attention"
search_summary = (
f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}"
if snapshot.request_type == RequestType.tv and missing
else "Ready to search for a release"
)
else:
search_state, search_summary = "waiting", "Search has not started"
completed_download_summary = (
"The requested content has been collected and is available to watch. "
"No further action is needed."
)
if fully_available:
download_stage_state, download_summary = "complete", completed_download_summary
pipeline_download_visible = False
pipeline_torrents: List[Dict[str, Any]] = []
elif arr_state == "available":
download_stage_state = "complete"
download_summary = f"{collector} has imported the collected file"
pipeline_download_visible = False
pipeline_torrents = []
elif download_visible:
download_stage_state = {
"downloading": "active",
"paused": "attention",
"completed": "complete",
"missing": "attention",
"error": "attention",
}.get(download_state, "waiting")
download_summary = str(download.get("summary") or "A prior download attempt was found")
pipeline_download_visible = True
pipeline_torrents = download.get("torrents") or []
else:
download_stage_state, download_summary = "waiting", "No download attempt yet"
pipeline_download_visible = False
pipeline_torrents = []
if jellyfin_partial:
available_label = "Partially available"
available_state = "partial"
available_state_label = "Partly ready"
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
elif jellyfin_found:
available_label = "Available to watch"
available_state = "complete"
available_state_label = "Ready"
available_summary = "This title is ready to watch in Grizzlyflix."
elif arr_state == "available":
available_label = "Adding to Grizzlyflix"
available_state = "active"
available_state_label = "Indexing"
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
else:
available_label = "Media server"
available_state = "waiting"
available_state_label = "Waiting"
available_summary = "This title has not reached Grizzlyflix yet."
display_download = dict(download)
if fully_available:
display_download.update(
{
"visible": False,
"state": "completed",
"summary": completed_download_summary,
"torrents": [],
}
)
return {
"status": {"label": status_label, "meaning": meaning},
"download": display_download,
"nextStep": {
"title": next_title,
"description": next_description,
"actionIds": recommended,
},
"pipeline": [
requested_stage,
approved_stage,
{
"id": "library",
"label": "Library collection",
"state": library_state,
"summary": library_summary,
"available": available,
"missing": missing,
"total": total,
"seasons": availability.get("seasons") or [],
"missingEpisodes": arr_details.get("missingEpisodes") or {},
},
{
"id": "search",
"label": "Release search",
"state": search_state,
"summary": search_summary,
"actionIds": [] if fully_available else [
action_id
for action_id in ("search_auto", "search_releases")
if action_id in action_ids
],
},
{
"id": "download",
"label": "Download complete" if fully_available else "Download",
"state": download_stage_state,
"summary": download_summary,
"visible": pipeline_download_visible,
"torrents": pipeline_torrents,
},
{
"id": "available",
"label": available_label,
"state": available_state,
"stateLabel": available_state_label,
"summary": available_summary,
"link": jellyfin_link,
},
],
}
async def build_snapshot(request_id: str) -> Snapshot:
timeline = []
runtime = get_runtime_settings()
@@ -449,7 +1060,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
poster_path = media.get("posterPath") or media.get("poster_path")
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
if snapshot.title in {None, "", "Unknown"} and allow_remote:
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
@@ -533,6 +1144,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
arr_details["queue"] = arr_queue
episodes = await sonarr.get_episodes(series_id)
arr_details["availability"] = _episode_availability(episodes)
missing_by_season = _missing_episode_numbers_by_season(episodes)
if missing_by_season:
arr_details["missingEpisodes"] = missing_by_season
@@ -581,6 +1193,12 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_state = "added"
else:
arr_state = "missing"
arr_details["availability"] = {
"available": 1 if arr_item and arr_item.get("hasFile") else 0,
"missing": 0 if arr_item and arr_item.get("hasFile") else 1,
"total": 1,
"seasons": [],
}
if arr_item and isinstance(arr_item.get("id"), int):
arr_queue = await radarr.get_queue(int(arr_item["id"]))
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
@@ -592,15 +1210,20 @@ async def build_snapshot(request_id: str) -> Snapshot:
if arr_state is None:
arr_state = "unknown"
_apply_arr_identity(snapshot, arr_item)
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
prowlarr_state = "unknown"
try:
prowlarr_health = await prowlarr.get_health()
if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
prowlarr_state = "issues"
timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
else:
prowlarr_state = "ok"
timeline.append(TimelineHop(service="Prowlarr", status="ok"))
except Exception as exc:
prowlarr_state = "error"
timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
jellyfin_available = False
@@ -668,34 +1291,66 @@ async def build_snapshot(request_id: str) -> Snapshot:
except Exception:
pass
qbit_state = None
qbit_message = None
qbit_state = "not_started"
qbit_message = "No download attempt has been observed."
download_ids = _download_ids(_queue_records(arr_queue))
download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100)
torrent_list: List[Dict[str, Any]] = []
download_visible = bool(download_ids) or bool(download_history.get("observed"))
qbit_error = None
try:
download_ids = _download_ids(_queue_records(arr_queue))
torrent_list: List[Dict[str, Any]] = []
if qbittorrent.configured():
if download_ids:
torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
torrent_list = torrents if isinstance(torrents, list) else []
else:
category = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_category(category)
request_tag = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
torrent_list = torrents if isinstance(torrents, list) else []
summary = _summarize_qbit(torrent_list)
qbit_state = summary.get("state")
qbit_message = summary.get("message")
timeline.append(
TimelineHop(
service="qBittorrent",
status=summary["state"],
details={
"summary": summary["message"],
"torrents": torrent_list,
},
for torrent in torrent_list:
if isinstance(torrent, dict):
torrent["progressPercent"] = _torrent_progress(torrent)
if torrent_list:
download_visible = True
summary = _summarize_qbit(torrent_list)
qbit_state = str(summary.get("state") or "idle")
qbit_message = str(summary.get("message") or "Download found in qBittorrent.")
elif download_ids:
qbit_state = "missing"
qbit_message = (
"The collector queued a download, but it is no longer visible in qBittorrent."
)
elif download_history.get("observed"):
qbit_state = "missing"
qbit_message = (
"A previous download was observed, but it is not currently visible in qBittorrent."
)
)
except Exception as exc:
timeline.append(TimelineHop(service="qBittorrent", status="error", details={"error": str(exc)}))
qbit_error = str(exc)
if download_visible:
qbit_state = "error"
qbit_message = (
"A download attempt exists, but Magent cannot currently read its state from qBittorrent."
)
download_presentation = {
"visible": download_visible,
"observed": download_visible,
"state": qbit_state,
"summary": qbit_message,
"torrents": torrent_list,
"lastSeenAt": download_history.get("last_seen_at"),
}
timeline.append(
TimelineHop(
service="qBittorrent",
status=qbit_state,
details={
**download_presentation,
"error": qbit_error,
},
)
)
status_code = None
try:
@@ -720,8 +1375,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state_reason = qbit_message
elif qbit_state == "completed":
if arr_state == "available":
snapshot.state = NormalizedState.completed
snapshot.state_reason = "In your library and ready to watch."
snapshot.state = NormalizedState.importing
snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
else:
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Download finished. Waiting for library import."
@@ -737,8 +1392,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state = NormalizedState.searching
snapshot.state_reason = "Searching for a matching release."
elif arr_state == "available":
snapshot.state = NormalizedState.completed
snapshot.state_reason = "In your library and ready to watch."
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
snapshot.state = NormalizedState.added_to_arr
snapshot.state_reason = "Item is present in Sonarr/Radarr"
@@ -766,23 +1421,32 @@ async def build_snapshot(request_id: str) -> Snapshot:
actions.append(
ActionOption(
id="readd_to_arr",
label="Push to Sonarr/Radarr",
label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}",
risk="medium",
description="Send this approved request to the library collector.",
)
)
elif arr_item and arr_state != "available":
elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}:
missing_count = int((arr_details.get("availability") or {}).get("missing") or 0)
automatic_label = (
f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}"
if snapshot.request_type == RequestType.tv and missing_count
else "Search automatically for a release"
)
actions.append(
ActionOption(
id="search_auto",
label="Search and auto-download",
label=automatic_label,
risk="low",
description="Ask the library collector to find and download the best permitted match.",
)
)
actions.append(
ActionOption(
id="search_releases",
label="Search and choose a download",
label="Review available releases",
risk="low",
description="Search the configured indexers and choose a release yourself.",
)
)
@@ -793,18 +1457,28 @@ async def build_snapshot(request_id: str) -> Snapshot:
id="resume_torrent",
label="Resume the download",
risk="low",
description="Resume the existing qBittorrent job if it is paused or stalled.",
)
)
snapshot.actions = actions
jellyfin_link = None
if runtime.jellyfin_public_url and snapshot.state in {
NormalizedState.available,
NormalizedState.completed,
}:
if runtime.jellyfin_public_url and jellyfin_available:
base_url = runtime.jellyfin_public_url.rstrip("/")
query = quote(snapshot.title or "")
jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
if jellyfin_item_id:
jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}"
else:
query = quote(snapshot.title or "")
jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
availability = arr_details.get("availability") or {}
is_partial = bool(
jellyfin_available
and snapshot.request_type == RequestType.tv
and int(availability.get("missing") or 0) > 0
)
if jellyfin_available and not is_partial:
snapshot.actions = []
snapshot.raw = {
"jellyseerr": jelly_request,
"arr": {
@@ -813,15 +1487,47 @@ async def build_snapshot(request_id: str) -> Snapshot:
},
"jellyfin": {
"publicUrl": runtime.jellyfin_public_url,
"available": snapshot.state in {
"found": jellyfin_available,
"available": jellyfin_available and snapshot.state in {
NormalizedState.available,
NormalizedState.completed,
},
"partial": is_partial,
"link": jellyfin_link,
"item": jellyfin_item,
},
"qbittorrent": {
**download_presentation,
"downloadIds": download_ids,
"error": qbit_error,
},
}
snapshot.presentation = _build_presentation(
snapshot,
approved=derived_approved,
arr_state=arr_state,
arr_details=arr_details,
prowlarr_state=prowlarr_state,
download=download_presentation,
jellyfin_found=jellyfin_available,
jellyfin_link=jellyfin_link,
)
repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
repair_activity = _build_repair_activity(
snapshot,
action=repair_action,
arr_state=arr_state,
arr_details=arr_details,
download=download_presentation,
jellyfin_found=jellyfin_available,
)
if repair_activity:
snapshot.presentation["repairActivity"] = repair_activity
status_presentation = snapshot.presentation.get("status")
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
snapshot.state_reason = str(status_presentation["meaning"])
await _maybe_refresh_jellyfin(snapshot)
await asyncio.to_thread(save_snapshot, snapshot)
return snapshot
+27
View File
@@ -89,6 +89,33 @@ def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int
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]:
+4 -4
View File
@@ -2,8 +2,8 @@ fastapi==0.134.0
uvicorn==0.41.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.13.1
python-jose[cryptography]==3.5.0
pydantic-settings==2.14.2
PyJWT==2.13.0
passlib==1.7.4
python-multipart==0.0.22
Pillow==12.1.1
python-multipart==0.0.31
Pillow==12.3.0
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
name: magent-beta
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_NAME: Magent Beta
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true"
SITE_BANNER_MESSAGE: "Beta environment"
SITE_BANNER_TONE: warning
ports:
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
- "127.0.0.1:8100:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
+110
View File
@@ -0,0 +1,110 @@
/* Top-navigation workspace and streamlined account screens. */
.page > main:not(.auth-screen):not(.auth-card):not(.login-page) { width: calc(100% - 64px); max-width: 1440px; margin: 32px auto 0; padding: 0; border: 0 !important; background: transparent !important; }
.page > .site-banner, .page > .user-view-banner { width: calc(100% - 64px); max-width: 1440px; margin: 16px auto 0; }
.admin-shell.admin-shell--top-nav { display: block; width: calc(100% - 64px); max-width: 1440px; margin: 24px auto 0; }
.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; }
.page > main.account-page { max-width: 920px !important; margin-top: 42px; }
.account-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.account-heading h1 { font-size: clamp(30px, 4vw, 40px); line-height: 1.2; margin: 7px 0 0; color: var(--ops-text); }
.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) {
.page > main:not(.auth-screen):not(.auth-card):not(.login-page), .page > .site-banner, .page > .user-view-banner, .admin-shell.admin-shell--top-nav { width: calc(100% - 32px); }
.page > main.account-page { margin-top: 28px; }
.account-heading { align-items: flex-start; gap: 16px; margin-bottom: 24px; }
.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
@@ -0,0 +1,72 @@
'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
@@ -0,0 +1,13 @@
'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>
)
}
+2
View File
@@ -8,9 +8,11 @@ const ALLOWED_SECTIONS = new Set([
'artwork',
'sonarr',
'radarr',
'bazarr',
'prowlarr',
'qbittorrent',
'requests',
'issue-workflow',
'cache',
'logs',
'maintenance',
+106
View File
@@ -0,0 +1,106 @@
/* 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
@@ -0,0 +1,34 @@
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')
+1 -13
View File
@@ -7,19 +7,7 @@ export default function AdminDiagnosticsPage() {
return (
<AdminShell
title="Diagnostics"
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
rail={
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Diagnostics</span>
<h2>Shared console</h2>
<p>
This page and Maintenance now use the same diagnostics panel, so every test target and
notification ping stays in one source of truth.
</p>
</div>
</div>
}
subtitle="Check connections and investigate service problems."
>
<AdminDiagnosticsPanel />
</AdminShell>
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from '../../portal/PortalClient'
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
}
+64 -13
View File
@@ -1,26 +1,77 @@
'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="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.
<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>
</div>
</section>
)}
</AdminShell>
)
}
+2 -2
View File
@@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Landing page</h3>
<p>Recent requests and service summaries refresh live for signed-in users.</p>
<p>Recent request activity refreshes live for signed-in users.</p>
</article>
<article className="system-guide-card">
<h3>Request pages</h3>
@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {
</article>
<article className="system-guide-card">
<h3>Admin views</h3>
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
</article>
</div>
</div>
+12 -11
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
type Profile = {
username?: string
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
throw new Error('Could not load profile.')
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
}
}
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, {
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -58,17 +60,16 @@ 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 {
+1028 -3
View File
File diff suppressed because it is too large Load Diff
+5 -24
View File
@@ -1,11 +1,10 @@
import './globals.css'
import './ops-redesign.css'
import './admin/config.css'
import './account.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 BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus'
import ApplicationChrome from './ui/ApplicationChrome'
export const metadata = {
title: 'Magent',
@@ -18,25 +17,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body>
<BrandingFavicon />
<div className="page">
<header className="header">
<div className="header-left">
<a className="brand-link" href="/">
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">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 />
<ApplicationChrome />
{children}
</div>
</body>
+72 -12
View File
@@ -1,27 +1,53 @@
const AUTH_STATE_COOKIE = 'magent_logged_in'
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
export const getToken = () => {
if (typeof window === 'undefined') return null
return window.localStorage.getItem('magent_token')
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
}
export const setToken = (token: string) => {
if (typeof window === 'undefined') return
window.localStorage.setItem('magent_token', token)
const clearCookie = (name: string) => {
if (typeof document === 'undefined') return
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
}
export const getToken = () => {
if (typeof document === 'undefined') return null
const cookies = document.cookie.split(';').map((entry) => entry.trim())
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
if (!marker) return null
const [, value] = marker.split('=', 2)
return value || null
}
export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
}
export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE)
if (typeof window === 'undefined') return
window.localStorage.removeItem('magent_token')
const baseUrl = getApiBase()
void fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
keepalive: true,
}).catch(() => undefined)
}
export const logout = async () => {
const baseUrl = getApiBase()
clearCookie(AUTH_STATE_COOKIE)
await fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
}
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const token = getToken()
const headers = new Headers(init?.headers || {})
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
return fetch(input, { ...init, headers, credentials: 'include' })
}
export const getEventStreamToken = async () => {
@@ -38,3 +64,37 @@ export const getEventStreamToken = async () => {
}
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 ''
}
}
+48
View File
@@ -0,0 +1,48 @@
'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
}
+88 -154
View File
@@ -1,176 +1,110 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { getApiBase, setToken, clearToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
import { useEffect, useState, type FormEvent } from 'react'
import { getApiBase, setToken } from '../lib/auth'
import MagentMark from '../ui/MagentMark'
const DEFAULT_LOGIN_OPTIONS = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
}
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 }
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
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 [loading, setLoading] = useState(false)
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
? 'jellyfin'
: loginOptions.showLocalLogin
? 'local'
: null
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
event.preventDefault()
if (!primaryMode) {
setError('Login is currently disabled. Contact an administrator.')
return
}
setError(null)
setLoading(true)
try {
clearToken()
const baseUrl = getApiBase()
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
const body = new URLSearchParams({ username, password })
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
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)
}
}
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin'
useEffect(() => {
let active = true
const loadLoginOptions = async () => {
const controller = new AbortController()
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/site/public`)
if (!response.ok) {
return
}
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal })
if (!response.ok) throw new Error('Options unavailable')
const data = await response.json()
const login = data?.login ?? {}
if (!active) return
setLoginOptions({
showJellyfinLogin: login.showJellyfinLogin !== false,
showLocalLogin: login.showLocalLogin !== false,
showForgotPassword: login.showForgotPassword !== false,
showSignupLink: login.showSignupLink !== false,
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,
})
} catch (err) {
console.error(err)
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 loadLoginOptions()
return () => {
active = false
}
void load()
return () => controller.abort()
}, [])
const loginHelpText = (() => {
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
}
if (loginOptions.showJellyfinLogin) {
return 'Use your Jellyfin account to sign in.'
}
if (loginOptions.showLocalLogin) {
return 'Use your local Magent admin account to sign in.'
}
return 'No sign-in methods are currently available. Contact an administrator.'
})()
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (loading || !canSignIn || !optionsReady) return
setError('')
setLoading(true)
try {
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: username.trim(), password }),
credentials: 'include',
})
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
}
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) }
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">{loginHelpText}</p>
<form
onSubmit={(event) => {
if (!primaryMode) {
event.preventDefault()
setError('Login is currently disabled. Contact an administrator.')
return
}
void submit(event, primaryMode)
}}
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">
{loginOptions.showJellyfinLogin ? (
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button>
) : null}
</div>
{loginOptions.showLocalLogin ? (
<button
type="button"
className="ghost-button"
disabled={loading}
onClick={(event) => submit(event, 'local')}
>
Sign in with Magent account
</button>
) : null}
{loginOptions.showForgotPassword ? (
<a className="ghost-button" href="/forgot-password">
Forgot password?
</a>
) : null}
{loginOptions.showSignupLink ? (
<a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent)
</a>
) : null}
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
) : null}
</form>
<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">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
{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>
)}
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
)
}
@@ -0,0 +1,528 @@
'use client'
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">
<header className="request-portal-hero">
<div>
<span className="section-kicker">New requests</span>
<h1>Find something worth watching.</h1>
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
</div>
<div className="request-portal-route">
<span>Seerr</span><i aria-hidden="true" />
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
<span>Grizzlyflix</span>
</div>
</header>
<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
@@ -0,0 +1,9 @@
import NewRequestClient from './NewRequestClient'
export const metadata = {
title: 'New Requests | Magent',
}
export default function NewRequestsPage() {
return <NewRequestClient />
}
File diff suppressed because it is too large Load Diff
+158 -339
View File
@@ -16,6 +16,7 @@ const normalizeRecentResults = (items: any[]) =>
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
type: item.type,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
@@ -41,8 +42,9 @@ export default function HomePage() {
id: number
title: string
year?: number
type?: string
statusLabel?: string
artwork?: { poster_url?: string }
artwork?: { poster_url?: string; backdrop_url?: string }
createdAt?: string | null
}[]
>([])
@@ -64,14 +66,6 @@ export default function HomePage() {
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
>(null)
const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
@@ -84,61 +78,6 @@ export default function HomePage() {
void runSearch(trimmed)
}
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -198,45 +137,7 @@ export default function HomePage() {
if (!authReady) {
return
}
const load = async () => {
setServicesLoading(true)
setServicesError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Service status failed: ${response.status}`)
}
const data = await response.json()
setServicesStatus(data)
} catch (error) {
console.error(error)
setServicesError('Service status is not available right now.')
} finally {
setServicesLoading(false)
}
}
void load()
if (liveStreamConnected) {
return
}
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
if (!getToken()) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
@@ -257,14 +158,8 @@ export default function HomePage() {
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
@@ -281,29 +176,14 @@ export default function HomePage() {
}
return
}
if (payload.type === 'home_services') {
if (payload.status && typeof payload.status === 'object') {
setServicesStatus(payload.status)
setServicesError(null)
setServicesLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setServicesError('Service status is not available right now.')
setServicesLoading(false)
}
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (error) {
if (closed) return
console.error(error)
setLiveStreamConnected(false)
}
}
@@ -311,7 +191,6 @@ export default function HomePage() {
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [authReady, recentDays, recentStage])
@@ -362,230 +241,170 @@ export default function HomePage() {
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">
<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'}`}
<main className="card home-page">
<section className="home-command">
<div className="home-command-copy">
<span className="section-kicker">Media operations</span>
<h1>My requests</h1>
<p>Manage and track your media processing queue.</p>
</div>
<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>
</section>
{(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}`)}
>
{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>
</div>
<div className="system-list">
{(() => {
const order = [
'Seerr',
'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'
const testing = serviceTesting[name] ?? false
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span>
{serviceTestResults[name] && (
<span className="system-test-message">{serviceTestResults[name]}</span>
)}
</div>
<div className="system-actions">
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div>
)
})
})()}
</div>
</div>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests</span>
</div>
) : recentError ? (
<button type="button" disabled>
{recentError}
<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>
) : recent.length === 0 ? (
<button type="button" disabled>
No recent requests found
</button>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card"
>
{item.artwork?.poster_url && (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
)}
<span className="recent-info">
<span className="recent-title">
{item.title || 'Untitled'}
{item.year ? ` (${item.year})` : ''}
</span>
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
</button>
))
)}
))}
</div>
)}
</section>
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Search all requests</h1>
<p className="lede">
Search any request by title + year or request number and see whether it already
exists in the system.
</p>
)}
<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 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>
{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>
<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>
) : 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"
/>
) : (
searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
disabled={!item.requestId}
onClick={() =>
item.requestId && router.push(`/requests/${item.requestId}`)
}
>
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId
? '- not requested'
: item.statusLabel
? `- ${item.statusLabel}`
: '- already requested'}
</button>
))
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
</div>
</section>
</section>
</aside>
</div>
<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>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
</main>
)
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function IssuePortalPage() {
return <PortalClient workspace="issue" />
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function PortalIndexPage() {
redirect('/new-requests')
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RequestPortalPage() {
redirect('/new-requests')
}
+176 -492
View File
@@ -1,135 +1,75 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
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
auth_provider: string
invite_management_enabled?: boolean
}
type ProfileResponse = {
user: ProfileInfo
}
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_expired?: boolean
is_usable?: boolean
created_at?: string | null
updated_at?: string | null
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[]
count?: number
invite_access?: {
enabled?: boolean
managed_by_master?: boolean
}
master_invite?: {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
enabled?: boolean
expires_at?: string | null
is_usable?: boolean
} | null
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 OwnedInviteForm = {
code: string
label: string
description: string
recipient_email: string
max_uses: string
expires_at: string
enabled: boolean
send_email: boolean
message: string
type InviteForm = {
code: string; label: string; description: string; recipient_email: string
enabled: boolean; message: string
}
type DeliveryMethod = '' | 'manual' | 'email'
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
recipient_email: '',
max_uses: '',
expires_at: '',
enabled: true,
send_email: false,
message: '',
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)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
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 [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [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 loadPage = async () => {
const baseUrl = getApiBase()
const [profileResponse, invitesResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/profile`),
authFetch(`${baseUrl}/auth/profile/invites`),
])
if (!profileResponse.ok || !invitesResponse.ok) {
if (profileResponse.status === 401 || invitesResponse.status === 401) {
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 invite tools.')
throw new Error('Could not load your invite workspace.')
}
const [profileData, inviteData] = (await Promise.all([
profileResponse.json(),
invitesResponse.json(),
])) as [ProfileResponse, OwnedInvitesResponse]
const user = profileData?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
})
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(inviteData?.master_invite ?? null)
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(() => {
@@ -139,10 +79,21 @@ export default function ProfileInvitesPage() {
}
const load = async () => {
try {
await loadPage()
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)
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.')
} finally {
setLoading(false)
}
@@ -150,69 +101,65 @@ export default function ProfileInvitesPage() {
void load()
}, [router])
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultOwnedInviteForm())
const resetFlow = () => {
setEditingId(null)
setFlowStep(1)
setUseCustomCode(false)
setDeliveryMethod('')
setInviteForm(defaultInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setInviteEditingId(invite.id)
setInviteError(null)
setInviteStatus(null)
setEditingId(invite.id)
setCreatedInvite(null)
setFlowStep(4)
setUseCustomCode(true)
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
setInviteForm({
code: invite.code ?? '',
code: invite.code,
label: invite.label ?? '',
description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
send_email: false,
message: '',
})
}
const reloadInvites = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Invite refresh failed: ${response.status}`)
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data?.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(data?.master_invite ?? null)
setError(null)
setStatus(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setInviteSaving(true)
setInviteError(null)
setInviteStatus(null)
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 baseUrl = getApiBase()
const response = await authFetch(
inviteEditingId == null
? `${baseUrl}/auth/profile/invites`
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`,
{
method: inviteEditingId == null ? 'POST' : 'PUT',
method: editingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteForm.code || null,
label: inviteForm.label || null,
code: useCustomCode ? inviteForm.code || null : null,
label: inviteName,
description: inviteForm.description || null,
recipient_email: inviteForm.recipient_email || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
recipient_email: deliveryMethod === 'email' ? recipientEmail : null,
enabled: inviteForm.enabled,
send_email: inviteForm.send_email,
send_email: editingId == null && deliveryMethod === 'email',
message: inviteForm.message || null,
}),
}
@@ -223,399 +170,136 @@ export default function ProfileInvitesPage() {
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite save failed')
throw new Error((await response.text()) || 'Could not save the invite.')
}
const data = await response.json().catch(() => ({}))
if (data?.email?.status === 'ok') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
resetInviteEditor()
await reloadInvites()
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)
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
setError(err instanceof Error ? err.message : 'Could not save the invite.')
} finally {
setInviteSaving(false)
setSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setInviteError(null)
setInviteStatus(null)
if (!window.confirm(`Delete invite ${invite.label || invite.code}?`)) return
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite delete failed')
}
if (inviteEditingId === invite.id) {
resetInviteEditor()
}
setInviteStatus(`Deleted invite ${invite.code}.`)
await reloadInvites()
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)
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
setError(err instanceof Error ? err.message : 'Could not delete the invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setInviteStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
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 tools...</main>
}
if (loading) return <main className="card">Loading invite workspace</main>
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My invites</h1>
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
Back to profile
</button>
<span className="section-kicker">04 · Invites</span>
<h1>Invite someone to Grizzlyflix</h1>
<p className="lede">Create a secure invitation one simple decision at a time.</p>
</div>
</div>
{profile ? (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}).
</div>
) : null}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=activity')}
>
Activity
</button>
<button type="button" role="tab" aria-selected className="is-active">
My invites
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=security')}
>
Security
</button>
</div>
</div>
{inviteError && <div className="error-banner">{inviteError}</div>}
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{!canManageInvites ? (
<section className="profile-section profile-tab-panel">
<h2>Invite access is disabled</h2>
<p className="lede">
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
</p>
<div className="admin-inline-actions">
<button type="button" onClick={() => router.push('/profile')}>
Return to profile
</button>
</div>
<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="user-directory-panel-header">
<div>
<h2>Invite workspace</h2>
<p className="lede">
{inviteManagedByMaster
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
</p>
</div>
<div 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>
<div className="profile-invites-layout">
<div className="profile-invite-form-card">
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
<p className="meta profile-invite-form-lede">
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
</p>
{inviteManagedByMaster && masterInviteTemplate ? (
<div className="status-banner profile-invite-master-banner">
Using master invite rule <code>{masterInviteTemplate.code}</code>
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
</div>
) : null}
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Optional code and label for easier tracking.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(event) =>
setInviteForm((current) => ({ ...current, code: event.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(event) =>
setInviteForm((current) => ({ ...current, label: event.target.value }))
}
placeholder="Family invite"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="friend@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
{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>
<div className="profile-invites-list">
{invites.length === 0 ? (
<div className="status-banner">You have not created any invites yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
<span className="small-pill is-muted">
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
</span>
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => copyInviteLink(invite)}
>
Copy link
</button>
<button
type="button"
className="ghost-button"
onClick={() => editInvite(invite)}
>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
<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>
)}
</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>
)}
+172 -400
View File
@@ -1,461 +1,233 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } 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
invite_management_enabled?: boolean
password_change_supported?: boolean
password_provider?: 'local' | 'jellyfin' | null
}
type ProfileStats = {
total: number
ready: number
pending: number
in_progress: number
declined: number
working: number
partial: number
approved: number
last_request_at?: string | null
share: number
global_total: number
most_active_user?: { username: string; total: number } | null
}
type ActivityEntry = {
ip: string
user_agent: string
first_seen_at: string
last_seen_at: string
hit_count: number
}
type ProfileActivity = {
last_ip?: string | null
last_user_agent?: string | null
last_seen_at?: string | null
device_count: number
recent: ActivityEntry[]
}
type ProfileResponse = {
user: ProfileInfo
stats: ProfileStats
activity: ProfileActivity
stats?: { total: number; ready: number; in_progress: number }
activity?: { recent: ActivityEntry[] }
}
type ProfileTab = 'overview' | 'activity' | 'security'
const normalizeProfileTab = (value?: string | null): ProfileTab => {
if (value === 'activity' || value === 'security') {
return value
}
return 'overview'
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
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 date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
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'
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
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [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 [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
const [loading, setLoading] = useState(true)
const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordNotice, setPasswordNotice] = useState<Notice>(null)
const [showAllActivity, setShowAllActivity] = useState(false)
const inviteLink = useMemo(() => '/profile/invites', [])
useEffect(() => {
if (typeof window === 'undefined') return
const syncTabFromLocation = () => {
const params = new URLSearchParams(window.location.search)
setActiveTab(normalizeProfileTab(params.get('tab')))
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)
}
syncTabFromLocation()
window.addEventListener('popstate', syncTabFromLocation)
return () => window.removeEventListener('popstate', syncTabFromLocation)
}, [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}`)
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
event.preventDefault()
selectTab(TABS[next].key)
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus()
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
if (!profileResponse.ok) {
clearToken()
router.push('/login')
return
}
const data = (await profileResponse.json()) as ProfileResponse
const user = data?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
password_change_supported: Boolean(user?.password_change_supported ?? false),
password_provider:
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
? user.password_provider
: null,
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
} catch (err) {
console.error(err)
setStatus({ tone: 'error', message: 'Could not load your profile.' })
} finally {
setLoading(false)
}
}
void load()
}, [router])
const submit = async (event: React.FormEvent) => {
const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
if (!currentPassword || !newPassword) {
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
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.' })
return
}
if (newPassword !== confirmPassword) {
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
setPasswordNotice({ tone: 'error', message: 'The new passwords do not match.' })
return
}
setPasswordSaving(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
const response = await authFetch(`${getApiBase()}/auth/password`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
})
if (!response.ok) {
let detail = 'Update failed'
try {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
detail = payload.detail
}
} catch {
const text = await response.text().catch(() => '')
if (text?.trim()) detail = text
}
throw new Error(detail)
}
const data = await response.json().catch(() => ({}))
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setStatus({
tone: 'status',
message:
data?.provider === 'jellyfin'
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
: 'Password updated.',
})
} catch (err) {
console.error(err)
if (err instanceof Error && err.message) {
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
} else {
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
}
}
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) }
}
const authProvider = profile?.auth_provider ?? 'local'
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
const securityHelpText =
passwordProvider === 'jellyfin'
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
: passwordProvider === 'local'
? 'Change your Magent account password.'
: 'Password changes are not available for this sign-in provider.'
if (loading) {
return <main className="card">Loading profile...</main>
}
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>
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My profile</h1>
<p className="lede">Review your account, activity, and security settings.</p>
<main className="account-page">
<header className="account-heading">
<div><span className="account-eyebrow">YOUR ACCOUNT</span><h1>My profile</h1></div>
{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>}
</header>
{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>)}
</div>
{canManageInvites || canChangePassword ? (
<div className="admin-inline-actions">
{canManageInvites ? (
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
Open invite page
</button>
) : null}
{canChangePassword ? (
<button type="button" onClick={() => selectTab('security')}>
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
</button>
) : null}
</div>
) : null}
</div>
{profile && (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
{profile.auth_provider}.
</div>
)}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'overview'}
className={activeTab === 'overview' ? 'is-active' : ''}
onClick={() => selectTab('overview')}
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'activity'}
className={activeTab === 'activity' ? 'is-active' : ''}
onClick={() => selectTab('activity')}
>
Activity
</button>
{canManageInvites ? (
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
My invites
</button>
) : null}
<button
type="button"
role="tab"
aria-selected={activeTab === 'security'}
className={activeTab === 'security' ? 'is-active' : ''}
onClick={() => selectTab('security')}
>
Password
</button>
</div>
</div>
{activeTab === 'overview' && (
<section className="profile-section profile-tab-panel">
{canManageInvites ? (
<div className="profile-quick-link-card">
<div>
<h2>Invite tools</h2>
<p className="lede">
Create invite links, send them by email, and track who you have invited from a dedicated page.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" onClick={() => router.push(inviteLink)}>
Go to invites
</button>
</div>
<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>
) : null}
{canChangePassword ? (
<div className="profile-quick-link-card">
<div>
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
<p className="lede">
{passwordProvider === 'jellyfin'
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
: 'Update your Magent account password.'}
</p>
</div>
<div className="admin-inline-actions">
<button type="button" onClick={() => selectTab('security')}>
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
</button>
</div>
</div>
) : null}
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Requests submitted</div>
<div className="stat-value">{stats?.total ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Ready to watch</div>
<div className="stat-value">{stats?.ready ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">In progress</div>
<div className="stat-value">{stats?.in_progress ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Pending approval</div>
<div className="stat-value">{stats?.pending ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Working</div>
<div className="stat-value">{stats?.working ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Partial</div>
<div className="stat-value">{stats?.partial ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Approved</div>
<div className="stat-value">{stats?.approved ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Last request</div>
<div className="stat-value stat-value--small">
{formatDate(stats?.last_request_at)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Total requests (global)</div>
<div className="stat-value">{stats?.global_total ?? 0}</div>
</div>
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
) : null}
</div>
</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>
</section>
)}
{activeTab === 'activity' && (
<section className="profile-section profile-tab-panel">
<h2>Connection history</h2>
<div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
</div>
<div className="connection-list">
{(activity?.recent ?? []).map((entry, index) => (
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
<div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div>
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div>
<div className="connection-count">{entry.hit_count} visits</div>
</div>
))}
{activity && activity.recent.length === 0 ? (
<div className="status-banner">No connection history yet.</div>
) : null}
</div>
<section 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>
)}
{activeTab === 'security' && (
<section className="profile-section profile-tab-panel">
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
<div className="status-banner">{securityHelpText}</div>
{canChangePassword ? (
<form onSubmit={submit} className="auth-form profile-security-form">
<label>
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status ? (
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
{status.message}
</div>
) : null}
<div className="auth-actions">
<button type="submit">
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
</button>
</div>
</form>
) : (
<div className="status-banner">
Password changes are not available for {authProvider} sign-in accounts from Magent.
</div>
)}
<section 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>
)}
</>}
</main>
)
}
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -106,6 +106,7 @@ function SignupPageContent() {
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(),
@@ -117,12 +118,12 @@ function SignupPageContent() {
throw new Error(text || 'Sign-up failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
if (data?.authenticated) {
setToken('cookie')
window.location.href = '/'
return
}
throw new Error('Sign-up did not return a token')
throw new Error('Sign-up did not complete')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.')
+5 -8
View File
@@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
import AdminSidebar from './AdminSidebar'
import SettingsNavigation from './SettingsNavigation'
type AdminShellProps = {
title: string
@@ -12,24 +12,21 @@ type AdminShellProps = {
}
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
const hasRail = Boolean(rail)
return (
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
<aside className="admin-shell-nav">
<AdminSidebar />
</aside>
<div className="admin-shell admin-shell--top-nav">
<SettingsNavigation />
<main className="card admin-card">
<div className="admin-header">
<div>
<span className="section-kicker">Configuration</span>
<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>
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
</div>
)
}
-64
View File
@@ -1,64 +0,0 @@
'use client'
import { usePathname } from 'next/navigation'
const NAV_GROUPS = [
{
title: 'Services',
items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/seerr', label: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
],
},
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
],
},
{
title: 'Admin',
items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'How it works' },
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
],
},
]
export default function AdminSidebar() {
const pathname = usePathname()
return (
<nav className="admin-sidebar">
<div className="admin-sidebar-title">Settings</div>
{NAV_GROUPS.map((group) => (
<div key={group.title} className="admin-nav-group">
<span className="admin-nav-title">{group.title}</span>
<div className="admin-nav-links">
{group.items.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
return (
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
{item.label}
</a>
)
})}
</div>
</div>
))}
</nav>
)
}
+24
View File
@@ -0,0 +1,24 @@
'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 (pathname === '/login') 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 />
</>
}
+35 -5
View File
@@ -1,14 +1,44 @@
'use client'
import { useState } from 'react'
type BrandingLogoProps = {
className?: string
alt?: string
}
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
return (
<img
className={className}
src="/api/branding/logo.png"
alt={alt}
/>
<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>
)
}
+81 -12
View File
@@ -1,29 +1,46 @@
'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 = await authFetch(`${baseUrl}/auth/me`)
const [response, siteResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
fetch(`${baseUrl}/site/public`).catch(() => null),
])
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
await response.json()
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()
@@ -33,16 +50,68 @@ 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 (
<div className="header-actions">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<div className="header-actions-center">
<a href="/how-it-works">How it works</a>
</div>
<div className="header-actions-right">
<a href="/">Requests</a>
<a href="/profile/invites">Invites</a>
</div>
</div>
<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>
)
}
+55 -33
View File
@@ -1,12 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
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()
@@ -27,6 +29,9 @@ 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) {
@@ -49,7 +54,9 @@ export default function HeaderIdentity() {
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = () => {
const signOut = async () => {
setUserViewPreview(false)
await logout().catch(() => undefined)
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
@@ -57,39 +64,54 @@ export default function HeaderIdentity() {
}
return (
<div className="signed-in-menu">
<button
type="button"
className="avatar-button"
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
title={label}
>
{initial}
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">Signed in as {label}</div>
<div className="signed-in-actions">
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{identity.role === 'admin' ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
<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>
) : null}
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={signOut}>
Sign out
</button>
{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>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
</div>
)}
)}
</div>
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
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>
}
+18
View File
@@ -0,0 +1,18 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { CONFIG_GROUPS } from '../admin/configNavigation'
export default function SettingsNavigation() {
const pathname = usePathname()
const router = useRouter()
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname)
if (pathname === '/admin') return null
return <nav className="settings-top-navigation" aria-label="Settings navigation">
<a href="/admin"> All settings</a>
<label><span>Jump to</span><select aria-label="Settings section" value={current?.href ?? '/admin'} onChange={(event) => router.push(event.target.value)}>
<option value="/admin">Settings overview</option>
{CONFIG_GROUPS.map((group) => <optgroup label={group.title} key={group.title}>{group.items.map((item) => <option key={item.href} value={item.href}>{item.label}</option>)}</optgroup>)}
</select></label>
</nav>
}
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
export default function UserViewBanner() {
const enabled = useUserViewPreview()
if (!enabled) return null
return (
<div className="user-view-banner" role="status">
<div>
<strong>User view</strong>
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
</div>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken } from '../lib/auth'
type NavigationItem = {
href: string
label: string
shortLabel: string
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings'
adminOnly?: boolean
match: (path: string) => boolean
}
const NAVIGATION: NavigationItem[] = [
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
]
const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password', '/how-it-works']
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
invites: <><circle cx="9" cy="8" r="3" /><path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>,
}
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
}
export default function WorkspaceNavigation() {
const pathname = usePathname()
const [role, setRole] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [showRequestsNav, setShowRequestsNav] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
setReady(true)
return
}
Promise.all([
authFetch(`${getApiBase()}/auth/me`),
fetch(`${getApiBase()}/site/public`).catch(() => null),
])
.then(async ([response, siteResponse]) => {
if (response.ok) setRole((await response.json())?.role ?? 'user')
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
})
.catch(() => undefined)
.finally(() => setReady(true))
}, [])
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
return null
}
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests'))
return (
<>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.slice(0, 5).map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
</a>
))}
</nav>
</>
)
}
+96
View File
@@ -20,6 +20,7 @@ type UserStats = {
type AdminUser = {
id?: number
username: string
email?: string | null
role: string
auth_provider?: string | null
last_login_at?: string | null
@@ -100,8 +101,10 @@ export default function UserDetailPage() {
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [profileSelection, setProfileSelection] = useState('')
const [expiryInput, setExpiryInput] = useState('')
const [emailInput, setEmailInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false)
const [savingEmail, setSavingEmail] = useState(false)
const [systemActionBusy, setSystemActionBusy] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null)
const [lineage, setLineage] = useState<UserLineage>(null)
@@ -164,6 +167,7 @@ export default function UserDetailPage() {
: String(nextUser.profile_id)
)
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
setEmailInput(nextUser?.email ?? '')
setError(null)
} catch (err) {
console.error(err)
@@ -217,6 +221,47 @@ export default function UserDetailPage() {
}
}
const saveUserEmail = async (clear = false) => {
if (!user) return
const email = clear ? '' : emailInput.trim()
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Enter a valid email address.')
setActionStatus(null)
return
}
setSavingEmail(true)
setError(null)
setActionStatus(null)
try {
const response = await authFetch(
`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email || null }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Email update failed')
}
setEmailInput(data?.user?.email ?? '')
await loadUser()
setActionStatus(email ? 'Contact email saved.' : 'Contact email removed.')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update the contact email.')
} finally {
setSavingEmail(false)
}
}
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
@@ -459,6 +504,10 @@ export default function UserDetailPage() {
</p>
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Email</span>
<strong>{user.email || 'Not set'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
@@ -541,6 +590,53 @@ export default function UserDetailPage() {
</div>
<div className="user-detail-side-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Contact email</h2>
<p className="lede">Used by Magent for account recovery and issue updates.</p>
</div>
<form
className="user-detail-actions user-detail-actions--stacked"
onSubmit={(event) => {
event.preventDefault()
void saveUserEmail()
}}
>
<label>
<span className="user-bulk-label">Email address</span>
<input
type="email"
value={emailInput}
onChange={(event) => setEmailInput(event.target.value)}
placeholder="person@example.com"
autoComplete="off"
disabled={savingEmail}
/>
</label>
<div className="user-detail-helper">
This updates Magent only. It does not change the user&apos;s Jellyfin or Seerr account.
</div>
<div className="admin-inline-actions">
<button
type="submit"
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? '')}
>
{savingEmail ? 'Saving...' : user.email ? 'Save email' : 'Add email'}
</button>
{user.email && (
<button
type="button"
className="ghost-button"
onClick={() => void saveUserEmail(true)}
disabled={savingEmail}
>
Remove email
</button>
)}
</div>
</form>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Access controls</h2>
+6
View File
@@ -9,6 +9,7 @@ import AdminShell from '../ui/AdminShell'
type AdminUser = {
id: number
username: string
email?: string | null
role: string
authProvider?: string | null
lastLoginAt?: string | null
@@ -109,6 +110,7 @@ export default function UsersPage() {
setUsers(
data.users.map((user: any) => ({
username: user.username ?? 'Unknown',
email: user.email ?? null,
role: user.role ?? 'user',
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
@@ -239,6 +241,7 @@ export default function UsersPage() {
? users.filter((user) => {
const fields = [
user.username,
user.email || '',
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
@@ -419,6 +422,9 @@ export default function UsersPage() {
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<div className="user-directory-subtext">
{user.email || 'No email on file'}
</div>
<div className="user-directory-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</div>
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
"files": {
"includes": [
"app/**/*.{ts,tsx}",
"next.config.js",
"!node_modules",
"!.next"
]
},
"formatter": {
"enabled": false
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useExhaustiveDependencies": "off"
},
"performance": {
"noImgElement": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
"noDocumentCookie": "off",
"noExplicitAny": "off"
}
}
}
}
+4
View File
@@ -1,2 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+490 -218
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -1,26 +1,28 @@
{
"name": "magent-frontend",
"private": true,
"version": "0303261611",
"version": "0803262237",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "biome lint ."
},
"dependencies": {
"next": "16.1.6",
"next": "16.2.12",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"typescript": "5.9.3",
"@biomejs/biome": "2.5.6",
"@types/node": "24.11.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
},
"overrides": {
"nanoid": "3.3.18",
"postcss": "8.5.25",
"sharp": "0.35.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(70 21.00012)">
<path d="M105.302 154.943L112.824 869.492C52.651 877.014 7.52158 846.927 7.52158 786.755L0 192.55C0 4.51106 172.996-40.6184 278.298 34.5974L812.33 342.982C887.546 395.633 902.589 493.413 864.981 561.107 857.46 508.456 834.895 478.37 789.765 448.284L188.039 109.813C142.91 79.7268 105.302 87.2484 105.302 154.943Z" fill="#24292E"/>
<path d="M0 376.079C45.1295 391.122 90.259 383.6 127.867 361.036L744.636 0C782.244 52.651 774.723 105.302 729.593 135.388L210.604 436.251C135.388 473.859 37.6079 436.251 0 376.079Z" transform="translate(60.17249 531.0214)" fill="#24292E"/>
<path d="M0 413.687L368.557 203.083 7.52157 0 0 413.687Z" transform="translate(240.6902 282.8092)" fill="#FFC230"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 846 B

+9
View File
@@ -0,0 +1,9 @@
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/>
<path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/>
<g clip-rule="evenodd">
<path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/>
<path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/>
<path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+5
View File
@@ -3,6 +3,11 @@ $ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
if ($LASTEXITCODE -ne 0) {
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
}
$now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
python_bin="${PYTHON_BIN:-python3}"
echo "Installing backend Python requirements"
"$python_bin" -m pip install -r backend/requirements.txt
echo "Running Python dependency integrity check"
"$python_bin" -m pip check
echo "Running backend unit tests"
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
echo "Backend quality gate passed"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
docker compose up -d --build
"
echo "Running remote smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8000/health', 200),
('http://127.0.0.1:3000/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Deployment completed successfully"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
cp '${prod_path}/.env' '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data'
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
fi
cd '${deploy_path}'
docker compose -p magent-beta -f docker-compose.beta.yml build
docker compose -p magent-beta -f docker-compose.beta.yml up -d
"
echo "Running remote beta smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
import time
from urllib import error, request
checks = [
('http://127.0.0.1:8100/health', 200),
('http://${beta_frontend_bind}:3100/login', 200),
]
# Compose returns before the app is ready. Allow the new processes to start
# instead of reporting a failed deployment on the first connection reset.
deadline = time.monotonic() + 90
for url, expected in checks:
while True:
try:
with request.urlopen(url, timeout=5) as response:
if response.status != expected:
raise OSError(f'HTTP {response.status}, expected {expected}')
print(url, response.status)
break
except (error.URLError, OSError, TimeoutError) as exc:
if time.monotonic() >= deadline:
raise SystemExit(f'Beta did not become ready: {url}: {exc}') from exc
print(f'Waiting for beta to start: {url}', flush=True)
time.sleep(2)
PY
"
echo "Beta deployment completed successfully"
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
def _normalize_email(value: object) -> str | None:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as handle:
return [dict(row) for row in csv.DictReader(handle)]
def _ensure_email_column(conn: sqlite3.Connection) -> None:
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, username, email
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
rows = _load_rows(csv_path)
username_counts = Counter(
str(row.get("Username") or "").strip().lower()
for row in rows
if str(row.get("Username") or "").strip()
)
duplicate_usernames = {
username for username, count in username_counts.items() if username and count > 1
}
summary: dict[str, object] = {
"csv_path": str(csv_path),
"db_path": str(db_path),
"source_rows": len(rows),
"updated": 0,
"unchanged": 0,
"missing_email": [],
"missing_user": [],
"duplicate_source_username": [],
}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
_ensure_email_column(conn)
for row in rows:
username = str(row.get("Username") or "").strip()
if not username:
continue
username_key = username.lower()
if username_key in duplicate_usernames:
cast_list = summary["duplicate_source_username"]
assert isinstance(cast_list, list)
if username not in cast_list:
cast_list.append(username)
continue
email = _normalize_email(row.get("Email"))
if not email:
cast_list = summary["missing_email"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
matches = _lookup_user(conn, username)
if not matches:
cast_list = summary["missing_user"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
current_emails = {
normalized.lower()
for normalized in (_normalize_email(row["email"]) for row in matches)
if normalized
}
if current_emails == {email.lower()}:
summary["unchanged"] = int(summary["unchanged"]) + 1
continue
conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(email, username),
)
summary["updated"] = int(summary["updated"]) + 1
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
parser.add_argument(
"csv_path",
nargs="?",
default=str(DEFAULT_CSV_PATH),
help="CSV file containing Username and Email columns",
)
parser.add_argument(
"--db-path",
default=str(DEFAULT_DB_PATH),
help="Path to the Magent SQLite database",
)
args = parser.parse_args()
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
+4
View File
@@ -243,6 +243,10 @@ try {
$script:CurrentStep = "updating build metadata"
Update-BuildFiles -BuildNumber $buildNumber
$script:CurrentStep = "running backend quality gate"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1"
$script:CurrentStep = "rebuilding local docker stack"
docker compose up -d --build
Assert-LastExitCode -CommandName "docker compose up -d --build"
+185
View File
@@ -0,0 +1,185 @@
// UI contract checks. Every API request is mocked: no accounts, passwords,
// emails or live media are modified. Requires Node and Playwright.
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
const output = process.env.REVIEW_DIR
const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning' } }
;(async () => {
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
let options = structuredClone(site)
let loginStatus = 401
let profileStatus = 200
let emailStatus = 200
let passwordStatus = 200
let provider = 'jellyfin'
let supported = true
let savedEmail = 'member@example.com'
const calls = []
const errors = []
await context.route('**/api/**', async (route) => {
const request = route.request()
const path = new URL(request.url()).pathname
const method = request.method()
const reply = (json, status = 200) => route.fulfill({ status, json })
if (path === '/api/site/public' || path === '/api/site/info') return reply(options)
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'user' })
if (path === '/api/auth/profile') return reply(profileStatus === 200 ? {
user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported },
stats: { total: 12, ready: 9, in_progress: 3 },
activity: { recent: Array.from({ length: 7 }, (_, i) => ({ ip: '192.0.2.' + (i + 1), user_agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/140.0', first_seen_at: '2026-09-01T00:00:00Z', last_seen_at: '2026-09-06T01:00:00Z' })) },
} : { detail: 'Service unavailable' }, profileStatus)
if (method !== 'GET') calls.push({ path, method, body: request.postData() })
if (path === '/api/auth/profile/email') {
await new Promise((resolve) => setTimeout(resolve, 250))
if (emailStatus !== 200) return reply({ detail: 'This email is already in use.' }, emailStatus)
savedEmail = request.postDataJSON().email
return reply({ email: savedEmail })
}
if (path === '/api/auth/password') {
await new Promise((resolve) => setTimeout(resolve, 250))
return reply(passwordStatus === 200 ? { provider } : { detail: 'Current password is incorrect' }, passwordStatus)
}
if (path.endsWith('/login')) {
if (loginStatus === 0) return route.abort()
return reply({ authenticated: loginStatus === 200 }, loginStatus)
}
if (path.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': fixture\n\n' })
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
return reply({ requests: [], items: [], services: [], results: [] })
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(error.message))
const screenshot = async (name) => { if (output) await page.screenshot({ path: output + '/' + name + '.png', fullPage: true, animations: 'disabled' }) }
const openLogin = async () => { await page.goto(base + '/login'); await page.getByRole('heading', { name: 'Welcome back.' }).waitFor(); await page.waitForTimeout(250) }
const login = async () => { await page.getByLabel('Username', { exact: true }).fill('member'); await page.getByLabel('Password', { exact: true }).fill('test-password'); await page.getByRole('button', { name: 'Sign in', exact: true }).click() }
await openLogin()
assert.equal(await page.locator('.header,.workspace-sidebar,.admin-sidebar').count(), 0)
await screenshot('account-login-desktop')
await login()
await page.getByRole('alert').filter({ hasText: 'Check your username' }).waitFor()
assert.equal(calls.at(-1).path, '/api/auth/jellyfin/login')
await page.getByRole('button', { name: 'Magent', exact: true }).click()
loginStatus = 429
await login()
await page.getByRole('alert').filter({ hasText: 'Too many attempts' }).waitFor()
assert.equal(calls.at(-1).path, '/api/auth/login')
loginStatus = 503
await login()
await page.getByRole('alert').filter({ hasText: 'temporarily unavailable' }).waitFor()
loginStatus = 0
await login()
await page.getByRole('alert').filter({ hasText: 'Check your connection' }).waitFor()
await page.getByRole('button', { name: 'Show password' }).click()
assert.equal(await page.getByLabel('Password', { exact: true }).getAttribute('type'), 'text')
await page.getByRole('button', { name: 'Hide password' }).click()
options.login.showJellyfinLogin = false
await openLogin()
assert.equal(await page.getByRole('group', { name: 'Sign-in account' }).count(), 0)
loginStatus = 401
await login()
await page.getByRole('alert').waitFor()
assert.equal(calls.at(-1).path, '/api/auth/login')
options.login.showLocalLogin = false
options.login.showForgotPassword = false
options.login.showSignupLink = false
options.banner.message = 'Maintenance tonight'
await openLogin()
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0)
assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0)
assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0)
assert(await page.getByText('Maintenance tonight').isVisible())
options = structuredClone(site)
options.login.showLocalLogin = false
await openLogin()
loginStatus = 200
await login()
await page.waitForURL(base + '/')
assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1'))
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect and notices')
await page.goto(base + '/profile')
const email = page.getByLabel('Email address', { exact: true })
await email.waitFor()
assert.equal(await page.locator('.workspace-sidebar,.admin-sidebar').count(), 0)
assert(await page.getByRole('button', { name: 'Save email' }).isDisabled())
await screenshot('account-profile-desktop')
await email.fill('changed@example.com')
await page.getByRole('button', { name: 'Discard' }).click()
assert.equal(await email.inputValue(), savedEmail)
await email.fill('not-an-email')
assert.equal(await email.evaluate((el) => el.checkValidity()), false)
await email.fill('changed@example.com')
await page.getByRole('button', { name: 'Save email' }).click()
await page.getByRole('status').filter({ hasText: 'Email saved.' }).waitFor()
assert.equal(JSON.parse(calls.at(-1).body).email, 'changed@example.com')
await email.fill('')
assert(await page.getByText('Saving without an email stops account and issue emails.').isVisible())
await page.getByRole('button', { name: 'Save email' }).click()
await page.getByRole('status').filter({ hasText: 'Email removed.' }).waitFor()
assert.equal(JSON.parse(calls.at(-1).body).email, null)
emailStatus = 409
await email.fill('duplicate@example.com')
await page.getByRole('button', { name: 'Save email' }).click()
await page.getByRole('alert').filter({ hasText: 'already in use' }).waitFor()
await page.getByRole('tab', { name: 'Account', exact: true }).focus()
await page.keyboard.press('ArrowRight')
assert.equal(await page.getByRole('tab', { name: 'Security', exact: true }).getAttribute('aria-selected'), 'true')
await screenshot('account-security-desktop')
await page.getByLabel('Current password', { exact: true }).fill('current-password')
await page.getByLabel('New password', { exact: true }).fill('new-password')
await page.getByLabel('Confirm new password', { exact: true }).fill('mismatch-password')
const previousCalls = calls.length
await page.getByRole('button', { name: 'Update password' }).click()
await page.getByRole('alert').filter({ hasText: 'do not match' }).waitFor()
assert.equal(calls.length, previousCalls)
await page.getByLabel('Confirm new password', { exact: true }).fill('new-password')
passwordStatus = 401
await page.getByRole('button', { name: 'Update password' }).click()
await page.getByRole('alert').filter({ hasText: 'Current password is incorrect' }).waitFor()
assert.equal(new URL(page.url()).pathname, '/profile')
passwordStatus = 200
await page.getByRole('button', { name: 'Update password' }).click()
await page.getByRole('status').filter({ hasText: 'Password updated for Grizzlyflix' }).waitFor()
assert.equal(await page.getByLabel('Current password', { exact: true }).inputValue(), '')
await page.getByRole('tab', { name: 'Activity', exact: true }).click()
assert.equal(await page.locator('.account-access-list > li').count(), 5)
assert.equal(await page.getByText('192.0.2.1', { exact: true }).isVisible(), false)
await page.getByRole('button', { name: 'Show all activity' }).click()
assert.equal(await page.locator('.account-access-list > li').count(), 7)
await screenshot('account-activity-desktop')
provider = 'local'
await page.goto(base + '/profile?tab=security')
await page.getByText('Keep your Magent account secure.').waitFor()
supported = false
await page.reload()
await page.getByText('Password changes are managed by your sign-in provider.', { exact: false }).waitFor()
assert.equal(await page.getByRole('button', { name: 'Update password' }).count(), 0)
profileStatus = 503
await page.reload()
await page.getByRole('button', { name: 'Try again' }).waitFor()
assert.equal(new URL(page.url()).pathname, '/profile')
profileStatus = 200
supported = true
await page.getByRole('button', { name: 'Try again' }).click()
await page.getByRole('tab', { name: 'Account', exact: true }).waitFor()
console.log('PASS: email save/removal/discard/validation, password validation/providers, keyboard tabs, activity and load recovery')
options = structuredClone(site)
for (const width of [320, 390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 })
for (const path of ['/login', '/profile', '/profile?tab=security', '/profile?tab=activity']) {
await page.goto(base + path)
await page.waitForTimeout(300)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false, path + ' overflows at ' + width)
if (width === 390 && (path === '/profile' || path === '/login')) await screenshot('account-mobile-' + path.slice(1))
}
}
assert.deepEqual(errors, [])
console.log('PASS: responsive layout at 320, 390, 768, 1024 and 1440px; no JavaScript errors')
await context.unrouteAll({ behavior: 'ignoreErrors' })
await browser.close()
})().catch((error) => { console.error(error.message); process.exit(1) })
+164
View File
@@ -0,0 +1,164 @@
// Run with Node and Playwright. REVIEW_DIR/session.json holds an authorised
// short-lived session as { name, token }. Never commit this file. Live requests are read-only:
// all writes are blocked, with save/validation paths checked using fixtures.
const reviewDir = process.env.REVIEW_DIR || '/review'
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || reviewDir + '/node_modules/playwright')
const fs = require('node:fs')
const assert = require('node:assert/strict')
const base = process.env.REVIEW_BASE || 'https://beta.grizzlyflix.co.nz'
const liveBase = process.env.REVIEW_LIVE_BASE || 'https://beta.grizzlyflix.co.nz'
const prefix = process.env.REVIEW_PREFIX || 'before'
const session = JSON.parse(process.env.REVIEW_SESSION || fs.readFileSync(reviewDir + '/session.json', 'utf8'))
;(async () => {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] })
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
await context.addCookies([
{ name: session.name, value: session.token, url: base, httpOnly: true, secure: base.startsWith('https') },
{ name: 'magent_logged_in', value: '1', url: base },
])
const errors = []
const httpErrors = []
const blocked = []
await context.route('**/api/**', async (route) => {
const request = route.request()
if (request.url().includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' })
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
blocked.push({ path: new URL(request.url()).pathname, method: request.method() })
return route.fulfill({ status: 409, json: { detail: 'Read-only browser review' } })
}
if (base !== liveBase) {
const remote = liveBase + new URL(request.url()).pathname + new URL(request.url()).search
try {
const response = await route.fetch({ url: remote, headers: { ...request.headers(), cookie: session.name + '=' + session.token } })
return await route.fulfill({ response })
} catch {
// Browser cancellation must not print request headers (including cookies).
await route.abort().catch(() => {})
return
}
}
await route.continue()
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(error.message))
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ path: new URL(response.url()).pathname, status: response.status() }) })
const reports = []
const paths = process.env.REVIEW_PATHS ? process.env.REVIEW_PATHS.split(',') : prefix === 'before' ? ['/admin', '/admin/radarr', '/admin/notifications', '/admin/site'] : [
'/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr',
'/admin/prowlarr', '/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow',
'/admin/requests', '/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance',
'/admin/invites', '/admin/diagnostics', '/', '/new-requests', '/portal/issues', '/profile/invites', '/profile',
]
for (const path of paths) {
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(1600)
if (path.startsWith('/admin/') && !['/admin/invites','/admin/diagnostics'].includes(path)) {
await page.locator('.admin-card').waitFor({ timeout: 30000 }).catch(async (error) => {
console.log(JSON.stringify({ failedPath: path, location: page.url(), errors, httpErrors, blocked }))
throw error
})
} else await page.locator('main').first().waitFor()
const stats = await page.evaluate(() => ({
title: document.querySelector('main h1')?.textContent,
width: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
visibleInputs: [...document.querySelectorAll('main input,main select,main textarea')].filter((el) => el.getBoundingClientRect().height > 0).length,
}))
reports.push({ path, ...stats })
if (['/admin','/admin/radarr','/admin/notifications','/admin/site','/admin/issue-workflow'].includes(path)) {
await page.screenshot({ path: reviewDir + '/' + prefix + path.replaceAll('/','-') + '.png', fullPage: true })
}
console.log(JSON.stringify({ path, ...stats }))
}
for (const path of ['/admin','/admin/radarr','/admin/cache','/new-requests','/portal/issues','/profile/invites']) {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(1800)
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
reports.push({ mobile: path, overflow })
await page.screenshot({ path: reviewDir + '/' + prefix + '-mobile' + path.replaceAll('/','-') + '.png', fullPage: true })
console.log(JSON.stringify({ mobile: path, overflow }))
}
if (prefix !== 'before') {
await page.setViewportSize({ width: 1440, height: 1000 })
await page.goto(base + '/admin/radarr')
const field = page.locator('input[name=radarr_base_url]')
await field.waitFor()
const saved = await field.inputValue()
const region = page.locator('#config-radarr-connection')
assert(await region.getByRole('button', { name: 'Save changes', exact: true }).isDisabled())
await field.fill(saved + '/unsaved-review')
assert(await region.getByRole('button', { name: 'Test connection', exact: true }).isDisabled())
await region.getByRole('button', { name: 'Discard', exact: true }).click()
assert.equal(await field.inputValue(), saved)
// Saving is intercepted: verify only this region is submitted, and
// leaving an existing secret blank never overwrites that secret.
let payload
await page.route('**/api/admin/settings', async (route) => {
if (route.request().method() !== 'PUT') return route.fallback()
payload = route.request().postDataJSON()
return route.fulfill({ status: 200, json: { ok: true } })
})
await field.fill(saved + '/review')
await region.getByRole('button', { name: 'Save changes', exact: true }).click()
await page.waitForTimeout(1000)
assert(payload && payload.radarr_base_url.endsWith('/review'))
assert(!('radarr_api_key' in payload))
assert(!('sonarr_base_url' in payload))
await page.goto(base + '/admin/issue-workflow')
await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('0')
assert(!(await page.locator('[name=issue_confirmation_interval_value]').count()))
await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('3')
await page.locator('input[name=issue_confirmation_interval_value]').fill('366')
assert(!(await page.locator('input[name=issue_confirmation_interval_value]').evaluate((el) => el.checkValidity())))
await page.goto(base + '/admin/notifications')
const discord = page.locator('#config-magent-notify-discord')
await discord.getByRole('button', { name: /Discord/ }).click()
const toggle = discord.getByRole('switch')
await toggle.check()
assert(await discord.locator('[name=magent_notify_discord_webhook_url]').isVisible())
await discord.getByRole('button', { name: 'Discard', exact: true }).click()
assert(!(await discord.locator('[name=magent_notify_discord_webhook_url]').count()))
await page.setViewportSize({ width: 1024, height: 768 })
await page.goto(base + '/admin/cache')
await page.getByRole('button', { name: 'Load saved requests', exact: true }).waitFor()
await page.screenshot({ path: reviewDir + '/tablet-cache.png', fullPage: true })
assert(await page.getByRole('button', { name: 'Load saved requests', exact: true }).isVisible())
await page.getByRole('button', { name: 'Load saved requests', exact: true }).click()
await page.waitForTimeout(700)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false)
await page.goto(base + '/portal/issues')
await page.waitForTimeout(1800)
const report = page.locator('.portal-item-list > button').first()
if (await report.count()) {
await report.click()
const modal = page.locator('.issue-detail-modal.is-open')
await modal.waitFor()
const top = await modal.boundingBox()
const header = await page.locator('.header').boundingBox()
assert(top.y >= header.y + header.height, 'Issue window must clear the header')
await modal.getByRole('button', { name: 'Close', exact: true }).click()
assert(!(await page.locator('.issue-detail-modal.is-open').count()))
console.log('PASS: issue popup opens, clears the header and closes')
}
await page.goto(base + '/')
await page.locator('.recent-card').first().waitFor({ timeout: 30000 }).catch(() => {})
const request = page.locator('.recent-card').first()
if (await request.count()) {
await request.click()
await page.waitForTimeout(2500)
assert(new URL(page.url()).pathname.startsWith('/requests/'))
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false)
console.log('PASS: request detail navigation and layout')
}
assert(reports.every((report) => report.overflow == null || report.overflow === 0), 'Mobile overflow')
console.log('PASS: dirty state, discard, region-only save, secret preservation, confirmation limits')
}
fs.writeFileSync(reviewDir + '/' + prefix + '-report.json', JSON.stringify({ reports, errors, blocked }, null, 2))
console.log(JSON.stringify({ errors, blocked }))
await page.unrouteAll({ behavior: 'ignoreErrors' })
await context.unrouteAll({ behavior: 'ignoreErrors' })
await browser.close()
if (errors.length) process.exitCode = 1
})().catch((error) => { console.error(error); process.exit(1) })
+60
View File
@@ -0,0 +1,60 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
$env:PYTHONIOENCODING = "utf-8"
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)
if ($LASTEXITCODE -ne 0) {
throw "$CommandName failed with exit code $LASTEXITCODE."
}
}
function Get-PythonCommand {
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
return "python"
}
function Ensure-PythonModule {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$ModuleName,
[Parameter(Mandatory = $true)][string]$PackageName
)
& $PythonExe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)"
if ($LASTEXITCODE -eq 0) {
return
}
Write-Host "Installing missing Python package: $PackageName"
& $PythonExe -m pip install $PackageName
Assert-LastExitCode -CommandName "python -m pip install $PackageName"
}
$pythonExe = Get-PythonCommand
Write-Host "Installing backend Python requirements"
& $pythonExe -m pip install -r (Join-Path $repoRoot "backend\requirements.txt")
Assert-LastExitCode -CommandName "python -m pip install -r backend/requirements.txt"
Write-Host "Running Python dependency integrity check"
& $pythonExe -m pip check
Assert-LastExitCode -CommandName "python -m pip check"
Ensure-PythonModule -PythonExe $pythonExe -ModuleName "pip_audit" -PackageName "pip-audit"
Write-Host "Running Python vulnerability scan"
& $pythonExe -m pip_audit -r (Join-Path $repoRoot "backend\requirements.txt") --progress-spinner off --desc
Assert-LastExitCode -CommandName "python -m pip_audit"
Write-Host "Running backend unit tests"
& $pythonExe -m unittest discover -s backend/tests -p "test_*.py" -v
Assert-LastExitCode -CommandName "python -m unittest discover"
Write-Host "Backend quality gate passed"