Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
619708cae0 | ||
|
|
af67c888c6 | ||
|
|
52e3d680f7 | ||
|
|
00bccfa8b6 | ||
|
|
aa3532dd83 | ||
|
|
4ec2351241 | ||
|
|
6480478167 | ||
|
|
3739e11016 | ||
|
|
132e02e06e | ||
|
|
cc79685eaf | ||
|
|
b20cf0a9d2 | ||
|
|
eab212ea8d | ||
|
|
24685a5371 | ||
|
|
49e9ee771f | ||
|
|
69dc7febe2 | ||
|
|
7b8fc1d99b | ||
|
|
7a7d570852 | ||
|
|
3eb4b3f09f | ||
|
|
6425345c69 | ||
|
|
fe43a81175 |
@@ -1 +0,0 @@
|
|||||||
0803262237
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
*.ps1 text eol=crlf
|
|
||||||
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.ico binary
|
|
||||||
*.pdf binary
|
|
||||||
*.zip binary
|
|
||||||
*.gz binary
|
|
||||||
*.tgz binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
name: Magent CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- beta
|
|
||||||
- prod
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: magent-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run backend quality gate
|
|
||||||
run: bash scripts/ci_backend_quality_gate.sh
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
deploy-prod:
|
|
||||||
if: github.ref_name == 'prod'
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure SSH key
|
|
||||||
env:
|
|
||||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
|
||||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy to AMS-DEV01
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
|
||||||
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
|
||||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
|
||||||
run: bash scripts/deploy_ams_dev01.sh
|
|
||||||
|
|
||||||
deploy-beta:
|
|
||||||
if: github.ref_name == 'beta'
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure SSH key
|
|
||||||
env:
|
|
||||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
|
||||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy beta to AMS-DEV01
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
|
||||||
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
|
||||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
|
||||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
FROM node:24-slim AS frontend-builder
|
|
||||||
|
|
||||||
WORKDIR /frontend
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
|
|
||||||
NEXT_PUBLIC_API_BASE=/api
|
|
||||||
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
|
||||||
RUN npm ci --include=dev
|
|
||||||
|
|
||||||
COPY frontend/app ./app
|
|
||||||
COPY frontend/public ./public
|
|
||||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
|
||||||
COPY frontend/next.config.js ./next.config.js
|
|
||||||
COPY frontend/tsconfig.json ./tsconfig.json
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY backend/app ./app
|
|
||||||
COPY data/branding /app/data/branding
|
|
||||||
|
|
||||||
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
|
|
||||||
COPY --from=frontend-builder /frontend/public /app/frontend/public
|
|
||||||
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
|
||||||
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
|
||||||
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
|
||||||
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
|
|
||||||
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
|
||||||
|
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
|
||||||
|
|
||||||
EXPOSE 3000 8000
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Magent
|
# Magent
|
||||||
|
|
||||||
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1) Requests are pulled from Seerr and stored locally.
|
1) Requests are pulled from Jellyseerr and stored locally.
|
||||||
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
||||||
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
||||||
4) The UI renders a timeline and a central status box for each request.
|
4) The UI renders a timeline and a central status box for each request.
|
||||||
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
|
|||||||
|
|
||||||
- Request search by title/year or request ID.
|
- Request search by title/year or request ID.
|
||||||
- Recent requests list with posters and status.
|
- Recent requests list with posters and status.
|
||||||
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
||||||
- Central status box with clear reason + next steps.
|
- Central status box with clear reason + next steps.
|
||||||
- Safe action buttons (search, resume, re-add, etc.).
|
- Safe action buttons (search, resume, re-add, etc.).
|
||||||
- Admin settings for service URLs, API keys, profiles, and root folders.
|
- Admin settings for service URLs, API keys, profiles, and root folders.
|
||||||
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
|||||||
QBIT_USERNAME="..."
|
QBIT_USERNAME="..."
|
||||||
QBIT_PASSWORD="..."
|
QBIT_PASSWORD="..."
|
||||||
SQLITE_PATH="data/magent.db"
|
SQLITE_PATH="data/magent.db"
|
||||||
JWT_SECRET="replace-with-a-long-random-secret"
|
JWT_SECRET="change-me"
|
||||||
JWT_EXP_MINUTES="720"
|
JWT_EXP_MINUTES="720"
|
||||||
ADMIN_USERNAME="set-a-real-admin-username"
|
ADMIN_USERNAME="admin"
|
||||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
ADMIN_PASSWORD="adminadmin"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
|
|||||||
$env:QBIT_USERNAME="..."
|
$env:QBIT_USERNAME="..."
|
||||||
$env:QBIT_PASSWORD="..."
|
$env:QBIT_PASSWORD="..."
|
||||||
$env:SQLITE_PATH="data/magent.db"
|
$env:SQLITE_PATH="data/magent.db"
|
||||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
$env:JWT_SECRET="change-me"
|
||||||
$env:JWT_EXP_MINUTES="720"
|
$env:JWT_EXP_MINUTES="720"
|
||||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
$env:ADMIN_USERNAME="admin"
|
||||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
$env:ADMIN_PASSWORD="adminadmin"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (Next.js)
|
### Frontend (Next.js)
|
||||||
@@ -141,26 +141,6 @@ The frontend proxies `/api/*` to the backend container. Set:
|
|||||||
|
|
||||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
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
|
## History endpoints
|
||||||
|
|
||||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||||
@@ -180,7 +160,7 @@ Configure these Gitea Actions secrets before enabling the deploy job:
|
|||||||
|
|
||||||
### No recent requests
|
### No recent requests
|
||||||
|
|
||||||
- Confirm Seerr credentials in Settings.
|
- Confirm Jellyseerr credentials in Settings.
|
||||||
- Run a full sync from Settings -> Requests.
|
- Run a full sync from Settings -> Requests.
|
||||||
|
|
||||||
### Docker images not updating
|
### Docker images not updating
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
|||||||
|
|
||||||
if snapshot.state == NormalizedState.requested:
|
if snapshot.state == NormalizedState.requested:
|
||||||
root_cause = "approval"
|
root_cause = "approval"
|
||||||
summary = "The request is waiting for approval in Seerr."
|
summary = "The request is waiting for approval in Jellyseerr."
|
||||||
recommendations.append(
|
recommendations.append(
|
||||||
TriageRecommendation(
|
TriageRecommendation(
|
||||||
action_id="wait_for_approval",
|
action_id="wait_for_approval",
|
||||||
title="Ask an admin to approve the request",
|
title="Ask an admin to approve the request",
|
||||||
reason="Seerr has not marked this request as approved.",
|
reason="Jellyseerr has not marked this request as approved.",
|
||||||
risk="low",
|
risk="low",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
|||||||
recommendations.append(
|
recommendations.append(
|
||||||
TriageRecommendation(
|
TriageRecommendation(
|
||||||
action_id="readd_to_arr",
|
action_id="readd_to_arr",
|
||||||
title="Push to Sonarr/Radarr",
|
title="Add it to the library queue",
|
||||||
reason="Sonarr/Radarr has not created the entry for this request.",
|
reason="Sonarr/Radarr has not created the entry for this request.",
|
||||||
risk="medium",
|
risk="medium",
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
+6
-194
@@ -1,152 +1,19 @@
|
|||||||
from datetime import datetime, timezone
|
from typing import Dict, Any
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, Response, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
from .config import settings
|
from .db import get_user_by_username
|
||||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
from .security import safe_decode_token, TokenError
|
||||||
from .network_security import request_trusts_forwarded_headers
|
|
||||||
from .security import TokenError, safe_decode_token, verify_password
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(expires_at: str | None) -> bool:
|
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
|
||||||
if not isinstance(expires_at, str) or not expires_at.strip():
|
|
||||||
return False
|
|
||||||
candidate = expires_at.strip()
|
|
||||||
if candidate.endswith("Z"):
|
|
||||||
candidate = candidate[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed <= datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_client_ip(request: Request) -> str:
|
|
||||||
direct_host = request.client.host if request.client else None
|
|
||||||
if request_trusts_forwarded_headers(direct_host):
|
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if forwarded:
|
|
||||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
|
||||||
if parts:
|
|
||||||
return parts[0]
|
|
||||||
real_ip = request.headers.get("x-real-ip")
|
|
||||||
if real_ip:
|
|
||||||
return real_ip.strip()
|
|
||||||
if direct_host:
|
|
||||||
return direct_host
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _cookie_settings() -> dict[str, Any]:
|
|
||||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
|
||||||
if samesite not in {"lax", "strict", "none"}:
|
|
||||||
samesite = "lax"
|
|
||||||
return {
|
|
||||||
"secure": bool(settings.auth_cookie_secure),
|
|
||||||
"httponly": True,
|
|
||||||
"samesite": samesite,
|
|
||||||
"domain": settings.auth_cookie_domain or None,
|
|
||||||
"path": "/",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _state_cookie_settings() -> dict[str, Any]:
|
|
||||||
cookie = _cookie_settings()
|
|
||||||
cookie["httponly"] = False
|
|
||||||
return cookie
|
|
||||||
|
|
||||||
|
|
||||||
def set_auth_cookies(response: Response, token: str) -> None:
|
|
||||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
|
||||||
response.set_cookie(
|
|
||||||
settings.auth_cookie_name,
|
|
||||||
token,
|
|
||||||
max_age=max_age,
|
|
||||||
**_cookie_settings(),
|
|
||||||
)
|
|
||||||
response.set_cookie(
|
|
||||||
settings.auth_state_cookie_name,
|
|
||||||
"1",
|
|
||||||
max_age=max_age,
|
|
||||||
**_state_cookie_settings(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_auth_cookies(response: Response) -> None:
|
|
||||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
|
||||||
response.delete_cookie(
|
|
||||||
settings.auth_state_cookie_name,
|
|
||||||
path="/",
|
|
||||||
domain=settings.auth_cookie_domain or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
if auth_header.lower().startswith("bearer "):
|
|
||||||
return auth_header.split(" ", 1)[1].strip()
|
|
||||||
if oauth_token:
|
|
||||||
return oauth_token
|
|
||||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
|
||||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
|
||||||
return cookie_token.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return "local"
|
|
||||||
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
|
||||||
if provider != "local":
|
|
||||||
return provider
|
|
||||||
password_hash = user.get("password_hash")
|
|
||||||
if isinstance(password_hash, str) and password_hash:
|
|
||||||
if verify_password("jellyfin-user", password_hash):
|
|
||||||
return "jellyfin"
|
|
||||||
if verify_password("jellyseerr-user", password_hash):
|
|
||||||
return "jellyseerr"
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return {}
|
|
||||||
resolved_provider = resolve_user_auth_provider(user)
|
|
||||||
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
|
||||||
if resolved_provider != stored_provider:
|
|
||||||
username = str(user.get("username") or "").strip()
|
|
||||||
if username:
|
|
||||||
set_user_auth_provider(username, resolved_provider)
|
|
||||||
refreshed_user = get_user_by_username(username)
|
|
||||||
if refreshed_user:
|
|
||||||
user = refreshed_user
|
|
||||||
normalized = dict(user)
|
|
||||||
normalized["auth_provider"] = resolved_provider
|
|
||||||
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
|
|
||||||
normalized["password_provider"] = (
|
|
||||||
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
|
|
||||||
)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _load_current_user_from_token(
|
|
||||||
token: str,
|
|
||||||
request: Optional[Request] = None,
|
|
||||||
allowed_token_types: Optional[set[str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
payload = safe_decode_token(token)
|
payload = safe_decode_token(token)
|
||||||
except TokenError as exc:
|
except TokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||||
token_type = str(payload.get("typ") or "access").strip().lower()
|
|
||||||
if allowed_token_types and token_type not in allowed_token_types:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
@@ -157,70 +24,15 @@ def _load_current_user_from_token(
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
if user.get("is_blocked"):
|
if user.get("is_blocked"):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||||
if _is_expired(user.get("expires_at")):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
|
||||||
|
|
||||||
user = normalize_user_auth_provider(user)
|
|
||||||
|
|
||||||
if request is not None:
|
|
||||||
ip = _extract_client_ip(request)
|
|
||||||
user_agent = request.headers.get("user-agent", "unknown")
|
|
||||||
upsert_user_activity(user["username"], ip, user_agent)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"email": user.get("email"),
|
|
||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"auth_provider": user.get("auth_provider", "local"),
|
"auth_provider": user.get("auth_provider", "local"),
|
||||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
|
||||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
|
||||||
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
|
||||||
"profile_id": user.get("profile_id"),
|
|
||||||
"expires_at": user.get("expires_at"),
|
|
||||||
"is_expired": bool(user.get("is_expired", False)),
|
|
||||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
|
||||||
"password_provider": user.get("password_provider"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
request: Request,
|
|
||||||
token: Optional[str] = Depends(oauth2_scheme),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
resolved_token = _extract_access_token(request, token)
|
|
||||||
if not resolved_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
|
||||||
return _load_current_user_from_token(resolved_token, request)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_event_stream(
|
|
||||||
request: Request,
|
|
||||||
token: Optional[str] = Depends(oauth2_scheme),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
|
||||||
resolved_token = _extract_access_token(request, token)
|
|
||||||
stream_query_token = request.query_params.get("stream_token")
|
|
||||||
if resolved_token:
|
|
||||||
# Allow standard bearer tokens for non-browser EventSource clients.
|
|
||||||
return _load_current_user_from_token(resolved_token, None)
|
|
||||||
if not stream_query_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
|
||||||
return _load_current_user_from_token(
|
|
||||||
str(stream_query_token),
|
|
||||||
None,
|
|
||||||
allowed_token_types={"sse"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||||
if user.get("role") != "admin":
|
if user.get("role") != "admin":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_admin_event_stream(
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if user.get("role") != "admin":
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
||||||
return user
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+11
-390
@@ -1,259 +1,11 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..logging_config import sanitize_headers, sanitize_value
|
|
||||||
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:
|
class ApiClient:
|
||||||
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
||||||
self.base_url = base_url.rstrip("/") if base_url else None
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
|
||||||
|
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url)
|
return bool(self.base_url)
|
||||||
@@ -261,151 +13,20 @@ class ApiClient:
|
|||||||
def headers(self) -> Dict[str, str]:
|
def headers(self) -> Dict[str, str]:
|
||||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||||
|
|
||||||
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
|
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if response is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = sanitize_value(response.json())
|
|
||||||
except ValueError:
|
|
||||||
payload = sanitize_value(response.text)
|
|
||||||
if isinstance(payload, str) and len(payload) > 500:
|
|
||||||
return f"{payload[:500]}..."
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def _send_request(
|
|
||||||
self,
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Dict[str, str],
|
|
||||||
params: Optional[Dict[str, Any]],
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
) -> httpx.Response:
|
|
||||||
return await client.request(
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
timeout_seconds: float = 10.0,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
response = await client.get(url, headers=self.headers(), params=params)
|
||||||
active_message, _ = _operation_messages(service_name, method, path)
|
|
||||||
operation_event_id = start_remote_call(service_name, active_message)
|
|
||||||
self.logger.debug(
|
|
||||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
sanitize_value(params),
|
|
||||||
sanitize_value(payload),
|
|
||||||
sanitize_headers(self.headers()),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
|
||||||
response = await self._send_request(
|
|
||||||
client,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=self.headers(),
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
return response.json()
|
||||||
self.logger.debug(
|
|
||||||
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
response.status_code,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
result = response.json() if response.content else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_operation_result_message(
|
|
||||||
service_name,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
result,
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
response = exc.response
|
|
||||||
status = response.status_code if response is not None else "unknown"
|
|
||||||
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
|
|
||||||
log_fn(
|
|
||||||
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
status,
|
|
||||||
duration_ms,
|
|
||||||
self._response_summary(response),
|
|
||||||
)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status if isinstance(status, int) else None,
|
|
||||||
message=_operation_error_message(
|
|
||||||
service_name,
|
|
||||||
status if isinstance(status, int) else None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
self.logger.exception(
|
|
||||||
"outbound request failed method=%s url=%s duration_ms=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
message=_operation_error_message(service_name, None),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
timeout_seconds: float = 10.0,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"GET", path, params=params, timeout_seconds=timeout_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
return await self._request("POST", path, payload=payload)
|
if not self.base_url:
|
||||||
|
return None
|
||||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
url = f"{self.base_url}{path}"
|
||||||
return await self._request("PUT", path, payload=payload)
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(url, headers=self.headers(), json=payload)
|
||||||
async def delete(
|
response.raise_for_status()
|
||||||
self,
|
return response.json()
|
||||||
path: str,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request("DELETE", path, params=params)
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from .base import ApiClient
|
|
||||||
|
|
||||||
|
|
||||||
class BazarrClient(ApiClient):
|
|
||||||
async def get_system_status(self) -> Optional[Any]:
|
|
||||||
return await self._request("GET", "/api/system/status")
|
|
||||||
|
|
||||||
async def search_movie_subtitles(
|
|
||||||
self,
|
|
||||||
radarr_id: int,
|
|
||||||
*,
|
|
||||||
language: str,
|
|
||||||
forced: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"PATCH",
|
|
||||||
"/api/movies/subtitles",
|
|
||||||
params={
|
|
||||||
"radarrid": radarr_id,
|
|
||||||
"language": language,
|
|
||||||
"forced": str(forced).lower(),
|
|
||||||
"hi": "false",
|
|
||||||
},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def search_episode_subtitles(
|
|
||||||
self,
|
|
||||||
series_id: int,
|
|
||||||
episode_id: int,
|
|
||||||
*,
|
|
||||||
language: str,
|
|
||||||
forced: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"PATCH",
|
|
||||||
"/api/episodes/subtitles",
|
|
||||||
params={
|
|
||||||
"seriesid": series_id,
|
|
||||||
"episodeid": episode_id,
|
|
||||||
"language": language,
|
|
||||||
"forced": str(forced).lower(),
|
|
||||||
"hi": "false",
|
|
||||||
},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
@@ -1,24 +1,6 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import time
|
from .base import ApiClient
|
||||||
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):
|
class JellyfinClient(ApiClient):
|
||||||
@@ -28,165 +10,32 @@ class JellyfinClient(ApiClient):
|
|||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url and self.api_key)
|
return bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
def _emby_headers(self) -> Dict[str, str]:
|
|
||||||
return {"X-Emby-Token": self.api_key} if self.api_key else {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_user_id(payload: Any) -> Optional[str]:
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
|
||||||
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
|
|
||||||
if not isinstance(candidate, dict):
|
|
||||||
return None
|
|
||||||
for key in ("Id", "id", "UserId", "userId"):
|
|
||||||
value = candidate.get(key)
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (str, int)):
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/Users"
|
url = f"{self.base_url}/Users"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/{user_id}"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.get(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
|
|
||||||
users = await self.get_users()
|
|
||||||
if not isinstance(users, list):
|
|
||||||
return None
|
|
||||||
target = username.strip().lower()
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
name = str(user.get("Name") or "").strip().lower()
|
|
||||||
if name and name == target:
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||||
payload = {"Username": username, "Pw": password}
|
payload = {"Username": username, "Pw": password}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/New"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
payload = {"Name": username}
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.content:
|
|
||||||
return None
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def set_user_password(self, user_id: str, password: str) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
headers = self._emby_headers()
|
|
||||||
payloads = [
|
|
||||||
{"CurrentPw": "", "NewPw": password},
|
|
||||||
{"CurrentPwd": "", "NewPw": password},
|
|
||||||
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
|
|
||||||
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
|
|
||||||
{"NewPw": password, "ResetPassword": False},
|
|
||||||
]
|
|
||||||
paths = [
|
|
||||||
f"/Users/{user_id}/Password",
|
|
||||||
f"/Users/{user_id}/EasyPassword",
|
|
||||||
]
|
|
||||||
last_error: Exception | None = None
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
for path in paths:
|
|
||||||
url = f"{self.base_url}{path}"
|
|
||||||
for payload in payloads:
|
|
||||||
try:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
return
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
if last_error:
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
user = await self.get_user(user_id)
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
raise RuntimeError("Jellyfin user details not available")
|
|
||||||
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
|
|
||||||
payload = {**policy, "IsDisabled": bool(disabled)}
|
|
||||||
url = f"{self.base_url}/Users/{user_id}/Policy"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: str) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/{user_id}"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.delete(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
||||||
created = await self.create_user(username)
|
|
||||||
user_id = self._extract_user_id(created)
|
|
||||||
if not user_id:
|
|
||||||
users = await self.get_users()
|
|
||||||
if isinstance(users, list):
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
name = str(user.get("Name") or "").strip()
|
|
||||||
if name.lower() == username.strip().lower():
|
|
||||||
created = user
|
|
||||||
user_id = self._extract_user_id(user)
|
|
||||||
break
|
|
||||||
if not user_id:
|
|
||||||
raise RuntimeError("Jellyfin user created but user ID was not returned")
|
|
||||||
await self.set_user_password(user_id, password)
|
|
||||||
return created
|
|
||||||
|
|
||||||
async def search_items(
|
async def search_items(
|
||||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
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"
|
url = f"{self.base_url}/Items"
|
||||||
params = {
|
params = {
|
||||||
"SearchTerm": term,
|
"SearchTerm": term,
|
||||||
@@ -194,78 +43,18 @@ class JellyfinClient(ApiClient):
|
|||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"Limit": limit,
|
"Limit": limit,
|
||||||
}
|
}
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers, params=params)
|
response = await client.get(url, headers=headers, params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
return 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_system_info(self) -> Optional[Dict[str, Any]]:
|
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/System/Info"
|
url = f"{self.base_url}/System/Info"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
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"}
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,44 +1,8 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from urllib.parse import quote, unquote, urlsplit
|
|
||||||
import httpx
|
|
||||||
from .base import ApiClient
|
from .base import ApiClient
|
||||||
|
|
||||||
|
|
||||||
class JellyseerrClient(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]]:
|
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v1/status")
|
return await self.get("/api/v1/status")
|
||||||
|
|
||||||
@@ -54,6 +18,9 @@ class JellyseerrClient(ApiClient):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
return await self.get(f"/api/v1/media/{media_id}")
|
||||||
|
|
||||||
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
||||||
|
|
||||||
@@ -61,65 +28,10 @@ class JellyseerrClient(ApiClient):
|
|||||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||||
|
|
||||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||||
# Seerr rejects the `+` encoding that standard query builders use for
|
|
||||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
|
||||||
# percent-encoded values.
|
|
||||||
encoded_query = quote(query, safe="")
|
|
||||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
|
||||||
|
|
||||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
|
||||||
service = "sonarr" if media_type == "tv" else "radarr"
|
|
||||||
return await self.get(f"/api/v1/settings/{service}")
|
|
||||||
|
|
||||||
async def create_request(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
media_type: str,
|
|
||||||
media_id: int,
|
|
||||||
seasons: Optional[list[int]] = None,
|
|
||||||
is_4k: Optional[bool] = None,
|
|
||||||
server_id: Optional[int] = None,
|
|
||||||
profile_id: Optional[int] = None,
|
|
||||||
root_folder: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"mediaType": media_type,
|
|
||||||
"mediaId": media_id,
|
|
||||||
}
|
|
||||||
if isinstance(seasons, list) and seasons:
|
|
||||||
payload["seasons"] = seasons
|
|
||||||
if isinstance(is_4k, bool):
|
|
||||||
payload["is4k"] = is_4k
|
|
||||||
if isinstance(server_id, int):
|
|
||||||
payload["serverId"] = server_id
|
|
||||||
if isinstance(profile_id, int):
|
|
||||||
payload["profileId"] = profile_id
|
|
||||||
if isinstance(root_folder, str) and root_folder.strip():
|
|
||||||
payload["rootFolder"] = root_folder.strip()
|
|
||||||
return await self.post("/api/v1/request", payload=payload)
|
|
||||||
|
|
||||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/api/v1/user",
|
"/api/v1/search",
|
||||||
params={
|
params={
|
||||||
"take": take,
|
"query": query,
|
||||||
"skip": skip,
|
"page": page,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v1/user/{user_id}")
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.delete(f"/api/v1/user/{user_id}")
|
|
||||||
|
|
||||||
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
|
||||||
payload = {"email": email, "password": password}
|
|
||||||
try:
|
|
||||||
return await self.post("/api/v1/auth/local", payload=payload)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
# Backward compatibility for older Seerr/Overseerr deployments
|
|
||||||
# that still expose /auth/login instead of /auth/local.
|
|
||||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
|
||||||
return await self.post("/api/v1/auth/login", payload=payload)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,59 +1,6 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
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):
|
class QBittorrentClient(ApiClient):
|
||||||
@@ -61,7 +8,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
super().__init__(base_url, None)
|
super().__init__(base_url, None)
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url and self.username and self.password)
|
return bool(self.base_url and self.username and self.password)
|
||||||
@@ -75,109 +21,34 @@ class QBittorrentClient(ApiClient):
|
|||||||
headers={"Referer": self.base_url},
|
headers={"Referer": self.base_url},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
text = response.text.strip().lower()
|
if response.text.strip().lower() != "ok.":
|
||||||
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")
|
raise RuntimeError("qBittorrent login failed")
|
||||||
|
|
||||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
await self._login(client)
|
await self._login(client)
|
||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
return 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]:
|
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
await self._login(client)
|
await self._login(client)
|
||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.text.strip()
|
return 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:
|
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
await self._login(client)
|
await self._login(client)
|
||||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||||
response.raise_for_status()
|
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]:
|
async def get_torrents(self) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info")
|
return await self._get("/api/v2/torrents/info")
|
||||||
@@ -188,9 +59,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
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]:
|
async def get_app_version(self) -> Optional[Any]:
|
||||||
return await self._get_text("/api/v2/app/version")
|
return await self._get_text("/api/v2/app/version")
|
||||||
|
|
||||||
@@ -203,20 +71,8 @@ class QBittorrentClient(ApiClient):
|
|||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def add_torrent_url(
|
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
||||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
url_host = None
|
|
||||||
if isinstance(url, str) and "://" in url:
|
|
||||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
|
||||||
self.logger.warning(
|
|
||||||
"qBittorrent add_torrent_url invoked: category=%s host=%s",
|
|
||||||
category,
|
|
||||||
url_host or "unknown",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = {"urls": url}
|
data: Dict[str, Any] = {"urls": url}
|
||||||
if category:
|
if category:
|
||||||
data["category"] = category
|
data["category"] = category
|
||||||
if tags:
|
|
||||||
data["tags"] = tags
|
|
||||||
await self._post_form("/api/v2/torrents/add", data=data)
|
await self._post_form("/api/v2/torrents/add", data=data)
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
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})
|
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||||
|
|
||||||
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
|
|
||||||
return result if isinstance(result, dict) else None
|
|
||||||
|
|
||||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
|
||||||
|
|
||||||
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/movie")
|
return await self.get("/api/v3/movie")
|
||||||
|
|
||||||
@@ -28,32 +21,9 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
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]]:
|
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
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(
|
async def add_movie(
|
||||||
self,
|
self,
|
||||||
tmdb_id: int,
|
tmdb_id: int,
|
||||||
@@ -61,15 +31,9 @@ class RadarrClient(ApiClient):
|
|||||||
root_folder: str,
|
root_folder: str,
|
||||||
monitored: bool = True,
|
monitored: bool = True,
|
||||||
search_for_movie: bool = True,
|
search_for_movie: bool = True,
|
||||||
title: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> 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 = {
|
payload = {
|
||||||
"tmdbId": tmdb_id,
|
"tmdbId": tmdb_id,
|
||||||
"title": resolved_title,
|
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
@@ -77,17 +41,5 @@ class RadarrClient(ApiClient):
|
|||||||
}
|
}
|
||||||
return await self.post("/api/v3/movie", payload=payload)
|
return await self.post("/api/v3/movie", payload=payload)
|
||||||
|
|
||||||
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put("/api/v3/movie", payload=payload)
|
|
||||||
|
|
||||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||||
|
|
||||||
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.post("/api/v3/release/push", payload=payload)
|
|
||||||
|
|
||||||
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.post(
|
|
||||||
"/api/v3/command",
|
|
||||||
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,23 +9,6 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
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})
|
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||||
|
|
||||||
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
|
|
||||||
if not isinstance(result, list):
|
|
||||||
return None
|
|
||||||
for item in result:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if int(item.get("tvdbId")) == tvdb_id:
|
|
||||||
return item
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return next((item for item in result if isinstance(item, dict)), None)
|
|
||||||
|
|
||||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v3/series/{series_id}")
|
|
||||||
|
|
||||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/rootfolder")
|
return await self.get("/api/v3/rootfolder")
|
||||||
|
|
||||||
@@ -35,42 +18,15 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||||
|
|
||||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get("/api/v3/indexer")
|
|
||||||
|
|
||||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
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]]:
|
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
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]]:
|
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})
|
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(
|
async def add_series(
|
||||||
self,
|
self,
|
||||||
tvdb_id: int,
|
tvdb_id: int,
|
||||||
@@ -80,32 +36,17 @@ class SonarrClient(ApiClient):
|
|||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
search_missing: bool = True,
|
search_missing: bool = True,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> 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 = {
|
payload = {
|
||||||
"tvdbId": tvdb_id,
|
"tvdbId": tvdb_id,
|
||||||
"title": resolved_title,
|
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
"seasonFolder": True,
|
"seasonFolder": True,
|
||||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||||
}
|
}
|
||||||
|
if title:
|
||||||
|
payload["title"] = title
|
||||||
return await self.post("/api/v3/series", payload=payload)
|
return await self.post("/api/v3/series", payload=payload)
|
||||||
|
|
||||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put("/api/v3/series", payload=payload)
|
|
||||||
|
|
||||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||||
|
|
||||||
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.post("/api/v3/release/push", payload=payload)
|
|
||||||
|
|
||||||
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.post(
|
|
||||||
"/api/v3/command",
|
|
||||||
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
|
|
||||||
)
|
|
||||||
|
|||||||
+3
-234
@@ -2,68 +2,18 @@ from typing import Optional
|
|||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from .build_info import BUILD_NUMBER, CHANGELOG
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_prefix="")
|
model_config = SettingsConfigDict(env_prefix="")
|
||||||
app_name: str = "Magent"
|
app_name: str = "Magent"
|
||||||
cors_allow_origin: str = "http://localhost:3000"
|
cors_allow_origin: str = "http://localhost:3000"
|
||||||
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
||||||
sqlite_journal_mode: str = Field(
|
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||||
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"))
|
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
|
||||||
auth_rate_limit_window_seconds: int = Field(
|
|
||||||
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
|
||||||
)
|
|
||||||
auth_rate_limit_max_attempts_ip: int = Field(
|
|
||||||
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
|
||||||
)
|
|
||||||
auth_rate_limit_max_attempts_user: int = Field(
|
|
||||||
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_window_seconds: int = Field(
|
|
||||||
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_max_attempts_ip: int = Field(
|
|
||||||
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_max_attempts_identifier: int = Field(
|
|
||||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
|
||||||
)
|
|
||||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
admin_password: str = Field(default="adminadmin", 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_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||||
log_file_max_bytes: int = Field(
|
|
||||||
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
|
||||||
)
|
|
||||||
log_file_backup_count: int = Field(
|
|
||||||
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
|
|
||||||
)
|
|
||||||
log_http_client_level: str = Field(
|
|
||||||
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
|
|
||||||
)
|
|
||||||
log_background_sync_level: str = Field(
|
|
||||||
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
|
|
||||||
)
|
|
||||||
requests_sync_ttl_minutes: int = Field(
|
requests_sync_ttl_minutes: int = Field(
|
||||||
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
||||||
)
|
)
|
||||||
@@ -85,172 +35,9 @@ class Settings(BaseSettings):
|
|||||||
requests_data_source: str = Field(
|
requests_data_source: str = Field(
|
||||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
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(
|
artwork_cache_mode: str = Field(
|
||||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||||
)
|
)
|
||||||
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
|
|
||||||
site_banner_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
|
||||||
)
|
|
||||||
site_banner_message: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
|
|
||||||
)
|
|
||||||
site_banner_tone: str = Field(
|
|
||||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
|
||||||
)
|
|
||||||
site_login_show_jellyfin_login: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
|
||||||
)
|
|
||||||
site_login_show_local_login: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
|
|
||||||
)
|
|
||||||
site_login_show_forgot_password: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
|
|
||||||
)
|
|
||||||
site_login_show_signup_link: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
|
||||||
)
|
|
||||||
site_nav_show_requests: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
|
||||||
)
|
|
||||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
|
||||||
|
|
||||||
magent_application_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
|
|
||||||
)
|
|
||||||
magent_application_port: int = Field(
|
|
||||||
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
|
|
||||||
)
|
|
||||||
magent_api_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
|
|
||||||
)
|
|
||||||
magent_api_port: int = Field(
|
|
||||||
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
|
|
||||||
)
|
|
||||||
magent_bind_host: str = Field(
|
|
||||||
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
|
|
||||||
)
|
|
||||||
magent_proxy_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
|
|
||||||
)
|
|
||||||
magent_proxy_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
|
|
||||||
)
|
|
||||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
|
||||||
)
|
|
||||||
magent_proxy_trusted_proxies: str = Field(
|
|
||||||
default="127.0.0.1,::1",
|
|
||||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
|
||||||
)
|
|
||||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
|
||||||
)
|
|
||||||
magent_ssl_bind_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
|
|
||||||
)
|
|
||||||
magent_ssl_certificate_path: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
|
|
||||||
)
|
|
||||||
magent_ssl_private_key_path: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
|
|
||||||
)
|
|
||||||
magent_ssl_certificate_pem: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
|
|
||||||
)
|
|
||||||
magent_ssl_private_key_pem: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_email_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_host: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_port: int = Field(
|
|
||||||
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_username: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_password: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
|
|
||||||
)
|
|
||||||
magent_notify_email_from_address: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
|
|
||||||
)
|
|
||||||
magent_notify_email_from_name: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
|
|
||||||
)
|
|
||||||
magent_notify_email_use_tls: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
|
|
||||||
)
|
|
||||||
magent_notify_email_use_ssl: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_discord_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_discord_webhook_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_telegram_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_telegram_bot_token: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
|
|
||||||
)
|
|
||||||
magent_notify_telegram_chat_id: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_push_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_push_provider: Optional[str] = Field(
|
|
||||||
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
|
|
||||||
)
|
|
||||||
magent_notify_push_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
|
|
||||||
)
|
|
||||||
magent_notify_push_topic: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
|
|
||||||
)
|
|
||||||
magent_notify_push_token: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
|
|
||||||
)
|
|
||||||
magent_notify_push_user_key: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
|
|
||||||
)
|
|
||||||
magent_notify_push_device: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_webhook_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_webhook_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
|
||||||
)
|
|
||||||
magent_allow_private_notification_targets: bool = Field(
|
|
||||||
default=False,
|
|
||||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
|
||||||
)
|
|
||||||
|
|
||||||
jellyseerr_base_url: Optional[str] = Field(
|
jellyseerr_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||||
@@ -283,10 +70,6 @@ class Settings(BaseSettings):
|
|||||||
sonarr_root_folder: Optional[str] = Field(
|
sonarr_root_folder: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
|
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
|
||||||
)
|
)
|
||||||
sonarr_qbittorrent_category: Optional[str] = Field(
|
|
||||||
default="sonarr",
|
|
||||||
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
|
|
||||||
)
|
|
||||||
|
|
||||||
radarr_base_url: Optional[str] = Field(
|
radarr_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
|
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
|
||||||
@@ -300,20 +83,6 @@ class Settings(BaseSettings):
|
|||||||
radarr_root_folder: Optional[str] = Field(
|
radarr_root_folder: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
|
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
|
||||||
)
|
)
|
||||||
radarr_qbittorrent_category: Optional[str] = Field(
|
|
||||||
default="radarr",
|
|
||||||
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(
|
prowlarr_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
||||||
@@ -333,7 +102,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
discord_webhook_url: Optional[str] = Field(
|
discord_webhook_url: Optional[str] = Field(
|
||||||
default=None,
|
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
||||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+117
-3204
File diff suppressed because it is too large
Load Diff
@@ -1,148 +1,10 @@
|
|||||||
import contextvars
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Any, Mapping, Optional
|
from typing import Optional
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
||||||
"magent_request_id", default="-"
|
|
||||||
)
|
|
||||||
|
|
||||||
_SENSITIVE_KEYWORDS = (
|
|
||||||
"api_key",
|
|
||||||
"authorization",
|
|
||||||
"cert",
|
|
||||||
"cookie",
|
|
||||||
"jwt",
|
|
||||||
"key",
|
|
||||||
"pass",
|
|
||||||
"password",
|
|
||||||
"pem",
|
|
||||||
"private",
|
|
||||||
"secret",
|
|
||||||
"session",
|
|
||||||
"signature",
|
|
||||||
"token",
|
|
||||||
)
|
|
||||||
_MAX_BODY_BYTES = 4096
|
|
||||||
|
|
||||||
|
|
||||||
class RequestContextFilter(logging.Filter):
|
def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
record.request_id = REQUEST_ID_CONTEXT.get("-")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
|
||||||
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
|
||||||
|
|
||||||
|
|
||||||
def reset_request_id(token: contextvars.Token[str]) -> None:
|
|
||||||
REQUEST_ID_CONTEXT.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def current_request_id() -> str:
|
|
||||||
return REQUEST_ID_CONTEXT.get("-")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_key(key: str) -> bool:
|
|
||||||
lowered = key.strip().lower()
|
|
||||||
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_scalar(value: Any) -> Any:
|
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
|
||||||
return value
|
|
||||||
text = str(value)
|
|
||||||
if len(text) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{text[:2]}***{text[-2:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
|
||||||
if key_hint and _is_sensitive_key(key_hint):
|
|
||||||
return _redact_scalar(value)
|
|
||||||
if value is None or isinstance(value, (bool, int, float)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return f"<bytes:{len(value)}>"
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value if len(value) <= 512 else f"{value[:509]}..."
|
|
||||||
if depth >= 3:
|
|
||||||
return f"<{type(value).__name__}>"
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {
|
|
||||||
str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
|
|
||||||
for key, item in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
|
|
||||||
if hasattr(value, "model_dump"):
|
|
||||||
try:
|
|
||||||
return sanitize_value(value.model_dump(), depth=depth + 1)
|
|
||||||
except Exception:
|
|
||||||
return f"<{type(value).__name__}>"
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
str(key).lower(): sanitize_value(value, key_hint=str(key))
|
|
||||||
for key, value in headers.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
normalized = (content_type or "").split(";")[0].strip().lower()
|
|
||||||
if normalized == "application/json":
|
|
||||||
preview = body[:_MAX_BODY_BYTES]
|
|
||||||
try:
|
|
||||||
payload = json.loads(preview.decode("utf-8"))
|
|
||||||
summary = sanitize_value(payload)
|
|
||||||
if len(body) > _MAX_BODY_BYTES:
|
|
||||||
return {"truncated": True, "bytes": len(body), "payload": summary}
|
|
||||||
return summary
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if normalized == "application/x-www-form-urlencoded":
|
|
||||||
try:
|
|
||||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
|
||||||
compact = {
|
|
||||||
key: value[0] if len(value) == 1 else value
|
|
||||||
for key, value in parsed.items()
|
|
||||||
}
|
|
||||||
return sanitize_value(compact)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if normalized.startswith("multipart/"):
|
|
||||||
return {"content_type": normalized, "bytes": len(body)}
|
|
||||||
preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
|
|
||||||
return {
|
|
||||||
"content_type": normalized or "unknown",
|
|
||||||
"bytes": len(body),
|
|
||||||
"preview": preview if len(body) <= 256 else f"{preview}...",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_level(level_name: Optional[str], fallback: int) -> int:
|
|
||||||
if not level_name:
|
|
||||||
return fallback
|
|
||||||
return getattr(logging, str(level_name).upper(), fallback)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(
|
|
||||||
log_level: Optional[str],
|
|
||||||
log_file: Optional[str],
|
|
||||||
*,
|
|
||||||
log_file_max_bytes: int = 20_000_000,
|
|
||||||
log_file_backup_count: int = 10,
|
|
||||||
log_http_client_level: Optional[str] = "INFO",
|
|
||||||
log_background_sync_level: Optional[str] = "INFO",
|
|
||||||
) -> None:
|
|
||||||
level_name = (log_level or "INFO").upper()
|
level_name = (log_level or "INFO").upper()
|
||||||
level = getattr(logging, level_name, logging.INFO)
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
@@ -156,20 +18,15 @@ def configure_logging(
|
|||||||
log_path = os.path.join(os.getcwd(), log_path)
|
log_path = os.path.join(os.getcwd(), log_path)
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
file_handler = RotatingFileHandler(
|
file_handler = RotatingFileHandler(
|
||||||
log_path,
|
log_path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
||||||
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
|
|
||||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
handlers.append(file_handler)
|
handlers.append(file_handler)
|
||||||
|
|
||||||
context_filter = RequestContextFilter()
|
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
handler.addFilter(context_filter)
|
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
@@ -181,10 +38,4 @@ def configure_logging(
|
|||||||
|
|
||||||
logging.getLogger("uvicorn").setLevel(level)
|
logging.getLogger("uvicorn").setLevel(level)
|
||||||
logging.getLogger("uvicorn.error").setLevel(level)
|
logging.getLogger("uvicorn.error").setLevel(level)
|
||||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
logging.getLogger("uvicorn.access").setLevel(level)
|
||||||
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
|
|
||||||
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
|
|
||||||
logging.getLogger("app.clients.base").setLevel(http_client_level)
|
|
||||||
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
|
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
||||||
|
|||||||
+11
-227
@@ -1,14 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import has_admin_user, init_db
|
from .db import init_db
|
||||||
from .routers.requests import (
|
from .routers.requests import (
|
||||||
router as requests_router,
|
router as requests_router,
|
||||||
startup_warmup_requests_cache,
|
startup_warmup_requests_cache,
|
||||||
@@ -17,42 +13,16 @@ from .routers.requests import (
|
|||||||
run_daily_db_cleanup,
|
run_daily_db_cleanup,
|
||||||
)
|
)
|
||||||
from .routers.auth import router as auth_router
|
from .routers.auth import router as auth_router
|
||||||
from .routers.admin import router as admin_router, events_router as admin_events_router
|
from .routers.admin import router as admin_router
|
||||||
from .routers.images import router as images_router
|
from .routers.images import router as images_router
|
||||||
from .routers.branding import router as branding_router
|
from .routers.branding import router as branding_router
|
||||||
from .routers.status import router as status_router
|
from .routers.status import router as status_router
|
||||||
from .routers.feedback import router as feedback_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.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .services.issue_resolution import run_issue_confirmation_loop
|
from .logging_config import configure_logging
|
||||||
from .services.operation_progress import (
|
|
||||||
begin_operation,
|
|
||||||
finish_operation,
|
|
||||||
normalize_operation_id,
|
|
||||||
reset_operation,
|
|
||||||
)
|
|
||||||
from .logging_config import (
|
|
||||||
bind_request_id,
|
|
||||||
configure_logging,
|
|
||||||
reset_request_id,
|
|
||||||
sanitize_headers,
|
|
||||||
sanitize_value,
|
|
||||||
summarize_http_body,
|
|
||||||
)
|
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
app = FastAPI(title=settings.app_name)
|
||||||
_background_tasks: list[asyncio.Task[None]] = []
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title=settings.app_name,
|
|
||||||
docs_url="/docs" if settings.api_docs_enabled else None,
|
|
||||||
redoc_url=None,
|
|
||||||
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -63,212 +33,26 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
|
||||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
|
||||||
token = bind_request_id(request_id)
|
|
||||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
|
||||||
operation_token = None
|
|
||||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
|
||||||
operation_token = begin_operation(
|
|
||||||
operation_id,
|
|
||||||
label=request.headers.get("X-Magent-Operation-Label"),
|
|
||||||
path=request.url.path,
|
|
||||||
)
|
|
||||||
request.state.request_id = request_id
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
body = await request.body()
|
|
||||||
body_summary = summarize_http_body(body, request.headers.get("content-type"))
|
|
||||||
|
|
||||||
async def receive() -> dict:
|
|
||||||
return {"type": "http.request", "body": body, "more_body": False}
|
|
||||||
|
|
||||||
request._receive = receive
|
|
||||||
logger.info(
|
|
||||||
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
sanitize_value(dict(request.query_params)),
|
|
||||||
request.client.host if request.client else "-",
|
|
||||||
sanitize_headers(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in request.headers.items()
|
|
||||||
if key.lower()
|
|
||||||
in {
|
|
||||||
"content-type",
|
|
||||||
"content-length",
|
|
||||||
"user-agent",
|
|
||||||
"x-forwarded-for",
|
|
||||||
"x-forwarded-proto",
|
|
||||||
"x-request-id",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
body_summary,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
except Exception:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
logger.exception(
|
|
||||||
"request failed method=%s path=%s duration_ms=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
if operation_id and operation_token is not None:
|
|
||||||
finish_operation(operation_id, success=False, status_code=500)
|
|
||||||
reset_operation(operation_token)
|
|
||||||
reset_request_id(token)
|
|
||||||
raise
|
|
||||||
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
response.headers.setdefault("X-Request-ID", request_id)
|
|
||||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
||||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
||||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
||||||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
||||||
# Keep API responses non-executable and non-embeddable by default.
|
|
||||||
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
|
||||||
response.headers.setdefault(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
response.status_code,
|
|
||||||
duration_ms,
|
|
||||||
sanitize_headers(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in response.headers.items()
|
|
||||||
if key.lower() in {"content-type", "content-length", "x-request-id"}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if operation_id and operation_token is not None:
|
|
||||||
finish_operation(
|
|
||||||
operation_id,
|
|
||||||
success=response.status_code < 400,
|
|
||||||
status_code=response.status_code,
|
|
||||||
)
|
|
||||||
reset_operation(operation_token)
|
|
||||||
reset_request_id(token)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
async def _run_background_task(
|
|
||||||
name: str, coroutine_factory: Callable[[], Awaitable[None]]
|
|
||||||
) -> None:
|
|
||||||
token = bind_request_id(f"task-{name}")
|
|
||||||
logger.info("background task started task=%s", name)
|
|
||||||
try:
|
|
||||||
await coroutine_factory()
|
|
||||||
logger.warning("background task exited task=%s", name)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("background task cancelled task=%s", name)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("background task crashed task=%s", name)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
reset_request_id(token)
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
|
|
||||||
task = asyncio.create_task(
|
|
||||||
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
|
|
||||||
)
|
|
||||||
_background_tasks.append(task)
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
@app.on_event("startup")
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
configure_logging(
|
|
||||||
settings.log_level,
|
|
||||||
settings.log_file,
|
|
||||||
log_file_max_bytes=settings.log_file_max_bytes,
|
|
||||||
log_file_backup_count=settings.log_file_backup_count,
|
|
||||||
log_http_client_level=settings.log_http_client_level,
|
|
||||||
log_background_sync_level=settings.log_background_sync_level,
|
|
||||||
)
|
|
||||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
|
||||||
_log_security_configuration_warnings()
|
|
||||||
init_db()
|
init_db()
|
||||||
_enforce_secure_startup_configuration()
|
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
configure_logging(
|
configure_logging(runtime.log_level, runtime.log_file)
|
||||||
runtime.log_level,
|
asyncio.create_task(run_daily_jellyfin_sync())
|
||||||
runtime.log_file,
|
asyncio.create_task(startup_warmup_requests_cache())
|
||||||
log_file_max_bytes=runtime.log_file_max_bytes,
|
asyncio.create_task(run_requests_delta_loop())
|
||||||
log_file_backup_count=runtime.log_file_backup_count,
|
asyncio.create_task(run_daily_requests_full_sync())
|
||||||
log_http_client_level=runtime.log_http_client_level,
|
asyncio.create_task(run_daily_db_cleanup())
|
||||||
log_background_sync_level=runtime.log_background_sync_level,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
|
||||||
runtime.log_level,
|
|
||||||
runtime.log_file,
|
|
||||||
runtime.log_file_max_bytes,
|
|
||||||
runtime.log_file_backup_count,
|
|
||||||
runtime.log_http_client_level,
|
|
||||||
runtime.log_background_sync_level,
|
|
||||||
runtime.requests_data_source,
|
|
||||||
)
|
|
||||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
|
||||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
|
||||||
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
|
||||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
|
||||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
|
||||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
|
||||||
logger.info("startup complete")
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(requests_router)
|
app.include_router(requests_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(admin_events_router)
|
|
||||||
app.include_router(images_router)
|
app.include_router(images_router)
|
||||||
app.include_router(branding_router)
|
app.include_router(branding_router)
|
||||||
app.include_router(status_router)
|
app.include_router(status_router)
|
||||||
app.include_router(feedback_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)
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class ActionOption(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
label: str
|
label: str
|
||||||
risk: str
|
risk: str
|
||||||
description: Optional[str] = None
|
|
||||||
requires_confirmation: bool = True
|
requires_confirmation: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ class Snapshot(BaseModel):
|
|||||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||||
actions: List[ActionOption] = Field(default_factory=list)
|
actions: List[ActionOption] = Field(default_factory=list)
|
||||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Iterable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
|
|
||||||
_METADATA_HOSTS = {
|
|
||||||
"169.254.169.254",
|
|
||||||
"metadata.google.internal",
|
|
||||||
"metadata.azure.internal",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text(value: object) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _split_csv(value: object) -> list[str]:
|
|
||||||
raw = _normalize_text(value)
|
|
||||||
if not raw:
|
|
||||||
return []
|
|
||||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
|
||||||
return bool(
|
|
||||||
ip_obj.is_loopback
|
|
||||||
or ip_obj.is_link_local
|
|
||||||
or ip_obj.is_multicast
|
|
||||||
or ip_obj.is_unspecified
|
|
||||||
or ip_obj.is_reserved
|
|
||||||
or ip_obj.is_private
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=256)
|
|
||||||
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
|
||||||
resolved: list[ipaddress._BaseAddress] = []
|
|
||||||
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
|
||||||
if family == socket.AF_INET:
|
|
||||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
|
||||||
elif family == socket.AF_INET6:
|
|
||||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
|
||||||
return tuple(resolved)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
|
||||||
candidate = _normalize_text(host)
|
|
||||||
if not candidate:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
host_ip = ipaddress.ip_address(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
|
||||||
|
|
||||||
for entry in trusted_proxies:
|
|
||||||
raw = _normalize_text(entry)
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if "/" in raw:
|
|
||||||
if host_ip in ipaddress.ip_network(raw, strict=False):
|
|
||||||
return True
|
|
||||||
elif host_ip == ipaddress.ip_address(raw):
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
|
||||||
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
|
||||||
return False
|
|
||||||
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
|
||||||
if not trusted:
|
|
||||||
return False
|
|
||||||
return _is_trusted_proxy_host(client_host or "", trusted)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_notification_target_url(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
allow_private: bool | None = None,
|
|
||||||
) -> str:
|
|
||||||
raw = _normalize_text(url)
|
|
||||||
if not raw:
|
|
||||||
raise ValueError("URL cannot be empty.")
|
|
||||||
|
|
||||||
parsed = urlparse(raw)
|
|
||||||
if parsed.scheme not in {"http", "https"}:
|
|
||||||
raise ValueError("URL must use http:// or https://.")
|
|
||||||
if parsed.username or parsed.password:
|
|
||||||
raise ValueError("URL must not embed credentials.")
|
|
||||||
hostname = _normalize_text(parsed.hostname).lower()
|
|
||||||
if not hostname:
|
|
||||||
raise ValueError("URL must include a valid host.")
|
|
||||||
|
|
||||||
allow_private_targets = (
|
|
||||||
settings.magent_allow_private_notification_targets
|
|
||||||
if allow_private is None
|
|
||||||
else bool(allow_private)
|
|
||||||
)
|
|
||||||
if hostname in _METADATA_HOSTS:
|
|
||||||
raise ValueError("Metadata service targets are not allowed.")
|
|
||||||
if hostname == "localhost" and not allow_private_targets:
|
|
||||||
raise ValueError("Local notification targets are not allowed.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
host_ip = ipaddress.ip_address(hostname)
|
|
||||||
except ValueError:
|
|
||||||
host_ip = None
|
|
||||||
|
|
||||||
if host_ip is not None:
|
|
||||||
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
|
||||||
raise ValueError("Private or local notification targets are not allowed.")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved_ips = _resolve_host_ips(hostname)
|
|
||||||
except socket.gaierror as exc:
|
|
||||||
raise ValueError("Host could not be resolved.") from exc
|
|
||||||
if not resolved_ips:
|
|
||||||
raise ValueError("Host could not be resolved.")
|
|
||||||
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
|
||||||
raise ValueError("Private or local notification targets are not allowed.")
|
|
||||||
return raw
|
|
||||||
+45
-1784
File diff suppressed because it is too large
Load Diff
+52
-1441
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,6 @@ router = APIRouter(prefix="/branding", tags=["branding"])
|
|||||||
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
||||||
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
||||||
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
||||||
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
|
|
||||||
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
|
||||||
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
|
||||||
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_branding_dir() -> None:
|
def _ensure_branding_dir() -> None:
|
||||||
@@ -45,18 +41,6 @@ def _ensure_default_branding() -> None:
|
|||||||
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
||||||
return
|
return
|
||||||
_ensure_branding_dir()
|
_ensure_branding_dir()
|
||||||
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
|
|
||||||
try:
|
|
||||||
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
|
|
||||||
target.write(source.read())
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
|
||||||
try:
|
|
||||||
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
|
|
||||||
target.write(source.read())
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if not os.path.exists(_LOGO_PATH):
|
if not os.path.exists(_LOGO_PATH):
|
||||||
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
@@ -81,32 +65,24 @@ def _ensure_default_branding() -> None:
|
|||||||
favicon.save(_FAVICON_PATH, format="ICO")
|
favicon.save(_FAVICON_PATH, format="ICO")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_branding_paths() -> tuple[str, str]:
|
|
||||||
if _BRANDING_SOURCE == "data":
|
|
||||||
_ensure_default_branding()
|
|
||||||
return _LOGO_PATH, _FAVICON_PATH
|
|
||||||
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
|
||||||
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
|
|
||||||
_ensure_default_branding()
|
|
||||||
return _LOGO_PATH, _FAVICON_PATH
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logo.png")
|
@router.get("/logo.png")
|
||||||
async def branding_logo() -> FileResponse:
|
async def branding_logo() -> FileResponse:
|
||||||
logo_path, _ = _resolve_branding_paths()
|
if not os.path.exists(_LOGO_PATH):
|
||||||
if not os.path.exists(logo_path):
|
_ensure_default_branding()
|
||||||
|
if not os.path.exists(_LOGO_PATH):
|
||||||
raise HTTPException(status_code=404, detail="Logo not found")
|
raise HTTPException(status_code=404, detail="Logo not found")
|
||||||
headers = {"Cache-Control": "no-store"}
|
headers = {"Cache-Control": "public, max-age=300"}
|
||||||
return FileResponse(logo_path, media_type="image/png", headers=headers)
|
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/favicon.ico")
|
@router.get("/favicon.ico")
|
||||||
async def branding_favicon() -> FileResponse:
|
async def branding_favicon() -> FileResponse:
|
||||||
_, favicon_path = _resolve_branding_paths()
|
if not os.path.exists(_FAVICON_PATH):
|
||||||
if not os.path.exists(favicon_path):
|
_ensure_default_branding()
|
||||||
|
if not os.path.exists(_FAVICON_PATH):
|
||||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||||
headers = {"Cache-Control": "no-store"}
|
headers = {"Cache-Control": "public, max-age=300"}
|
||||||
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
|
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from ..auth import get_current_user_event_stream
|
|
||||||
from . import requests as requests_router
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
|
||||||
|
|
||||||
|
|
||||||
def _sse_json(payload: Dict[str, Any]) -> str:
|
|
||||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _jsonable(value: Any) -> Any:
|
|
||||||
if hasattr(value, "model_dump"):
|
|
||||||
try:
|
|
||||||
return value.model_dump(mode="json")
|
|
||||||
except TypeError:
|
|
||||||
return value.model_dump()
|
|
||||||
if hasattr(value, "dict"):
|
|
||||||
try:
|
|
||||||
return value.dict()
|
|
||||||
except TypeError:
|
|
||||||
return value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
return []
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"request_id": entry.get("request_id"),
|
|
||||||
"state": entry.get("state"),
|
|
||||||
"state_reason": entry.get("state_reason"),
|
|
||||||
"created_at": entry.get("created_at"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
return []
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"request_id": entry.get("request_id"),
|
|
||||||
"action_id": entry.get("action_id"),
|
|
||||||
"label": entry.get("label"),
|
|
||||||
"status": entry.get("status"),
|
|
||||||
"message": entry.get("message"),
|
|
||||||
"created_at": entry.get("created_at"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def events_stream(
|
|
||||||
request: Request,
|
|
||||||
recent_days: int = 90,
|
|
||||||
recent_stage: str = "all",
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
recent_days = max(0, min(int(recent_days or 90), 3650))
|
|
||||||
recent_take = 50 if user.get("role") == "admin" else 6
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
yield "retry: 2000\n\n"
|
|
||||||
last_recent_signature: Optional[str] = None
|
|
||||||
next_recent_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
sent_any = False
|
|
||||||
|
|
||||||
if now >= next_recent_at:
|
|
||||||
next_recent_at = now + 15.0
|
|
||||||
try:
|
|
||||||
recent_payload = await requests_router.recent_requests(
|
|
||||||
take=recent_take,
|
|
||||||
skip=0,
|
|
||||||
days=recent_days,
|
|
||||||
stage=recent_stage,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
|
|
||||||
payload = {
|
|
||||||
"type": "home_recent",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"days": recent_days,
|
|
||||||
"stage": recent_stage,
|
|
||||||
"results": results if isinstance(results, list) else [],
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "home_recent",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"days": recent_days,
|
|
||||||
"stage": recent_stage,
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_recent_signature:
|
|
||||||
last_recent_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
|
||||||
heartbeat_counter = 0
|
|
||||||
else:
|
|
||||||
heartbeat_counter += 1
|
|
||||||
if heartbeat_counter >= 15:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
}
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}/stream")
|
|
||||||
async def request_events_stream(
|
|
||||||
request_id: str,
|
|
||||||
request: Request,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
request_id = str(request_id).strip()
|
|
||||||
if not request_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing request id")
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
yield "retry: 2000\n\n"
|
|
||||||
last_signature: Optional[str] = None
|
|
||||||
next_refresh_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
sent_any = False
|
|
||||||
|
|
||||||
if now >= next_refresh_at:
|
|
||||||
next_refresh_at = now + 2.0
|
|
||||||
try:
|
|
||||||
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
|
||||||
history_payload = await requests_router.request_history(
|
|
||||||
request_id=request_id, limit=5, user=user
|
|
||||||
)
|
|
||||||
actions_payload = await requests_router.request_actions(
|
|
||||||
request_id=request_id, limit=5, user=user
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"snapshot": _jsonable(snapshot),
|
|
||||||
"history": _request_history_brief(
|
|
||||||
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
|
||||||
),
|
|
||||||
"actions": _request_actions_brief(
|
|
||||||
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except HTTPException as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc.detail),
|
|
||||||
"status_code": int(exc.status_code),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_signature:
|
|
||||||
last_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
|
||||||
heartbeat_counter = 0
|
|
||||||
else:
|
|
||||||
heartbeat_counter += 1
|
|
||||||
if heartbeat_counter >= 15:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
}
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
|
||||||
@@ -3,7 +3,6 @@ import httpx
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||||
@@ -12,16 +11,9 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
webhook_url = (
|
webhook_url = runtime.discord_webhook_url
|
||||||
getattr(runtime, "magent_notify_discord_webhook_url", None)
|
|
||||||
or runtime.discord_webhook_url
|
|
||||||
)
|
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
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()
|
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||||
if feedback_type not in {"bug", "feature"}:
|
if feedback_type not in {"bug", "feature"}:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Response
|
from fastapi import APIRouter, HTTPException, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,7 +11,6 @@ router = APIRouter(prefix="/images", tags=["images"])
|
|||||||
|
|
||||||
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
||||||
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename(path: str) -> str:
|
def _safe_filename(path: str) -> str:
|
||||||
@@ -22,35 +19,23 @@ def _safe_filename(path: str) -> str:
|
|||||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
||||||
return safe or "image"
|
return safe or "image"
|
||||||
|
|
||||||
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
|
|
||||||
if not path or "://" in path or ".." in path:
|
|
||||||
return None
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = f"/{path}"
|
|
||||||
if size not in _ALLOWED_SIZES:
|
|
||||||
return None
|
|
||||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
|
||||||
return os.path.join(cache_dir, _safe_filename(path))
|
|
||||||
|
|
||||||
|
|
||||||
def is_tmdb_cached(path: str, size: str) -> bool:
|
|
||||||
file_path = tmdb_cache_path(path, size)
|
|
||||||
return bool(file_path and os.path.exists(file_path))
|
|
||||||
|
|
||||||
|
|
||||||
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||||
if not path or "://" in path or ".." in path:
|
if not path or "://" in path or ".." in path:
|
||||||
return False
|
return False
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = f"/{path}"
|
||||||
|
if size not in _ALLOWED_SIZES:
|
||||||
|
return False
|
||||||
|
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||||
if cache_mode != "cache":
|
if cache_mode != "cache":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file_path = tmdb_cache_path(path, size)
|
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||||
if not file_path:
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
return False
|
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -79,10 +64,9 @@ async def tmdb_image(path: str, size: str = "w342"):
|
|||||||
if cache_mode != "cache":
|
if cache_mode != "cache":
|
||||||
return RedirectResponse(url=url)
|
return RedirectResponse(url=url)
|
||||||
|
|
||||||
file_path = tmdb_cache_path(path, size)
|
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||||
if not file_path:
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
raise HTTPException(status_code=400, detail="Invalid image path")
|
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
||||||
headers = {"Cache-Control": "public, max-age=86400"}
|
headers = {"Cache-Control": "public, max-age=86400"}
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||||
@@ -93,8 +77,6 @@ async def tmdb_image(path: str, size: str = "w342"):
|
|||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||||
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
|
raise HTTPException(status_code=502, detail="Image cache failed")
|
||||||
except (httpx.HTTPError, OSError) as exc:
|
except httpx.HTTPError as exc:
|
||||||
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
|
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
|
||||||
|
|
||||||
return RedirectResponse(url=url)
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from ..auth import get_current_user
|
|
||||||
from ..services.operation_progress import get_operation
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/operations",
|
|
||||||
tags=["operations"],
|
|
||||||
dependencies=[Depends(get_current_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{operation_id}")
|
|
||||||
async def operation_status(operation_id: str) -> dict:
|
|
||||||
operation = get_operation(operation_id)
|
|
||||||
if not operation:
|
|
||||||
raise HTTPException(status_code=404, detail="Operation not found")
|
|
||||||
return operation
|
|
||||||
File diff suppressed because it is too large
Load Diff
+331
-2282
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
|||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from ..auth import get_current_user
|
|
||||||
from ..build_info import BUILD_NUMBER, CHANGELOG
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/site", tags=["site"])
|
|
||||||
|
|
||||||
_BANNER_TONES = {"info", "warning", "error", "maintenance"}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
banner_message = (runtime.site_banner_message or "").strip()
|
|
||||||
tone = (runtime.site_banner_tone or "info").strip().lower()
|
|
||||||
if tone not in _BANNER_TONES:
|
|
||||||
tone = "info"
|
|
||||||
info = {
|
|
||||||
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
|
|
||||||
"banner": {
|
|
||||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
|
||||||
"message": banner_message,
|
|
||||||
"tone": tone,
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
|
||||||
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
|
||||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
|
||||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"showRequests": bool(runtime.site_nav_show_requests),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if include_changelog:
|
|
||||||
info["changelog"] = (CHANGELOG or "").strip()
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/public")
|
|
||||||
async def site_public() -> Dict[str, Any]:
|
|
||||||
return _build_site_info(False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/info")
|
|
||||||
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
|
||||||
return _build_site_info(True)
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..auth import require_admin
|
from ..auth import get_current_user
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..clients.sonarr import SonarrClient
|
from ..clients.sonarr import SonarrClient
|
||||||
from ..clients.radarr import RadarrClient
|
from ..clients.radarr import RadarrClient
|
||||||
from ..clients.bazarr import BazarrClient
|
|
||||||
from ..clients.prowlarr import ProwlarrClient
|
from ..clients.prowlarr import ProwlarrClient
|
||||||
from ..clients.qbittorrent import QBittorrentClient
|
from ..clients.qbittorrent import QBittorrentClient
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
|
|
||||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
|
|
||||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||||
@@ -27,42 +26,12 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
|||||||
return {"name": name, "status": "down", "message": str(exc)}
|
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")
|
@router.get("/services")
|
||||||
async def services_status() -> Dict[str, Any]:
|
async def services_status() -> Dict[str, Any]:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_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)
|
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||||
qbittorrent = QBittorrentClient(
|
qbittorrent = QBittorrentClient(
|
||||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||||
@@ -72,7 +41,7 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
services = []
|
services = []
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Seerr",
|
"Jellyseerr",
|
||||||
jellyseerr.configured(),
|
jellyseerr.configured(),
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||||
)
|
)
|
||||||
@@ -91,13 +60,6 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
radarr.get_system_status,
|
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_status = await _check(
|
||||||
"Prowlarr",
|
"Prowlarr",
|
||||||
prowlarr.configured(),
|
prowlarr.configured(),
|
||||||
@@ -109,7 +71,13 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
prowlarr_status["status"] = "degraded"
|
prowlarr_status["status"] = "degraded"
|
||||||
prowlarr_status["message"] = "Health warnings"
|
prowlarr_status["message"] = "Health warnings"
|
||||||
services.append(prowlarr_status)
|
services.append(prowlarr_status)
|
||||||
services.append(await _check_qbittorrent(qbittorrent))
|
services.append(
|
||||||
|
await _check(
|
||||||
|
"qBittorrent",
|
||||||
|
qbittorrent.configured(),
|
||||||
|
qbittorrent.get_app_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Jellyfin",
|
"Jellyfin",
|
||||||
@@ -125,55 +93,3 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
overall = "degraded"
|
overall = "degraded"
|
||||||
|
|
||||||
return {"overall": overall, "services": services}
|
return {"overall": overall, "services": services}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/services/{service}/test")
|
|
||||||
async def test_service(service: str) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
||||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
|
||||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
|
||||||
qbittorrent = QBittorrentClient(
|
|
||||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
|
||||||
)
|
|
||||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
|
|
||||||
service_key = service.strip().lower()
|
|
||||||
checks = {
|
|
||||||
"seerr": (
|
|
||||||
"Seerr",
|
|
||||||
jellyseerr.configured(),
|
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
|
||||||
),
|
|
||||||
"jellyseerr": (
|
|
||||||
"Seerr",
|
|
||||||
jellyseerr.configured(),
|
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
|
||||||
),
|
|
||||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
|
||||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
|
||||||
"bazarr": (
|
|
||||||
"Bazarr",
|
|
||||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
|
||||||
bazarr.get_system_status,
|
|
||||||
),
|
|
||||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
|
||||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
|
||||||
}
|
|
||||||
|
|
||||||
if service_key == "qbittorrent":
|
|
||||||
return await _check_qbittorrent(qbittorrent)
|
|
||||||
|
|
||||||
if service_key not in checks:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown service")
|
|
||||||
|
|
||||||
name, configured, func = checks[service_key]
|
|
||||||
result = await _check(name, configured, func)
|
|
||||||
if name == "Prowlarr" and result.get("status") == "up":
|
|
||||||
health = result.get("detail")
|
|
||||||
if isinstance(health, list) and health:
|
|
||||||
result["status"] = "degraded"
|
|
||||||
result["message"] = "Health warnings"
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -2,48 +2,17 @@ from .config import settings
|
|||||||
from .db import get_settings_overrides
|
from .db import get_settings_overrides
|
||||||
|
|
||||||
_INT_FIELDS = {
|
_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",
|
"sonarr_quality_profile_id",
|
||||||
"radarr_quality_profile_id",
|
"radarr_quality_profile_id",
|
||||||
"jwt_exp_minutes",
|
"jwt_exp_minutes",
|
||||||
"log_file_max_bytes",
|
|
||||||
"log_file_backup_count",
|
|
||||||
"requests_sync_ttl_minutes",
|
"requests_sync_ttl_minutes",
|
||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"issue_confirmation_contact_attempts",
|
|
||||||
"issue_confirmation_interval_value",
|
|
||||||
"magent_notify_email_smtp_port",
|
|
||||||
}
|
}
|
||||||
_BOOL_FIELDS = {
|
_BOOL_FIELDS = {
|
||||||
"magent_proxy_enabled",
|
|
||||||
"magent_proxy_trust_forwarded_headers",
|
|
||||||
"magent_ssl_bind_enabled",
|
|
||||||
"magent_notify_enabled",
|
|
||||||
"magent_notify_email_enabled",
|
|
||||||
"magent_notify_email_use_tls",
|
|
||||||
"magent_notify_email_use_ssl",
|
|
||||||
"magent_notify_discord_enabled",
|
|
||||||
"magent_notify_telegram_enabled",
|
|
||||||
"magent_notify_push_enabled",
|
|
||||||
"magent_notify_webhook_enabled",
|
|
||||||
"jellyfin_sync_to_arr",
|
"jellyfin_sync_to_arr",
|
||||||
"site_banner_enabled",
|
|
||||||
"site_login_show_jellyfin_login",
|
|
||||||
"site_login_show_local_login",
|
|
||||||
"site_login_show_forgot_password",
|
|
||||||
"site_login_show_signup_link",
|
|
||||||
"site_nav_show_requests",
|
|
||||||
}
|
}
|
||||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_settings():
|
def get_runtime_settings():
|
||||||
@@ -52,8 +21,6 @@ def get_runtime_settings():
|
|||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
if key in _SKIP_OVERRIDE_FIELDS:
|
|
||||||
continue
|
|
||||||
if key in _INT_FIELDS:
|
if key in _INT_FIELDS:
|
||||||
try:
|
try:
|
||||||
update[key] = int(value)
|
update[key] = int(value)
|
||||||
|
|||||||
+4
-37
@@ -1,16 +1,13 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import jwt
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
_ALGORITHM = "HS256"
|
_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:
|
def hash_password(password: str) -> str:
|
||||||
@@ -21,44 +18,14 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||||||
return _pwd_context.verify(plain_password, hashed_password)
|
return _pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def validate_password_policy(password: str) -> str:
|
|
||||||
candidate = password.strip()
|
|
||||||
if len(candidate) < MIN_PASSWORD_LENGTH:
|
|
||||||
raise ValueError(PASSWORD_POLICY_MESSAGE)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _create_token(
|
|
||||||
subject: str,
|
|
||||||
role: str,
|
|
||||||
*,
|
|
||||||
expires_at: datetime,
|
|
||||||
token_type: str = "access",
|
|
||||||
) -> str:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"sub": subject,
|
|
||||||
"role": role,
|
|
||||||
"typ": token_type,
|
|
||||||
"exp": expires_at,
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
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
|
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||||
|
|
||||||
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
|
|
||||||
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="sse")
|
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Dict[str, Any]:
|
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])
|
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||||
|
|
||||||
|
|
||||||
@@ -69,5 +36,5 @@ class TokenError(Exception):
|
|||||||
def safe_decode_token(token: str) -> Dict[str, Any]:
|
def safe_decode_token(token: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return decode_token(token)
|
return decode_token(token)
|
||||||
except InvalidTokenError as exc:
|
except JWTError as exc:
|
||||||
raise TokenError("Invalid token") from exc
|
raise TokenError("Invalid token") from exc
|
||||||
|
|||||||
@@ -1,735 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from time import perf_counter
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
|
||||||
from ..clients.prowlarr import ProwlarrClient
|
|
||||||
from ..clients.qbittorrent import QBittorrentClient
|
|
||||||
from ..clients.radarr import RadarrClient
|
|
||||||
from ..clients.sonarr import SonarrClient
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import get_database_diagnostics
|
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
|
||||||
|
|
||||||
|
|
||||||
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DiagnosticCheck:
|
|
||||||
key: str
|
|
||||||
label: str
|
|
||||||
category: str
|
|
||||||
description: str
|
|
||||||
live_safe: bool
|
|
||||||
configured: bool
|
|
||||||
config_detail: str
|
|
||||||
target: Optional[str]
|
|
||||||
runner: DiagnosticRunner
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
|
||||||
if value is None:
|
|
||||||
return fallback
|
|
||||||
if isinstance(value, str):
|
|
||||||
trimmed = value.strip()
|
|
||||||
return trimmed if trimmed else fallback
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _url_target(url: Optional[str]) -> Optional[str]:
|
|
||||||
raw = _clean_text(url)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = urlparse(raw)
|
|
||||||
except Exception:
|
|
||||||
return raw
|
|
||||||
host = parsed.hostname or parsed.netloc or raw
|
|
||||||
if parsed.port:
|
|
||||||
host = f"{host}:{parsed.port}"
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
|
|
||||||
resolved_host = _clean_text(host)
|
|
||||||
if not resolved_host:
|
|
||||||
return None
|
|
||||||
if port is None:
|
|
||||||
return resolved_host
|
|
||||||
return f"{resolved_host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error_detail(exc: Exception) -> str:
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
|
||||||
response = exc.response
|
|
||||||
body = ""
|
|
||||||
try:
|
|
||||||
body = response.text.strip()
|
|
||||||
except Exception:
|
|
||||||
body = ""
|
|
||||||
if body:
|
|
||||||
return f"HTTP {response.status_code}: {body}"
|
|
||||||
return f"HTTP {response.status_code}"
|
|
||||||
return str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _config_status(detail: str) -> str:
|
|
||||||
lowered = detail.lower()
|
|
||||||
if "disabled" in lowered:
|
|
||||||
return "disabled"
|
|
||||||
return "not_configured"
|
|
||||||
|
|
||||||
|
|
||||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
|
||||||
return False, "Discord notifications are disabled."
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Discord webhook URL is required."
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
|
|
||||||
return False, "Telegram notifications are disabled."
|
|
||||||
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Telegram bot token and chat ID are required."
|
|
||||||
|
|
||||||
|
|
||||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
|
||||||
return False, "Generic webhook notifications are disabled."
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Generic webhook URL is required."
|
|
||||||
|
|
||||||
|
|
||||||
def _push_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
|
|
||||||
return False, "Push notifications are disabled."
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
if provider == "ntfy":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "ntfy requires a base URL and topic."
|
|
||||||
if provider == "gotify":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url and _clean_text(runtime.magent_notify_push_token):
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Gotify requires a base URL and app token."
|
|
||||||
if provider == "pushover":
|
|
||||||
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Pushover requires an application token and user key."
|
|
||||||
if provider == "webhook":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Webhook relay requires a target URL."
|
|
||||||
if provider == "telegram":
|
|
||||||
return _telegram_config_ready(runtime)
|
|
||||||
if provider == "discord":
|
|
||||||
return _discord_config_ready(runtime)
|
|
||||||
return False, f"Unsupported push provider: {provider or 'unknown'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
|
|
||||||
summary = {
|
|
||||||
"total": len(results),
|
|
||||||
"up": 0,
|
|
||||||
"down": 0,
|
|
||||||
"degraded": 0,
|
|
||||||
"not_configured": 0,
|
|
||||||
"disabled": 0,
|
|
||||||
}
|
|
||||||
for result in results:
|
|
||||||
status = str(result.get("status") or "").strip().lower()
|
|
||||||
if status in summary:
|
|
||||||
summary[status] += 1
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_json_get(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(url, headers=headers, params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
return {"response": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_text_get(url: str) -> Dict[str, Any]:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(url)
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.text
|
|
||||||
return {"response": body, "message": f"HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_post(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
json_payload: Optional[Dict[str, Any]] = None,
|
|
||||||
data_payload: Any = None,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
validate_notification_target_url(url)
|
|
||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
|
||||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.content:
|
|
||||||
return {"message": f"HTTP {response.status_code}"}
|
|
||||||
content_type = response.headers.get("content-type", "")
|
|
||||||
if "application/json" in content_type.lower():
|
|
||||||
try:
|
|
||||||
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_database_check() -> Dict[str, Any]:
|
|
||||||
detail = await asyncio.to_thread(get_database_diagnostics)
|
|
||||||
integrity = _clean_text(detail.get("integrity_check"), "unknown")
|
|
||||||
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
|
|
||||||
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
|
|
||||||
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
|
|
||||||
status = "up" if integrity == "ok" else "degraded"
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
|
|
||||||
"detail": detail,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
|
|
||||||
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
|
|
||||||
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
|
|
||||||
payload = result.get("response")
|
|
||||||
build_number = payload.get("build") if isinstance(payload, dict) else None
|
|
||||||
message = "Health endpoint responded"
|
|
||||||
if build_number:
|
|
||||||
message = f"Health endpoint responded (build {build_number})"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
|
|
||||||
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
|
|
||||||
result = await _run_http_text_get(base_url.rstrip("/"))
|
|
||||||
body = result.get("response")
|
|
||||||
if isinstance(body, str) and "<html" in body.lower():
|
|
||||||
return {"message": "Application page responded", "detail": "html"}
|
|
||||||
return {"status": "degraded", "message": "Application responded with unexpected content"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_seerr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
payload = await client.get_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Seerr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Seerr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
payload = await client.get_system_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Sonarr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Sonarr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_radarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
||||||
payload = await client.get_system_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Radarr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Radarr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
|
||||||
payload = await client.get_health()
|
|
||||||
if isinstance(payload, list) and payload:
|
|
||||||
return {
|
|
||||||
"status": "degraded",
|
|
||||||
"message": f"Prowlarr health warnings: {len(payload)}",
|
|
||||||
"detail": payload,
|
|
||||||
}
|
|
||||||
return {"message": "Prowlarr reported healthy", "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = QBittorrentClient(
|
|
||||||
runtime.qbittorrent_base_url,
|
|
||||||
runtime.qbittorrent_username,
|
|
||||||
runtime.qbittorrent_password,
|
|
||||||
)
|
|
||||||
version = await client.get_app_version()
|
|
||||||
message = "qBittorrent responded"
|
|
||||||
if isinstance(version, str) and version:
|
|
||||||
message = f"qBittorrent version {version}"
|
|
||||||
return {"message": message, "detail": version}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
payload = await client.get_system_info()
|
|
||||||
version = payload.get("Version") if isinstance(payload, dict) else None
|
|
||||||
message = "Jellyfin responded"
|
|
||||||
if version:
|
|
||||||
message = f"Jellyfin version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
result = await send_test_email(recipient_email=recipient_email)
|
|
||||||
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
|
|
||||||
warning = _clean_text(result.get("warning"))
|
|
||||||
if warning:
|
|
||||||
return {
|
|
||||||
"status": "degraded",
|
|
||||||
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
|
|
||||||
"detail": result,
|
|
||||||
}
|
|
||||||
return {"message": f"Test email sent to {recipient}", "detail": result}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_discord_check(runtime) -> Dict[str, Any]:
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
|
||||||
payload = {
|
|
||||||
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
|
||||||
}
|
|
||||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
|
||||||
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_telegram_check(runtime) -> Dict[str, Any]:
|
|
||||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
|
||||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
||||||
payload = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
|
||||||
}
|
|
||||||
result = await _run_http_post(url, json_payload=payload)
|
|
||||||
return {"message": "Telegram ping accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_webhook_check(runtime) -> Dict[str, Any]:
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
payload = {
|
|
||||||
"type": "diagnostics.ping",
|
|
||||||
"application": env_settings.app_name,
|
|
||||||
"build": env_settings.site_build_number,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
|
||||||
return {"message": "Webhook accepted ping", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_push_check(runtime) -> Dict[str, Any]:
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
message = f"{env_settings.app_name} diagnostics ping"
|
|
||||||
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
|
|
||||||
|
|
||||||
if provider == "ntfy":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
|
||||||
result = await _run_http_post(
|
|
||||||
f"{base_url.rstrip('/')}/{topic}",
|
|
||||||
data_payload=f"{message}\n{build_suffix}",
|
|
||||||
headers={"Content-Type": "text/plain; charset=utf-8"},
|
|
||||||
)
|
|
||||||
return {"message": "ntfy push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "gotify":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
result = await _run_http_post(
|
|
||||||
f"{base_url.rstrip('/')}/message",
|
|
||||||
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
|
|
||||||
params={"token": token},
|
|
||||||
)
|
|
||||||
return {"message": "Gotify push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "pushover":
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
|
||||||
device = _clean_text(runtime.magent_notify_push_device)
|
|
||||||
payload = {
|
|
||||||
"token": token,
|
|
||||||
"user": user_key,
|
|
||||||
"message": f"{message}\n{build_suffix}",
|
|
||||||
"title": env_settings.app_name,
|
|
||||||
}
|
|
||||||
if device:
|
|
||||||
payload["device"] = device
|
|
||||||
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
|
|
||||||
return {"message": "Pushover push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "webhook":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
payload = {
|
|
||||||
"type": "diagnostics.push",
|
|
||||||
"application": env_settings.app_name,
|
|
||||||
"build": env_settings.site_build_number,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
result = await _run_http_post(base_url, json_payload=payload)
|
|
||||||
return {"message": "Push webhook accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "telegram":
|
|
||||||
return await _run_telegram_check(runtime)
|
|
||||||
|
|
||||||
if provider == "discord":
|
|
||||||
return await _run_discord_check(runtime)
|
|
||||||
|
|
||||||
raise RuntimeError(f"Unsupported push provider: {provider}")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
seerr_target = _url_target(runtime.jellyseerr_base_url)
|
|
||||||
jellyfin_target = _url_target(runtime.jellyfin_base_url)
|
|
||||||
sonarr_target = _url_target(runtime.sonarr_base_url)
|
|
||||||
radarr_target = _url_target(runtime.radarr_base_url)
|
|
||||||
prowlarr_target = _url_target(runtime.prowlarr_base_url)
|
|
||||||
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
|
|
||||||
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
|
|
||||||
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
|
|
||||||
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
|
|
||||||
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
|
|
||||||
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
|
|
||||||
webhook_target = _url_target(runtime.magent_notify_webhook_url)
|
|
||||||
|
|
||||||
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
push_target = None
|
|
||||||
if push_provider == "pushover":
|
|
||||||
push_target = "api.pushover.net"
|
|
||||||
elif push_provider == "telegram":
|
|
||||||
push_target = telegram_target or "api.telegram.org"
|
|
||||||
elif push_provider == "discord":
|
|
||||||
push_target = discord_target or "discord.com"
|
|
||||||
else:
|
|
||||||
push_target = _url_target(runtime.magent_notify_push_base_url)
|
|
||||||
|
|
||||||
email_ready, email_detail = smtp_email_config_ready()
|
|
||||||
email_warning = smtp_email_delivery_warning()
|
|
||||||
discord_ready, discord_detail = _discord_config_ready(runtime)
|
|
||||||
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
|
||||||
push_ready, push_detail = _push_config_ready(runtime)
|
|
||||||
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
|
|
||||||
|
|
||||||
checks = [
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="magent-web",
|
|
||||||
label="Magent application",
|
|
||||||
category="Application",
|
|
||||||
description="Checks that the frontend application URL is responding.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target=application_target,
|
|
||||||
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="magent-api",
|
|
||||||
label="Magent API",
|
|
||||||
category="Application",
|
|
||||||
description="Checks the Magent API health endpoint.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target=api_target,
|
|
||||||
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="database",
|
|
||||||
label="SQLite database",
|
|
||||||
category="Application",
|
|
||||||
description="Runs SQLite integrity_check against the current Magent database.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target="sqlite",
|
|
||||||
runner=_run_database_check,
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="seerr",
|
|
||||||
label="Seerr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Seerr API reachability and version.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
|
|
||||||
config_detail="Seerr URL and API key are required.",
|
|
||||||
target=seerr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_seerr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="jellyfin",
|
|
||||||
label="Jellyfin",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Jellyfin system info with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
|
|
||||||
config_detail="Jellyfin URL and API key are required.",
|
|
||||||
target=jellyfin_target,
|
|
||||||
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="sonarr",
|
|
||||||
label="Sonarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Sonarr system status with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
|
|
||||||
config_detail="Sonarr URL and API key are required.",
|
|
||||||
target=sonarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="radarr",
|
|
||||||
label="Radarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Radarr system status with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
|
|
||||||
config_detail="Radarr URL and API key are required.",
|
|
||||||
target=radarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_radarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="prowlarr",
|
|
||||||
label="Prowlarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Prowlarr health and flags warnings as degraded.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
|
|
||||||
config_detail="Prowlarr URL and API key are required.",
|
|
||||||
target=prowlarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="qbittorrent",
|
|
||||||
label="qBittorrent",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks qBittorrent login and app version.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(
|
|
||||||
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
|
|
||||||
),
|
|
||||||
config_detail="qBittorrent URL, username, and password are required.",
|
|
||||||
target=qbittorrent_target,
|
|
||||||
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="email",
|
|
||||||
label="SMTP email",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test email using the configured SMTP provider.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=email_ready,
|
|
||||||
config_detail=email_warning or email_detail,
|
|
||||||
target=smtp_target,
|
|
||||||
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="discord",
|
|
||||||
label="Discord webhook",
|
|
||||||
category="Notifications",
|
|
||||||
description="Posts a live test message to the configured Discord webhook.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=discord_ready,
|
|
||||||
config_detail=discord_detail,
|
|
||||||
target=discord_target,
|
|
||||||
runner=lambda runtime=runtime: _run_discord_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="telegram",
|
|
||||||
label="Telegram",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test message to the configured Telegram chat.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=telegram_ready,
|
|
||||||
config_detail=telegram_detail,
|
|
||||||
target=telegram_target,
|
|
||||||
runner=lambda runtime=runtime: _run_telegram_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="push",
|
|
||||||
label="Push/mobile provider",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test message through the configured push provider.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=push_ready,
|
|
||||||
config_detail=push_detail,
|
|
||||||
target=push_target,
|
|
||||||
runner=lambda runtime=runtime: _run_push_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="webhook",
|
|
||||||
label="Generic webhook",
|
|
||||||
category="Notifications",
|
|
||||||
description="Posts a live test payload to the configured generic webhook.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=webhook_ready,
|
|
||||||
config_detail=webhook_detail,
|
|
||||||
target=webhook_target,
|
|
||||||
runner=lambda runtime=runtime: _run_webhook_check(runtime),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
return checks
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
|
|
||||||
if not check.configured:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": False,
|
|
||||||
"status": _config_status(check.config_detail),
|
|
||||||
"message": check.config_detail,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
"duration_ms": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
started = perf_counter()
|
|
||||||
checked_at = _now_iso()
|
|
||||||
try:
|
|
||||||
payload = await check.runner()
|
|
||||||
status = _clean_text(payload.get("status"), "up")
|
|
||||||
message = _clean_text(payload.get("message"), "Check passed")
|
|
||||||
detail = payload.get("detail")
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"detail": detail,
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": "down",
|
|
||||||
"message": _http_error_detail(exc),
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": "down",
|
|
||||||
"message": str(exc),
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_diagnostics_catalog() -> Dict[str, Any]:
|
|
||||||
checks = _build_diagnostic_checks()
|
|
||||||
items = []
|
|
||||||
for check in checks:
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"target": check.target,
|
|
||||||
"configured": check.configured,
|
|
||||||
"config_status": "configured" if check.configured else _config_status(check.config_detail),
|
|
||||||
"config_detail": "Ready to test." if check.configured else check.config_detail,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
categories = sorted({item["category"] for item in items})
|
|
||||||
return {
|
|
||||||
"checks": items,
|
|
||||||
"categories": categories,
|
|
||||||
"generated_at": _now_iso(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
checks = _build_diagnostic_checks(recipient_email=recipient_email)
|
|
||||||
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
|
|
||||||
if selected:
|
|
||||||
checks = [check for check in checks if check.key.lower() in selected]
|
|
||||||
results = await asyncio.gather(*(_execute_check(check) for check in checks))
|
|
||||||
return {
|
|
||||||
"results": results,
|
|
||||||
"summary": _summary_from_results(results),
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,378 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from html import escape
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import (
|
|
||||||
add_portal_item_activity,
|
|
||||||
get_portal_item,
|
|
||||||
get_user_by_username,
|
|
||||||
list_portal_items,
|
|
||||||
update_portal_item,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import resolve_user_delivery_email, send_generic_email
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
_SYSTEM_USER = "Magent"
|
|
||||||
|
|
||||||
|
|
||||||
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 _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_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:
|
|
||||||
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(15 * 60)
|
|
||||||
@@ -3,22 +3,8 @@ import logging
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import (
|
from ..db import create_user_if_missing
|
||||||
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 ..runtime import get_runtime_settings
|
||||||
from .user_cache import (
|
|
||||||
build_jellyseerr_candidate_map,
|
|
||||||
extract_jellyseerr_user_email,
|
|
||||||
find_matching_jellyseerr_user,
|
|
||||||
get_cached_jellyseerr_users,
|
|
||||||
match_jellyseerr_user_id,
|
|
||||||
save_jellyfin_users_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,11 +17,6 @@ async def sync_jellyfin_users() -> int:
|
|||||||
users = await client.get_users()
|
users = await client.get_users()
|
||||||
if not isinstance(users, list):
|
if not isinstance(users, list):
|
||||||
return 0
|
return 0
|
||||||
save_jellyfin_users_cache(users)
|
|
||||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
|
||||||
# matched as enrichment when possible.
|
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
|
||||||
imported = 0
|
imported = 0
|
||||||
for user in users:
|
for user in users:
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
@@ -43,31 +24,8 @@ async def sync_jellyfin_users() -> int:
|
|||||||
name = user.get("Name")
|
name = user.get("Name")
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
|
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
|
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
|
||||||
created = create_user_if_missing(
|
|
||||||
name,
|
|
||||||
"jellyfin-user",
|
|
||||||
role="user",
|
|
||||||
email=matched_email,
|
|
||||||
auth_provider="jellyfin",
|
|
||||||
jellyseerr_user_id=matched_id,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
imported += 1
|
imported += 1
|
||||||
else:
|
|
||||||
existing = get_user_by_username(name)
|
|
||||||
if (
|
|
||||||
existing
|
|
||||||
and str(existing.get("role") or "user").strip().lower() != "admin"
|
|
||||||
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
|
||||||
):
|
|
||||||
set_user_auth_provider(name, "jellyfin")
|
|
||||||
if matched_id is not None:
|
|
||||||
set_user_jellyseerr_id(name, matched_id)
|
|
||||||
if matched_email:
|
|
||||||
set_user_email(name, matched_email)
|
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import get_setting
|
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_generic_email
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
|
||||||
if value is None:
|
|
||||||
return fallback
|
|
||||||
if isinstance(value, str):
|
|
||||||
trimmed = value.strip()
|
|
||||||
return trimmed if trimmed else fallback
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_emails(value: str) -> list[str]:
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
|
||||||
return [entry for entry in parts if entry and "@" in entry]
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_app_url() -> str:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
for candidate in (
|
|
||||||
runtime.magent_application_url,
|
|
||||||
runtime.magent_proxy_base_url,
|
|
||||||
env_settings.cors_allow_origin,
|
|
||||||
):
|
|
||||||
normalized = _clean_text(candidate)
|
|
||||||
if normalized:
|
|
||||||
return normalized.rstrip("/")
|
|
||||||
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
|
||||||
return f"http://localhost:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _portal_item_url(item_id: int) -> str:
|
|
||||||
return f"{_resolve_app_url()}/portal?item={item_id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
validate_notification_target_url(url)
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except ValueError:
|
|
||||||
body = response.text
|
|
||||||
return {"status_code": response.status_code, "body": body}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
|
||||||
runtime.discord_webhook_url
|
|
||||||
)
|
|
||||||
if not webhook:
|
|
||||||
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
|
||||||
data = {
|
|
||||||
"content": f"**{title}**\n{message}",
|
|
||||||
"embeds": [
|
|
||||||
{
|
|
||||||
"title": title,
|
|
||||||
"description": message,
|
|
||||||
"fields": [
|
|
||||||
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
|
||||||
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
|
||||||
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
|
||||||
],
|
|
||||||
"url": _clean_text(payload.get("item_url")),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
result = await _http_post_json(webhook, data)
|
|
||||||
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
|
||||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
|
||||||
if not bot_token or not chat_id:
|
|
||||||
return {"status": "skipped", "detail": "Telegram is not configured."}
|
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
||||||
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
|
||||||
result = await _http_post_json(url, payload)
|
|
||||||
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
if not webhook:
|
|
||||||
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
|
||||||
result = await _http_post_json(webhook, payload)
|
|
||||||
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
|
||||||
if provider == "ntfy":
|
|
||||||
if not base_url or not topic:
|
|
||||||
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
|
||||||
validate_notification_target_url(base_url)
|
|
||||||
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
|
||||||
headers = {"Title": title, "Tags": "magent,portal"}
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
|
||||||
if provider == "gotify":
|
|
||||||
if not base_url or not token:
|
|
||||||
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
|
||||||
validate_notification_target_url(base_url)
|
|
||||||
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
|
||||||
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
|
||||||
result = await _http_post_json(url, body)
|
|
||||||
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
|
||||||
if provider == "pushover":
|
|
||||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
|
||||||
if not token or not user_key:
|
|
||||||
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
|
||||||
form = {"token": token, "user": user_key, "title": title, "message": message}
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
|
||||||
response.raise_for_status()
|
|
||||||
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
|
||||||
if provider == "discord":
|
|
||||||
return await _send_discord(title, message, payload)
|
|
||||||
if provider == "telegram":
|
|
||||||
return await _send_telegram(title, message)
|
|
||||||
if provider == "webhook":
|
|
||||||
return await _send_webhook(payload)
|
|
||||||
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
|
||||||
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
|
||||||
if fallback and fallback not in recipients:
|
|
||||||
recipients.append(fallback)
|
|
||||||
if not recipients:
|
|
||||||
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
|
||||||
|
|
||||||
body_text = (
|
|
||||||
f"{title}\n\n"
|
|
||||||
f"{message}\n\n"
|
|
||||||
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
|
||||||
f"Status: {_clean_text(payload.get('status'))}\n"
|
|
||||||
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
|
||||||
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
|
||||||
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
|
||||||
)
|
|
||||||
body_html = (
|
|
||||||
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
|
||||||
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
|
||||||
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
|
||||||
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
|
||||||
"</table>"
|
|
||||||
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
|
||||||
"</div>"
|
|
||||||
)
|
|
||||||
deliveries: list[Dict[str, Any]] = []
|
|
||||||
for recipient in recipients:
|
|
||||||
try:
|
|
||||||
result = await send_generic_email(
|
|
||||||
recipient_email=recipient,
|
|
||||||
subject=title,
|
|
||||||
body_text=body_text,
|
|
||||||
body_html=body_html,
|
|
||||||
)
|
|
||||||
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
|
||||||
except Exception as exc:
|
|
||||||
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
|
||||||
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
|
||||||
if successful:
|
|
||||||
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
|
||||||
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
|
||||||
|
|
||||||
|
|
||||||
async def send_portal_notification(
|
|
||||||
*,
|
|
||||||
event_type: str,
|
|
||||||
item: Dict[str, Any],
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
note: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
if not runtime.magent_notify_enabled:
|
|
||||||
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
|
||||||
|
|
||||||
item_id = int(item.get("id") or 0)
|
|
||||||
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
|
||||||
message_lines = [
|
|
||||||
f"Event: {event_type}",
|
|
||||||
f"Actor: {actor_username} ({actor_role})",
|
|
||||||
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
|
||||||
]
|
|
||||||
if note:
|
|
||||||
message_lines.append(f"Note: {note}")
|
|
||||||
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
|
||||||
message = "\n".join(message_lines)
|
|
||||||
payload = {
|
|
||||||
"type": "portal.notification",
|
|
||||||
"event": event_type,
|
|
||||||
"item_id": item_id,
|
|
||||||
"item_url": _portal_item_url(item_id),
|
|
||||||
"kind": _clean_text(item.get("kind")),
|
|
||||||
"status": _clean_text(item.get("status")),
|
|
||||||
"priority": _clean_text(item.get("priority")),
|
|
||||||
"requested_by": _clean_text(item.get("created_by_username")),
|
|
||||||
"actor_username": actor_username,
|
|
||||||
"actor_role": actor_role,
|
|
||||||
"note": note or "",
|
|
||||||
}
|
|
||||||
|
|
||||||
channels: Dict[str, Dict[str, Any]] = {}
|
|
||||||
if runtime.magent_notify_discord_enabled:
|
|
||||||
try:
|
|
||||||
channels["discord"] = await _send_discord(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["discord"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_telegram_enabled:
|
|
||||||
try:
|
|
||||||
channels["telegram"] = await _send_telegram(title, message)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_webhook_enabled:
|
|
||||||
try:
|
|
||||||
channels["webhook"] = await _send_webhook(payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_push_enabled:
|
|
||||||
try:
|
|
||||||
channels["push"] = await _send_push(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["push"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_email_enabled:
|
|
||||||
try:
|
|
||||||
channels["email"] = await _send_email(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["email"] = {"status": "error", "detail": str(exc)}
|
|
||||||
|
|
||||||
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
|
||||||
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
|
||||||
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
|
||||||
logger.info(
|
|
||||||
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
|
||||||
event_type,
|
|
||||||
item_id,
|
|
||||||
successful,
|
|
||||||
failed,
|
|
||||||
skipped,
|
|
||||||
)
|
|
||||||
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
|
||||||
if not channels:
|
|
||||||
overall = "skipped"
|
|
||||||
return {"status": overall, "channels": channels}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextvars import ContextVar, Token
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
|
||||||
_OPERATION_TTL_SECONDS = 15 * 60
|
|
||||||
_MAX_OPERATIONS = 500
|
|
||||||
_MAX_EVENTS = 60
|
|
||||||
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
|
||||||
"magent_operation_id", default=None
|
|
||||||
)
|
|
||||||
_operations: Dict[str, Dict[str, Any]] = {}
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_locked(now_monotonic: float) -> None:
|
|
||||||
expired = [
|
|
||||||
operation_id
|
|
||||||
for operation_id, operation in _operations.items()
|
|
||||||
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
|
||||||
]
|
|
||||||
for operation_id in expired:
|
|
||||||
_operations.pop(operation_id, None)
|
|
||||||
if len(_operations) <= _MAX_OPERATIONS:
|
|
||||||
return
|
|
||||||
oldest = sorted(
|
|
||||||
_operations,
|
|
||||||
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
|
||||||
)
|
|
||||||
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
|
||||||
_operations.pop(operation_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
now_iso = _now_iso()
|
|
||||||
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
|
||||||
with _lock:
|
|
||||||
_prune_locked(now_monotonic)
|
|
||||||
_operations[operation_id] = {
|
|
||||||
"id": operation_id,
|
|
||||||
"label": normalized_label,
|
|
||||||
"path": path,
|
|
||||||
"status": "running",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"updated_at": now_iso,
|
|
||||||
"updated_monotonic": now_monotonic,
|
|
||||||
"duration_ms": None,
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"id": uuid.uuid4().hex,
|
|
||||||
"service": "Magent",
|
|
||||||
"state": "complete",
|
|
||||||
"message": "Magent received the action.",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": now_iso,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"status_code": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return _current_operation_id.set(operation_id)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_operation(token: Token) -> None:
|
|
||||||
_current_operation_id.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
|
|
||||||
operation_id = _current_operation_id.get()
|
|
||||||
if not operation_id:
|
|
||||||
return None
|
|
||||||
event_id = uuid.uuid4().hex
|
|
||||||
now_iso = _now_iso()
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return None
|
|
||||||
operation["events"].append(
|
|
||||||
{
|
|
||||||
"id": event_id,
|
|
||||||
"service": service,
|
|
||||||
"state": "active",
|
|
||||||
"message": message or f"Contacting {service}…",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": None,
|
|
||||||
"duration_ms": None,
|
|
||||||
"status_code": None,
|
|
||||||
"started_monotonic": now_monotonic,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
|
||||||
operation["updated_at"] = now_iso
|
|
||||||
operation["updated_monotonic"] = now_monotonic
|
|
||||||
return event_id
|
|
||||||
|
|
||||||
|
|
||||||
def finish_remote_call(
|
|
||||||
event_id: Optional[str],
|
|
||||||
*,
|
|
||||||
success: bool,
|
|
||||||
status_code: Optional[int] = None,
|
|
||||||
message: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
operation_id = _current_operation_id.get()
|
|
||||||
if not operation_id or not event_id:
|
|
||||||
return
|
|
||||||
now_iso = _now_iso()
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return
|
|
||||||
event = next(
|
|
||||||
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not event:
|
|
||||||
return
|
|
||||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
|
||||||
event["state"] = "complete" if success else "error"
|
|
||||||
event["finished_at"] = now_iso
|
|
||||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
|
||||||
event["status_code"] = status_code
|
|
||||||
event["message"] = message or (
|
|
||||||
f"{event['service']} responded successfully."
|
|
||||||
if success
|
|
||||||
else f"{event['service']} returned an error."
|
|
||||||
)
|
|
||||||
operation["updated_at"] = now_iso
|
|
||||||
operation["updated_monotonic"] = now_monotonic
|
|
||||||
|
|
||||||
|
|
||||||
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
|
|
||||||
now_iso = _now_iso()
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return
|
|
||||||
for event in operation["events"]:
|
|
||||||
if event.get("state") == "active":
|
|
||||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
|
||||||
event["state"] = "error"
|
|
||||||
event["finished_at"] = now_iso
|
|
||||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
|
||||||
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
|
|
||||||
started = datetime.fromisoformat(str(operation["started_at"]))
|
|
||||||
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
|
|
||||||
operation["status"] = "complete" if success else "error"
|
|
||||||
operation["status_code"] = status_code
|
|
||||||
operation["duration_ms"] = round(duration_ms, 1)
|
|
||||||
operation["updated_at"] = now_iso
|
|
||||||
operation["updated_monotonic"] = now_monotonic
|
|
||||||
operation["events"].append(
|
|
||||||
{
|
|
||||||
"id": uuid.uuid4().hex,
|
|
||||||
"service": "Magent",
|
|
||||||
"state": "complete" if success else "error",
|
|
||||||
"message": (
|
|
||||||
"Magent finished processing the action."
|
|
||||||
if success
|
|
||||||
else "Magent could not complete the action."
|
|
||||||
),
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": now_iso,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
|
||||||
|
|
||||||
|
|
||||||
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
normalized = normalize_operation_id(operation_id)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(normalized)
|
|
||||||
if not operation:
|
|
||||||
return None
|
|
||||||
result = deepcopy(operation)
|
|
||||||
result.pop("updated_monotonic", None)
|
|
||||||
for event in result.get("events", []):
|
|
||||||
event.pop("started_monotonic", None)
|
|
||||||
return result
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
|
||||||
from ..db import (
|
|
||||||
create_password_reset_token,
|
|
||||||
delete_expired_password_reset_tokens,
|
|
||||||
get_password_reset_token,
|
|
||||||
get_user_by_jellyseerr_id,
|
|
||||||
get_user_by_username,
|
|
||||||
get_users_by_username_ci,
|
|
||||||
mark_password_reset_token_used,
|
|
||||||
set_user_auth_provider,
|
|
||||||
set_user_password,
|
|
||||||
sync_jellyfin_password_state,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_password_reset_email
|
|
||||||
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetUnavailableError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_handles(value: object) -> list[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return []
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
handles = [normalized]
|
|
||||||
if "@" in normalized:
|
|
||||||
handles.append(normalized.split("@", 1)[0])
|
|
||||||
return list(dict.fromkeys(handles))
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
|
|
||||||
if not users:
|
|
||||||
return None
|
|
||||||
requested = str(requested_identifier or "").strip().lower()
|
|
||||||
|
|
||||||
def _rank(user: dict) -> tuple[int, int, int, int]:
|
|
||||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
|
||||||
role = str(user.get("role") or "user").strip().lower()
|
|
||||||
username = str(user.get("username") or "").strip().lower()
|
|
||||||
return (
|
|
||||||
0 if role == "admin" else 1,
|
|
||||||
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
|
||||||
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
|
|
||||||
0 if username == requested else 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
return sorted(users, key=_rank)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
|
|
||||||
target_handles = set(_normalize_handles(identifier))
|
|
||||||
if not target_handles:
|
|
||||||
return None
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email"):
|
|
||||||
value = user.get(key)
|
|
||||||
if target_handles.intersection(_normalize_handles(value)):
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_all_seerr_users() -> list[dict]:
|
|
||||||
cached = get_cached_jellyseerr_users()
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
if not client.configured():
|
|
||||||
return []
|
|
||||||
users: list[dict] = []
|
|
||||||
take = 100
|
|
||||||
skip = 0
|
|
||||||
while True:
|
|
||||||
payload = await client.get_users(take=take, skip=skip)
|
|
||||||
if not payload:
|
|
||||||
break
|
|
||||||
if isinstance(payload, list):
|
|
||||||
batch = payload
|
|
||||||
elif isinstance(payload, dict):
|
|
||||||
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
|
|
||||||
else:
|
|
||||||
batch = None
|
|
||||||
if not isinstance(batch, list) or not batch:
|
|
||||||
break
|
|
||||||
users.extend([user for user in batch if isinstance(user, dict)])
|
|
||||||
if len(batch) < take:
|
|
||||||
break
|
|
||||||
skip += take
|
|
||||||
if users:
|
|
||||||
return save_jellyseerr_users_cache(users)
|
|
||||||
return users
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
|
|
||||||
if isinstance(local_user, dict):
|
|
||||||
stored_email = str(local_user.get("email") or "").strip()
|
|
||||||
if "@" in stored_email:
|
|
||||||
return stored_email
|
|
||||||
username = str(local_user.get("username") or "").strip()
|
|
||||||
if "@" in username:
|
|
||||||
return username
|
|
||||||
if isinstance(seerr_user, dict):
|
|
||||||
email = str(seerr_user.get("email") or "").strip()
|
|
||||||
if "@" in email:
|
|
||||||
return email
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
|
|
||||||
normalized_identifier = str(identifier or "").strip()
|
|
||||||
if not normalized_identifier:
|
|
||||||
return None
|
|
||||||
|
|
||||||
local_user = normalize_user_auth_provider(
|
|
||||||
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
|
|
||||||
)
|
|
||||||
seerr_users: list[dict] | None = None
|
|
||||||
seerr_user: dict | None = None
|
|
||||||
|
|
||||||
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
|
|
||||||
seerr_users = await _fetch_all_seerr_users()
|
|
||||||
seerr_user = next(
|
|
||||||
(
|
|
||||||
user
|
|
||||||
for user in seerr_users
|
|
||||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not local_user:
|
|
||||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
|
||||||
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
|
|
||||||
if seerr_user:
|
|
||||||
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
|
|
||||||
try:
|
|
||||||
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
seerr_user_id = None
|
|
||||||
if seerr_user_id is not None:
|
|
||||||
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
|
|
||||||
if not local_user:
|
|
||||||
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
|
|
||||||
if not isinstance(candidate, str) or not candidate.strip():
|
|
||||||
continue
|
|
||||||
local_user = normalize_user_auth_provider(
|
|
||||||
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
|
|
||||||
)
|
|
||||||
if local_user:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not local_user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
auth_provider = resolve_user_auth_provider(local_user)
|
|
||||||
username = str(local_user.get("username") or "").strip()
|
|
||||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
|
||||||
if not recipient_email:
|
|
||||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
|
||||||
if isinstance(local_user.get("jellyseerr_user_id"), int):
|
|
||||||
seerr_user = next(
|
|
||||||
(
|
|
||||||
user
|
|
||||||
for user in seerr_users
|
|
||||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not seerr_user:
|
|
||||||
seerr_user = _find_matching_seerr_user(username, seerr_users)
|
|
||||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
|
||||||
if not recipient_email:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if auth_provider == "jellyseerr":
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if jellyfin_client.configured():
|
|
||||||
try:
|
|
||||||
jellyfin_user = await jellyfin_client.find_user_by_name(username)
|
|
||||||
except Exception:
|
|
||||||
jellyfin_user = None
|
|
||||||
if isinstance(jellyfin_user, dict):
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
|
|
||||||
if auth_provider not in {"local", "jellyfin"}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"username": username,
|
|
||||||
"recipient_email": recipient_email,
|
|
||||||
"auth_provider": auth_provider,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _token_record_is_usable(record: Optional[dict]) -> bool:
|
|
||||||
if not isinstance(record, dict):
|
|
||||||
return False
|
|
||||||
if record.get("is_used"):
|
|
||||||
return False
|
|
||||||
if record.get("is_expired"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_email(email: str) -> str:
|
|
||||||
candidate = str(email or "").strip()
|
|
||||||
if "@" not in candidate:
|
|
||||||
return "valid reset link"
|
|
||||||
local_part, domain = candidate.split("@", 1)
|
|
||||||
if not local_part:
|
|
||||||
return f"***@{domain}"
|
|
||||||
if len(local_part) == 1:
|
|
||||||
return f"{local_part}***@{domain}"
|
|
||||||
return f"{local_part[0]}***{local_part[-1]}@{domain}"
|
|
||||||
|
|
||||||
|
|
||||||
async def request_password_reset(
|
|
||||||
identifier: str,
|
|
||||||
*,
|
|
||||||
requested_by_ip: Optional[str] = None,
|
|
||||||
requested_user_agent: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
target = await _resolve_reset_target(identifier)
|
|
||||||
if not target:
|
|
||||||
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
|
|
||||||
return {"status": "ok", "issued": False}
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
|
|
||||||
create_password_reset_token(
|
|
||||||
token,
|
|
||||||
target["username"],
|
|
||||||
target["recipient_email"],
|
|
||||||
target["auth_provider"],
|
|
||||||
expires_at,
|
|
||||||
requested_by_ip=requested_by_ip,
|
|
||||||
requested_user_agent=requested_user_agent,
|
|
||||||
)
|
|
||||||
await send_password_reset_email(
|
|
||||||
recipient_email=target["recipient_email"],
|
|
||||||
username=target["username"],
|
|
||||||
token=token,
|
|
||||||
expires_at=expires_at,
|
|
||||||
auth_provider=target["auth_provider"],
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"issued": True,
|
|
||||||
"username": target["username"],
|
|
||||||
"recipient_email": target["recipient_email"],
|
|
||||||
"auth_provider": target["auth_provider"],
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password_reset_token(token: str) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
record = get_password_reset_token(token)
|
|
||||||
if not _token_record_is_usable(record):
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
|
|
||||||
"auth_provider": record.get("auth_provider"),
|
|
||||||
"expires_at": record.get("expires_at"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
record = get_password_reset_token(token)
|
|
||||||
if not _token_record_is_usable(record):
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
username = str(record.get("username") or "").strip()
|
|
||||||
if not username:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
stored_user = normalize_user_auth_provider(get_user_by_username(username))
|
|
||||||
if not stored_user:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
auth_provider = resolve_user_auth_provider(stored_user)
|
|
||||||
if auth_provider == "jellyseerr":
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
|
|
||||||
if auth_provider == "local":
|
|
||||||
set_user_password(username, new_password)
|
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
|
|
||||||
set_user_auth_provider(username, "local")
|
|
||||||
mark_password_reset_token_used(token)
|
|
||||||
logger.info("password reset applied username=%s provider=local", username)
|
|
||||||
return {"status": "ok", "provider": "local", "username": username}
|
|
||||||
|
|
||||||
if auth_provider == "jellyfin":
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if not client.configured():
|
|
||||||
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
|
|
||||||
jellyfin_user = await client.find_user_by_name(username)
|
|
||||||
user_id = client._extract_user_id(jellyfin_user)
|
|
||||||
if not user_id:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
await client.set_user_password(user_id, new_password)
|
|
||||||
sync_jellyfin_password_state(username, new_password)
|
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
|
|
||||||
set_user_auth_provider(username, "jellyfin")
|
|
||||||
mark_password_reset_token_used(token)
|
|
||||||
logger.info("password reset applied username=%s provider=jellyfin", username)
|
|
||||||
return {"status": "ok", "provider": "jellyfin", "username": username}
|
|
||||||
|
|
||||||
raise ValueError("Password reset is not available for this sign-in provider.")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from ..db import get_setting, set_setting, delete_setting
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
|
|
||||||
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
|
|
||||||
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
|
|
||||||
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
|
|
||||||
parsed = _parse_iso(cached_at)
|
|
||||||
if not parsed:
|
|
||||||
return False
|
|
||||||
age = datetime.now(timezone.utc) - parsed
|
|
||||||
return age <= timedelta(minutes=max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cached_users(
|
|
||||||
cache_key: str, cache_at_key: str, max_age_minutes: int
|
|
||||||
) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
cached_at = get_setting(cache_at_key)
|
|
||||||
if not _cache_is_fresh(cached_at, max_age_minutes):
|
|
||||||
return None
|
|
||||||
raw = get_setting(cache_key)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [item for item in data if isinstance(item, dict)]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
|
|
||||||
payload = json.dumps(users, ensure_ascii=True)
|
|
||||||
set_setting(cache_key, payload)
|
|
||||||
set_setting(cache_at_key, _now_iso())
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_handles(value: Any) -> List[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return []
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
handles = [normalized]
|
|
||||||
if "@" in normalized:
|
|
||||||
handles.append(normalized.split("@", 1)[0])
|
|
||||||
return list(dict.fromkeys(handles))
|
|
||||||
|
|
||||||
|
|
||||||
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
|
|
||||||
candidate_to_id: Dict[str, int] = {}
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
|
||||||
try:
|
|
||||||
user_id = int(user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email", "displayName", "name"):
|
|
||||||
for handle in _normalized_handles(user.get(key)):
|
|
||||||
candidate_to_id.setdefault(handle, user_id)
|
|
||||||
return candidate_to_id
|
|
||||||
|
|
||||||
|
|
||||||
def find_matching_jellyseerr_user(
|
|
||||||
identifier: str, users: List[Dict[str, Any]]
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
target_handles = set(_normalized_handles(identifier))
|
|
||||||
if not target_handles:
|
|
||||||
return None
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email", "displayName", "name"):
|
|
||||||
if target_handles.intersection(_normalized_handles(user.get(key))):
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return None
|
|
||||||
value = user.get("email")
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
candidate = value.strip()
|
|
||||||
if not candidate or "@" not in candidate:
|
|
||||||
return None
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def match_jellyseerr_user_id(
|
|
||||||
username: str, candidate_map: Dict[str, int]
|
|
||||||
) -> Optional[int]:
|
|
||||||
for handle in _normalized_handles(username):
|
|
||||||
matched = candidate_map.get(handle)
|
|
||||||
if matched is not None:
|
|
||||||
return matched
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
normalized: List[Dict[str, Any]] = []
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": user.get("id") or user.get("userId") or user.get("Id"),
|
|
||||||
"email": user.get("email"),
|
|
||||||
"username": user.get("username"),
|
|
||||||
"displayName": user.get("displayName"),
|
|
||||||
"name": user.get("name"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
|
|
||||||
logger.debug("Cached Seerr users: %s", len(normalized))
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
normalized: List[Dict[str, Any]] = []
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": user.get("Id"),
|
|
||||||
"name": user.get("Name"),
|
|
||||||
"hasPassword": user.get("HasPassword"),
|
|
||||||
"lastLoginDate": user.get("LastLoginDate"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
|
|
||||||
logger.debug("Cached Jellyfin users: %s", len(normalized))
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_user_import_caches() -> Dict[str, int]:
|
|
||||||
cleared = 0
|
|
||||||
for key in (
|
|
||||||
JELLYSEERR_CACHE_KEY,
|
|
||||||
JELLYSEERR_CACHE_AT_KEY,
|
|
||||||
JELLYFIN_CACHE_KEY,
|
|
||||||
JELLYFIN_CACHE_AT_KEY,
|
|
||||||
):
|
|
||||||
delete_setting(key)
|
|
||||||
cleared += 1
|
|
||||||
logger.debug("Cleared user import cache keys: %s", cleared)
|
|
||||||
return {"settingsKeysCleared": cleared}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
fastapi==0.134.0
|
fastapi==0.115.0
|
||||||
uvicorn==0.41.0
|
uvicorn==0.30.6
|
||||||
httpx==0.28.1
|
httpx==0.27.2
|
||||||
pydantic==2.12.5
|
pydantic==2.9.2
|
||||||
pydantic-settings==2.14.2
|
pydantic-settings==2.5.2
|
||||||
PyJWT==2.13.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
python-multipart==0.0.31
|
python-multipart==0.0.9
|
||||||
Pillow==12.3.0
|
Pillow==10.4.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
name: magent-beta
|
|
||||||
|
|
||||||
services:
|
|
||||||
magent:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
environment:
|
|
||||||
APP_NAME: Magent Beta
|
|
||||||
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
|
|
||||||
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
|
|
||||||
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
|
|
||||||
AUTH_COOKIE_NAME: magent_beta_auth
|
|
||||||
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
|
||||||
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
|
||||||
SQLITE_PATH: /app/data/magent.db
|
|
||||||
LOG_FILE: /app/data/magent.log
|
|
||||||
SITE_BANNER_ENABLED: "true"
|
|
||||||
SITE_BANNER_MESSAGE: "Beta environment"
|
|
||||||
SITE_BANNER_TONE: warning
|
|
||||||
ports:
|
|
||||||
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
|
|
||||||
- "127.0.0.1:8100:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
+12
-3
@@ -1,10 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
magent:
|
backend:
|
||||||
image: rephl3xnz/magent:latest
|
image: rephl3xnz/magent-backend:latest
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: rephl3xnz/magent-frontend:latest
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_BASE=/api
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|||||||
+14
-3
@@ -1,12 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
magent:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_BASE=/api
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
logfile=/dev/null
|
|
||||||
logfile_maxbytes=0
|
|
||||||
pidfile=/tmp/supervisord.pid
|
|
||||||
|
|
||||||
[program:backend]
|
|
||||||
directory=/app
|
|
||||||
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
priority=10
|
|
||||||
|
|
||||||
[program:frontend]
|
|
||||||
directory=/app/frontend
|
|
||||||
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
|
|
||||||
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
priority=20
|
|
||||||
+941
-1692
File diff suppressed because it is too large
Load Diff
@@ -2,34 +2,26 @@ import { notFound } from 'next/navigation'
|
|||||||
import SettingsPage from '../SettingsPage'
|
import SettingsPage from '../SettingsPage'
|
||||||
|
|
||||||
const ALLOWED_SECTIONS = new Set([
|
const ALLOWED_SECTIONS = new Set([
|
||||||
'seerr',
|
|
||||||
'jellyseerr',
|
'jellyseerr',
|
||||||
'jellyfin',
|
'jellyfin',
|
||||||
'artwork',
|
'artwork',
|
||||||
'sonarr',
|
'sonarr',
|
||||||
'radarr',
|
'radarr',
|
||||||
'bazarr',
|
|
||||||
'prowlarr',
|
'prowlarr',
|
||||||
'qbittorrent',
|
'qbittorrent',
|
||||||
'requests',
|
'requests',
|
||||||
'issue-workflow',
|
|
||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
'magent',
|
|
||||||
'general',
|
|
||||||
'notifications',
|
|
||||||
'site',
|
|
||||||
])
|
])
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ section: string }>
|
params: { section: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminSectionPage({ params }: PageProps) {
|
export default function AdminSectionPage({ params }: PageProps) {
|
||||||
const { section } = await params
|
if (!ALLOWED_SECTIONS.has(params.section)) {
|
||||||
if (!ALLOWED_SECTIONS.has(section)) {
|
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
return <SettingsPage section={section} />
|
return <SettingsPage section={params.section} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
|
|
||||||
|
|
||||||
export default function AdminDiagnosticsPage() {
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="Diagnostics"
|
|
||||||
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
|
|
||||||
rail={
|
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">Diagnostics</span>
|
|
||||||
<h2>Shared console</h2>
|
|
||||||
<p>
|
|
||||||
This page and Maintenance now use the same diagnostics panel, so every test target and
|
|
||||||
notification ping stays in one source of truth.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AdminDiagnosticsPanel />
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
import PortalClient from '../../portal/PortalClient'
|
|
||||||
|
|
||||||
export default function AdminIssuesPage() {
|
|
||||||
return <PortalClient workspace="issue" />
|
|
||||||
}
|
|
||||||
+7
-328
@@ -1,345 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
|
|
||||||
type ServiceState = {
|
|
||||||
name: string
|
|
||||||
status: string
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecentRequest = {
|
|
||||||
id: number
|
|
||||||
title?: string | null
|
|
||||||
year?: number | null
|
|
||||||
statusLabel?: string | null
|
|
||||||
requestedBy?: string | null
|
|
||||||
createdAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type PortalOverview = {
|
|
||||||
overview?: {
|
|
||||||
total_items?: number
|
|
||||||
total_comments?: number
|
|
||||||
by_kind?: Record<string, number>
|
|
||||||
by_status?: Record<string, number>
|
|
||||||
}
|
|
||||||
my_items?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return 'Unknown'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeRecent = (items: any[]): RecentRequest[] =>
|
|
||||||
items
|
|
||||||
.filter((item) => item?.id)
|
|
||||||
.map((item) => ({
|
|
||||||
id: Number(item.id),
|
|
||||||
title: item.title ?? null,
|
|
||||||
year: item.year ?? null,
|
|
||||||
statusLabel: item.statusLabel ?? null,
|
|
||||||
requestedBy: item.requestedBy ?? null,
|
|
||||||
createdAt: item.createdAt ?? null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
export default function AdminLandingPage() {
|
export default function AdminLandingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [services, setServices] = useState<ServiceState[]>([])
|
|
||||||
const [serviceOverall, setServiceOverall] = useState('unknown')
|
|
||||||
const [recent, setRecent] = useState<RecentRequest[]>([])
|
|
||||||
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
|
||||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string>>({})
|
|
||||||
const [serviceCheckedAt, setServiceCheckedAt] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
|
|
||||||
authFetch(`${baseUrl}/auth/me`),
|
|
||||||
authFetch(`${baseUrl}/status/services`),
|
|
||||||
authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
|
|
||||||
authFetch(`${baseUrl}/portal/overview`),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (meResponse.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (meResponse.status === 403) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const me = await meResponse.json()
|
|
||||||
if (me?.role !== 'admin') {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serviceResponse.ok) {
|
|
||||||
const data = await serviceResponse.json()
|
|
||||||
setServiceOverall(data?.overall ?? 'unknown')
|
|
||||||
setServices(Array.isArray(data?.services) ? data.services : [])
|
|
||||||
setServiceCheckedAt(new Date().toISOString())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentResponse.ok) {
|
|
||||||
const data = await recentResponse.json()
|
|
||||||
setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overviewResponse.ok) {
|
|
||||||
const data = await overviewResponse.json()
|
|
||||||
setPortalOverview(data)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Unable to load the operations dashboard.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load()
|
|
||||||
|
|
||||||
const refreshTimer = window.setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/status/services`)
|
|
||||||
if (!response.ok) return
|
|
||||||
const data = await response.json()
|
|
||||||
setServiceOverall(data?.overall ?? 'unknown')
|
|
||||||
setServices(Array.isArray(data?.services) ? data.services : [])
|
|
||||||
setServiceCheckedAt(new Date().toISOString())
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}, 30_000)
|
|
||||||
|
|
||||||
return () => window.clearInterval(refreshTimer)
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const testService = async (service: ServiceState) => {
|
|
||||||
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
||||||
setServiceTesting((current) => ({ ...current, [service.name]: true }))
|
|
||||||
setServiceTestResults((current) => {
|
|
||||||
const next = { ...current }
|
|
||||||
delete next[service.name]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/status/services/${slug}/test`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || `Service test failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const result = await response.json()
|
|
||||||
setServices((current) => current.map((item) =>
|
|
||||||
item.name === service.name
|
|
||||||
? { ...item, status: result?.status ?? item.status, message: result?.message ?? item.message }
|
|
||||||
: item
|
|
||||||
))
|
|
||||||
setServiceTestResults((current) => ({
|
|
||||||
...current,
|
|
||||||
[service.name]: result?.message || (result?.status === 'up' ? 'Connection test passed.' : 'Connection test completed.'),
|
|
||||||
}))
|
|
||||||
setServiceCheckedAt(new Date().toISOString())
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setServiceTestResults((current) => ({ ...current, [service.name]: 'Connection test failed.' }))
|
|
||||||
} finally {
|
|
||||||
setServiceTesting((current) => ({ ...current, [service.name]: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceCounts = useMemo(() => {
|
|
||||||
const up = services.filter((service) => service.status === 'up').length
|
|
||||||
const down = services.filter((service) => service.status === 'down').length
|
|
||||||
const degraded = services.filter((service) => service.status === 'degraded').length
|
|
||||||
const notConfigured = services.filter((service) => service.status === 'not_configured').length
|
|
||||||
return { up, down, degraded, notConfigured, total: services.length }
|
|
||||||
}, [services])
|
|
||||||
|
|
||||||
const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
|
|
||||||
const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
|
|
||||||
const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
|
|
||||||
|
|
||||||
const rail = (
|
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">Fleet summary</span>
|
|
||||||
<h2>{serviceCounts.up} of {serviceCounts.total || 0} online</h2>
|
|
||||||
<p>
|
|
||||||
{serviceCounts.down + serviceCounts.degraded > 0
|
|
||||||
? `${serviceCounts.down + serviceCounts.degraded} configured service${serviceCounts.down + serviceCounts.degraded === 1 ? '' : 's'} need attention.`
|
|
||||||
: 'No configured service is currently reporting a fault.'}
|
|
||||||
</p>
|
|
||||||
<a className="admin-rail-action" href="/admin/diagnostics">Open full diagnostics</a>
|
|
||||||
</div>
|
|
||||||
<div className="admin-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">Quick actions</span>
|
|
||||||
<div className="quick-action-grid">
|
|
||||||
<a href="/admin/requests-all">Review requests</a>
|
|
||||||
<a href="/admin/issues">Manage issues</a>
|
|
||||||
<a href="/users">User directory</a>
|
|
||||||
<a href="/admin/logs">Activity log</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Admin overview"
|
title="Settings"
|
||||||
subtitle="Service health, request movement, issue intake, and the controls that keep Magent running."
|
subtitle="Choose what you want to manage."
|
||||||
rail={rail}
|
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/admin/diagnostics')}>
|
<button type="button" onClick={() => router.push('/')}>
|
||||||
Run diagnostics
|
Back to requests
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
<section className="admin-section">
|
||||||
{error ? <div className="error-banner">{error}</div> : null}
|
<div className="status-banner">
|
||||||
|
Pick a section from the left. Each page explains what it does and how it helps.
|
||||||
<section className="ops-metric-grid">
|
|
||||||
<div className="ops-metric-card">
|
|
||||||
<span className="section-kicker">Services online</span>
|
|
||||||
<strong>
|
|
||||||
{serviceCounts.up}/{serviceCounts.total || 0}
|
|
||||||
</strong>
|
|
||||||
<p>{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="ops-metric-card">
|
|
||||||
<span className="section-kicker">Recent requests</span>
|
|
||||||
<strong>{recent.length}</strong>
|
|
||||||
<p>Loaded from the live request cache.</p>
|
|
||||||
</div>
|
|
||||||
<div className="ops-metric-card">
|
|
||||||
<span className="section-kicker">Open issue items</span>
|
|
||||||
<strong>{issueCount}</strong>
|
|
||||||
<p>{commentCount} portal comments recorded.</p>
|
|
||||||
</div>
|
|
||||||
<div className="ops-metric-card">
|
|
||||||
<span className="section-kicker">Portal requests</span>
|
|
||||||
<strong>{requestItemCount}</strong>
|
|
||||||
<p>Tracked in the dedicated request workflow.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="admin-zone fleet-status-panel">
|
|
||||||
<div className="section-header fleet-status-header">
|
|
||||||
<div>
|
|
||||||
<span className="section-kicker">Fleet service mesh</span>
|
|
||||||
<h2>System status</h2>
|
|
||||||
<p className="section-subtitle">
|
|
||||||
Admin-only connectivity status for the services used by Magent.
|
|
||||||
{serviceCheckedAt ? ` Last checked ${formatDateTime(serviceCheckedAt)}.` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={`small-pill system-pill-${serviceOverall}`}>
|
|
||||||
{serviceOverall.replaceAll('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{services.length === 0 ? (
|
|
||||||
<div className="status-banner">Service status is not available yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="fleet-service-grid">
|
|
||||||
{services.map((service) => {
|
|
||||||
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
||||||
const testing = Boolean(serviceTesting[service.name])
|
|
||||||
return (
|
|
||||||
<article className={`fleet-service-card system-${service.status}`} key={service.name}>
|
|
||||||
<div className="fleet-service-title">
|
|
||||||
<span className="system-dot" aria-hidden="true" />
|
|
||||||
<div>
|
|
||||||
<h3>{service.name}</h3>
|
|
||||||
<span className={`small-pill system-pill-${service.status}`}>
|
|
||||||
{service.status.replaceAll('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>{serviceTestResults[service.name] ?? service.message ?? 'No recent detail was returned.'}</p>
|
|
||||||
<div className="fleet-service-actions">
|
|
||||||
<a href={`/admin/${slug}`}>Configure</a>
|
|
||||||
<button type="button" className="ghost-button" disabled={testing} onClick={() => void testService(service)}>
|
|
||||||
{testing ? 'Testing...' : 'Test connection'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="admin-zone">
|
|
||||||
<div className="section-header">
|
|
||||||
<div>
|
|
||||||
<h2>Recent activity</h2>
|
|
||||||
<p className="section-subtitle">Live request cache entries, newest first.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{recent.length === 0 ? (
|
|
||||||
<div className="status-banner">No recent requests were returned.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table dashboard-activity-table">
|
|
||||||
<div className="admin-table-head">
|
|
||||||
<span>Request</span>
|
|
||||||
<span>Status</span>
|
|
||||||
<span>User</span>
|
|
||||||
<span>Created</span>
|
|
||||||
</div>
|
|
||||||
{recent.map((row) => (
|
|
||||||
<button
|
|
||||||
key={row.id}
|
|
||||||
type="button"
|
|
||||||
className="admin-table-row"
|
|
||||||
onClick={() => router.push(`/requests/${row.id}`)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{row.title || `Request #${row.id}`}
|
|
||||||
{row.year ? ` (${row.year})` : ''}
|
|
||||||
</span>
|
|
||||||
<span>{row.statusLabel || 'Unknown'}</span>
|
|
||||||
<span>{row.requestedBy || 'Unknown'}</span>
|
|
||||||
<span>{formatDateTime(row.createdAt)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="admin-zone">
|
|
||||||
<div className="section-header">
|
|
||||||
<div>
|
|
||||||
<h2>Attention states</h2>
|
|
||||||
<p className="section-subtitle">Service states that affect request processing.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ops-status-strip">
|
|
||||||
<span>{serviceCounts.down} down</span>
|
|
||||||
<span>{serviceCounts.degraded} degraded</span>
|
|
||||||
<span>{serviceCounts.notConfigured} not configured</span>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function AdminProfilesRedirectPage() {
|
|
||||||
redirect('/admin/invites')
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
|
|
||||||
type RequestRow = {
|
|
||||||
id: number
|
|
||||||
title?: string | null
|
|
||||||
year?: number | null
|
|
||||||
type?: string | null
|
|
||||||
statusLabel?: string | null
|
|
||||||
requestedBy?: string | null
|
|
||||||
createdAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_STAGE_OPTIONS = [
|
|
||||||
{ value: 'all', label: 'All stages' },
|
|
||||||
{ value: 'pending', label: 'Waiting for approval' },
|
|
||||||
{ value: 'approved', label: 'Approved' },
|
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
|
||||||
{ value: 'working', label: 'Working on it' },
|
|
||||||
{ value: 'partial', label: 'Partially ready' },
|
|
||||||
{ value: 'ready', label: 'Ready to watch' },
|
|
||||||
{ value: 'declined', label: 'Declined' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return 'Unknown'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminRequestsAllPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [rows, setRows] = useState<RequestRow[]>([])
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [pageSize, setPageSize] = useState(50)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [stage, setStage] = useState('all')
|
|
||||||
|
|
||||||
const pageCount = useMemo(() => {
|
|
||||||
if (!total || pageSize <= 0) return 1
|
|
||||||
return Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
}, [total, pageSize])
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const skip = (page - 1) * pageSize
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
take: String(pageSize),
|
|
||||||
skip: String(skip),
|
|
||||||
})
|
|
||||||
if (stage !== 'all') {
|
|
||||||
params.set('stage', stage)
|
|
||||||
}
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/requests/all?${params.toString()}`
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Load failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setRows(Array.isArray(data?.results) ? data.results : [])
|
|
||||||
setTotal(Number(data?.total ?? 0))
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Unable to load requests.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [page, pageSize, stage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > pageCount) {
|
|
||||||
setPage(pageCount)
|
|
||||||
}
|
|
||||||
}, [pageCount, page])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
}, [stage])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="All requests"
|
|
||||||
subtitle="Paginated view of every cached request."
|
|
||||||
actions={
|
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
|
||||||
Back to settings
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<section className="admin-section">
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<div className="admin-toolbar-info">
|
|
||||||
<span>{total.toLocaleString()} total</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-toolbar-actions">
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Stage</span>
|
|
||||||
<select value={stage} onChange={(e) => setStage(e.target.value)}>
|
|
||||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Per page</span>
|
|
||||||
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={200}>200</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="status-banner">Loading requests…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="error-banner">{error}</div>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<div className="status-banner">No requests found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table">
|
|
||||||
<div className="admin-table-head">
|
|
||||||
<span>Request</span>
|
|
||||||
<span>Status</span>
|
|
||||||
<span>Requested by</span>
|
|
||||||
<span>Created</span>
|
|
||||||
</div>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<button
|
|
||||||
key={row.id}
|
|
||||||
type="button"
|
|
||||||
className="admin-table-row"
|
|
||||||
onClick={() => router.push(`/requests/${row.id}`)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{row.title || `Request #${row.id}`}
|
|
||||||
{row.year ? ` (${row.year})` : ''}
|
|
||||||
</span>
|
|
||||||
<span>{row.statusLabel || 'Unknown'}</span>
|
|
||||||
<span>{row.requestedBy || 'Unknown'}</span>
|
|
||||||
<span>{formatDateTime(row.createdAt)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="admin-pagination">
|
|
||||||
<button type="button" onClick={() => setPage(1)} disabled={page <= 1}>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setPage(page - 1)} disabled={page <= 1}>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
Page {page} of {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(pageCount)}
|
|
||||||
disabled={page >= pageCount}
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
|
|
||||||
type FlowStage = {
|
|
||||||
title: string
|
|
||||||
input: string
|
|
||||||
action: string
|
|
||||||
output: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_FLOW: FlowStage[] = [
|
|
||||||
{
|
|
||||||
title: 'Identity + access',
|
|
||||||
input: 'Jellyfin/local login',
|
|
||||||
action: 'Magent validates credentials and role',
|
|
||||||
output: 'JWT token + user scope',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Request intake',
|
|
||||||
input: 'Seerr request ID',
|
|
||||||
action: 'Magent snapshots request + media metadata',
|
|
||||||
output: 'Unified request state',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Queue orchestration',
|
|
||||||
input: 'Approved request',
|
|
||||||
action: 'Sonarr/Radarr add/search operations',
|
|
||||||
output: 'Grab decision',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Download execution',
|
|
||||||
input: 'Selected release',
|
|
||||||
action: 'qBittorrent downloads + reports progress',
|
|
||||||
output: 'Import-ready payload',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Library import',
|
|
||||||
input: 'Completed download',
|
|
||||||
action: 'Sonarr/Radarr import and finalize',
|
|
||||||
output: 'Available media object',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Playback availability',
|
|
||||||
input: 'Imported media',
|
|
||||||
action: 'Jellyfin refresh + link resolution',
|
|
||||||
output: 'Ready-to-watch state',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function AdminSystemGuidePage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [authorized, setAuthorized] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const me = await response.json()
|
|
||||||
if (!active) return
|
|
||||||
if (me?.role !== 'admin') {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAuthorized(true)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
router.push('/')
|
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <main className="card">Loading system guide...</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authorized) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const rail = (
|
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">How it works</span>
|
|
||||||
<h2>Admin flow map</h2>
|
|
||||||
<p>Identity → Request intake → Queue orchestration → Download → Import → Playback.</p>
|
|
||||||
<span className="small-pill">Admin only</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="How it works"
|
|
||||||
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
|
||||||
rail={rail}
|
|
||||||
actions={
|
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
|
||||||
Back to settings
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<section className="admin-section system-guide">
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>End-to-end system flow</h2>
|
|
||||||
<p className="lede">
|
|
||||||
This is the runtime path the platform follows from authentication through to playback
|
|
||||||
availability.
|
|
||||||
</p>
|
|
||||||
<div className="system-flow-track">
|
|
||||||
{REQUEST_FLOW.map((stage, index) => (
|
|
||||||
<div key={stage.title} className="system-flow-segment">
|
|
||||||
<article className="system-flow-card">
|
|
||||||
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Input</span>
|
|
||||||
<strong>{stage.input}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Action</span>
|
|
||||||
<strong>{stage.action}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Output</span>
|
|
||||||
<strong>{stage.output}</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true">→</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>What each service is responsible for</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Magent</h3>
|
|
||||||
<p>
|
|
||||||
Handles authentication, request pages, live event updates, invite workflows,
|
|
||||||
diagnostics, notifications, and admin operations.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Seerr</h3>
|
|
||||||
<p>
|
|
||||||
Stores the request itself and remains the request-state source for approval and
|
|
||||||
media request metadata.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Jellyfin</h3>
|
|
||||||
<p>
|
|
||||||
Provides user sign-in identity and the final playback destination once content is
|
|
||||||
available.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Sonarr / Radarr</h3>
|
|
||||||
<p>
|
|
||||||
Control queue placement, quality-profile decisions, import handling, and release
|
|
||||||
monitoring.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Prowlarr</h3>
|
|
||||||
<p>Provides search/indexer coverage for Arr-side release searches.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>qBittorrent</h3>
|
|
||||||
<p>
|
|
||||||
Executes the download and exposes live progress, paused states, and queue
|
|
||||||
visibility.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Operational controls by area</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>General</h3>
|
|
||||||
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Users</h3>
|
|
||||||
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Invite management</h3>
|
|
||||||
<p>
|
|
||||||
Master template, profile assignment, invite access policy, invite emails, and trace
|
|
||||||
map lineage.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Requests + cache</h3>
|
|
||||||
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Maintenance + diagnostics</h3>
|
|
||||||
<p>
|
|
||||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
|
||||||
nuclear flush/resync operations.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>User and invite model</h2>
|
|
||||||
<ol className="system-decision-list">
|
|
||||||
<li>
|
|
||||||
Jellyfin is used for sign-in identity and user presence across the platform.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Seerr provides request ownership and request-state data for Magent request pages.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Invite links, invite profiles, blanket rules, and invite-access controls are managed
|
|
||||||
inside Magent.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
If invite tracing is enabled, the lineage view shows who invited whom and how the
|
|
||||||
chain branches.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Cross-system removal and ban flows are initiated from Magent admin controls.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Stall recovery path (decision flow)</h2>
|
|
||||||
<ol className="system-decision-list">
|
|
||||||
<li>
|
|
||||||
Request approved but not in Arr queue <span>→</span> run <strong>Re-add to Arr</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
In queue but no release found <span>→</span> run <strong>Search releases</strong> and inspect options.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Release exists and user should not pick manually <span>→</span> run <strong>Search + auto-download</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Download paused/stalled in qBittorrent <span>→</span> run <strong>Resume download</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Imported but not visible to user <span>→</span> validate Jellyfin visibility/link from request page.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Live update surfaces</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Landing page</h3>
|
|
||||||
<p>Recent request activity refreshes live for signed-in users.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Request pages</h3>
|
|
||||||
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Admin views</h3>
|
|
||||||
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|
||||||
|
|
||||||
type SiteInfo = {
|
|
||||||
changelog?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChangelogGroup = {
|
|
||||||
date: string
|
|
||||||
entries: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
||||||
|
|
||||||
const parseChangelog = (raw: string): ChangelogGroup[] => {
|
|
||||||
const groups: ChangelogGroup[] = []
|
|
||||||
for (const rawLine of raw.split('\n')) {
|
|
||||||
const line = rawLine.trim()
|
|
||||||
if (!line) continue
|
|
||||||
const [candidateDate, ...messageParts] = line.split('|')
|
|
||||||
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
|
|
||||||
const message = messageParts.join('|').trim()
|
|
||||||
if (!message) continue
|
|
||||||
const currentGroup = groups[groups.length - 1]
|
|
||||||
if (currentGroup?.date === candidateDate) {
|
|
||||||
currentGroup.entries.push(message)
|
|
||||||
} else {
|
|
||||||
groups.push({ date: candidateDate, entries: [message] })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (groups.length === 0) {
|
|
||||||
groups.push({ date: 'Updates', entries: [line] })
|
|
||||||
} else {
|
|
||||||
groups[groups.length - 1].entries.push(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChangelogPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [groups, setGroups] = useState<ChangelogGroup[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/site/info`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Failed to load changelog')
|
|
||||||
}
|
|
||||||
const data: SiteInfo = await response.json()
|
|
||||||
if (!active) return
|
|
||||||
setGroups(parseChangelog(data?.changelog ?? ''))
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
if (!active) return
|
|
||||||
setGroups([])
|
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (loading) {
|
|
||||||
return <div className="loading-text">Loading changelog...</div>
|
|
||||||
}
|
|
||||||
if (groups.length === 0) {
|
|
||||||
return <div className="meta">No updates posted yet.</div>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="changelog-groups">
|
|
||||||
{groups.map((group) => (
|
|
||||||
<section key={group.date} className="changelog-group">
|
|
||||||
<h2>{group.date}</h2>
|
|
||||||
<ul className="changelog-list">
|
|
||||||
{group.entries.map((entry, index) => (
|
|
||||||
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}, [groups, loading])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<section className="card changelog-card">
|
|
||||||
<div className="changelog-header">
|
|
||||||
<h1>Changelog</h1>
|
|
||||||
<p className="lede">Latest updates and release notes.</p>
|
|
||||||
</div>
|
|
||||||
{content}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
type Profile = {
|
type Profile = {
|
||||||
username?: string
|
username?: string
|
||||||
@@ -24,17 +24,15 @@ export default function FeedbackPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Could not load profile.')
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setProfile({ username: data?.username })
|
setProfile({ username: data?.username })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +49,7 @@ export default function FeedbackPage() {
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -60,16 +58,17 @@ export default function FeedbackPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
throw new Error(text || `Request failed: ${response.status}`)
|
throw new Error(text || `Request failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setStatus('Thanks! Your message has been sent.')
|
setStatus('Thanks! Your message has been sent.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setStatus('That did not send. Please try again.')
|
setStatus('That did not send. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
|
||||||
import { getApiBase } from '../lib/auth'
|
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [identifier, setIdentifier] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!identifier.trim()) {
|
|
||||||
setError('Enter your username or email.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/auth/password/forgot`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ identifier: identifier.trim() }),
|
|
||||||
})
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.')
|
|
||||||
}
|
|
||||||
setStatus(
|
|
||||||
typeof data?.message === 'string'
|
|
||||||
? data.message
|
|
||||||
: 'If an account exists for that username or email, a password reset link has been sent.',
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to send reset link.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="card auth-card">
|
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
|
||||||
<h1>Forgot password</h1>
|
|
||||||
<p className="lede">
|
|
||||||
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
|
|
||||||
will be emailed to you.
|
|
||||||
</p>
|
|
||||||
<form className="auth-form" onSubmit={submit}>
|
|
||||||
<label>
|
|
||||||
Username or email
|
|
||||||
<input
|
|
||||||
value={identifier}
|
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
{status && <div className="status-banner">{status}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit" disabled={loading}>
|
|
||||||
{loading ? 'Sending reset link…' : 'Send reset link'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+164
-6103
File diff suppressed because it is too large
Load Diff
@@ -4,181 +4,82 @@ export default function HowItWorksPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="card how-page">
|
<main className="card how-page">
|
||||||
<header className="how-hero">
|
<header className="how-hero">
|
||||||
<p className="eyebrow">How it works</p>
|
<p className="eyebrow">How this works</p>
|
||||||
<h1>How Magent works for users</h1>
|
<h1>Your request, step by step</h1>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
Use Magent to find a request, watch it move through the pipeline, and know when it is
|
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
|
||||||
ready without constantly refreshing the page.
|
your request is and what you can safely do next.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-grid">
|
||||||
<h2>What Magent is for</h2>
|
|
||||||
<div className="how-grid">
|
|
||||||
<article className="how-card">
|
<article className="how-card">
|
||||||
<h3>Track requests</h3>
|
<h2>Jellyseerr</h2>
|
||||||
|
<p className="how-title">The request box</p>
|
||||||
<p>
|
<p>
|
||||||
Search by title, year, or request number to open the request page and see where an
|
This is where you ask for a movie or show. It keeps the request and whether it is
|
||||||
item is up to.
|
approved.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="how-card">
|
<article className="how-card">
|
||||||
<h3>See live progress</h3>
|
<h2>Sonarr / Radarr</h2>
|
||||||
|
<p className="how-title">The library manager</p>
|
||||||
<p>
|
<p>
|
||||||
Request status, timeline events, and download progress update live while you are
|
These add the request to the library list and decide what quality to look for.
|
||||||
viewing the page.
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="how-card">
|
<article className="how-card">
|
||||||
<h3>Know when it is ready</h3>
|
<h2>Prowlarr</h2>
|
||||||
|
<p className="how-title">The search helper</p>
|
||||||
<p>
|
<p>
|
||||||
When the request is fully imported and available, Magent shows it as ready and links
|
This checks your search sources and reports back what it finds.
|
||||||
you through to Jellyfin.
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h2>qBittorrent</h2>
|
||||||
|
<p className="how-title">The downloader</p>
|
||||||
|
<p>
|
||||||
|
This downloads the file. Magent can tell if it is downloading, paused, or finished.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h2>Jellyfin</h2>
|
||||||
|
<p className="how-title">The place you watch</p>
|
||||||
|
<p>
|
||||||
|
When the file is ready, Jellyfin shows it in your library so you can watch it.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-flow">
|
||||||
<h2>The request pipeline</h2>
|
<h2>The pipeline in plain English</h2>
|
||||||
<ol className="how-steps">
|
<ol className="how-steps">
|
||||||
<li>
|
<li>
|
||||||
<strong>You request a movie or show</strong> through Seerr.
|
<strong>You request a title</strong> in Jellyseerr.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Magent picks up the request</strong> and shows its current state.
|
<strong>Sonarr/Radarr adds it</strong> to the library list.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>The automation stack searches and downloads it</strong> if it can find a valid
|
<strong>Prowlarr looks for sources</strong> and sends results back.
|
||||||
release.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>The file is imported into the library</strong>.
|
<strong>qBittorrent downloads</strong> the match.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Jellyfin serves it</strong> once it is ready to watch.
|
<strong>Sonarr/Radarr imports</strong> it into your library.
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>What the statuses usually mean</h2>
|
|
||||||
<div className="how-grid">
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Pending</h3>
|
|
||||||
<p>The request exists, but it is still waiting for approval or the next step.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Approved / Processing</h3>
|
|
||||||
<p>The request has been accepted and the automation tools are working on it.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Downloading</h3>
|
|
||||||
<p>Magent can show live progress while the content is still being downloaded.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Ready</h3>
|
|
||||||
<p>The item has been imported and should now be available in Jellyfin.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Partial / Waiting</h3>
|
|
||||||
<p>
|
|
||||||
Part of the workflow completed, but the request is still waiting on another service or
|
|
||||||
on content becoming available.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Declined</h3>
|
|
||||||
<p>The request was rejected or cannot proceed in its current form.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>Live updates you can expect</h2>
|
|
||||||
<div className="how-step-grid">
|
|
||||||
<article className="how-step-card step-seerr">
|
|
||||||
<div className="step-badge">1</div>
|
|
||||||
<h3>Recent requests refresh automatically</h3>
|
|
||||||
<p className="step-note">
|
|
||||||
Your request list and landing-page activity update automatically while you are signed
|
|
||||||
in.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-step-card step-qbit">
|
|
||||||
<div className="step-badge">2</div>
|
|
||||||
<h3>Request pages update in real time</h3>
|
|
||||||
<p className="step-note">
|
|
||||||
State changes, timeline steps, and downloader progress are pushed to the page live.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-step-card step-jellyfin">
|
|
||||||
<div className="step-badge">3</div>
|
|
||||||
<h3>Ready state appears as soon as the import completes</h3>
|
|
||||||
<p className="step-note">
|
|
||||||
Once the content is actually available, Magent updates the request page without a hard
|
|
||||||
refresh.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>User actions you may see</h2>
|
|
||||||
<div className="how-grid">
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Open request</h3>
|
|
||||||
<p>Jump into the full request page to inspect the current state and activity.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Open in Jellyfin</h3>
|
|
||||||
<p>Appears when the request is ready and Magent can link you through for playback.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Search + auto-download</h3>
|
|
||||||
<p>
|
|
||||||
Only appears for accounts that have been granted self-service download access by the
|
|
||||||
admin team.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>My invites</h3>
|
|
||||||
<p>
|
|
||||||
If your account is allowed to invite others, you can create and manage invite links
|
|
||||||
from your profile.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>Invites and signup</h2>
|
|
||||||
<ol className="how-steps">
|
|
||||||
<li>
|
|
||||||
<strong>You receive an invite link</strong> by email or directly from the person who
|
|
||||||
invited you.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>You sign up through Magent</strong> and your account is linked into the media
|
<strong>Jellyfin shows it</strong> when it is ready to watch.
|
||||||
stack.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Your account defaults apply</strong> based on the invite or your assigned
|
|
||||||
profile.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>You sign in and track requests</strong> from the landing page and your request
|
|
||||||
pages.
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-callout">
|
<section className="how-callout">
|
||||||
<h2>If a request looks stuck</h2>
|
<h2>Why Magent sometimes says “waiting”</h2>
|
||||||
<p>
|
<p>
|
||||||
A waiting request usually means no usable release has been found yet, the download is
|
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
|
||||||
still in progress, or the import has not completed. Magent will keep updating as the
|
That does not mean it is broken. It usually means the release is not available yet.
|
||||||
underlying services move forward.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import './ops-redesign.css'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
|
||||||
import BrandingLogo from './ui/BrandingLogo'
|
|
||||||
import HeaderActions from './ui/HeaderActions'
|
import HeaderActions from './ui/HeaderActions'
|
||||||
import HeaderIdentity from './ui/HeaderIdentity'
|
import HeaderIdentity from './ui/HeaderIdentity'
|
||||||
import SiteStatus from './ui/SiteStatus'
|
import ThemeToggle from './ui/ThemeToggle'
|
||||||
import UserViewBanner from './ui/UserViewBanner'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
|
import BrandingLogo from './ui/BrandingLogo'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Magent',
|
title: 'Magent',
|
||||||
@@ -25,20 +23,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||||
<div className="brand-stack">
|
<div className="brand-stack">
|
||||||
<div className="brand">Magent</div>
|
<div className="brand">Magent</div>
|
||||||
<div className="tagline">GrizzlyFlix media operations</div>
|
<div className="tagline">Find and fix media requests fast.</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<span className="beta-chip" title="Beta environment">Beta</span>
|
|
||||||
<HeaderIdentity />
|
<HeaderIdentity />
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<div className="header-nav">
|
<div className="header-nav">
|
||||||
<HeaderActions />
|
<HeaderActions />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<UserViewBanner />
|
|
||||||
<SiteStatus />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+10
-85
@@ -1,100 +1,25 @@
|
|||||||
const AUTH_STATE_COOKIE = 'magent_logged_in'
|
|
||||||
|
|
||||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
||||||
|
|
||||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCookie = (name: string) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getToken = () => {
|
export const getToken = () => {
|
||||||
if (typeof document === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
const cookies = document.cookie.split(';').map((entry) => entry.trim())
|
return window.localStorage.getItem('magent_token')
|
||||||
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) => {
|
export const setToken = (token: string) => {
|
||||||
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem('magent_token', token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearToken = () => {
|
export const clearToken = () => {
|
||||||
clearCookie(AUTH_STATE_COOKIE)
|
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const baseUrl = getApiBase()
|
window.localStorage.removeItem('magent_token')
|
||||||
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) => {
|
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const token = getToken()
|
||||||
const headers = new Headers(init?.headers || {})
|
const headers = new Headers(init?.headers || {})
|
||||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
if (token) {
|
||||||
}
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
export const getEventStreamToken = async () => {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/stream-token`)
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || `Stream token request failed: ${response.status}`)
|
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
return fetch(input, { ...init, headers })
|
||||||
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Stream token not returned')
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Unauthorized')
|
|
||||||
this.name = 'UnauthorizedError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ForbiddenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Forbidden')
|
|
||||||
this.name = 'ForbiddenError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const response = await authFetch(input, init)
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
throw new UnauthorizedError()
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
throw new ForbiddenError()
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readResponseText = async (response: Response) => {
|
|
||||||
try {
|
|
||||||
return (await response.text()).trim()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
|
|
||||||
const USER_VIEW_EVENT = 'magent:user-view-change'
|
|
||||||
|
|
||||||
const readUserViewPreview = () => {
|
|
||||||
if (typeof window === 'undefined') return false
|
|
||||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyDocumentMode = (enabled: boolean) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setUserViewPreview = (enabled: boolean) => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
if (enabled) {
|
|
||||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
|
|
||||||
} else {
|
|
||||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
|
|
||||||
}
|
|
||||||
applyDocumentMode(enabled)
|
|
||||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUserViewPreview = () => {
|
|
||||||
const [enabled, setEnabled] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sync = () => {
|
|
||||||
const nextValue = readUserViewPreview()
|
|
||||||
applyDocumentMode(nextValue)
|
|
||||||
setEnabled(nextValue)
|
|
||||||
}
|
|
||||||
sync()
|
|
||||||
window.addEventListener(USER_VIEW_EVENT, sync)
|
|
||||||
window.addEventListener('storage', sync)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(USER_VIEW_EVENT, sync)
|
|
||||||
window.removeEventListener('storage', sync)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
+9
-108
@@ -1,36 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { getApiBase, setToken, clearToken } from '../lib/auth'
|
import { getApiBase, setToken, clearToken } from '../lib/auth'
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
import BrandingLogo from '../ui/BrandingLogo'
|
||||||
|
|
||||||
const DEFAULT_LOGIN_OPTIONS = {
|
|
||||||
showJellyfinLogin: true,
|
|
||||||
showLocalLogin: true,
|
|
||||||
showForgotPassword: true,
|
|
||||||
showSignupLink: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
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') => {
|
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!primaryMode) {
|
|
||||||
setError('Login is currently disabled. Contact an administrator.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -42,14 +25,13 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body,
|
body,
|
||||||
credentials: 'include',
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Login failed')
|
throw new Error('Login failed')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.authenticated) {
|
if (data?.access_token) {
|
||||||
setToken('cookie')
|
setToken(data.access_token)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
return
|
return
|
||||||
@@ -66,98 +48,35 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const loadLoginOptions = async () => {
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/site/public`)
|
|
||||||
if (!response.ok) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const login = data?.login ?? {}
|
|
||||||
if (!active) return
|
|
||||||
setLoginOptions({
|
|
||||||
showJellyfinLogin: login.showJellyfinLogin !== false,
|
|
||||||
showLocalLogin: login.showLocalLogin !== false,
|
|
||||||
showForgotPassword: login.showForgotPassword !== false,
|
|
||||||
showSignupLink: login.showSignupLink !== false,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadLoginOptions()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loginHelpText = (() => {
|
|
||||||
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
|
|
||||||
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
|
|
||||||
}
|
|
||||||
if (loginOptions.showJellyfinLogin) {
|
|
||||||
return 'Use your Jellyfin account to sign in.'
|
|
||||||
}
|
|
||||||
if (loginOptions.showLocalLogin) {
|
|
||||||
return 'Use your local Magent admin account to sign in.'
|
|
||||||
}
|
|
||||||
return 'No sign-in methods are currently available. Contact an administrator.'
|
|
||||||
})()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-screen">
|
<main className="card auth-card">
|
||||||
<section className="auth-hero">
|
|
||||||
<div className="auth-mark">
|
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||||
</div>
|
<h1>Sign in</h1>
|
||||||
<div className="auth-title-block">
|
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p>
|
||||||
<span className="section-kicker">Secure access</span>
|
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
|
||||||
<h1>Magent operational gateway</h1>
|
|
||||||
<p>{loginHelpText}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
if (!primaryMode) {
|
|
||||||
event.preventDefault()
|
|
||||||
setError('Login is currently disabled. Contact an administrator.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void submit(event, primaryMode)
|
|
||||||
}}
|
|
||||||
className="auth-form auth-panel"
|
|
||||||
>
|
|
||||||
<label>
|
<label>
|
||||||
<span>Username</span>
|
Username
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder="Enter your username"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Password</span>
|
Password
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
<div className="auth-actions">
|
<div className="auth-actions">
|
||||||
{loginOptions.showJellyfinLogin ? (
|
|
||||||
<button type="submit" disabled={loading}>
|
<button type="submit" disabled={loading}>
|
||||||
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
|
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{loginOptions.showLocalLogin ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
@@ -166,24 +85,6 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
Sign in with Magent account
|
Sign in with Magent account
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
{loginOptions.showForgotPassword ? (
|
|
||||||
<a className="ghost-button" href="/forgot-password">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{loginOptions.showSignupLink ? (
|
|
||||||
<a className="ghost-button" href="/signup">
|
|
||||||
Have an invite? Create your account (Jellyfin + Magent)
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
|
||||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
|
||||||
) : null}
|
|
||||||
<div className="auth-footnote">
|
|
||||||
<span className="live-dot" aria-hidden="true" />
|
|
||||||
Beta environment
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,507 +0,0 @@
|
|||||||
'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')
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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
+191
-213
@@ -2,36 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
||||||
|
|
||||||
const normalizeRecentResults = (items: any[]) =>
|
|
||||||
items
|
|
||||||
.filter((item: any) => item?.id)
|
|
||||||
.map((item: any) => {
|
|
||||||
const id = item.id
|
|
||||||
const rawTitle = item.title
|
|
||||||
const placeholder =
|
|
||||||
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
|
||||||
year: item.year,
|
|
||||||
statusLabel: item.statusLabel,
|
|
||||||
artwork: item.artwork,
|
|
||||||
createdAt: item.createdAt ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const REQUEST_STAGE_OPTIONS = [
|
|
||||||
{ value: 'all', label: 'All stages' },
|
|
||||||
{ value: 'pending', label: 'Waiting' },
|
|
||||||
{ value: 'approved', label: 'Approved' },
|
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
|
||||||
{ value: 'working', label: 'Working' },
|
|
||||||
{ value: 'partial', label: 'Partial' },
|
|
||||||
{ value: 'ready', label: 'Ready' },
|
|
||||||
{ value: 'declined', label: 'Declined' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -43,27 +14,22 @@ export default function HomePage() {
|
|||||||
year?: number
|
year?: number
|
||||||
statusLabel?: string
|
statusLabel?: string
|
||||||
artwork?: { poster_url?: string }
|
artwork?: { poster_url?: string }
|
||||||
createdAt?: string | null
|
|
||||||
}[]
|
}[]
|
||||||
>([])
|
>([])
|
||||||
const [recentError, setRecentError] = useState<string | null>(null)
|
const [recentError, setRecentError] = useState<string | null>(null)
|
||||||
const [recentLoading, setRecentLoading] = useState(false)
|
const [recentLoading, setRecentLoading] = useState(false)
|
||||||
const [searchResults, setSearchResults] = useState<
|
const [searchResults, setSearchResults] = useState<
|
||||||
{
|
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[]
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
type?: string
|
|
||||||
requestId?: number
|
|
||||||
statusLabel?: string
|
|
||||||
requestedBy?: string | null
|
|
||||||
accessible?: boolean
|
|
||||||
}[]
|
|
||||||
>([])
|
>([])
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
const [role, setRole] = useState<string | null>(null)
|
const [role, setRole] = useState<string | null>(null)
|
||||||
const [recentDays, setRecentDays] = useState(90)
|
const [recentDays, setRecentDays] = useState(90)
|
||||||
const [recentStage, setRecentStage] = useState('all')
|
|
||||||
const [authReady, setAuthReady] = useState(false)
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
const [servicesStatus, setServicesStatus] = useState<
|
||||||
|
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
|
||||||
|
>(null)
|
||||||
|
const [servicesLoading, setServicesLoading] = useState(false)
|
||||||
|
const [servicesError, setServicesError] = useState<string | null>(null)
|
||||||
|
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -100,14 +66,9 @@ export default function HomePage() {
|
|||||||
setRole(userRole)
|
setRole(userRole)
|
||||||
setAuthReady(true)
|
setAuthReady(true)
|
||||||
const take = userRole === 'admin' ? 50 : 6
|
const take = userRole === 'admin' ? 50 : 6
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(
|
||||||
take: String(take),
|
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
|
||||||
days: String(recentDays),
|
)
|
||||||
})
|
|
||||||
if (recentStage !== 'all') {
|
|
||||||
params.set('stage', recentStage)
|
|
||||||
}
|
|
||||||
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -118,7 +79,24 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (Array.isArray(data?.results)) {
|
if (Array.isArray(data?.results)) {
|
||||||
setRecent(normalizeRecentResults(data.results))
|
setRecent(
|
||||||
|
data.results
|
||||||
|
.filter((item: any) => item?.id)
|
||||||
|
.map((item: any) => {
|
||||||
|
const id = item.id
|
||||||
|
const rawTitle = item.title
|
||||||
|
const placeholder =
|
||||||
|
typeof rawTitle === 'string' &&
|
||||||
|
rawTitle.trim().toLowerCase() === `request ${id}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||||
|
year: item.year,
|
||||||
|
statusLabel: item.statusLabel,
|
||||||
|
artwork: item.artwork,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -129,69 +107,40 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load()
|
load()
|
||||||
}, [recentDays, recentStage])
|
}, [recentDays])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authReady) {
|
if (!authReady) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!getToken()) {
|
const load = async () => {
|
||||||
return
|
setServicesLoading(true)
|
||||||
}
|
setServicesError(null)
|
||||||
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
let closed = false
|
const response = await authFetch(`${baseUrl}/status/services`)
|
||||||
let source: EventSource | null = null
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
const connect = async () => {
|
clearToken()
|
||||||
try {
|
router.push('/login')
|
||||||
const streamToken = await getEventStreamToken()
|
|
||||||
if (closed) return
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
stream_token: streamToken,
|
|
||||||
recent_days: String(recentDays),
|
|
||||||
})
|
|
||||||
if (recentStage !== 'all') {
|
|
||||||
params.set('recent_stage', recentStage)
|
|
||||||
}
|
|
||||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
|
||||||
source = new EventSource(streamUrl)
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
if (closed) return
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data)
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_recent') {
|
throw new Error(`Service status failed: ${response.status}`)
|
||||||
if (Array.isArray(payload.results)) {
|
|
||||||
setRecent(normalizeRecentResults(payload.results))
|
|
||||||
setRecentError(null)
|
|
||||||
setRecentLoading(false)
|
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
|
||||||
setRecentError('Recent requests are not available right now.')
|
|
||||||
setRecentLoading(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setServicesStatus(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setServicesError('Service status is not available right now.')
|
||||||
|
} finally {
|
||||||
|
setServicesLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
load()
|
||||||
if (closed) return
|
const timer = setInterval(load, 30000)
|
||||||
console.error(error)
|
return () => clearInterval(timer)
|
||||||
}
|
}, [authReady, router])
|
||||||
}
|
|
||||||
|
|
||||||
void connect()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
closed = true
|
|
||||||
source?.close()
|
|
||||||
}
|
|
||||||
}, [authReady, recentDays, recentStage])
|
|
||||||
|
|
||||||
const runSearch = async (term: string) => {
|
const runSearch = async (term: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -214,8 +163,6 @@ export default function HomePage() {
|
|||||||
type: item.type,
|
type: item.type,
|
||||||
requestId: item.requestId,
|
requestId: item.requestId,
|
||||||
statusLabel: item.statusLabel,
|
statusLabel: item.statusLabel,
|
||||||
requestedBy: item.requestedBy ?? null,
|
|
||||||
accessible: Boolean(item.accessible),
|
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
setSearchError(null)
|
setSearchError(null)
|
||||||
@@ -232,132 +179,93 @@ export default function HomePage() {
|
|||||||
return url.startsWith('http') ? url : `${getApiBase()}${url}`
|
return url.startsWith('http') ? url : `${getApiBase()}${url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatRequestTime = (value?: string | null) => {
|
|
||||||
if (!value) return null
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeRecentCount = recent.filter((item) => {
|
|
||||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
|
||||||
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
|
|
||||||
}).length
|
|
||||||
const readyRecentCount = recent.filter((item) => {
|
|
||||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
|
||||||
return label.includes('ready') || label.includes('available')
|
|
||||||
}).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card home-page">
|
<main className="card">
|
||||||
<section className="home-command">
|
<div className="layout-grid">
|
||||||
<div className="home-command-copy">
|
<section className="recent centerpiece">
|
||||||
<span className="section-kicker">Request lookup</span>
|
<div className="system-status">
|
||||||
<h1>My requests</h1>
|
<div className="system-header">
|
||||||
<p>
|
<h2>System status</h2>
|
||||||
Enter a title and year, or jump straight to a request using its request number.
|
<span
|
||||||
</p>
|
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
||||||
</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}`)}
|
|
||||||
>
|
>
|
||||||
<span>
|
{servicesLoading
|
||||||
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
|
? 'Checking services...'
|
||||||
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
|
: servicesError
|
||||||
|
? 'Status not available yet'
|
||||||
|
: servicesStatus?.overall === 'up'
|
||||||
|
? 'Services are up and running'
|
||||||
|
: servicesStatus?.overall === 'down'
|
||||||
|
? 'Something is down'
|
||||||
|
: 'Some services need attention'}
|
||||||
</span>
|
</span>
|
||||||
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="system-list">
|
||||||
</section>
|
{(() => {
|
||||||
)}
|
const order = [
|
||||||
|
'Jellyseerr',
|
||||||
<section className="home-metric-strip" aria-label="Request summary">
|
'Sonarr',
|
||||||
<div><span>In view</span><strong>{recent.length}</strong></div>
|
'Radarr',
|
||||||
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
|
'Prowlarr',
|
||||||
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
|
'qBittorrent',
|
||||||
</section>
|
'Jellyfin',
|
||||||
|
]
|
||||||
<section className="recent home-recent">
|
const items = servicesStatus?.services ?? []
|
||||||
<div className="recent-header home-section-heading">
|
return order.map((name) => {
|
||||||
<div>
|
const item = items.find((entry) => entry.name === name)
|
||||||
<span className="section-kicker">Request activity</span>
|
const status = item?.status ?? 'unknown'
|
||||||
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
|
return (
|
||||||
|
<div key={name} className={`system-item system-${status}`}>
|
||||||
|
<span className="system-dot" />
|
||||||
|
<span className="system-name">{name}</span>
|
||||||
|
<span className="system-state">
|
||||||
|
{status === 'up'
|
||||||
|
? 'Up'
|
||||||
|
: status === 'down'
|
||||||
|
? 'Down'
|
||||||
|
: status === 'degraded'
|
||||||
|
? 'Needs attention'
|
||||||
|
: status === 'not_configured'
|
||||||
|
? 'Not configured'
|
||||||
|
: 'Unknown'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-header">
|
||||||
|
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||||
{authReady && (
|
{authReady && (
|
||||||
<div className="recent-filter-group">
|
|
||||||
<label className="recent-filter">
|
<label className="recent-filter">
|
||||||
<span>Period</span>
|
<span>Show last</span>
|
||||||
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
|
<select
|
||||||
<option value={0}>All time</option>
|
value={recentDays}
|
||||||
|
onChange={(event) => setRecentDays(Number(event.target.value))}
|
||||||
|
>
|
||||||
<option value={30}>30 days</option>
|
<option value={30}>30 days</option>
|
||||||
<option value={60}>60 days</option>
|
<option value={60}>60 days</option>
|
||||||
<option value={90}>90 days</option>
|
<option value={90}>90 days</option>
|
||||||
<option value={180}>180 days</option>
|
<option value={180}>180 days</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
<div className="recent-grid home-recent-grid">
|
<div className="recent-grid">
|
||||||
{recentLoading ? (
|
{recentLoading ? (
|
||||||
<div className="loading-center">
|
<div className="loading-center">
|
||||||
<div className="spinner" aria-hidden="true" />
|
<div className="spinner" aria-hidden="true" />
|
||||||
<span className="loading-text">Loading recent requests...</span>
|
<span className="loading-text">Loading recent requests…</span>
|
||||||
</div>
|
</div>
|
||||||
) : recentError ? (
|
) : recentError ? (
|
||||||
<div className="error-banner">{recentError}</div>
|
<button type="button" disabled>
|
||||||
|
{recentError}
|
||||||
|
</button>
|
||||||
) : recent.length === 0 ? (
|
) : recent.length === 0 ? (
|
||||||
<div className="home-empty-state">
|
<button type="button" disabled>
|
||||||
<strong>No requests match these filters</strong>
|
No recent requests found
|
||||||
<span>Try a wider period or a different stage.</span>
|
</button>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
recent.map((item) => (
|
recent.map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -366,29 +274,99 @@ export default function HomePage() {
|
|||||||
onClick={() => router.push(`/requests/${item.id}`)}
|
onClick={() => router.push(`/requests/${item.id}`)}
|
||||||
className="recent-card"
|
className="recent-card"
|
||||||
>
|
>
|
||||||
{item.artwork?.poster_url ? (
|
{item.artwork?.poster_url && (
|
||||||
<img
|
<img
|
||||||
className="recent-poster"
|
className="recent-poster"
|
||||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="recent-info">
|
<span className="recent-info">
|
||||||
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
|
<span className="recent-title">
|
||||||
|
{item.title || 'Untitled'}
|
||||||
|
{item.year ? ` (${item.year})` : ''}
|
||||||
|
</span>
|
||||||
<span className="recent-meta">
|
<span className="recent-meta">
|
||||||
{item.statusLabel || 'Status not available yet'} · Request {item.id}
|
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
|
||||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
{item.id}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="recent-open-cue" aria-hidden="true">Open</span>
|
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<aside className="side-panel">
|
||||||
|
<section className="main-panel find-panel">
|
||||||
|
<div className="find-header">
|
||||||
|
<h1>Find my request</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Search by title + year, paste a request number, or pick from your recent requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="find-controls">
|
||||||
|
<form onSubmit={submit} className="search search-row">
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="e.g. Dune 2021 or 1289"
|
||||||
|
/>
|
||||||
|
<button type="submit">Check status</button>
|
||||||
|
</form>
|
||||||
|
<div className="filters filters-compact">
|
||||||
|
<div className="filter">
|
||||||
|
<span>Type</span>
|
||||||
|
<div className="pill-group">
|
||||||
|
<button type="button">TV</button>
|
||||||
|
<button type="button">Movie</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filter">
|
||||||
|
<span>Status</span>
|
||||||
|
<div className="pill-group">
|
||||||
|
<button type="button">Pending</button>
|
||||||
|
<button type="button">Approved</button>
|
||||||
|
<button type="button">Processing</button>
|
||||||
|
<button type="button">Failed</button>
|
||||||
|
<button type="button">Available</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="recent results-panel">
|
||||||
|
<h2>Search results</h2>
|
||||||
|
<div className="recent-grid">
|
||||||
|
{searchError ? (
|
||||||
|
<button type="button" disabled>
|
||||||
|
{searchError}
|
||||||
|
</button>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<button type="button" disabled>
|
||||||
|
No matches yet
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
searchResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${item.title || 'Untitled'}-${index}`}
|
||||||
|
type="button"
|
||||||
|
disabled={!item.requestId}
|
||||||
|
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
||||||
|
>
|
||||||
|
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
|
||||||
|
{!item.requestId
|
||||||
|
? '- not requested'
|
||||||
|
: item.statusLabel
|
||||||
|
? `- ${item.statusLabel}`
|
||||||
|
: ''}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
import PortalClient from '../PortalClient'
|
|
||||||
|
|
||||||
export default function IssuePortalPage() {
|
|
||||||
return <PortalClient workspace="issue" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function PortalIndexPage() {
|
|
||||||
redirect('/new-requests')
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function RequestPortalPage() {
|
|
||||||
redirect('/new-requests')
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
|
|
||||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
|
|
||||||
type OwnedInvite = {
|
|
||||||
id: number; code: string; label?: string | null; description?: string | null
|
|
||||||
recipient_email?: string | null; max_uses?: number | null; use_count: number
|
|
||||||
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
|
|
||||||
is_usable?: boolean; created_at?: string | null
|
|
||||||
}
|
|
||||||
type OwnedInvitesResponse = {
|
|
||||||
invites?: OwnedInvite[]
|
|
||||||
invite_access?: { enabled?: boolean; managed_by_master?: boolean }
|
|
||||||
master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null
|
|
||||||
}
|
|
||||||
type InviteForm = {
|
|
||||||
code: string; label: string; description: string; recipient_email: string
|
|
||||||
enabled: boolean; message: string
|
|
||||||
}
|
|
||||||
type DeliveryMethod = '' | 'manual' | 'email'
|
|
||||||
|
|
||||||
const defaultInviteForm = (): InviteForm => ({
|
|
||||||
code: '', label: '', description: '', recipient_email: '', enabled: true, message: '',
|
|
||||||
})
|
|
||||||
const formatDate = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
|
||||||
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString()
|
|
||||||
}
|
|
||||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
|
||||||
|
|
||||||
export default function ProfileInvitesPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
|
||||||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
|
||||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
|
||||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
|
||||||
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse['master_invite']>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
|
||||||
const [flowStep, setFlowStep] = useState(1)
|
|
||||||
const [useCustomCode, setUseCustomCode] = useState(false)
|
|
||||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>('')
|
|
||||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
|
|
||||||
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null)
|
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
|
||||||
if (typeof window === 'undefined') return '/signup'
|
|
||||||
return `${window.location.origin}/signup`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadInvites = async () => {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Could not load your invite workspace.')
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as OwnedInvitesResponse
|
|
||||||
setInvites(Array.isArray(data.invites) ? data.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(data.invite_access?.enabled))
|
|
||||||
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master))
|
|
||||||
setMasterInvite(data.master_invite ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`)
|
|
||||||
if (!profileResponse.ok) {
|
|
||||||
if (profileResponse.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Could not load your profile.')
|
|
||||||
}
|
|
||||||
const profileData = await profileResponse.json()
|
|
||||||
setProfile(profileData?.user ?? null)
|
|
||||||
await loadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const resetFlow = () => {
|
|
||||||
setEditingId(null)
|
|
||||||
setFlowStep(1)
|
|
||||||
setUseCustomCode(false)
|
|
||||||
setDeliveryMethod('')
|
|
||||||
setInviteForm(defaultInviteForm())
|
|
||||||
}
|
|
||||||
|
|
||||||
const editInvite = (invite: OwnedInvite) => {
|
|
||||||
setEditingId(invite.id)
|
|
||||||
setCreatedInvite(null)
|
|
||||||
setFlowStep(4)
|
|
||||||
setUseCustomCode(true)
|
|
||||||
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
|
|
||||||
setInviteForm({
|
|
||||||
code: invite.code,
|
|
||||||
label: invite.label ?? '',
|
|
||||||
description: invite.description ?? '',
|
|
||||||
recipient_email: invite.recipient_email ?? '',
|
|
||||||
enabled: invite.enabled !== false,
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const inviteName = inviteForm.label.trim()
|
|
||||||
const recipientEmail = inviteForm.recipient_email.trim()
|
|
||||||
if (!inviteName) {
|
|
||||||
setError('Give this invite a name so you can recognise it later.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!deliveryMethod) {
|
|
||||||
setError('Choose how you want to deliver the invite.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
|
|
||||||
setError('Enter a valid recipient email address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(
|
|
||||||
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`,
|
|
||||||
{
|
|
||||||
method: editingId == null ? 'POST' : 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: useCustomCode ? inviteForm.code || null : null,
|
|
||||||
label: inviteName,
|
|
||||||
description: inviteForm.description || null,
|
|
||||||
recipient_email: deliveryMethod === 'email' ? recipientEmail : null,
|
|
||||||
enabled: inviteForm.enabled,
|
|
||||||
send_email: editingId == null && deliveryMethod === 'email',
|
|
||||||
message: inviteForm.message || null,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error((await response.text()) || 'Could not save the invite.')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const savedInvite = data?.invite as OwnedInvite | undefined
|
|
||||||
setStatus(
|
|
||||||
data?.email?.status === 'ok'
|
|
||||||
? `Invite created and emailed to ${data.email.recipient_email}.`
|
|
||||||
: data?.email?.status === 'error'
|
|
||||||
? `Invite created, but the email could not be sent: ${data.email.detail}`
|
|
||||||
: editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.'
|
|
||||||
)
|
|
||||||
resetFlow()
|
|
||||||
if (editingId == null && savedInvite) setCreatedInvite(savedInvite)
|
|
||||||
await loadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not save the invite.')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteInvite = async (invite: OwnedInvite) => {
|
|
||||||
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' })
|
|
||||||
if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.')
|
|
||||||
if (editingId === invite.id) resetFlow()
|
|
||||||
setStatus(`Deleted ${invite.label || invite.code}.`)
|
|
||||||
await loadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not delete the invite.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
|
||||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url)
|
|
||||||
setStatus(`Copied the link for ${invite.label || invite.code}.`)
|
|
||||||
} catch {
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
|
|
||||||
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6))
|
|
||||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
|
||||||
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : ''
|
|
||||||
|
|
||||||
if (loading) return <main className="card">Loading invite workspace…</main>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="card">
|
|
||||||
<div className="user-directory-panel-header profile-page-header">
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
{status && <div className="status-banner">{status}</div>}
|
|
||||||
|
|
||||||
{!canManageInvites ? (
|
|
||||||
<section className="profile-section profile-tab-panel">
|
|
||||||
<h2>Invites are not enabled for your account</h2>
|
|
||||||
<p className="lede">Ask an administrator if you need permission to invite someone.</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
|
||||||
<div className="invite-flow-heading">
|
|
||||||
<div><span className="eyebrow">Invite flow</span><h2>{editingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2><p className="lede">Set up the invite one decision at a time.</p></div>
|
|
||||||
{editingId != null && <button type="button" className="ghost-button" onClick={resetFlow}>Cancel edit</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createdInvite && editingId == null ? (
|
|
||||||
<div className="invite-created-card" role="status">
|
|
||||||
<span className="eyebrow">Invite ready</span><h3>{createdInvite.label || 'Your invite'}</h3>
|
|
||||||
<p>{createdInvite.recipient_email ? `The invite was emailed to ${createdInvite.recipient_email}.` : 'Copy this link and send it to the person you are inviting.'}</p>
|
|
||||||
<div className="invite-created-link"><input value={createdInviteUrl} readOnly aria-label="Created invite link" /><button type="button" onClick={() => void copyInviteLink(createdInvite)}>Copy link</button></div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => { setCreatedInvite(null); resetFlow() }}>Create another invite</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={saveInvite} className="invite-flow-form">
|
|
||||||
<ol className="invite-flow-route" aria-label="Invite creation progress">
|
|
||||||
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
|
|
||||||
const step = index + 1
|
|
||||||
return <li key={label} className={step === flowStep ? 'is-active' : step < flowStep ? 'is-complete' : ''}><span>{String(step).padStart(2, '0')}</span><strong>{label}</strong></li>
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<section className={`invite-flow-step ${flowStep > 1 ? 'is-complete' : 'is-active'}`}>
|
|
||||||
<header><span className="invite-flow-number">01</span><div><span className="eyebrow">Identity</span><h3>Who is this invite for?</h3><p>Give it a name that will make sense when you return later.</p></div></header>
|
|
||||||
<div className="invite-flow-fields">
|
|
||||||
<label><span>Invite name</span><input value={inviteForm.label} onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))} placeholder="Family, that guy from work, the neighbour" /></label>
|
|
||||||
<label className="invite-flow-choice-line"><input type="checkbox" checked={useCustomCode} disabled={editingId != null} onChange={(event) => { setUseCustomCode(event.target.checked); if (!event.target.checked) setInviteForm((current) => ({ ...current, code: '' })) }} /><span><strong>Choose a custom invite code</strong><small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small></span></label>
|
|
||||||
{useCustomCode && <label><span>Custom code</span><input value={inviteForm.code} disabled={editingId != null} onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))} placeholder="At least 6 letters or numbers" /><small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small></label>}
|
|
||||||
{flowStep === 1 && <div className="invite-flow-actions"><button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>Continue to description</button></div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{flowStep >= 2 && <section className={`invite-flow-step ${flowStep > 2 ? 'is-complete' : 'is-active'}`}>
|
|
||||||
<header><span className="invite-flow-number">02</span><div><span className="eyebrow">Description</span><h3>Add a welcome note</h3><p>This optional message is shown on the sign-up page.</p></div></header>
|
|
||||||
<div className="invite-flow-fields"><label><span>Welcome note (optional)</span><textarea rows={3} value={inviteForm.description} onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))} placeholder="Welcome to Grizzlyflix. Use this link to create your account." /></label>{flowStep === 2 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(1)}>Back</button><button type="button" className="ghost-button" onClick={() => { setInviteForm((current) => ({ ...current, description: '' })); setFlowStep(3) }}>Skip</button><button type="button" onClick={() => setFlowStep(3)}>Continue</button></div>}</div>
|
|
||||||
</section>}
|
|
||||||
|
|
||||||
{flowStep >= 3 && <section className={`invite-flow-step ${flowStep > 3 ? 'is-complete' : 'is-active'}`}>
|
|
||||||
<header><span className="invite-flow-number">03</span><div><span className="eyebrow">Access</span><h3>Account access is applied automatically</h3><p>Magent uses the safe invite policy configured by an administrator.</p></div></header>
|
|
||||||
<div className="invite-flow-fields"><div className="invite-policy-note"><strong>Standard user access</strong><span>{inviteManagedByMaster && masterInvite ? `Using the “${masterInvite.label || masterInvite.code}” invite policy. Usage and expiry limits are automatic.` : 'This invite creates a standard user account using your configured defaults.'}</span></div>{flowStep === 3 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(2)}>Back</button><button type="button" onClick={() => setFlowStep(4)}>Continue to delivery</button></div>}</div>
|
|
||||||
</section>}
|
|
||||||
|
|
||||||
{flowStep >= 4 && <section className="invite-flow-step is-active">
|
|
||||||
<header><span className="invite-flow-number">04</span><div><span className="eyebrow">Delivery</span><h3>How will they receive it?</h3><p>Copy the link yourself, or let Magent email it directly.</p></div></header>
|
|
||||||
<div className="invite-flow-fields">
|
|
||||||
<div className="invite-delivery-grid"><button type="button" className={deliveryMethod === 'manual' ? 'is-selected' : ''} onClick={() => { setDeliveryMethod('manual'); setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }}><span className="eyebrow">Manual</span><strong>Give me a link</strong><small>Magent creates the URL. You copy and share it yourself.</small></button><button type="button" className={deliveryMethod === 'email' ? 'is-selected' : ''} onClick={() => setDeliveryMethod('email')}><span className="eyebrow">Email</span><strong>Send it for me</strong><small>Magent emails the invitation and still gives you a copyable URL.</small></button></div>
|
|
||||||
{deliveryMethod === 'manual' && <div className="invite-delivery-summary"><strong>Your link will appear as soon as the invite is created.</strong><span>No email address is required and Magent will not send a message.</span></div>}
|
|
||||||
{deliveryMethod === 'email' && <div className="invite-flow-field-grid"><label><span>Recipient email</span><input type="email" value={inviteForm.recipient_email} onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))} placeholder="person@example.com" /></label><label><span>Email note (optional)</span><textarea rows={3} value={inviteForm.message} onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))} placeholder="A short personal message" /></label></div>}
|
|
||||||
{editingId != null && <label className="invite-status-control"><input type="checkbox" checked={inviteForm.enabled} onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))} /><span><strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong><small>Disable this existing invite to stop its link from accepting sign-ups.</small></span></label>}
|
|
||||||
<div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(3)}>Back</button><button type="submit" disabled={saving || !deliveryMethod || (deliveryMethod === 'email' && !isValidEmail(inviteForm.recipient_email))}>{saving ? 'Saving…' : editingId != null ? 'Save invite' : deliveryMethod === 'email' ? 'Create and email invite' : 'Create invite link'}</button></div>
|
|
||||||
</div>
|
|
||||||
</section>}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="profile-invites-list">
|
|
||||||
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
|
|
||||||
{invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+22
-446
@@ -1,117 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
type ProfileInfo = {
|
type ProfileInfo = {
|
||||||
username: string
|
username: string
|
||||||
email?: string | null
|
|
||||||
role: string
|
role: string
|
||||||
auth_provider: string
|
auth_provider: string
|
||||||
invite_management_enabled?: boolean
|
|
||||||
password_change_supported?: boolean
|
|
||||||
password_provider?: 'local' | 'jellyfin' | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileStats = {
|
|
||||||
total: number
|
|
||||||
ready: number
|
|
||||||
pending: number
|
|
||||||
in_progress: number
|
|
||||||
declined: number
|
|
||||||
working: number
|
|
||||||
partial: number
|
|
||||||
approved: number
|
|
||||||
last_request_at?: string | null
|
|
||||||
share: number
|
|
||||||
global_total: number
|
|
||||||
most_active_user?: { username: string; total: number } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityEntry = {
|
|
||||||
ip: string
|
|
||||||
user_agent: string
|
|
||||||
first_seen_at: string
|
|
||||||
last_seen_at: string
|
|
||||||
hit_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileActivity = {
|
|
||||||
last_ip?: string | null
|
|
||||||
last_user_agent?: string | null
|
|
||||||
last_seen_at?: string | null
|
|
||||||
device_count: number
|
|
||||||
recent: ActivityEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileResponse = {
|
|
||||||
user: ProfileInfo
|
|
||||||
stats: ProfileStats
|
|
||||||
activity: ProfileActivity
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileTab = 'overview' | 'activity' | 'security'
|
|
||||||
|
|
||||||
const normalizeProfileTab = (value?: string | null): ProfileTab => {
|
|
||||||
if (value === 'activity' || value === 'security') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'overview'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
|
||||||
|
|
||||||
const parseBrowser = (agent?: string | null) => {
|
|
||||||
if (!agent) return 'Unknown'
|
|
||||||
const value = agent.toLowerCase()
|
|
||||||
if (value.includes('edg/')) return 'Edge'
|
|
||||||
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
|
|
||||||
if (value.includes('firefox/')) return 'Firefox'
|
|
||||||
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
|
|
||||||
return 'Unknown'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
|
||||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [emailSaving, setEmailSaving] = useState(false)
|
|
||||||
const [emailStatus, setEmailStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
|
||||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const inviteLink = useMemo(() => '/profile/invites', [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
const syncTabFromLocation = () => {
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
setActiveTab(normalizeProfileTab(params.get('tab')))
|
|
||||||
}
|
|
||||||
syncTabFromLocation()
|
|
||||||
window.addEventListener('popstate', syncTabFromLocation)
|
|
||||||
return () => window.removeEventListener('popstate', syncTabFromLocation)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const selectTab = (tab: ProfileTab) => {
|
|
||||||
setActiveTab(tab)
|
|
||||||
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -120,32 +26,21 @@ export default function ProfilePage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
if (!profileResponse.ok) {
|
if (!response.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = (await profileResponse.json()) as ProfileResponse
|
const data = await response.json()
|
||||||
const user = data?.user ?? {}
|
|
||||||
setProfile({
|
setProfile({
|
||||||
username: user?.username ?? 'Unknown',
|
username: data?.username ?? 'Unknown',
|
||||||
email: user?.email ?? null,
|
role: data?.role ?? 'user',
|
||||||
role: user?.role ?? 'user',
|
auth_provider: data?.auth_provider ?? 'local',
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
setEmail(user?.email ?? '')
|
|
||||||
setStats(data?.stats ?? null)
|
|
||||||
setActivity(data?.activity ?? null)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus({ tone: 'error', message: 'Could not load your profile.' })
|
setStatus('Could not load your profile.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -157,11 +52,7 @@ export default function ProfilePage() {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
if (!currentPassword || !newPassword) {
|
if (!currentPassword || !newPassword) {
|
||||||
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
|
setStatus('Enter your current password and a new password.')
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -175,100 +66,17 @@ export default function ProfilePage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = 'Update failed'
|
const text = await response.text()
|
||||||
try {
|
throw new Error(text || 'Update failed')
|
||||||
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('')
|
setCurrentPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setConfirmPassword('')
|
setStatus('Password updated.')
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
if (err instanceof Error && err.message) {
|
setStatus('Could not update password. Check your current password.')
|
||||||
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
|
|
||||||
} else {
|
|
||||||
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const saveEmail = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const nextEmail = email.trim()
|
|
||||||
setEmailStatus(null)
|
|
||||||
if (nextEmail && !isValidEmail(nextEmail)) {
|
|
||||||
setEmailStatus({ tone: 'error', message: 'Enter a valid email address.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEmailSaving(true)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: nextEmail || null }),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let detail = 'Could not save your email address.'
|
|
||||||
try {
|
|
||||||
const payload = await response.json()
|
|
||||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) detail = payload.detail
|
|
||||||
} catch {
|
|
||||||
// Keep the plain fallback when the response is not JSON.
|
|
||||||
}
|
|
||||||
throw new Error(detail)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const savedEmail = typeof data?.email === 'string' ? data.email : ''
|
|
||||||
setEmail(savedEmail)
|
|
||||||
setProfile((current) => current ? { ...current, email: savedEmail || null } : current)
|
|
||||||
setEmailStatus({
|
|
||||||
tone: 'status',
|
|
||||||
message: savedEmail
|
|
||||||
? 'Contact email saved. Magent can now use it for account and issue updates.'
|
|
||||||
: 'Contact email removed from your account.',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setEmailStatus({
|
|
||||||
tone: 'error',
|
|
||||||
message: err instanceof Error ? err.message : 'Could not save your email address.',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setEmailSaving(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) {
|
if (loading) {
|
||||||
return <main className="card">Loading profile...</main>
|
return <main className="card">Loading profile...</main>
|
||||||
@@ -276,232 +84,21 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
<div className="user-directory-panel-header profile-page-header">
|
|
||||||
<div>
|
|
||||||
<h1>My profile</h1>
|
<h1>My profile</h1>
|
||||||
<p className="lede">Review your account, activity, and security settings.</p>
|
|
||||||
</div>
|
|
||||||
{canManageInvites || canChangePassword ? (
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
{canManageInvites ? (
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
|
||||||
Open invite page
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{canChangePassword ? (
|
|
||||||
<button type="button" onClick={() => selectTab('security')}>
|
|
||||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
||||||
{profile.auth_provider}.
|
{profile.auth_provider}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{profile?.auth_provider !== 'local' ? (
|
||||||
<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>
|
|
||||||
<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">
|
|
||||||
<div className="profile-quick-link-card profile-contact-card">
|
|
||||||
<div>
|
|
||||||
<h2>Contact email</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Used for password recovery, invite messages, and updates about issues you report.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form className="profile-contact-form" onSubmit={saveEmail}>
|
|
||||||
<label>
|
|
||||||
<span>Email address</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{emailStatus ? (
|
|
||||||
<div className={emailStatus.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
|
||||||
{emailStatus.message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="submit" disabled={emailSaving || Boolean(email.trim() && !isValidEmail(email))}>
|
|
||||||
{emailSaving ? 'Saving…' : 'Save email'}
|
|
||||||
</button>
|
|
||||||
{profile?.email ? (
|
|
||||||
<button type="button" className="ghost-button" onClick={() => setEmail('')}>
|
|
||||||
Clear field
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{canManageInvites ? (
|
|
||||||
<div className="profile-quick-link-card">
|
|
||||||
<div>
|
|
||||||
<h2>Invite tools</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Create invite links, send them by email, and track who you have invited from a dedicated page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={() => router.push(inviteLink)}>
|
|
||||||
Go to invites
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{canChangePassword ? (
|
|
||||||
<div className="profile-quick-link-card">
|
|
||||||
<div>
|
|
||||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
|
|
||||||
<p className="lede">
|
|
||||||
{passwordProvider === 'jellyfin'
|
|
||||||
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
|
|
||||||
: 'Update your Magent account password.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={() => selectTab('security')}>
|
|
||||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<h2>Account stats</h2>
|
|
||||||
<div className="stat-grid">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Requests submitted</div>
|
|
||||||
<div className="stat-value">{stats?.total ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Ready to watch</div>
|
|
||||||
<div className="stat-value">{stats?.ready ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">In progress</div>
|
|
||||||
<div className="stat-value">{stats?.in_progress ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Pending approval</div>
|
|
||||||
<div className="stat-value">{stats?.pending ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Declined</div>
|
|
||||||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Working</div>
|
|
||||||
<div className="stat-value">{stats?.working ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Partial</div>
|
|
||||||
<div className="stat-value">{stats?.partial ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Approved</div>
|
|
||||||
<div className="stat-value">{stats?.approved ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Last request</div>
|
|
||||||
<div className="stat-value stat-value--small">
|
|
||||||
{formatDate(stats?.last_request_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Share of all requests</div>
|
|
||||||
<div className="stat-value">
|
|
||||||
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Total requests (global)</div>
|
|
||||||
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
{profile?.role === 'admin' ? (
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Most active user</div>
|
|
||||||
<div className="stat-value stat-value--small">
|
|
||||||
{stats?.most_active_user
|
|
||||||
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
|
|
||||||
: 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'activity' && (
|
|
||||||
<section className="profile-section profile-tab-panel">
|
|
||||||
<h2>Connection history</h2>
|
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
Password changes are only available for local Magent accounts.
|
||||||
</div>
|
</div>
|
||||||
<div className="connection-list">
|
) : (
|
||||||
{(activity?.recent ?? []).map((entry, index) => (
|
<form onSubmit={submit} className="auth-form">
|
||||||
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
|
|
||||||
<div>
|
|
||||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
|
||||||
<div className="meta">IP: {entry.ip}</div>
|
|
||||||
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
|
||||||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="connection-count">{entry.hit_count} visits</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{activity && activity.recent.length === 0 ? (
|
|
||||||
<div className="status-banner">No connection history yet.</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
|
||||||
<section className="profile-section profile-tab-panel">
|
|
||||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
|
|
||||||
<div className="status-banner">{securityHelpText}</div>
|
|
||||||
{canChangePassword ? (
|
|
||||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
|
||||||
<label>
|
<label>
|
||||||
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
|
Current password
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
@@ -510,7 +107,7 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
|
New password
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
@@ -518,32 +115,11 @@ export default function ProfilePage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
{status && <div className="status-banner">{status}</div>}
|
||||||
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">
|
<div className="auth-actions">
|
||||||
<button type="submit">
|
<button type="submit">Update password</button>
|
||||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
|
||||||
<div className="status-banner">
|
|
||||||
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
+509
-912
File diff suppressed because it is too large
Load Diff
@@ -1,156 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
|
||||||
import { getApiBase } from '../lib/auth'
|
|
||||||
|
|
||||||
type ResetVerification = {
|
|
||||||
status: string
|
|
||||||
recipient_hint?: string
|
|
||||||
auth_provider?: string
|
|
||||||
expires_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResetPasswordPageContent() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const token = searchParams.get('token') ?? ''
|
|
||||||
const [verification, setVerification] = useState<ResetVerification | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [verifying, setVerifying] = useState(true)
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const verifyToken = async () => {
|
|
||||||
if (!token) {
|
|
||||||
setError('Password reset link is invalid or missing.')
|
|
||||||
setVerifying(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setVerifying(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(
|
|
||||||
`${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`,
|
|
||||||
)
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Password reset link is invalid.')
|
|
||||||
}
|
|
||||||
setVerification(data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setVerification(null)
|
|
||||||
setError(err instanceof Error ? err.message : 'Password reset link is invalid.')
|
|
||||||
} finally {
|
|
||||||
setVerifying(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void verifyToken()
|
|
||||||
}, [token])
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!token) {
|
|
||||||
setError('Password reset link is invalid or missing.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.trim().length < 8) {
|
|
||||||
setError('Password must be at least 8 characters.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setError('Passwords do not match.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/auth/password/reset`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token, new_password: password }),
|
|
||||||
})
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to reset password.')
|
|
||||||
}
|
|
||||||
setStatus('Password updated. You can now sign in with the new password.')
|
|
||||||
setPassword('')
|
|
||||||
setConfirmPassword('')
|
|
||||||
window.setTimeout(() => router.push('/login'), 1200)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to reset password.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerLabel =
|
|
||||||
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="card auth-card">
|
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
|
||||||
<h1>Reset password</h1>
|
|
||||||
<p className="lede">Choose a new password for your account.</p>
|
|
||||||
<form className="auth-form" onSubmit={submit}>
|
|
||||||
{verifying && <div className="status-banner">Checking password reset link…</div>}
|
|
||||||
{!verifying && verification && (
|
|
||||||
<div className="status-banner">
|
|
||||||
This reset link was sent to {verification.recipient_hint || 'your email'} and will update the password
|
|
||||||
used for {providerLabel}.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<label>
|
|
||||||
New password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
disabled={!verification || loading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Confirm new password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
disabled={!verification || loading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
{status && <div className="status-banner">{status}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit" disabled={loading || verifying || !verification}>
|
|
||||||
{loading ? 'Updating password…' : 'Reset password'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<main className="card auth-card">Loading password reset…</main>}>
|
|
||||||
<ResetPasswordPageContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
|
||||||
import { clearToken, getApiBase, setToken } from '../lib/auth'
|
|
||||||
|
|
||||||
type InviteInfo = {
|
|
||||||
code: string
|
|
||||||
label?: string | null
|
|
||||||
description?: string | null
|
|
||||||
enabled: boolean
|
|
||||||
is_expired?: boolean
|
|
||||||
is_usable?: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
use_count?: number | null
|
|
||||||
remaining_uses?: number | null
|
|
||||||
profile?: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function SignupPageContent() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
|
|
||||||
const [invite, setInvite] = useState<InviteInfo | null>(null)
|
|
||||||
const [inviteLoading, setInviteLoading] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
|
||||||
return Boolean(invite?.is_usable && username.trim() && password && !loading)
|
|
||||||
}, [invite, username, password, loading])
|
|
||||||
|
|
||||||
const lookupInvite = async (code: string) => {
|
|
||||||
const trimmed = code.trim()
|
|
||||||
if (!trimmed) {
|
|
||||||
setInvite(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setInviteLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Invite not found')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setInvite(data?.invite ?? null)
|
|
||||||
setStatus('Invite loaded.')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setInvite(null)
|
|
||||||
setError('Invite code not found or unavailable.')
|
|
||||||
} finally {
|
|
||||||
setInviteLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initialCode = searchParams.get('code') ?? ''
|
|
||||||
if (initialCode) {
|
|
||||||
setInviteCode(initialCode)
|
|
||||||
void lookupInvite(initialCode)
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setError('Passwords do not match.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!inviteCode.trim()) {
|
|
||||||
setError('Invite code is required.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!invite?.is_usable) {
|
|
||||||
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
clearToken()
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/auth/signup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
invite_code: inviteCode,
|
|
||||||
username: username.trim(),
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Sign-up failed')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
if (data?.authenticated) {
|
|
||||||
setToken('cookie')
|
|
||||||
window.location.href = '/'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Sign-up did not complete')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="card auth-card">
|
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
|
||||||
<h1>Create account</h1>
|
|
||||||
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
|
|
||||||
<form onSubmit={submit} className="auth-form">
|
|
||||||
<label>
|
|
||||||
Invite code
|
|
||||||
<div className="invite-lookup-row">
|
|
||||||
<input
|
|
||||||
value={inviteCode}
|
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
|
||||||
placeholder="Paste your invite code"
|
|
||||||
autoCapitalize="characters"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
disabled={inviteLoading}
|
|
||||||
onClick={() => void lookupInvite(inviteCode)}
|
|
||||||
>
|
|
||||||
{inviteLoading ? 'Checking…' : 'Check invite'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{invite && (
|
|
||||||
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
|
|
||||||
<div className="invite-summary-row">
|
|
||||||
<strong>{invite.label || invite.code}</strong>
|
|
||||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
|
||||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{invite.description && <p>{invite.description}</p>}
|
|
||||||
<div className="admin-meta-row">
|
|
||||||
<span>Code: {invite.code}</span>
|
|
||||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
|
||||||
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
|
|
||||||
<span>Profile: {invite.profile?.name || 'None'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Confirm password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
{status && <div className="status-banner">{status}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit" disabled={!canSubmit}>
|
|
||||||
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<main className="card auth-card">Loading sign-up…</main>}>
|
|
||||||
<SignupPageContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|
||||||
|
|
||||||
type DiagnosticCatalogItem = {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
category: string
|
|
||||||
description: string
|
|
||||||
live_safe: boolean
|
|
||||||
target: string | null
|
|
||||||
configured: boolean
|
|
||||||
config_status: string
|
|
||||||
config_detail: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiagnosticResult = {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
category: string
|
|
||||||
description: string
|
|
||||||
target: string | null
|
|
||||||
live_safe: boolean
|
|
||||||
configured: boolean
|
|
||||||
status: string
|
|
||||||
message: string
|
|
||||||
detail?: unknown
|
|
||||||
checked_at?: string
|
|
||||||
duration_ms?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiagnosticsResponse = {
|
|
||||||
checks: DiagnosticCatalogItem[]
|
|
||||||
categories: string[]
|
|
||||||
generated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunDiagnosticsResponse = {
|
|
||||||
results: DiagnosticResult[]
|
|
||||||
summary: {
|
|
||||||
total: number
|
|
||||||
up: number
|
|
||||||
down: number
|
|
||||||
degraded: number
|
|
||||||
not_configured: number
|
|
||||||
disabled: number
|
|
||||||
}
|
|
||||||
checked_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunMode = 'safe' | 'all' | 'single'
|
|
||||||
|
|
||||||
type AdminDiagnosticsPanelProps = {
|
|
||||||
embedded?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type DatabaseDiagnosticDetail = {
|
|
||||||
integrity_check?: string
|
|
||||||
database_path?: string
|
|
||||||
database_size_bytes?: number
|
|
||||||
wal_size_bytes?: number
|
|
||||||
shm_size_bytes?: number
|
|
||||||
page_size_bytes?: number
|
|
||||||
page_count?: number
|
|
||||||
freelist_pages?: number
|
|
||||||
allocated_bytes?: number
|
|
||||||
free_bytes?: number
|
|
||||||
row_counts?: Record<string, number>
|
|
||||||
timings_ms?: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 30000
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
idle: 'Ready',
|
|
||||||
up: 'Up',
|
|
||||||
down: 'Down',
|
|
||||||
degraded: 'Degraded',
|
|
||||||
disabled: 'Disabled',
|
|
||||||
not_configured: 'Not configured',
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCheckedAt(value?: string) {
|
|
||||||
if (!value) return 'Not yet run'
|
|
||||||
const parsed = new Date(value)
|
|
||||||
if (Number.isNaN(parsed.getTime())) return value
|
|
||||||
return parsed.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(value?: number) {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
|
|
||||||
return 'Pending'
|
|
||||||
}
|
|
||||||
return `${value.toFixed(1)} ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
|
||||||
return STATUS_LABELS[status] ?? status
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value?: number) {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
|
|
||||||
return '0 B'
|
|
||||||
}
|
|
||||||
if (value >= 1024 * 1024 * 1024) {
|
|
||||||
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
||||||
}
|
|
||||||
if (value >= 1024 * 1024) {
|
|
||||||
return `${(value / (1024 * 1024)).toFixed(2)} MB`
|
|
||||||
}
|
|
||||||
if (value >= 1024) {
|
|
||||||
return `${(value / 1024).toFixed(1)} KB`
|
|
||||||
}
|
|
||||||
return `${value} B`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetailLabel(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/_/g, ' ')
|
|
||||||
.replace(/\b\w/g, (character) => character.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
|
|
||||||
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return detail as DatabaseDiagnosticDetail
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="diagnostic-detail-group">
|
|
||||||
<h4>{title}</h4>
|
|
||||||
<div className="diagnostic-detail-grid">
|
|
||||||
{values.map(([label, value]) => (
|
|
||||||
<div key={`${title}-${label}`} className="diagnostic-detail-item">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [authorized, setAuthorized] = useState(false)
|
|
||||||
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([])
|
|
||||||
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({})
|
|
||||||
const [runningKeys, setRunningKeys] = useState<string[]>([])
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
|
||||||
const [pageError, setPageError] = useState('')
|
|
||||||
const [lastRunAt, setLastRunAt] = useState<string | null>(null)
|
|
||||||
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null)
|
|
||||||
const [emailRecipient, setEmailRecipient] = useState('')
|
|
||||||
|
|
||||||
const liveSafeKeys = checks.filter((check) => check.live_safe).map((check) => check.key)
|
|
||||||
|
|
||||||
async function runDiagnostics(keys?: string[], mode: RunMode = 'single') {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key)
|
|
||||||
if (effectiveKeys.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])))
|
|
||||||
setPageError('')
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
keys: effectiveKeys,
|
|
||||||
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || `Diagnostics run failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse
|
|
||||||
const nextResults: Record<string, DiagnosticResult> = {}
|
|
||||||
for (const result of data.results ?? []) {
|
|
||||||
nextResults[result.key] = result
|
|
||||||
}
|
|
||||||
setResultsByKey((current) => ({ ...current, ...nextResults }))
|
|
||||||
setLastRunAt(data.checked_at ?? new Date().toISOString())
|
|
||||||
setLastRunMode(mode)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
setPageError(error instanceof Error ? error.message : 'Diagnostics run failed.')
|
|
||||||
} finally {
|
|
||||||
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
|
|
||||||
const loadPage = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const authResponse = await authFetch(`${baseUrl}/auth/me`)
|
|
||||||
if (!authResponse.ok) {
|
|
||||||
if (authResponse.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const me = await authResponse.json()
|
|
||||||
if (!active) return
|
|
||||||
if (me?.role !== 'admin') {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`)
|
|
||||||
if (!diagnosticsResponse.ok) {
|
|
||||||
const text = await diagnosticsResponse.text()
|
|
||||||
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`)
|
|
||||||
}
|
|
||||||
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse
|
|
||||||
if (!active) return
|
|
||||||
setChecks(data.checks ?? [])
|
|
||||||
setAuthorized(true)
|
|
||||||
setLoading(false)
|
|
||||||
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key)
|
|
||||||
if (safeKeys.length > 0) {
|
|
||||||
void runDiagnostics(safeKeys, 'safe')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
if (!active) return
|
|
||||||
setPageError(error instanceof Error ? error.message : 'Unable to load diagnostics.')
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadPage()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
void runDiagnostics(liveSafeKeys, 'safe')
|
|
||||||
}, REFRESH_INTERVAL_MS)
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [authorized, autoRefresh, liveSafeKeys.join('|')])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="admin-panel">Loading diagnostics...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authorized) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedCategories: string[] = []
|
|
||||||
for (const check of checks) {
|
|
||||||
if (!orderedCategories.includes(check.category)) {
|
|
||||||
orderedCategories.push(check.category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedResults = checks.map((check) => {
|
|
||||||
const result = resultsByKey[check.key]
|
|
||||||
if (result) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: check.key,
|
|
||||||
label: check.label,
|
|
||||||
category: check.category,
|
|
||||||
description: check.description,
|
|
||||||
target: check.target,
|
|
||||||
live_safe: check.live_safe,
|
|
||||||
configured: check.configured,
|
|
||||||
status: check.configured ? 'idle' : check.config_status,
|
|
||||||
message: check.configured ? 'Ready to test.' : check.config_detail,
|
|
||||||
checked_at: undefined,
|
|
||||||
duration_ms: undefined,
|
|
||||||
} satisfies DiagnosticResult
|
|
||||||
})
|
|
||||||
|
|
||||||
const summary = {
|
|
||||||
total: mergedResults.length,
|
|
||||||
up: 0,
|
|
||||||
down: 0,
|
|
||||||
degraded: 0,
|
|
||||||
disabled: 0,
|
|
||||||
not_configured: 0,
|
|
||||||
idle: 0,
|
|
||||||
}
|
|
||||||
for (const result of mergedResults) {
|
|
||||||
const key = result.status as keyof typeof summary
|
|
||||||
if (key in summary) {
|
|
||||||
summary[key] += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`diagnostics-page${embedded ? ' diagnostics-page-embedded' : ''}`}>
|
|
||||||
<div className="admin-panel diagnostics-control-panel">
|
|
||||||
<div className="diagnostics-control-copy">
|
|
||||||
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Use live checks for Magent and service connectivity. Use run all when you want outbound notification
|
|
||||||
channels to send a real ping through the configured provider.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-control-actions">
|
|
||||||
<label className="diagnostics-email-recipient">
|
|
||||||
<span>Test email recipient</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Leave blank to use configured sender"
|
|
||||||
value={emailRecipient}
|
|
||||||
onChange={(event) => setEmailRecipient(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={autoRefresh ? 'is-active' : ''}
|
|
||||||
onClick={() => setAutoRefresh((current) => !current)}
|
|
||||||
>
|
|
||||||
{autoRefresh ? 'Disable auto refresh' : 'Enable auto refresh'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void runDiagnostics(liveSafeKeys, 'safe')
|
|
||||||
}}
|
|
||||||
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
|
|
||||||
>
|
|
||||||
Run live checks
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void runDiagnostics(undefined, 'all')
|
|
||||||
}}
|
|
||||||
disabled={runningKeys.length > 0 || checks.length === 0}
|
|
||||||
>
|
|
||||||
Run all tests
|
|
||||||
</button>
|
|
||||||
<span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}>
|
|
||||||
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
|
|
||||||
</span>
|
|
||||||
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : 'No run yet'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel diagnostics-inline-summary">
|
|
||||||
<div className="diagnostics-inline-metric">
|
|
||||||
<span>Total</span>
|
|
||||||
<strong>{summary.total}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-inline-metric">
|
|
||||||
<span>Up</span>
|
|
||||||
<strong>{summary.up}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-inline-metric">
|
|
||||||
<span>Degraded</span>
|
|
||||||
<strong>{summary.degraded}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-inline-metric">
|
|
||||||
<span>Down</span>
|
|
||||||
<strong>{summary.down}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-inline-metric">
|
|
||||||
<span>Disabled</span>
|
|
||||||
<strong>{summary.disabled}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-inline-metric">
|
|
||||||
<span>Not configured</span>
|
|
||||||
<strong>{summary.not_configured}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostics-inline-last-run">
|
|
||||||
Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
|
|
||||||
|
|
||||||
{orderedCategories.map((category) => {
|
|
||||||
const categoryChecks = mergedResults.filter((check) => check.category === category)
|
|
||||||
return (
|
|
||||||
<div key={category} className="admin-panel diagnostics-category-panel">
|
|
||||||
<div className="diagnostics-category-header">
|
|
||||||
<div>
|
|
||||||
<h2>{category}</h2>
|
|
||||||
<p>{category === 'Notifications' ? 'These tests can emit real messages.' : 'Safe live health checks.'}</p>
|
|
||||||
</div>
|
|
||||||
<span className="small-pill">{categoryChecks.length} checks</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="diagnostics-grid">
|
|
||||||
{categoryChecks.map((check) => {
|
|
||||||
const isRunning = runningKeys.includes(check.key)
|
|
||||||
return (
|
|
||||||
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
|
|
||||||
<div className="diagnostic-card-top">
|
|
||||||
<div className="diagnostic-card-copy">
|
|
||||||
<div className="diagnostic-card-title-row">
|
|
||||||
<h3>{check.label}</h3>
|
|
||||||
<span className={`system-pill system-pill-${check.status}`}>{statusLabel(check.status)}</span>
|
|
||||||
</div>
|
|
||||||
<p>{check.description}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="system-test"
|
|
||||||
onClick={() => {
|
|
||||||
void runDiagnostics([check.key], 'single')
|
|
||||||
}}
|
|
||||||
disabled={isRunning}
|
|
||||||
>
|
|
||||||
{check.live_safe ? 'Ping' : 'Send test'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="diagnostic-meta-grid">
|
|
||||||
<div className="diagnostic-meta-item">
|
|
||||||
<span>Target</span>
|
|
||||||
<strong>{check.target || 'Not set'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostic-meta-item">
|
|
||||||
<span>Latency</span>
|
|
||||||
<strong>{formatDuration(check.duration_ms)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostic-meta-item">
|
|
||||||
<span>Mode</span>
|
|
||||||
<strong>{check.live_safe ? 'Live safe' : 'Manual only'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="diagnostic-meta-item">
|
|
||||||
<span>Last checked</span>
|
|
||||||
<strong>{formatCheckedAt(check.checked_at)}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
|
|
||||||
<span className="system-dot" />
|
|
||||||
<span>{isRunning ? 'Running diagnostic...' : check.message}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{check.key === 'database'
|
|
||||||
? (() => {
|
|
||||||
const detail = asDatabaseDiagnosticDetail(check.detail)
|
|
||||||
if (!detail) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="diagnostic-detail-panel">
|
|
||||||
{renderDatabaseMetricGroup('Storage', [
|
|
||||||
['Database file', formatBytes(detail.database_size_bytes)],
|
|
||||||
['WAL file', formatBytes(detail.wal_size_bytes)],
|
|
||||||
['Shared memory', formatBytes(detail.shm_size_bytes)],
|
|
||||||
['Allocated bytes', formatBytes(detail.allocated_bytes)],
|
|
||||||
['Free bytes', formatBytes(detail.free_bytes)],
|
|
||||||
['Page size', formatBytes(detail.page_size_bytes)],
|
|
||||||
['Page count', `${detail.page_count?.toLocaleString() ?? 0}`],
|
|
||||||
['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`],
|
|
||||||
])}
|
|
||||||
{renderDatabaseMetricGroup(
|
|
||||||
'Tables',
|
|
||||||
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
|
|
||||||
formatDetailLabel(key),
|
|
||||||
value.toLocaleString(),
|
|
||||||
]),
|
|
||||||
)}
|
|
||||||
{renderDatabaseMetricGroup(
|
|
||||||
'Timings',
|
|
||||||
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
|
|
||||||
formatDetailLabel(key),
|
|
||||||
`${value.toFixed(1)} ms`,
|
|
||||||
]),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
: null}
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,22 +7,18 @@ type AdminShellProps = {
|
|||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
rail?: ReactNode
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
|
||||||
const hasRail = Boolean(rail)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
|
<div className="admin-shell">
|
||||||
<aside className="admin-shell-nav">
|
<aside className="admin-shell-nav">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
</aside>
|
</aside>
|
||||||
<main className="card admin-card">
|
<main className="card admin-card">
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-kicker">Beta stream</span>
|
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{subtitle && <p className="lede">{subtitle}</p>}
|
{subtitle && <p className="lede">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +26,6 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,50 +4,30 @@ import { usePathname } from 'next/navigation'
|
|||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
title: 'Configuration',
|
title: 'Services',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin', label: 'Config overview' },
|
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
|
||||||
{ href: '/admin/general', label: 'Application & proxy' },
|
|
||||||
{ href: '/admin/site', label: 'Site & login' },
|
|
||||||
{ href: '/admin/notifications', label: 'Notifications' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Media Services',
|
|
||||||
items: [
|
|
||||||
{ href: '/admin/seerr', label: 'Seerr' },
|
|
||||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||||
{ href: '/admin/radarr', label: 'Radarr' },
|
{ href: '/admin/radarr', label: 'Radarr' },
|
||||||
{ href: '/admin/bazarr', label: 'Bazarr' },
|
|
||||||
{ href: '/admin/prowlarr', label: 'Prowlarr' },
|
{ href: '/admin/prowlarr', label: 'Prowlarr' },
|
||||||
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
|
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Request Pipeline',
|
title: 'Requests',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/requests', label: 'Sync & retention' },
|
{ href: '/admin/requests', label: 'Request syncing' },
|
||||||
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
|
{ href: '/admin/artwork', label: 'Artwork' },
|
||||||
{ href: '/admin/cache', label: 'Request cache' },
|
{ href: '/admin/cache', label: 'Cache' },
|
||||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
|
||||||
{ href: '/admin/requests-all', label: 'All requests' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Users & Access',
|
title: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/invites', label: 'Invite management' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'System',
|
|
||||||
items: [
|
|
||||||
{ href: '/admin/diagnostics', label: 'System health' },
|
|
||||||
{ href: '/admin/logs', label: 'Activity log' },
|
{ href: '/admin/logs', label: 'Activity log' },
|
||||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||||
{ href: '/admin/system', label: 'How it works' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -64,9 +44,7 @@ export default function AdminSidebar() {
|
|||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== '/' &&
|
(item.href !== '/' && pathname.startsWith(item.href))
|
||||||
item.href !== '/admin' &&
|
|
||||||
pathname.startsWith(`${item.href}/`))
|
|
||||||
return (
|
return (
|
||||||
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
type BrandingLogoProps = {
|
type BrandingLogoProps = {
|
||||||
className?: string
|
className?: string
|
||||||
alt?: string
|
alt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
||||||
const [loaded, setLoaded] = useState(false)
|
|
||||||
const [failed, setFailed] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
|
||||||
{!failed ? (
|
|
||||||
<img
|
<img
|
||||||
className={loaded ? 'is-loaded' : undefined}
|
className={className}
|
||||||
src="/api/branding/logo.png"
|
src="/api/branding/logo.png"
|
||||||
alt=""
|
alt={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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
export default function HeaderActions() {
|
export default function HeaderActions() {
|
||||||
const [signedIn, setSignedIn] = useState(false)
|
const [signedIn, setSignedIn] = useState(false)
|
||||||
const [role, setRole] = useState<string | null>(null)
|
const [role, setRole] = useState<string | null>(null)
|
||||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
setSignedIn(Boolean(token))
|
setSignedIn(Boolean(token))
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setShowRequestsNav(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const [response, siteResponse] = await Promise.all([
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
authFetch(`${baseUrl}/auth/me`),
|
|
||||||
fetch(`${baseUrl}/site/public`).catch(() => null),
|
|
||||||
])
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
setSignedIn(false)
|
setSignedIn(false)
|
||||||
@@ -32,86 +25,35 @@ export default function HeaderActions() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setRole(data?.role ?? null)
|
setRole(data?.role ?? null)
|
||||||
if (siteResponse?.ok) {
|
|
||||||
const siteData = await siteResponse.json()
|
|
||||||
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
|
|
||||||
} else {
|
|
||||||
setShowRequestsNav(true)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setShowRequestsNav(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const signOut = () => {
|
||||||
|
clearToken()
|
||||||
|
setSignedIn(false)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
return null
|
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'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
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 (
|
return (
|
||||||
<nav className="header-actions" aria-label="Primary">
|
<div className="header-actions">
|
||||||
{items.map((item, index) => {
|
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||||
const active = item.match(pathname)
|
<a href="/">Requests</a>
|
||||||
return (
|
<a href="/how-it-works">How it works</a>
|
||||||
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
<a href="/profile">My profile</a>
|
||||||
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
{role === 'admin' && <a href="/admin">Settings</a>}
|
||||||
{item.label}
|
<button type="button" className="header-link" onClick={signOut}>
|
||||||
</a>
|
Sign out
|
||||||
)
|
</button>
|
||||||
})}
|
</div>
|
||||||
</nav>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
|
|
||||||
|
|
||||||
export default function HeaderIdentity() {
|
export default function HeaderIdentity() {
|
||||||
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
const [identity, setIdentity] = useState<string | null>(null)
|
||||||
const [buildNumber, setBuildNumber] = useState<string | null>(null)
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const viewAsUser = useUserViewPreview()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setIdentity(null)
|
setIdentity(null)
|
||||||
setBuildNumber(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -28,17 +24,7 @@ export default function HeaderIdentity() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.username) {
|
if (data?.username) {
|
||||||
setIdentity({ username: data.username, role: data.role })
|
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`)
|
||||||
if (data.role !== 'admin') {
|
|
||||||
setUserViewPreview(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const siteResponse = await fetch(`${baseUrl}/site/public`)
|
|
||||||
if (siteResponse.ok) {
|
|
||||||
const siteInfo = await siteResponse.json()
|
|
||||||
if (siteInfo?.buildNumber) {
|
|
||||||
setBuildNumber(siteInfo.buildNumber)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -52,66 +38,16 @@ export default function HeaderIdentity() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
|
||||||
const initial = identity.username.slice(0, 1).toUpperCase()
|
|
||||||
const signOut = async () => {
|
|
||||||
setUserViewPreview(false)
|
|
||||||
await logout().catch(() => undefined)
|
|
||||||
clearToken()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signed-in-context">
|
|
||||||
{identity.role === 'admin' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
|
|
||||||
aria-pressed={viewAsUser}
|
|
||||||
onClick={() => setUserViewPreview(!viewAsUser)}
|
|
||||||
>
|
|
||||||
{viewAsUser ? 'Exit user view' : 'View as user'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="signed-in-menu">
|
<div className="signed-in-menu">
|
||||||
<button
|
<button type="button" className="signed-in" onClick={() => setOpen((prev) => !prev)}>
|
||||||
type="button"
|
Signed in as {identity}
|
||||||
className="avatar-button"
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded={open}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="signed-in-dropdown">
|
<div className="signed-in-dropdown">
|
||||||
<div className="signed-in-header">
|
<a href="/profile">My profile</a>
|
||||||
Signed in as {label}
|
|
||||||
{viewAsUser ? <span>Previewing user view</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="signed-in-actions">
|
|
||||||
<a href="/profile" onClick={() => setOpen(false)}>
|
|
||||||
My profile
|
|
||||||
</a>
|
|
||||||
{identity.role === 'admin' ? (
|
|
||||||
<a href="/admin" onClick={() => setOpen(false)}>
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
|
||||||
Changelog
|
|
||||||
</a>
|
|
||||||
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|
||||||
|
|
||||||
type BannerInfo = {
|
|
||||||
enabled: boolean
|
|
||||||
message: string
|
|
||||||
tone?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SiteInfo = {
|
|
||||||
buildNumber?: string
|
|
||||||
banner?: BannerInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildRequest = () => {
|
|
||||||
const token = getToken()
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
|
|
||||||
const fetcher = token ? authFetch : fetch
|
|
||||||
return { token, url, fetcher }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SiteStatus() {
|
|
||||||
const [info, setInfo] = useState<SiteInfo | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const { token, url, fetcher } = buildRequest()
|
|
||||||
const response = await fetcher(url)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401 && token) {
|
|
||||||
clearToken()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
if (!active) return
|
|
||||||
setInfo(data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const banner = info?.banner
|
|
||||||
const tone = banner?.tone || 'info'
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{banner?.enabled && banner.message ? (
|
|
||||||
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,787 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
|
|
||||||
type UserStats = {
|
|
||||||
total: number
|
|
||||||
ready: number
|
|
||||||
pending: number
|
|
||||||
approved: number
|
|
||||||
working: number
|
|
||||||
partial: number
|
|
||||||
declined: number
|
|
||||||
in_progress: number
|
|
||||||
last_request_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdminUser = {
|
|
||||||
id?: number
|
|
||||||
username: string
|
|
||||||
email?: string | null
|
|
||||||
role: string
|
|
||||||
auth_provider?: string | null
|
|
||||||
last_login_at?: string | null
|
|
||||||
is_blocked?: boolean
|
|
||||||
auto_search_enabled?: boolean
|
|
||||||
invite_management_enabled?: boolean
|
|
||||||
jellyseerr_user_id?: number | null
|
|
||||||
profile_id?: number | null
|
|
||||||
expires_at?: string | null
|
|
||||||
is_expired?: boolean
|
|
||||||
invited_by_code?: string | null
|
|
||||||
invited_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserLineage = {
|
|
||||||
invite_code?: string | null
|
|
||||||
invited_by?: string | null
|
|
||||||
invite?: {
|
|
||||||
id?: number
|
|
||||||
code?: string
|
|
||||||
label?: string | null
|
|
||||||
created_by?: string | null
|
|
||||||
created_at?: string | null
|
|
||||||
enabled?: boolean
|
|
||||||
is_usable?: boolean
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
|
|
||||||
type UserProfileOption = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
is_active?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toLocalDateTimeInput = (value?: string | null) => {
|
|
||||||
if (!value) return ''
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return ''
|
|
||||||
const offsetMs = date.getTimezoneOffset() * 60_000
|
|
||||||
const local = new Date(date.getTime() - offsetMs)
|
|
||||||
return local.toISOString().slice(0, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromLocalDateTimeInput = (value: string) => {
|
|
||||||
if (!value.trim()) return null
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return null
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeStats = (stats: any): UserStats => ({
|
|
||||||
total: Number(stats?.total ?? 0),
|
|
||||||
ready: Number(stats?.ready ?? 0),
|
|
||||||
pending: Number(stats?.pending ?? 0),
|
|
||||||
approved: Number(stats?.approved ?? 0),
|
|
||||||
working: Number(stats?.working ?? 0),
|
|
||||||
partial: Number(stats?.partial ?? 0),
|
|
||||||
declined: Number(stats?.declined ?? 0),
|
|
||||||
in_progress: Number(stats?.in_progress ?? 0),
|
|
||||||
last_request_at: stats?.last_request_at ?? null,
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
|
|
||||||
const [user, setUser] = useState<AdminUser | null>(null)
|
|
||||||
const [stats, setStats] = useState<UserStats | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
|
|
||||||
const [profileSelection, setProfileSelection] = useState('')
|
|
||||||
const [expiryInput, setExpiryInput] = useState('')
|
|
||||||
const [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)
|
|
||||||
|
|
||||||
const loadProfiles = async () => {
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/admin/profiles`)
|
|
||||||
if (!response.ok) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
if (!Array.isArray(data?.profiles)) {
|
|
||||||
setProfiles([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setProfiles(
|
|
||||||
data.profiles.map((profile: any) => ({
|
|
||||||
id: Number(profile.id ?? 0),
|
|
||||||
name: String(profile.name ?? 'Unnamed profile'),
|
|
||||||
is_active: Boolean(profile.is_active ?? true),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadUser = async () => {
|
|
||||||
if (!idParam) return
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response.status === 404) {
|
|
||||||
setError('User not found.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Could not load user.')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const nextUser = data?.user ?? null
|
|
||||||
setUser(nextUser)
|
|
||||||
setStats(normalizeStats(data?.stats))
|
|
||||||
setLineage((data?.lineage ?? null) as UserLineage)
|
|
||||||
setProfileSelection(
|
|
||||||
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
|
|
||||||
? ''
|
|
||||||
: String(nextUser.profile_id)
|
|
||||||
)
|
|
||||||
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
|
|
||||||
setEmailInput(nextUser?.email ?? '')
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not load user.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleUserBlock = async (blocked: boolean) => {
|
|
||||||
if (!user) return
|
|
||||||
try {
|
|
||||||
setActionStatus(null)
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
|
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Update failed')
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not update user access.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateUserRole = async (role: string) => {
|
|
||||||
if (!user) return
|
|
||||||
try {
|
|
||||||
setActionStatus(null)
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ role }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Update failed')
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(`Role updated to ${role}.`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not update user role.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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 {
|
|
||||||
setActionStatus(null)
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Update failed')
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not update auto search access.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateInviteManagementEnabled = async (enabled: boolean) => {
|
|
||||||
if (!user) return
|
|
||||||
try {
|
|
||||||
setActionStatus(null)
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Update failed')
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not update invite access.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyProfileToUser = async (profileOverride?: string | null) => {
|
|
||||||
if (!user) return
|
|
||||||
const profileValue = profileOverride ?? profileSelection
|
|
||||||
setSavingProfile(true)
|
|
||||||
setError(null)
|
|
||||||
setActionStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ profile_id: profileValue || null }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Profile update failed')
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not update user profile.')
|
|
||||||
} finally {
|
|
||||||
setSavingProfile(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveUserExpiry = async () => {
|
|
||||||
if (!user) return
|
|
||||||
const expiresAt = fromLocalDateTimeInput(expiryInput)
|
|
||||||
if (expiryInput.trim() && !expiresAt) {
|
|
||||||
setError('Invalid expiry date/time.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSavingExpiry(true)
|
|
||||||
setError(null)
|
|
||||||
setActionStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ expires_at: expiresAt }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Expiry update failed')
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not update user expiry.')
|
|
||||||
} finally {
|
|
||||||
setSavingExpiry(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearUserExpiry = async () => {
|
|
||||||
if (!user) return
|
|
||||||
setSavingExpiry(true)
|
|
||||||
setError(null)
|
|
||||||
setActionStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ clear: true }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Expiry clear failed')
|
|
||||||
}
|
|
||||||
setExpiryInput('')
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus('User expiry cleared.')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Could not clear user expiry.')
|
|
||||||
} finally {
|
|
||||||
setSavingExpiry(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
|
|
||||||
if (!user) return
|
|
||||||
if (action === 'remove') {
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
`Remove ${user.username} from Magent and external systems? This is destructive.`
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
if (action === 'ban') {
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
`Ban ${user.username} across systems and disable invites they created?`
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
setSystemActionBusy(true)
|
|
||||||
setError(null)
|
|
||||||
setActionStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const text = await response.text()
|
|
||||||
let data: any = null
|
|
||||||
try {
|
|
||||||
data = text ? JSON.parse(text) : null
|
|
||||||
} catch {
|
|
||||||
data = null
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data?.detail || text || 'Cross-system action failed')
|
|
||||||
}
|
|
||||||
const state = data?.status === 'partial' ? 'partial' : 'complete'
|
|
||||||
if (action === 'remove') {
|
|
||||||
setActionStatus(`User removed (${state}).`)
|
|
||||||
router.push('/users')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await loadUser()
|
|
||||||
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
|
|
||||||
} finally {
|
|
||||||
setSystemActionBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void loadUser()
|
|
||||||
void loadProfiles()
|
|
||||||
}, [router, idParam])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <main className="card">Loading user...</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title={user?.username || 'User'}
|
|
||||||
subtitle="User overview and request stats."
|
|
||||||
actions={
|
|
||||||
<button type="button" onClick={() => router.push('/users')}>
|
|
||||||
Back to users
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<section className="admin-section">
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
{actionStatus && <div className="status-banner">{actionStatus}</div>}
|
|
||||||
{!user ? (
|
|
||||||
<div className="status-banner">No user data found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="user-detail-page-grid">
|
|
||||||
<div className="user-detail-main-column">
|
|
||||||
<div className="admin-panel user-detail-panel">
|
|
||||||
<div className="user-detail-panel-header">
|
|
||||||
<div className="user-detail-title-row">
|
|
||||||
<strong className="user-detail-name">{user.username}</strong>
|
|
||||||
<span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
|
|
||||||
{user.is_blocked ? 'Blocked' : 'Active'}
|
|
||||||
</span>
|
|
||||||
<span className={`user-grid-pill ${user.is_expired ? 'is-blocked' : ''}`}>
|
|
||||||
{user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="lede">
|
|
||||||
User identity, access state, and request history for this account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-grid">
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Email</span>
|
|
||||||
<strong>{user.email || 'Not set'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Seerr ID</span>
|
|
||||||
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Role</span>
|
|
||||||
<strong>{user.role}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Login type</span>
|
|
||||||
<strong>{user.auth_provider || 'local'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Assigned profile</span>
|
|
||||||
<strong>{user.profile_id ?? 'None'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Invited by</span>
|
|
||||||
<strong>{lineage?.invited_by || 'Direct / unknown'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Invite code used</span>
|
|
||||||
<strong>{lineage?.invite_code || user.invited_by_code || 'None'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Last login</span>
|
|
||||||
<strong>{formatDateTime(user.last_login_at)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-meta-item">
|
|
||||||
<span className="label">Account expiry</span>
|
|
||||||
<strong>{user.expires_at ? formatDateTime(user.expires_at) : 'Never'}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel user-detail-panel">
|
|
||||||
<div className="user-detail-panel-header">
|
|
||||||
<h2>Request statistics</h2>
|
|
||||||
<p className="lede">Snapshot of request states and recent activity for this user.</p>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-grid">
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Total</span>
|
|
||||||
<span className="value">{stats?.total ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Ready</span>
|
|
||||||
<span className="value">{stats?.ready ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Pending</span>
|
|
||||||
<span className="value">{stats?.pending ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Approved</span>
|
|
||||||
<span className="value">{stats?.approved ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Working</span>
|
|
||||||
<span className="value">{stats?.working ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Partial</span>
|
|
||||||
<span className="value">{stats?.partial ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">Declined</span>
|
|
||||||
<span className="value">{stats?.declined ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat">
|
|
||||||
<span className="label">In progress</span>
|
|
||||||
<span className="value">{stats?.in_progress ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-stat user-detail-stat--wide">
|
|
||||||
<span className="label">Last request</span>
|
|
||||||
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="user-detail-side-column">
|
|
||||||
<div className="admin-panel user-detail-panel">
|
|
||||||
<div className="user-detail-panel-header">
|
|
||||||
<h2>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'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>
|
|
||||||
<p className="lede">Role, login access, and auto-download behavior.</p>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-control-stack">
|
|
||||||
<label className="toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={user.role === 'admin'}
|
|
||||||
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
|
|
||||||
/>
|
|
||||||
<span>Make admin</span>
|
|
||||||
</label>
|
|
||||||
<label className="toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(user.auto_search_enabled ?? true)}
|
|
||||||
disabled={user.role === 'admin'}
|
|
||||||
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Allow auto search/download</span>
|
|
||||||
</label>
|
|
||||||
<label className="toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(user.invite_management_enabled ?? false)}
|
|
||||||
disabled={user.role === 'admin'}
|
|
||||||
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Allow self-service invites</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => toggleUserBlock(!user.is_blocked)}
|
|
||||||
disabled={systemActionBusy}
|
|
||||||
>
|
|
||||||
{user.is_blocked ? 'Allow access' : 'Block access'}
|
|
||||||
</button>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
|
|
||||||
disabled={systemActionBusy}
|
|
||||||
>
|
|
||||||
{systemActionBusy
|
|
||||||
? 'Working...'
|
|
||||||
: user.is_blocked
|
|
||||||
? 'Unban everywhere'
|
|
||||||
: 'Ban everywhere'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => void runSystemAction('remove')}
|
|
||||||
disabled={systemActionBusy}
|
|
||||||
>
|
|
||||||
Remove everywhere
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{user.role === 'admin' && (
|
|
||||||
<div className="user-detail-helper">
|
|
||||||
Admins always have auto search/download and invite-management access.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel user-detail-panel">
|
|
||||||
<div className="user-detail-panel-header">
|
|
||||||
<h2>Profile defaults</h2>
|
|
||||||
<p className="lede">Assign or clear an invite profile for this user.</p>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-actions user-detail-actions--stacked">
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Assigned profile</span>
|
|
||||||
<select
|
|
||||||
value={profileSelection}
|
|
||||||
onChange={(event) => setProfileSelection(event.target.value)}
|
|
||||||
disabled={savingProfile}
|
|
||||||
>
|
|
||||||
<option value="">None</option>
|
|
||||||
{profiles.map((profile) => (
|
|
||||||
<option key={profile.id} value={profile.id}>
|
|
||||||
{profile.name}
|
|
||||||
{profile.is_active === false ? ' (disabled)' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
|
|
||||||
{savingProfile ? 'Applying...' : 'Apply profile defaults'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => {
|
|
||||||
setProfileSelection('')
|
|
||||||
void applyProfileToUser('')
|
|
||||||
}}
|
|
||||||
disabled={savingProfile}
|
|
||||||
>
|
|
||||||
Clear profile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel user-detail-panel">
|
|
||||||
<div className="user-detail-panel-header">
|
|
||||||
<h2>Account expiry</h2>
|
|
||||||
<p className="lede">Set a specific expiry date/time for this user account.</p>
|
|
||||||
</div>
|
|
||||||
<div className="user-detail-actions user-detail-actions--stacked">
|
|
||||||
<label>
|
|
||||||
<span className="user-bulk-label">Account expiry</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={expiryInput}
|
|
||||||
onChange={(event) => setExpiryInput(event.target.value)}
|
|
||||||
disabled={savingExpiry}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
|
|
||||||
{savingExpiry ? 'Saving...' : 'Save expiry'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={clearUserExpiry}
|
|
||||||
disabled={savingExpiry}
|
|
||||||
>
|
|
||||||
Clear expiry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+46
-353
@@ -2,35 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: number
|
|
||||||
username: string
|
username: string
|
||||||
email?: string | null
|
|
||||||
role: string
|
role: string
|
||||||
authProvider?: string | null
|
authProvider?: string | null
|
||||||
lastLoginAt?: string | null
|
lastLoginAt?: string | null
|
||||||
isBlocked?: boolean
|
isBlocked?: boolean
|
||||||
autoSearchEnabled?: boolean
|
|
||||||
profileId?: number | null
|
|
||||||
expiresAt?: string | null
|
|
||||||
isExpired?: boolean
|
|
||||||
stats?: UserStats
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserStats = {
|
|
||||||
total: number
|
|
||||||
ready: number
|
|
||||||
pending: number
|
|
||||||
approved: number
|
|
||||||
working: number
|
|
||||||
partial: number
|
|
||||||
declined: number
|
|
||||||
in_progress: number
|
|
||||||
last_request_at?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatLastLogin = (value?: string | null) => {
|
const formatLastLogin = (value?: string | null) => {
|
||||||
@@ -40,59 +20,16 @@ const formatLastLogin = (value?: string | null) => {
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatLastRequest = (value?: string | null) => {
|
|
||||||
if (!value) return '—'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatExpiry = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyStats: UserStats = {
|
|
||||||
total: 0,
|
|
||||||
ready: 0,
|
|
||||||
pending: 0,
|
|
||||||
approved: 0,
|
|
||||||
working: 0,
|
|
||||||
partial: 0,
|
|
||||||
declined: 0,
|
|
||||||
in_progress: 0,
|
|
||||||
last_request_at: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeStats = (stats: any): UserStats => ({
|
|
||||||
total: Number(stats?.total ?? 0),
|
|
||||||
ready: Number(stats?.ready ?? 0),
|
|
||||||
pending: Number(stats?.pending ?? 0),
|
|
||||||
approved: Number(stats?.approved ?? 0),
|
|
||||||
working: Number(stats?.working ?? 0),
|
|
||||||
partial: Number(stats?.partial ?? 0),
|
|
||||||
declined: Number(stats?.declined ?? 0),
|
|
||||||
in_progress: Number(stats?.in_progress ?? 0),
|
|
||||||
last_request_at: stats?.last_request_at ?? null,
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
|
|
||||||
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
|
|
||||||
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
|
|
||||||
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/admin/users/summary`)
|
const response = await authFetch(`${baseUrl}/admin/users`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -110,20 +47,10 @@ export default function UsersPage() {
|
|||||||
setUsers(
|
setUsers(
|
||||||
data.users.map((user: any) => ({
|
data.users.map((user: any) => ({
|
||||||
username: user.username ?? 'Unknown',
|
username: user.username ?? 'Unknown',
|
||||||
email: user.email ?? null,
|
|
||||||
role: user.role ?? 'user',
|
role: user.role ?? 'user',
|
||||||
authProvider: user.auth_provider ?? 'local',
|
authProvider: user.auth_provider ?? 'local',
|
||||||
lastLoginAt: user.last_login_at ?? null,
|
lastLoginAt: user.last_login_at ?? null,
|
||||||
isBlocked: Boolean(user.is_blocked),
|
isBlocked: Boolean(user.is_blocked),
|
||||||
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
|
|
||||||
profileId:
|
|
||||||
user.profile_id == null || Number.isNaN(Number(user.profile_id))
|
|
||||||
? null
|
|
||||||
: Number(user.profile_id),
|
|
||||||
expiresAt: user.expires_at ?? null,
|
|
||||||
isExpired: Boolean(user.is_expired),
|
|
||||||
id: Number(user.id ?? 0),
|
|
||||||
stats: normalizeStats(user.stats ?? emptyStats),
|
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -138,87 +65,45 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncJellyseerrUsers = async () => {
|
const toggleUserBlock = async (username: string, blocked: boolean) => {
|
||||||
setJellyseerrSyncStatus(null)
|
|
||||||
setJellyseerrSyncBusy(true)
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
|
const response = await authFetch(
|
||||||
method: 'POST',
|
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
|
||||||
})
|
{ method: 'POST' }
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Sync failed')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setJellyseerrSyncStatus(
|
|
||||||
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
|
|
||||||
)
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Update failed')
|
||||||
|
}
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setJellyseerrSyncStatus('Could not sync Seerr users.')
|
setError('Could not update user access.')
|
||||||
} finally {
|
|
||||||
setJellyseerrSyncBusy(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resyncJellyseerrUsers = async () => {
|
const updateUserRole = async (username: string, role: string) => {
|
||||||
const confirmed = window.confirm(
|
|
||||||
'This will remove all non-admin users and re-import from Seerr. Continue?'
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
setJellyseerrSyncStatus(null)
|
|
||||||
setJellyseerrResyncBusy(true)
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
|
const response = await authFetch(
|
||||||
method: 'POST',
|
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
|
||||||
})
|
{
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Resync failed')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setJellyseerrSyncStatus(
|
|
||||||
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
|
|
||||||
)
|
|
||||||
await loadUsers()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setJellyseerrSyncStatus('Could not resync Seerr users.')
|
|
||||||
} finally {
|
|
||||||
setJellyseerrResyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bulkUpdateAutoSearch = async (enabled: boolean) => {
|
|
||||||
setBulkAutoSearchBusy(true)
|
|
||||||
setJellyseerrSyncStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ role }),
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Bulk update failed')
|
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
|
||||||
setJellyseerrSyncStatus(
|
|
||||||
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
|
|
||||||
)
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Update failed')
|
||||||
|
}
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError('Could not update auto search/download for all users.')
|
setError('Could not update user role.')
|
||||||
} finally {
|
|
||||||
setBulkAutoSearchBusy(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -231,242 +116,50 @@ export default function UsersPage() {
|
|||||||
return <main className="card">Loading users...</main>
|
return <main className="card">Loading users...</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
|
|
||||||
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
|
|
||||||
const blockedCount = users.filter((user) => user.isBlocked).length
|
|
||||||
const expiredCount = users.filter((user) => user.isExpired).length
|
|
||||||
const adminCount = users.filter((user) => user.role === 'admin').length
|
|
||||||
const normalizedQuery = query.trim().toLowerCase()
|
|
||||||
const filteredUsers = normalizedQuery
|
|
||||||
? users.filter((user) => {
|
|
||||||
const fields = [
|
|
||||||
user.username,
|
|
||||||
user.email || '',
|
|
||||||
user.role,
|
|
||||||
user.authProvider || '',
|
|
||||||
user.profileId != null ? String(user.profileId) : '',
|
|
||||||
]
|
|
||||||
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
|
|
||||||
})
|
|
||||||
: users
|
|
||||||
const filteredCountLabel =
|
|
||||||
filteredUsers.length === users.length
|
|
||||||
? `${users.length} users`
|
|
||||||
: `${filteredUsers.length} of ${users.length} users`
|
|
||||||
const usersRail = (
|
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card users-rail-summary">
|
|
||||||
<div className="user-directory-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>Directory summary</h2>
|
|
||||||
<p className="lede">A quick view of user access and account state.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="users-summary-grid">
|
|
||||||
<div className="users-summary-card">
|
|
||||||
<div className="users-summary-row">
|
|
||||||
<span className="users-summary-label">Total users</span>
|
|
||||||
<strong className="users-summary-value">{users.length}</strong>
|
|
||||||
</div>
|
|
||||||
<p className="users-summary-meta">{adminCount} admin accounts</p>
|
|
||||||
</div>
|
|
||||||
<div className="users-summary-card">
|
|
||||||
<div className="users-summary-row">
|
|
||||||
<span className="users-summary-label">Auto search</span>
|
|
||||||
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
|
|
||||||
</div>
|
|
||||||
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
|
|
||||||
</div>
|
|
||||||
<div className="users-summary-card">
|
|
||||||
<div className="users-summary-row">
|
|
||||||
<span className="users-summary-label">Blocked</span>
|
|
||||||
<strong className="users-summary-value">{blockedCount}</strong>
|
|
||||||
</div>
|
|
||||||
<p className="users-summary-meta">
|
|
||||||
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="users-summary-card">
|
|
||||||
<div className="users-summary-row">
|
|
||||||
<span className="users-summary-label">Expired</span>
|
|
||||||
<strong className="users-summary-value">{expiredCount}</strong>
|
|
||||||
</div>
|
|
||||||
<p className="users-summary-meta">
|
|
||||||
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Users"
|
title="Users"
|
||||||
subtitle="Directory, access status, and request activity."
|
subtitle="Manage who can use Magent."
|
||||||
rail={usersRail}
|
actions={
|
||||||
>
|
|
||||||
<section className="admin-section">
|
|
||||||
<div className="admin-panel users-page-toolbar">
|
|
||||||
<div className="users-page-toolbar-grid">
|
|
||||||
<div className="users-page-toolbar-group">
|
|
||||||
<span className="users-page-toolbar-label">Directory actions</span>
|
|
||||||
<div className="users-page-toolbar-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => router.push('/admin/invites')}
|
|
||||||
>
|
|
||||||
Invite management
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={loadUsers}>
|
<button type="button" onClick={loadUsers}>
|
||||||
Reload list
|
Reload list
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
<div className="users-page-toolbar-group">
|
|
||||||
<span className="users-page-toolbar-label">Seerr sync</span>
|
|
||||||
<div className="users-page-toolbar-actions">
|
|
||||||
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
|
|
||||||
{jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resyncJellyseerrUsers}
|
|
||||||
disabled={jellyseerrResyncBusy}
|
|
||||||
>
|
>
|
||||||
{jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
|
<section className="admin-section">
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
|
{users.length === 0 ? (
|
||||||
<div className="admin-panel user-directory-bulk-panel">
|
<div className="status-banner">No users found yet.</div>
|
||||||
<div className="user-directory-panel-header">
|
) : (
|
||||||
|
<div className="admin-grid">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.username} className="summary-card user-card">
|
||||||
<div>
|
<div>
|
||||||
<h2>Bulk controls</h2>
|
<strong>{user.username}</strong>
|
||||||
<p className="lede">
|
<span className="meta">Role: {user.role}</span>
|
||||||
Auto search/download can be enabled or disabled for all non-admin users.
|
<span className="meta">Login type: {user.authProvider || 'local'}</span>
|
||||||
</p>
|
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="user-actions">
|
||||||
<div className="user-bulk-toolbar">
|
<label className="toggle">
|
||||||
<div className="user-bulk-summary">
|
<input
|
||||||
<strong>Auto search/download</strong>
|
type="checkbox"
|
||||||
<span>
|
checked={user.role === 'admin'}
|
||||||
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
|
onChange={(event) =>
|
||||||
</span>
|
updateUserRole(user.username, event.target.checked ? 'admin' : 'user')
|
||||||
</div>
|
}
|
||||||
<div className="user-bulk-actions">
|
/>
|
||||||
<button
|
<span>Make admin</span>
|
||||||
type="button"
|
</label>
|
||||||
onClick={() => bulkUpdateAutoSearch(true)}
|
|
||||||
disabled={bulkAutoSearchBusy}
|
|
||||||
>
|
|
||||||
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={() => bulkUpdateAutoSearch(false)}
|
onClick={() => toggleUserBlock(user.username, !user.isBlocked)}
|
||||||
disabled={bulkAutoSearchBusy}
|
|
||||||
>
|
>
|
||||||
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
|
{user.isBlocked ? 'Allow access' : 'Block access'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="admin-panel user-directory-search-panel">
|
|
||||||
<div className="user-directory-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>Directory search</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Filter by username, role, login provider, or assigned profile.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="small-pill">{filteredCountLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-toolbar">
|
|
||||||
<div className="user-directory-search">
|
|
||||||
<label>
|
|
||||||
<span className="user-bulk-label">Search users</span>
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="Search username, login type, role, profile…"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{filteredUsers.length === 0 ? (
|
|
||||||
<div className="status-banner">No users found yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="user-directory-list">
|
|
||||||
<div className="user-directory-header">
|
|
||||||
<span>User</span>
|
|
||||||
<span>Access</span>
|
|
||||||
<span>Requests</span>
|
|
||||||
<span>Activity</span>
|
|
||||||
</div>
|
|
||||||
{filteredUsers.map((user) => (
|
|
||||||
<Link
|
|
||||||
key={user.username}
|
|
||||||
className="user-directory-row"
|
|
||||||
href={`/users/${user.id}`}
|
|
||||||
>
|
|
||||||
<div className="user-directory-cell user-directory-cell--identity">
|
|
||||||
<div className="user-directory-title-row">
|
|
||||||
<strong>{user.username}</strong>
|
|
||||||
<span className="user-grid-meta">{user.role}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-subtext">
|
|
||||||
{user.email || 'No email on file'}
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-subtext">
|
|
||||||
Login: {user.authProvider || 'local'} • Profile: {user.profileId ?? 'None'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-cell">
|
|
||||||
<div className="user-directory-pill-row">
|
|
||||||
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
|
|
||||||
{user.isBlocked ? 'Blocked' : 'Active'}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}
|
|
||||||
>
|
|
||||||
Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
|
|
||||||
</span>
|
|
||||||
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
|
|
||||||
{user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-subtext">
|
|
||||||
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-cell">
|
|
||||||
<div className="user-directory-stats-inline">
|
|
||||||
<span><strong>{user.stats?.total ?? 0}</strong> total</span>
|
|
||||||
<span><strong>{user.stats?.ready ?? 0}</strong> ready</span>
|
|
||||||
<span><strong>{user.stats?.pending ?? 0}</strong> pending</span>
|
|
||||||
<span><strong>{user.stats?.in_progress ?? 0}</strong> in progress</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-cell">
|
|
||||||
<div className="user-directory-subtext">
|
|
||||||
Last login: {formatLastLogin(user.lastLoginAt)}
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-subtext">
|
|
||||||
Last request: {formatLastRequest(user.stats?.last_request_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-row-chevron" aria-hidden="true">
|
|
||||||
Open
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-4
@@ -1,6 +1,2 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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.
|
|
||||||
|
|||||||
Generated
-1252
File diff suppressed because it is too large
Load Diff
+9
-15
@@ -1,28 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0803262237",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome lint ."
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.12",
|
"next": "14.2.5",
|
||||||
"react": "19.2.4",
|
"react": "18.3.1",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"typescript": "5.5.4",
|
||||||
"@types/node": "24.11.0",
|
"@types/node": "20.14.10",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "18.3.0"
|
||||||
"typescript": "5.9.3"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"nanoid": "3.3.18",
|
|
||||||
"postcss": "8.5.25",
|
|
||||||
"sharp": "0.35.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 846 B |
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
+3
-14
@@ -11,20 +11,9 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
$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")
|
|
||||||
|
|
||||||
Write-Host "Build number: $buildNumber"
|
|
||||||
|
|
||||||
git tag $buildNumber
|
|
||||||
git push origin $buildNumber
|
|
||||||
|
|
||||||
$backendImage = "rephl3xnz/magent-backend:$buildNumber"
|
|
||||||
$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
|
|
||||||
|
|
||||||
docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
|
|
||||||
docker build -f frontend/Dockerfile -t $frontendImage frontend
|
|
||||||
|
|
||||||
docker tag $backendImage rephl3xnz/magent-backend:latest
|
|
||||||
docker tag $frontendImage rephl3xnz/magent-frontend:latest
|
|
||||||
|
|
||||||
docker push $backendImage
|
|
||||||
docker push $frontendImage
|
|
||||||
docker push rephl3xnz/magent-backend:latest
|
|
||||||
docker push rephl3xnz/magent-frontend:latest
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user