Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded794a819 | ||
|
|
c6d449dc17 | ||
|
|
06d944c9d9 | ||
|
|
0ac53b7f59 | ||
|
|
2dbe11e6bc | ||
|
|
3aac40ba0f | ||
|
|
b6c48a0be7 | ||
|
|
3fc52f70c7 | ||
|
|
6391fbfd81 | ||
|
|
0ed22dd315 | ||
|
|
7ed0f4b103 | ||
|
|
b3c41f6dea | ||
|
|
976d24217b | ||
|
|
c49a149cfd | ||
|
|
5de14b1cb7 | ||
|
|
16876e1cf0 | ||
|
|
c7a56f2525 | ||
|
|
87a4aae246 | ||
|
|
e58614305e | ||
|
|
2adbed7259 | ||
|
|
393b8c2a88 | ||
|
|
b0eff9ffcf | ||
|
|
ae6cee5d0b | ||
|
|
906a777b95 | ||
|
|
ec8145a58a | ||
|
|
f8770cb44a | ||
|
|
0e04d219a0 | ||
|
|
3402e53c31 | ||
|
|
ecf9b230c1 | ||
|
|
8f810e0f36 | ||
|
|
82d87d968e | ||
|
|
372f4a1bfc | ||
|
|
547ed754e6 | ||
|
|
a55369190b | ||
|
|
ee81749b43 | ||
|
|
9dfea25d56 | ||
|
|
963506d098 | ||
|
|
02245d365e | ||
|
|
2cbd9fe73f | ||
|
|
9db32481bd | ||
|
|
3815dfea60 | ||
|
|
c073581639 | ||
|
|
06a000bb06 | ||
|
|
391cd41d71 | ||
|
|
96fc43365f | ||
|
|
655e2f8158 |
+1
-1
@@ -1 +1 @@
|
|||||||
0403261321
|
0803262237
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
name: Magent CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- beta
|
||||||
|
- prod
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: magent-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run backend quality gate
|
||||||
|
run: bash scripts/ci_backend_quality_gate.sh
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
if: github.ref_name == 'prod'
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure SSH key
|
||||||
|
env:
|
||||||
|
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||||
|
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
||||||
|
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to AMS-DEV01
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||||
|
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||||
|
run: bash scripts/deploy_ams_dev01.sh
|
||||||
|
|
||||||
|
deploy-beta:
|
||||||
|
if: github.ref_name == 'beta'
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure SSH key
|
||||||
|
env:
|
||||||
|
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||||
|
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
||||||
|
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy beta to AMS-DEV01
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||||
|
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||||
|
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||||
|
run: bash scripts/deploy_beta_ams_dev01.sh
|
||||||
@@ -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="change-me"
|
JWT_SECRET="replace-with-a-long-random-secret"
|
||||||
JWT_EXP_MINUTES="720"
|
JWT_EXP_MINUTES="720"
|
||||||
ADMIN_USERNAME="admin"
|
ADMIN_USERNAME="set-a-real-admin-username"
|
||||||
ADMIN_PASSWORD="adminadmin"
|
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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="change-me"
|
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
||||||
$env:JWT_EXP_MINUTES="720"
|
$env:JWT_EXP_MINUTES="720"
|
||||||
$env:ADMIN_USERNAME="admin"
|
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||||
$env:ADMIN_PASSWORD="adminadmin"
|
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (Next.js)
|
### Frontend (Next.js)
|
||||||
@@ -141,6 +141,26 @@ The frontend proxies `/api/*` to the backend container. Set:
|
|||||||
|
|
||||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
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
|
||||||
|
|||||||
+93
-28
@@ -1,13 +1,15 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, Request, Response, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||||
from .security import safe_decode_token, TokenError, verify_password
|
from .network_security import request_trusts_forwarded_headers
|
||||||
|
from .security import TokenError, safe_decode_token, verify_password
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(expires_at: str | None) -> bool:
|
def _is_expired(expires_at: str | None) -> bool:
|
||||||
@@ -24,20 +26,79 @@ def _is_expired(expires_at: str | None) -> bool:
|
|||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
return parsed <= datetime.now(timezone.utc)
|
return parsed <= datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _extract_client_ip(request: Request) -> str:
|
def _extract_client_ip(request: Request) -> str:
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
direct_host = request.client.host if request.client else None
|
||||||
if forwarded:
|
if request_trusts_forwarded_headers(direct_host):
|
||||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
if parts:
|
if forwarded:
|
||||||
return parts[0]
|
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||||
real_ip = request.headers.get("x-real-ip")
|
if parts:
|
||||||
if real_ip:
|
return parts[0]
|
||||||
return real_ip.strip()
|
real_ip = request.headers.get("x-real-ip")
|
||||||
if request.client and request.client.host:
|
if real_ip:
|
||||||
return request.client.host
|
return real_ip.strip()
|
||||||
|
if direct_host:
|
||||||
|
return direct_host
|
||||||
return "unknown"
|
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:
|
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
return "local"
|
return "local"
|
||||||
@@ -122,24 +183,28 @@ def _load_current_user_from_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
|
def get_current_user(
|
||||||
return _load_current_user_from_token(token, request)
|
request: Request,
|
||||||
|
token: Optional[str] = Depends(oauth2_scheme),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
resolved_token = _extract_access_token(request, token)
|
||||||
|
if not resolved_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||||
|
return _load_current_user_from_token(resolved_token, request)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
|
def get_current_user_event_stream(
|
||||||
|
request: Request,
|
||||||
|
token: Optional[str] = Depends(oauth2_scheme),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
||||||
token = None
|
resolved_token = _extract_access_token(request, token)
|
||||||
stream_query_token = None
|
stream_query_token = request.query_params.get("stream_token")
|
||||||
auth_header = request.headers.get("authorization", "")
|
if resolved_token:
|
||||||
if auth_header.lower().startswith("bearer "):
|
# Allow standard bearer tokens for non-browser EventSource clients.
|
||||||
token = auth_header.split(" ", 1)[1].strip()
|
return _load_current_user_from_token(resolved_token, None)
|
||||||
if not token:
|
if not stream_query_token:
|
||||||
stream_query_token = request.query_params.get("stream_token")
|
|
||||||
if not token and not stream_query_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||||
if token:
|
|
||||||
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
|
|
||||||
return _load_current_user_from_token(token, None)
|
|
||||||
return _load_current_user_from_token(
|
return _load_current_user_from_token(
|
||||||
str(stream_query_token),
|
str(stream_query_token),
|
||||||
None,
|
None,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+313
-10
@@ -4,6 +4,249 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..logging_config import sanitize_headers, sanitize_value
|
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:
|
||||||
@@ -29,6 +272,24 @@ class ApiClient:
|
|||||||
return f"{payload[:500]}..."
|
return f"{payload[:500]}..."
|
||||||
return payload
|
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(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -36,12 +297,16 @@ class ApiClient:
|
|||||||
*,
|
*,
|
||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
timeout_seconds: float = 10.0,
|
||||||
) -> Optional[Any]:
|
) -> 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)
|
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()
|
started_at = time.perf_counter()
|
||||||
|
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
||||||
|
active_message, _ = _operation_messages(service_name, method, path)
|
||||||
|
operation_event_id = start_remote_call(service_name, active_message)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||||
method,
|
method,
|
||||||
@@ -51,13 +316,14 @@ class ApiClient:
|
|||||||
sanitize_headers(self.headers()),
|
sanitize_headers(self.headers()),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||||
response = await client.request(
|
response = await self._send_request(
|
||||||
|
client,
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
headers=self.headers(),
|
headers=self.headers(),
|
||||||
params=params,
|
params=params,
|
||||||
json=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
@@ -68,9 +334,21 @@ class ApiClient:
|
|||||||
response.status_code,
|
response.status_code,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
)
|
)
|
||||||
if not response.content:
|
result = response.json() if response.content else None
|
||||||
return None
|
finish_remote_call(
|
||||||
return response.json()
|
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:
|
except httpx.HTTPStatusError as exc:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
response = exc.response
|
response = exc.response
|
||||||
@@ -84,6 +362,15 @@ class ApiClient:
|
|||||||
duration_ms,
|
duration_ms,
|
||||||
self._response_summary(response),
|
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
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
@@ -93,10 +380,22 @@ class ApiClient:
|
|||||||
url,
|
url,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
)
|
)
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=False,
|
||||||
|
message=_operation_error_message(service_name, None),
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def get(
|
||||||
return await self._request("GET", path, params=params)
|
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)
|
return await self._request("POST", path, payload=payload)
|
||||||
@@ -104,5 +403,9 @@ class ApiClient:
|
|||||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
return await self._request("PUT", path, payload=payload)
|
return await self._request("PUT", path, payload=payload)
|
||||||
|
|
||||||
async def delete(self, path: str) -> Optional[Any]:
|
async def delete(
|
||||||
return await self._request("DELETE", path)
|
self,
|
||||||
|
path: str,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
return await self._request("DELETE", path, params=params)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .base import ApiClient
|
||||||
|
|
||||||
|
|
||||||
|
class BazarrClient(ApiClient):
|
||||||
|
async def get_system_status(self) -> Optional[Any]:
|
||||||
|
return await self._request("GET", "/api/system/status")
|
||||||
|
|
||||||
|
async def search_movie_subtitles(
|
||||||
|
self,
|
||||||
|
radarr_id: int,
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
forced: bool = False,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
return await self._request(
|
||||||
|
"PATCH",
|
||||||
|
"/api/movies/subtitles",
|
||||||
|
params={
|
||||||
|
"radarrid": radarr_id,
|
||||||
|
"language": language,
|
||||||
|
"forced": str(forced).lower(),
|
||||||
|
"hi": "false",
|
||||||
|
},
|
||||||
|
timeout_seconds=90.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def search_episode_subtitles(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
episode_id: int,
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
forced: bool = False,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
return await self._request(
|
||||||
|
"PATCH",
|
||||||
|
"/api/episodes/subtitles",
|
||||||
|
params={
|
||||||
|
"seriesid": series_id,
|
||||||
|
"episodeid": episode_id,
|
||||||
|
"language": language,
|
||||||
|
"forced": str(forced).lower(),
|
||||||
|
"hi": "false",
|
||||||
|
},
|
||||||
|
timeout_seconds=90.0,
|
||||||
|
)
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
from .base import ApiClient
|
import time
|
||||||
|
from .base import ApiClient, _operation_error_message
|
||||||
|
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||||
|
|
||||||
|
|
||||||
|
def _availability_message(result: Any) -> str:
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return "Jellyfin did not return any matching library items."
|
||||||
|
total = result.get("TotalRecordCount")
|
||||||
|
items = result.get("Items")
|
||||||
|
available = (
|
||||||
|
(isinstance(total, int) and total > 0)
|
||||||
|
or (isinstance(items, list) and len(items) > 0)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"The title is available to watch in Jellyfin."
|
||||||
|
if available
|
||||||
|
else "The title is not currently available in Jellyfin."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class JellyfinClient(ApiClient):
|
class JellyfinClient(ApiClient):
|
||||||
@@ -167,6 +185,8 @@ class JellyfinClient(ApiClient):
|
|||||||
) -> 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,
|
||||||
@@ -175,10 +195,29 @@ class JellyfinClient(ApiClient):
|
|||||||
"Limit": limit,
|
"Limit": limit,
|
||||||
}
|
}
|
||||||
headers = self._emby_headers()
|
headers = self._emby_headers()
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
try:
|
||||||
response = await client.get(url, headers=headers, params=params)
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response.raise_for_status()
|
response = await client.get(url, headers=headers, params=params)
|
||||||
return response.json()
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=True,
|
||||||
|
status_code=response.status_code,
|
||||||
|
message=_availability_message(result),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=False,
|
||||||
|
status_code=status_code,
|
||||||
|
message=_operation_error_message("Jellyfin", status_code),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_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:
|
||||||
@@ -190,12 +229,43 @@ class JellyfinClient(ApiClient):
|
|||||||
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:
|
async def refresh_library(self, recursive: bool = True) -> None:
|
||||||
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", "Asking Jellyfin to refresh its library…")
|
||||||
url = f"{self.base_url}/Library/Refresh"
|
url = f"{self.base_url}/Library/Refresh"
|
||||||
headers = self._emby_headers()
|
headers = self._emby_headers()
|
||||||
params = {"Recursive": "true" if recursive else "false"}
|
params = {"Recursive": "true" if recursive else "false"}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
try:
|
||||||
response = await client.post(url, headers=headers, params=params)
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response.raise_for_status()
|
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,9 +1,44 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote, unquote, urlsplit
|
||||||
import httpx
|
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")
|
||||||
|
|
||||||
@@ -26,13 +61,42 @@ 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]]:
|
||||||
return await self.get(
|
# Seerr rejects the `+` encoding that standard query builders use for
|
||||||
"/api/v1/search",
|
# spaces. Build this query explicitly so multi-word titles are sent as
|
||||||
params={
|
# percent-encoded values.
|
||||||
"query": query,
|
encoded_query = quote(query, safe="")
|
||||||
"page": page,
|
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]]:
|
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(
|
return await self.get(
|
||||||
|
|||||||
@@ -1,7 +1,59 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
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):
|
||||||
@@ -23,34 +75,109 @@ class QBittorrentClient(ApiClient):
|
|||||||
headers={"Referer": self.base_url},
|
headers={"Referer": self.base_url},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
if response.text.strip().lower() != "ok.":
|
text = response.text.strip().lower()
|
||||||
|
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
||||||
|
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
||||||
raise RuntimeError("qBittorrent login failed")
|
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
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
started_at = time.perf_counter()
|
||||||
await self._login(client)
|
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
try:
|
||||||
response.raise_for_status()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
return response.json()
|
await self._login(client)
|
||||||
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=True,
|
||||||
|
status_code=response.status_code,
|
||||||
|
message=_torrent_result_message(result),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=False,
|
||||||
|
status_code=status_code,
|
||||||
|
message=_operation_error_message("qBittorrent", status_code),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
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
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
started_at = time.perf_counter()
|
||||||
await self._login(client)
|
operation_event_id = start_remote_call("qBittorrent")
|
||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
try:
|
||||||
response.raise_for_status()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
return response.text.strip()
|
await self._login(client)
|
||||||
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.text.strip()
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=True,
|
||||||
|
status_code=response.status_code,
|
||||||
|
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=False,
|
||||||
|
status_code=status_code,
|
||||||
|
message=_operation_error_message("qBittorrent", status_code),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
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
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
started_at = time.perf_counter()
|
||||||
await self._login(client)
|
operation_event_id = start_remote_call("qBittorrent")
|
||||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
try:
|
||||||
response.raise_for_status()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
await self._login(client)
|
||||||
|
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=True,
|
||||||
|
status_code=response.status_code,
|
||||||
|
message=_torrent_action_message(path),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
|
finish_remote_call(
|
||||||
|
operation_event_id,
|
||||||
|
success=False,
|
||||||
|
status_code=status_code,
|
||||||
|
message=_operation_error_message("qBittorrent", status_code),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def is_webui_reachable(self) -> bool:
|
||||||
|
if not self.base_url:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||||
|
response = await client.get(self.base_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
async def get_torrents(self) -> Optional[Any]:
|
async def get_torrents(self) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info")
|
return await self._get("/api/v2/torrents/info")
|
||||||
@@ -61,6 +188,9 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
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")
|
||||||
|
|
||||||
@@ -73,7 +203,9 @@ class QBittorrentClient(ApiClient):
|
|||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
async def add_torrent_url(
|
||||||
|
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
url_host = None
|
url_host = None
|
||||||
if isinstance(url, str) and "://" in url:
|
if isinstance(url, str) and "://" in url:
|
||||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
||||||
@@ -85,4 +217,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
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,6 +9,10 @@ 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]]:
|
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
return await self.get(f"/api/v3/movie/{movie_id}")
|
||||||
|
|
||||||
@@ -24,12 +28,32 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
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]]:
|
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/indexer")
|
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,
|
||||||
@@ -37,9 +61,15 @@ 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,
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ 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]]:
|
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v3/series/{series_id}")
|
return await self.get(f"/api/v3/series/{series_id}")
|
||||||
|
|
||||||
@@ -27,12 +41,36 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
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,
|
||||||
@@ -42,16 +80,19 @@ 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]]:
|
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
+48
-3
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
|||||||
sqlite_journal_mode: str = Field(
|
sqlite_journal_mode: str = Field(
|
||||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||||
)
|
)
|
||||||
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
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"))
|
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||||
auth_rate_limit_window_seconds: int = Field(
|
auth_rate_limit_window_seconds: int = Field(
|
||||||
@@ -34,7 +34,22 @@ class Settings(BaseSettings):
|
|||||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
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="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||||
|
auth_cookie_name: str = Field(
|
||||||
|
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||||
|
)
|
||||||
|
auth_cookie_secure: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||||
|
)
|
||||||
|
auth_cookie_samesite: str = Field(
|
||||||
|
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
||||||
|
)
|
||||||
|
auth_cookie_domain: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||||
|
)
|
||||||
|
auth_state_cookie_name: str = Field(
|
||||||
|
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||||
|
)
|
||||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
log_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(
|
log_file_max_bytes: int = Field(
|
||||||
@@ -70,6 +85,15 @@ 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")
|
||||||
)
|
)
|
||||||
@@ -95,6 +119,9 @@ class Settings(BaseSettings):
|
|||||||
site_login_show_signup_link: bool = Field(
|
site_login_show_signup_link: bool = Field(
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
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)
|
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||||
|
|
||||||
magent_application_url: Optional[str] = Field(
|
magent_application_url: Optional[str] = Field(
|
||||||
@@ -121,6 +148,10 @@ class Settings(BaseSettings):
|
|||||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
magent_proxy_trust_forwarded_headers: bool = Field(
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
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(
|
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
||||||
)
|
)
|
||||||
@@ -216,6 +247,10 @@ class Settings(BaseSettings):
|
|||||||
magent_notify_webhook_url: Optional[str] = Field(
|
magent_notify_webhook_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
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")
|
||||||
@@ -270,6 +305,16 @@ class Settings(BaseSettings):
|
|||||||
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
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")
|
||||||
)
|
)
|
||||||
@@ -288,7 +333,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
discord_webhook_url: Optional[str] = Field(
|
discord_webhook_url: Optional[str] = Field(
|
||||||
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
default=None,
|
||||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+878
-8
@@ -20,6 +20,9 @@ SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
|
|||||||
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
||||||
SQLITE_CACHE_SIZE_KIB = 32_768
|
SQLITE_CACHE_SIZE_KIB = 32_768
|
||||||
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
||||||
|
_DB_UNSET = object()
|
||||||
|
_DEFAULT_JWT_SECRET = "change-me"
|
||||||
|
_DEFAULT_ADMIN_PASSWORD = "adminadmin"
|
||||||
|
|
||||||
|
|
||||||
def _db_path() -> str:
|
def _db_path() -> str:
|
||||||
@@ -177,6 +180,11 @@ def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||||
|
password = str(settings.admin_password or "")
|
||||||
|
return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -349,6 +357,70 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
media_type TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
external_ref TEXT,
|
||||||
|
source_system TEXT,
|
||||||
|
source_request_id INTEGER,
|
||||||
|
related_item_id INTEGER,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
workflow_request_status TEXT,
|
||||||
|
workflow_media_status TEXT,
|
||||||
|
issue_type TEXT,
|
||||||
|
issue_resolved_at TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
priority TEXT NOT NULL,
|
||||||
|
created_by_username TEXT NOT NULL,
|
||||||
|
created_by_id INTEGER,
|
||||||
|
assignee_username TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_activity_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
author_username TEXT NOT NULL,
|
||||||
|
author_role TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_item_activity (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_username TEXT NOT NULL,
|
||||||
|
actor_role TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
metadata_json TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item
|
||||||
|
ON portal_item_activity (item_id, created_at ASC, id ASC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
||||||
@@ -367,12 +439,16 @@ def init_db() -> None:
|
|||||||
ON requests_cache (updated_at DESC, request_id DESC)
|
ON requests_cache (updated_at DESC, request_id DESC)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
try:
|
||||||
"""
|
conn.execute(
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
"""
|
||||||
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
||||||
"""
|
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
||||||
)
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Older databases may not have requested_by_id until later migrations run.
|
||||||
|
pass
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
|
||||||
@@ -409,6 +485,48 @@ def init_db() -> None:
|
|||||||
ON password_reset_tokens (expires_at)
|
ON password_reset_tokens (expires_at)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_kind_status
|
||||||
|
ON portal_items (kind, status, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_creator
|
||||||
|
ON portal_items (created_by_username, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_status
|
||||||
|
ON portal_items (status, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||||
|
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||||
|
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
|
||||||
|
ON portal_comments (item_id, created_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS user_activity (
|
CREATE TABLE IF NOT EXISTS user_activity (
|
||||||
@@ -491,6 +609,48 @@ def init_db() -> None:
|
|||||||
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||||
|
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||||
|
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -552,6 +712,18 @@ def save_snapshot(snapshot: Snapshot) -> None:
|
|||||||
payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
|
payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
|
||||||
created_at = datetime.now(timezone.utc).isoformat()
|
created_at = datetime.now(timezone.utc).isoformat()
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
|
latest = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT state, state_reason
|
||||||
|
FROM snapshots
|
||||||
|
WHERE request_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(snapshot.request_id,),
|
||||||
|
).fetchone()
|
||||||
|
if latest and latest[0] == snapshot.state.value and latest[1] == snapshot.state_reason:
|
||||||
|
return
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
|
INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
|
||||||
@@ -586,6 +758,7 @@ def save_action(
|
|||||||
|
|
||||||
|
|
||||||
def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
|
bounded_limit = max(1, min(int(limit or 10), 100))
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -595,10 +768,15 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
|
|||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(request_id, limit),
|
(request_id, min(bounded_limit * 20, 500)),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
results = []
|
results = []
|
||||||
|
previous_signature: tuple[str, Optional[str]] | None = None
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
signature = (row[1], row[2])
|
||||||
|
if signature == previous_signature:
|
||||||
|
continue
|
||||||
|
previous_signature = signature
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"request_id": row[0],
|
"request_id": row[0],
|
||||||
@@ -608,6 +786,8 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
|
|||||||
"payload": json.loads(row[4]),
|
"payload": json.loads(row[4]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if len(results) >= bounded_limit:
|
||||||
|
break
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -638,8 +818,57 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str, Any]:
|
||||||
|
"""Return the most recent proof that this request reached qBittorrent.
|
||||||
|
|
||||||
|
A current qBittorrent API error is not proof that a download exists. Historical
|
||||||
|
snapshots are used so a torrent that has since been removed can still be
|
||||||
|
described honestly in the UI.
|
||||||
|
"""
|
||||||
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT created_at, payload_json
|
||||||
|
FROM snapshots
|
||||||
|
WHERE request_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(request_id, max(1, min(int(limit or 100), 500))),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for created_at, payload_json in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_json)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
timeline = payload.get("timeline") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(timeline, list):
|
||||||
|
continue
|
||||||
|
for hop in timeline:
|
||||||
|
if not isinstance(hop, dict) or hop.get("service") != "qBittorrent":
|
||||||
|
continue
|
||||||
|
details = hop.get("details") if isinstance(hop.get("details"), dict) else {}
|
||||||
|
torrents = details.get("torrents")
|
||||||
|
if isinstance(torrents, list) and torrents:
|
||||||
|
return {
|
||||||
|
"observed": True,
|
||||||
|
"last_seen_at": created_at,
|
||||||
|
"state": hop.get("status"),
|
||||||
|
"summary": details.get("summary"),
|
||||||
|
"torrents": torrents,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"observed": False,
|
||||||
|
"last_seen_at": None,
|
||||||
|
"state": None,
|
||||||
|
"summary": None,
|
||||||
|
"torrents": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def ensure_admin_user() -> None:
|
def ensure_admin_user() -> None:
|
||||||
if not settings.admin_username or not settings.admin_password:
|
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
||||||
return
|
return
|
||||||
existing = get_user_by_username(settings.admin_username)
|
existing = get_user_by_username(settings.admin_username)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -647,6 +876,14 @@ def ensure_admin_user() -> None:
|
|||||||
create_user(settings.admin_username, settings.admin_password, role="admin")
|
create_user(settings.admin_username, settings.admin_password, role="admin")
|
||||||
|
|
||||||
|
|
||||||
|
def has_admin_user() -> bool:
|
||||||
|
with _connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
def create_user(
|
def create_user(
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
@@ -2879,6 +3116,637 @@ def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int])
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"kind": row[1],
|
||||||
|
"title": row[2],
|
||||||
|
"description": row[3],
|
||||||
|
"media_type": row[4],
|
||||||
|
"year": row[5],
|
||||||
|
"external_ref": row[6],
|
||||||
|
"source_system": row[7],
|
||||||
|
"source_request_id": row[8],
|
||||||
|
"related_item_id": row[9],
|
||||||
|
"status": row[10],
|
||||||
|
"workflow_request_status": row[11],
|
||||||
|
"workflow_media_status": row[12],
|
||||||
|
"issue_type": row[13],
|
||||||
|
"issue_resolved_at": row[14],
|
||||||
|
"metadata_json": row[15],
|
||||||
|
"priority": row[16],
|
||||||
|
"created_by_username": row[17],
|
||||||
|
"created_by_id": row[18],
|
||||||
|
"assignee_username": row[19],
|
||||||
|
"created_at": row[20],
|
||||||
|
"updated_at": row[21],
|
||||||
|
"last_activity_at": row[22],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_comment_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"item_id": row[1],
|
||||||
|
"author_username": row[2],
|
||||||
|
"author_role": row[3],
|
||||||
|
"message": row[4],
|
||||||
|
"is_internal": bool(row[5]),
|
||||||
|
"created_at": row[6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_portal_item(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
created_by_username: str,
|
||||||
|
created_by_id: Optional[int],
|
||||||
|
media_type: Optional[str] = None,
|
||||||
|
year: Optional[int] = None,
|
||||||
|
external_ref: Optional[str] = None,
|
||||||
|
source_system: Optional[str] = None,
|
||||||
|
source_request_id: Optional[int] = None,
|
||||||
|
related_item_id: Optional[int] = None,
|
||||||
|
status: str = "new",
|
||||||
|
workflow_request_status: Optional[str] = None,
|
||||||
|
workflow_media_status: Optional[str] = None,
|
||||||
|
issue_type: Optional[str] = None,
|
||||||
|
issue_resolved_at: Optional[str] = None,
|
||||||
|
metadata_json: Optional[str] = None,
|
||||||
|
priority: str = "normal",
|
||||||
|
assignee_username: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with _connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO portal_items (
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
media_type,
|
||||||
|
year,
|
||||||
|
external_ref,
|
||||||
|
source_system,
|
||||||
|
source_request_id,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
priority,
|
||||||
|
created_by_username,
|
||||||
|
created_by_id,
|
||||||
|
assignee_username,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
last_activity_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
media_type,
|
||||||
|
year,
|
||||||
|
external_ref,
|
||||||
|
source_system,
|
||||||
|
source_request_id,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
priority,
|
||||||
|
created_by_username,
|
||||||
|
created_by_id,
|
||||||
|
assignee_username,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
item_id = cursor.lastrowid
|
||||||
|
created = get_portal_item(item_id)
|
||||||
|
if not created:
|
||||||
|
raise RuntimeError("Portal item could not be loaded after insert.")
|
||||||
|
logger.info(
|
||||||
|
"portal item created id=%s kind=%s status=%s priority=%s created_by=%s",
|
||||||
|
created["id"],
|
||||||
|
created["kind"],
|
||||||
|
created["status"],
|
||||||
|
created["priority"],
|
||||||
|
created["created_by_username"],
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
with _connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
media_type,
|
||||||
|
year,
|
||||||
|
external_ref,
|
||||||
|
source_system,
|
||||||
|
source_request_id,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
priority,
|
||||||
|
created_by_username,
|
||||||
|
created_by_id,
|
||||||
|
assignee_username,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
last_activity_at
|
||||||
|
FROM portal_items
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _portal_item_from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_portal_items(
|
||||||
|
*,
|
||||||
|
kind: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
workflow_request_status: Optional[str] = None,
|
||||||
|
workflow_media_status: Optional[str] = None,
|
||||||
|
source_system: Optional[str] = None,
|
||||||
|
source_request_id: Optional[int] = None,
|
||||||
|
related_item_id: Optional[int] = None,
|
||||||
|
mine_username: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Dict[str, Any]]:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if isinstance(kind, str) and kind.strip():
|
||||||
|
clauses.append("kind = ?")
|
||||||
|
params.append(kind.strip().lower())
|
||||||
|
if isinstance(status, str) and status.strip():
|
||||||
|
clauses.append("status = ?")
|
||||||
|
params.append(status.strip().lower())
|
||||||
|
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
|
||||||
|
clauses.append("workflow_request_status = ?")
|
||||||
|
params.append(workflow_request_status.strip().lower())
|
||||||
|
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
|
||||||
|
clauses.append("workflow_media_status = ?")
|
||||||
|
params.append(workflow_media_status.strip().lower())
|
||||||
|
if isinstance(source_system, str) and source_system.strip():
|
||||||
|
clauses.append("source_system = ?")
|
||||||
|
params.append(source_system.strip().lower())
|
||||||
|
if isinstance(source_request_id, int):
|
||||||
|
clauses.append("source_request_id = ?")
|
||||||
|
params.append(source_request_id)
|
||||||
|
if isinstance(related_item_id, int):
|
||||||
|
clauses.append("related_item_id = ?")
|
||||||
|
params.append(related_item_id)
|
||||||
|
if isinstance(mine_username, str) and mine_username.strip():
|
||||||
|
clauses.append("created_by_username = ?")
|
||||||
|
params.append(mine_username.strip())
|
||||||
|
if isinstance(search, str) and search.strip():
|
||||||
|
token = f"%{search.strip().lower()}%"
|
||||||
|
clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
|
||||||
|
params.extend([token, token, search.strip()])
|
||||||
|
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
safe_offset = max(0, int(offset))
|
||||||
|
params.extend([safe_limit, safe_offset])
|
||||||
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
media_type,
|
||||||
|
year,
|
||||||
|
external_ref,
|
||||||
|
source_system,
|
||||||
|
source_request_id,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
priority,
|
||||||
|
created_by_username,
|
||||||
|
created_by_id,
|
||||||
|
assignee_username,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
last_activity_at
|
||||||
|
FROM portal_items
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY last_activity_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
).fetchall()
|
||||||
|
return [_portal_item_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def count_portal_items(
|
||||||
|
*,
|
||||||
|
kind: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
workflow_request_status: Optional[str] = None,
|
||||||
|
workflow_media_status: Optional[str] = None,
|
||||||
|
source_system: Optional[str] = None,
|
||||||
|
source_request_id: Optional[int] = None,
|
||||||
|
related_item_id: Optional[int] = None,
|
||||||
|
mine_username: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if isinstance(kind, str) and kind.strip():
|
||||||
|
clauses.append("kind = ?")
|
||||||
|
params.append(kind.strip().lower())
|
||||||
|
if isinstance(status, str) and status.strip():
|
||||||
|
clauses.append("status = ?")
|
||||||
|
params.append(status.strip().lower())
|
||||||
|
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
|
||||||
|
clauses.append("workflow_request_status = ?")
|
||||||
|
params.append(workflow_request_status.strip().lower())
|
||||||
|
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
|
||||||
|
clauses.append("workflow_media_status = ?")
|
||||||
|
params.append(workflow_media_status.strip().lower())
|
||||||
|
if isinstance(source_system, str) and source_system.strip():
|
||||||
|
clauses.append("source_system = ?")
|
||||||
|
params.append(source_system.strip().lower())
|
||||||
|
if isinstance(source_request_id, int):
|
||||||
|
clauses.append("source_request_id = ?")
|
||||||
|
params.append(source_request_id)
|
||||||
|
if isinstance(related_item_id, int):
|
||||||
|
clauses.append("related_item_id = ?")
|
||||||
|
params.append(related_item_id)
|
||||||
|
if isinstance(mine_username, str) and mine_username.strip():
|
||||||
|
clauses.append("created_by_username = ?")
|
||||||
|
params.append(mine_username.strip())
|
||||||
|
if isinstance(search, str) and search.strip():
|
||||||
|
token = f"%{search.strip().lower()}%"
|
||||||
|
clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
|
||||||
|
params.extend([token, token, search.strip()])
|
||||||
|
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
with _connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM portal_items {where_sql}",
|
||||||
|
tuple(params),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0] or 0) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_portal_item(
|
||||||
|
item_id: int,
|
||||||
|
*,
|
||||||
|
title: Any = _DB_UNSET,
|
||||||
|
description: Any = _DB_UNSET,
|
||||||
|
status: Any = _DB_UNSET,
|
||||||
|
priority: Any = _DB_UNSET,
|
||||||
|
assignee_username: Any = _DB_UNSET,
|
||||||
|
media_type: Any = _DB_UNSET,
|
||||||
|
year: Any = _DB_UNSET,
|
||||||
|
external_ref: Any = _DB_UNSET,
|
||||||
|
source_system: Any = _DB_UNSET,
|
||||||
|
source_request_id: Any = _DB_UNSET,
|
||||||
|
related_item_id: Any = _DB_UNSET,
|
||||||
|
workflow_request_status: Any = _DB_UNSET,
|
||||||
|
workflow_media_status: Any = _DB_UNSET,
|
||||||
|
issue_type: Any = _DB_UNSET,
|
||||||
|
issue_resolved_at: Any = _DB_UNSET,
|
||||||
|
metadata_json: Any = _DB_UNSET,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if title is not _DB_UNSET:
|
||||||
|
updates.append("title = ?")
|
||||||
|
params.append(title)
|
||||||
|
if description is not _DB_UNSET:
|
||||||
|
updates.append("description = ?")
|
||||||
|
params.append(description)
|
||||||
|
if status is not _DB_UNSET:
|
||||||
|
updates.append("status = ?")
|
||||||
|
params.append(status)
|
||||||
|
if priority is not _DB_UNSET:
|
||||||
|
updates.append("priority = ?")
|
||||||
|
params.append(priority)
|
||||||
|
if assignee_username is not _DB_UNSET:
|
||||||
|
updates.append("assignee_username = ?")
|
||||||
|
params.append(assignee_username)
|
||||||
|
if media_type is not _DB_UNSET:
|
||||||
|
updates.append("media_type = ?")
|
||||||
|
params.append(media_type)
|
||||||
|
if year is not _DB_UNSET:
|
||||||
|
updates.append("year = ?")
|
||||||
|
params.append(year)
|
||||||
|
if external_ref is not _DB_UNSET:
|
||||||
|
updates.append("external_ref = ?")
|
||||||
|
params.append(external_ref)
|
||||||
|
if source_system is not _DB_UNSET:
|
||||||
|
updates.append("source_system = ?")
|
||||||
|
params.append(source_system)
|
||||||
|
if source_request_id is not _DB_UNSET:
|
||||||
|
updates.append("source_request_id = ?")
|
||||||
|
params.append(source_request_id)
|
||||||
|
if related_item_id is not _DB_UNSET:
|
||||||
|
updates.append("related_item_id = ?")
|
||||||
|
params.append(related_item_id)
|
||||||
|
if workflow_request_status is not _DB_UNSET:
|
||||||
|
updates.append("workflow_request_status = ?")
|
||||||
|
params.append(workflow_request_status)
|
||||||
|
if workflow_media_status is not _DB_UNSET:
|
||||||
|
updates.append("workflow_media_status = ?")
|
||||||
|
params.append(workflow_media_status)
|
||||||
|
if issue_type is not _DB_UNSET:
|
||||||
|
updates.append("issue_type = ?")
|
||||||
|
params.append(issue_type)
|
||||||
|
if issue_resolved_at is not _DB_UNSET:
|
||||||
|
updates.append("issue_resolved_at = ?")
|
||||||
|
params.append(issue_resolved_at)
|
||||||
|
if metadata_json is not _DB_UNSET:
|
||||||
|
updates.append("metadata_json = ?")
|
||||||
|
params.append(metadata_json)
|
||||||
|
if not updates:
|
||||||
|
return get_portal_item(item_id)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
updates.append("updated_at = ?")
|
||||||
|
updates.append("last_activity_at = ?")
|
||||||
|
params.extend([now, now, item_id])
|
||||||
|
with _connect() as conn:
|
||||||
|
changed = conn.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE portal_items
|
||||||
|
SET {', '.join(updates)}
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
).rowcount
|
||||||
|
if not changed:
|
||||||
|
return None
|
||||||
|
updated = get_portal_item(item_id)
|
||||||
|
if updated:
|
||||||
|
logger.info(
|
||||||
|
"portal item updated id=%s status=%s priority=%s assignee=%s",
|
||||||
|
updated["id"],
|
||||||
|
updated["status"],
|
||||||
|
updated["priority"],
|
||||||
|
updated["assignee_username"],
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def delete_portal_item(item_id: int) -> bool:
|
||||||
|
with _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE portal_items SET related_item_id = NULL WHERE related_item_id = ?",
|
||||||
|
(item_id,),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM portal_comments WHERE item_id = ?", (item_id,))
|
||||||
|
conn.execute("DELETE FROM portal_item_activity WHERE item_id = ?", (item_id,))
|
||||||
|
deleted = conn.execute(
|
||||||
|
"DELETE FROM portal_items WHERE id = ?",
|
||||||
|
(item_id,),
|
||||||
|
).rowcount
|
||||||
|
if deleted:
|
||||||
|
logger.info("portal item deleted id=%s", item_id)
|
||||||
|
return bool(deleted)
|
||||||
|
|
||||||
|
|
||||||
|
def add_portal_comment(
|
||||||
|
item_id: int,
|
||||||
|
*,
|
||||||
|
author_username: str,
|
||||||
|
author_role: str,
|
||||||
|
message: str,
|
||||||
|
is_internal: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with _connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO portal_comments (
|
||||||
|
item_id,
|
||||||
|
author_username,
|
||||||
|
author_role,
|
||||||
|
message,
|
||||||
|
is_internal,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
author_username,
|
||||||
|
author_role,
|
||||||
|
message,
|
||||||
|
1 if is_internal else 0,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE portal_items
|
||||||
|
SET last_activity_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now, now, item_id),
|
||||||
|
)
|
||||||
|
comment_id = cursor.lastrowid
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, item_id, author_username, author_role, message, is_internal, created_at
|
||||||
|
FROM portal_comments
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(comment_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError("Portal comment could not be loaded after insert.")
|
||||||
|
comment = _portal_comment_from_row(row)
|
||||||
|
logger.info(
|
||||||
|
"portal comment created id=%s item_id=%s author=%s internal=%s",
|
||||||
|
comment["id"],
|
||||||
|
comment["item_id"],
|
||||||
|
comment["author_username"],
|
||||||
|
comment["is_internal"],
|
||||||
|
)
|
||||||
|
return comment
|
||||||
|
|
||||||
|
|
||||||
|
def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: int = 200) -> list[Dict[str, Any]]:
|
||||||
|
clauses = ["item_id = ?"]
|
||||||
|
params: list[Any] = [item_id]
|
||||||
|
if not include_internal:
|
||||||
|
clauses.append("is_internal = 0")
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
params.append(safe_limit)
|
||||||
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, item_id, author_username, author_role, message, is_internal, created_at
|
||||||
|
FROM portal_comments
|
||||||
|
WHERE {' AND '.join(clauses)}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
).fetchall()
|
||||||
|
return [_portal_comment_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def add_portal_item_activity(
|
||||||
|
item_id: int,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
actor_username: str,
|
||||||
|
actor_role: str,
|
||||||
|
message: str,
|
||||||
|
metadata_json: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with _connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO portal_item_activity (
|
||||||
|
item_id,
|
||||||
|
event_type,
|
||||||
|
actor_username,
|
||||||
|
actor_role,
|
||||||
|
message,
|
||||||
|
metadata_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
event_type,
|
||||||
|
actor_username,
|
||||||
|
actor_role,
|
||||||
|
message,
|
||||||
|
metadata_json,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
activity_id = cursor.lastrowid
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
|
||||||
|
FROM portal_item_activity
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(activity_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError("Portal activity could not be loaded after insert.")
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"item_id": row[1],
|
||||||
|
"event_type": row[2],
|
||||||
|
"actor_username": row[3],
|
||||||
|
"actor_role": row[4],
|
||||||
|
"message": row[5],
|
||||||
|
"metadata_json": row[6],
|
||||||
|
"created_at": row[7],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]:
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
|
||||||
|
FROM portal_item_activity
|
||||||
|
WHERE item_id = ?
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(item_id, safe_limit),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"item_id": row[1],
|
||||||
|
"event_type": row[2],
|
||||||
|
"actor_username": row[3],
|
||||||
|
"actor_role": row[4],
|
||||||
|
"message": row[5],
|
||||||
|
"metadata_json": row[6],
|
||||||
|
"created_at": row[7],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_portal_overview() -> Dict[str, Any]:
|
||||||
|
with _connect() as conn:
|
||||||
|
kind_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT kind, COUNT(*)
|
||||||
|
FROM portal_items
|
||||||
|
GROUP BY kind
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
status_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT status, COUNT(*)
|
||||||
|
FROM portal_items
|
||||||
|
GROUP BY status
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
request_workflow_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(workflow_request_status, ''),
|
||||||
|
COALESCE(workflow_media_status, ''),
|
||||||
|
COUNT(*)
|
||||||
|
FROM portal_items
|
||||||
|
WHERE kind = 'request'
|
||||||
|
GROUP BY workflow_request_status, workflow_media_status
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
|
||||||
|
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
|
||||||
|
request_workflow: Dict[str, Dict[str, int]] = {}
|
||||||
|
for row in request_workflow_rows:
|
||||||
|
request_status = str(row[0] or "")
|
||||||
|
media_status = str(row[1] or "")
|
||||||
|
request_workflow.setdefault(request_status, {})
|
||||||
|
request_workflow[request_status][media_status] = int(row[2] or 0)
|
||||||
|
return {
|
||||||
|
"total_items": int(total_items_row[0] or 0) if total_items_row else 0,
|
||||||
|
"total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0,
|
||||||
|
"by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows},
|
||||||
|
"by_status": {str(row[0]): int(row[1] or 0) for row in status_rows},
|
||||||
|
"request_workflow": request_workflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_integrity_check() -> str:
|
def run_integrity_check() -> str:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||||
@@ -2922,6 +3790,8 @@ def get_database_diagnostics() -> Dict[str, Any]:
|
|||||||
"snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0),
|
"snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0),
|
||||||
"seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0),
|
"seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0),
|
||||||
"password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0),
|
"password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0),
|
||||||
|
"portal_items": int(conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()[0] or 0),
|
||||||
|
"portal_comments": int(conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()[0] or 0),
|
||||||
}
|
}
|
||||||
row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)
|
row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)
|
||||||
|
|
||||||
|
|||||||
+49
-5
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import init_db
|
from .db import has_admin_user, 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,
|
||||||
@@ -24,7 +24,16 @@ 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.site import router as site_router
|
||||||
from .routers.events import router as events_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 .services.operation_progress import (
|
||||||
|
begin_operation,
|
||||||
|
finish_operation,
|
||||||
|
normalize_operation_id,
|
||||||
|
reset_operation,
|
||||||
|
)
|
||||||
from .logging_config import (
|
from .logging_config import (
|
||||||
bind_request_id,
|
bind_request_id,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
@@ -58,6 +67,14 @@ app.add_middleware(
|
|||||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
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]
|
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||||
token = bind_request_id(request_id)
|
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
|
request.state.request_id = request_id
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
@@ -100,6 +117,9 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
|||||||
request.url.path,
|
request.url.path,
|
||||||
duration_ms,
|
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)
|
reset_request_id(token)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -129,6 +149,13 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if operation_id and operation_token is not None:
|
||||||
|
finish_operation(
|
||||||
|
operation_id,
|
||||||
|
success=response.status_code < 400,
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
reset_operation(operation_token)
|
||||||
reset_request_id(token)
|
reset_request_id(token)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -164,13 +191,15 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
|
|||||||
|
|
||||||
|
|
||||||
def _log_security_configuration_warnings() -> None:
|
def _log_security_configuration_warnings() -> None:
|
||||||
if str(settings.jwt_secret or "").strip() == "change-me":
|
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||||
|
if not jwt_secret or jwt_secret == "change-me":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"security configuration warning: JWT_SECRET is still set to the default value"
|
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
||||||
)
|
)
|
||||||
if str(settings.admin_password or "") == "adminadmin":
|
admin_password = str(settings.admin_password or "")
|
||||||
|
if not admin_password or admin_password == "adminadmin":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||||
)
|
)
|
||||||
if bool(settings.api_docs_enabled):
|
if bool(settings.api_docs_enabled):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -178,6 +207,17 @@ def _log_security_configuration_warnings() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
configure_logging(
|
||||||
@@ -191,6 +231,7 @@ async def startup() -> None:
|
|||||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||||
_log_security_configuration_warnings()
|
_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_level,
|
||||||
@@ -215,6 +256,7 @@ async def startup() -> None:
|
|||||||
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
||||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
||||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
||||||
|
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||||
logger.info("startup complete")
|
logger.info("startup complete")
|
||||||
|
|
||||||
|
|
||||||
@@ -228,3 +270,5 @@ app.include_router(status_router)
|
|||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
|
app.include_router(portal_router)
|
||||||
|
app.include_router(operations_router)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Iterable
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
_METADATA_HOSTS = {
|
||||||
|
"169.254.169.254",
|
||||||
|
"metadata.google.internal",
|
||||||
|
"metadata.azure.internal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(value: object) -> list[str]:
|
||||||
|
raw = _normalize_text(value)
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
||||||
|
return bool(
|
||||||
|
ip_obj.is_loopback
|
||||||
|
or ip_obj.is_link_local
|
||||||
|
or ip_obj.is_multicast
|
||||||
|
or ip_obj.is_unspecified
|
||||||
|
or ip_obj.is_reserved
|
||||||
|
or ip_obj.is_private
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
||||||
|
resolved: list[ipaddress._BaseAddress] = []
|
||||||
|
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
||||||
|
if family == socket.AF_INET:
|
||||||
|
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||||
|
elif family == socket.AF_INET6:
|
||||||
|
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||||
|
return tuple(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
||||||
|
candidate = _normalize_text(host)
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
host_ip = ipaddress.ip_address(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
||||||
|
|
||||||
|
for entry in trusted_proxies:
|
||||||
|
raw = _normalize_text(entry)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if "/" in raw:
|
||||||
|
if host_ip in ipaddress.ip_network(raw, strict=False):
|
||||||
|
return True
|
||||||
|
elif host_ip == ipaddress.ip_address(raw):
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
||||||
|
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
||||||
|
return False
|
||||||
|
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
||||||
|
if not trusted:
|
||||||
|
return False
|
||||||
|
return _is_trusted_proxy_host(client_host or "", trusted)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_notification_target_url(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
allow_private: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
raw = _normalize_text(url)
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("URL cannot be empty.")
|
||||||
|
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
raise ValueError("URL must use http:// or https://.")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError("URL must not embed credentials.")
|
||||||
|
hostname = _normalize_text(parsed.hostname).lower()
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL must include a valid host.")
|
||||||
|
|
||||||
|
allow_private_targets = (
|
||||||
|
settings.magent_allow_private_notification_targets
|
||||||
|
if allow_private is None
|
||||||
|
else bool(allow_private)
|
||||||
|
)
|
||||||
|
if hostname in _METADATA_HOSTS:
|
||||||
|
raise ValueError("Metadata service targets are not allowed.")
|
||||||
|
if hostname == "localhost" and not allow_private_targets:
|
||||||
|
raise ValueError("Local notification targets are not allowed.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
host_ip = ipaddress.ip_address(hostname)
|
||||||
|
except ValueError:
|
||||||
|
host_ip = None
|
||||||
|
|
||||||
|
if host_ip is not None:
|
||||||
|
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
||||||
|
raise ValueError("Private or local notification targets are not allowed.")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved_ips = _resolve_host_ips(hostname)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise ValueError("Host could not be resolved.") from exc
|
||||||
|
if not resolved_ips:
|
||||||
|
raise ValueError("Host could not be resolved.")
|
||||||
|
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
||||||
|
raise ValueError("Private or local notification targets are not allowed.")
|
||||||
|
return raw
|
||||||
@@ -20,6 +20,7 @@ from ..auth import (
|
|||||||
resolve_user_auth_provider,
|
resolve_user_auth_provider,
|
||||||
)
|
)
|
||||||
from ..config import settings as env_settings
|
from ..config import settings as env_settings
|
||||||
|
from ..network_security import validate_notification_target_url
|
||||||
from ..db import (
|
from ..db import (
|
||||||
delete_setting,
|
delete_setting,
|
||||||
get_all_users,
|
get_all_users,
|
||||||
@@ -121,6 +122,15 @@ def _require_recipient_email(value: object) -> str:
|
|||||||
detail="recipient_email is required and must be a valid email address",
|
detail="recipient_email is required and must be a valid email address",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_recipient_email(value: object) -> Optional[str]:
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
return None
|
||||||
|
normalized = normalize_delivery_email(value)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"magent_ssl_certificate_pem",
|
"magent_ssl_certificate_pem",
|
||||||
"magent_ssl_private_key_pem",
|
"magent_ssl_private_key_pem",
|
||||||
@@ -134,6 +144,7 @@ SENSITIVE_KEYS = {
|
|||||||
"jellyfin_api_key",
|
"jellyfin_api_key",
|
||||||
"sonarr_api_key",
|
"sonarr_api_key",
|
||||||
"radarr_api_key",
|
"radarr_api_key",
|
||||||
|
"bazarr_api_key",
|
||||||
"prowlarr_api_key",
|
"prowlarr_api_key",
|
||||||
"qbittorrent_password",
|
"qbittorrent_password",
|
||||||
}
|
}
|
||||||
@@ -149,10 +160,17 @@ URL_SETTING_KEYS = {
|
|||||||
"jellyfin_public_url",
|
"jellyfin_public_url",
|
||||||
"sonarr_base_url",
|
"sonarr_base_url",
|
||||||
"radarr_base_url",
|
"radarr_base_url",
|
||||||
|
"bazarr_base_url",
|
||||||
"prowlarr_base_url",
|
"prowlarr_base_url",
|
||||||
"qbittorrent_base_url",
|
"qbittorrent_base_url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NOTIFICATION_URL_SETTING_KEYS = {
|
||||||
|
"magent_notify_discord_webhook_url",
|
||||||
|
"magent_notify_push_base_url",
|
||||||
|
"magent_notify_webhook_url",
|
||||||
|
}
|
||||||
|
|
||||||
SETTING_KEYS: List[str] = [
|
SETTING_KEYS: List[str] = [
|
||||||
"magent_application_url",
|
"magent_application_url",
|
||||||
"magent_application_port",
|
"magent_application_port",
|
||||||
@@ -209,6 +227,9 @@ SETTING_KEYS: List[str] = [
|
|||||||
"radarr_quality_profile_id",
|
"radarr_quality_profile_id",
|
||||||
"radarr_root_folder",
|
"radarr_root_folder",
|
||||||
"radarr_qbittorrent_category",
|
"radarr_qbittorrent_category",
|
||||||
|
"bazarr_base_url",
|
||||||
|
"bazarr_api_key",
|
||||||
|
"bazarr_default_language",
|
||||||
"prowlarr_base_url",
|
"prowlarr_base_url",
|
||||||
"prowlarr_api_key",
|
"prowlarr_api_key",
|
||||||
"qbittorrent_base_url",
|
"qbittorrent_base_url",
|
||||||
@@ -227,6 +248,9 @@ SETTING_KEYS: List[str] = [
|
|||||||
"requests_cleanup_time",
|
"requests_cleanup_time",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"requests_data_source",
|
"requests_data_source",
|
||||||
|
"issue_confirmation_contact_attempts",
|
||||||
|
"issue_confirmation_interval_value",
|
||||||
|
"issue_confirmation_interval_unit",
|
||||||
"site_banner_enabled",
|
"site_banner_enabled",
|
||||||
"site_banner_message",
|
"site_banner_message",
|
||||||
"site_banner_tone",
|
"site_banner_tone",
|
||||||
@@ -234,6 +258,7 @@ SETTING_KEYS: List[str] = [
|
|||||||
"site_login_show_local_login",
|
"site_login_show_local_login",
|
||||||
"site_login_show_forgot_password",
|
"site_login_show_forgot_password",
|
||||||
"site_login_show_signup_link",
|
"site_login_show_signup_link",
|
||||||
|
"site_nav_show_requests",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -653,12 +678,38 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
changed_keys.append(key)
|
changed_keys.append(key)
|
||||||
continue
|
continue
|
||||||
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
||||||
|
if key == "issue_confirmation_contact_attempts":
|
||||||
|
try:
|
||||||
|
attempts = int(value_to_store)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
|
||||||
|
if attempts < 0 or attempts > 10:
|
||||||
|
raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
|
||||||
|
value_to_store = str(attempts)
|
||||||
|
if key == "issue_confirmation_interval_value":
|
||||||
|
try:
|
||||||
|
interval_value = int(value_to_store)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
|
||||||
|
if interval_value < 1 or interval_value > 365:
|
||||||
|
raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
|
||||||
|
value_to_store = str(interval_value)
|
||||||
|
if key == "issue_confirmation_interval_unit":
|
||||||
|
value_to_store = value_to_store.lower()
|
||||||
|
if value_to_store not in {"days", "weeks", "months"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
|
||||||
if key in URL_SETTING_KEYS and value_to_store:
|
if key in URL_SETTING_KEYS and value_to_store:
|
||||||
try:
|
try:
|
||||||
value_to_store = _normalize_service_url(value_to_store)
|
value_to_store = _normalize_service_url(value_to_store)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
friendly_key = key.replace("_", " ")
|
friendly_key = key.replace("_", " ")
|
||||||
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
||||||
|
if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
|
||||||
|
try:
|
||||||
|
value_to_store = validate_notification_target_url(value_to_store)
|
||||||
|
except ValueError as exc:
|
||||||
|
friendly_key = key.replace("_", " ")
|
||||||
|
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
||||||
set_setting(key, value_to_store)
|
set_setting(key, value_to_store)
|
||||||
updates += 1
|
updates += 1
|
||||||
changed_keys.append(key)
|
changed_keys.append(key)
|
||||||
@@ -1307,6 +1358,35 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
|
|||||||
return {"status": "ok", "username": username, "role": role}
|
return {"status": "ok", "username": username, "role": role}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{username}/email")
|
||||||
|
async def update_user_email(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
user = get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
|
||||||
|
email = _optional_recipient_email(payload.get("email"))
|
||||||
|
if email:
|
||||||
|
duplicate = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in get_all_users()
|
||||||
|
if str(candidate.get("username") or "").casefold() != username.casefold()
|
||||||
|
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(status_code=409, detail="That email address is already assigned to another user")
|
||||||
|
|
||||||
|
if not set_user_email(username, email):
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
refreshed = get_user_by_username(username)
|
||||||
|
logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email))
|
||||||
|
return {"status": "ok", "user": refreshed, "email": email}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{username}/auto-search")
|
@router.post("/users/{username}/auto-search")
|
||||||
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||||
@@ -1653,9 +1733,34 @@ async def get_invites() -> Dict[str, Any]:
|
|||||||
results = []
|
results = []
|
||||||
for invite in invites:
|
for invite in invites:
|
||||||
profile = profiles.get(invite.get("profile_id"))
|
profile = profiles.get(invite.get("profile_id"))
|
||||||
|
if not invite.get("enabled"):
|
||||||
|
operational_state = "disabled"
|
||||||
|
state_label = "Disabled"
|
||||||
|
attention_reason = "This invite has been switched off."
|
||||||
|
elif invite.get("is_expired"):
|
||||||
|
operational_state = "expired"
|
||||||
|
state_label = "Expired"
|
||||||
|
attention_reason = "The invite has passed its expiry date."
|
||||||
|
elif invite.get("remaining_uses") == 0:
|
||||||
|
operational_state = "exhausted"
|
||||||
|
state_label = "Fully used"
|
||||||
|
attention_reason = "Every permitted sign-up has been used."
|
||||||
|
elif invite.get("profile_id") is not None and (
|
||||||
|
profile is None or profile.get("is_active") is False
|
||||||
|
):
|
||||||
|
operational_state = "profile_unavailable"
|
||||||
|
state_label = "Profile unavailable"
|
||||||
|
attention_reason = "The assigned profile is missing or disabled."
|
||||||
|
else:
|
||||||
|
operational_state = "ready"
|
||||||
|
state_label = "Ready to use"
|
||||||
|
attention_reason = None
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
**invite,
|
**invite,
|
||||||
|
"operational_state": operational_state,
|
||||||
|
"state_label": state_label,
|
||||||
|
"attention_reason": attention_reason,
|
||||||
"profile": (
|
"profile": (
|
||||||
{
|
{
|
||||||
"id": profile.get("id"),
|
"id": profile.get("id"),
|
||||||
@@ -1666,7 +1771,16 @@ async def get_invites() -> Dict[str, Any]:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {"invites": results}
|
return {
|
||||||
|
"invites": results,
|
||||||
|
"summary": {
|
||||||
|
"total": len(results),
|
||||||
|
"ready": sum(1 for invite in results if invite["operational_state"] == "ready"),
|
||||||
|
"attention": sum(1 for invite in results if invite["operational_state"] != "ready"),
|
||||||
|
"used_signups": sum(int(invite.get("use_count") or 0) for invite in results),
|
||||||
|
"with_recipient": sum(1 for invite in results if invite.get("recipient_email")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invites/policy")
|
@router.get("/invites/policy")
|
||||||
@@ -1851,8 +1965,10 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
|||||||
role = _normalize_role_or_none(payload.get("role"))
|
role = _normalize_role_or_none(payload.get("role"))
|
||||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
recipient_email = _require_recipient_email(payload.get("recipient_email"))
|
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||||
send_email = bool(payload.get("send_email"))
|
send_email = bool(payload.get("send_email"))
|
||||||
|
if send_email and not recipient_email:
|
||||||
|
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||||
try:
|
try:
|
||||||
invite = create_signup_invite(
|
invite = create_signup_invite(
|
||||||
@@ -1922,8 +2038,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
role = _normalize_role_or_none(payload.get("role"))
|
role = _normalize_role_or_none(payload.get("role"))
|
||||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||||
send_email = bool(payload.get("send_email"))
|
send_email = bool(payload.get("send_email"))
|
||||||
|
if send_email and not recipient_email:
|
||||||
|
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||||
try:
|
try:
|
||||||
invite = update_signup_invite(
|
invite = update_signup_invite(
|
||||||
|
|||||||
+137
-47
@@ -7,7 +7,7 @@ import time
|
|||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
from ..db import (
|
from ..db import (
|
||||||
@@ -17,6 +17,7 @@ from ..db import (
|
|||||||
set_last_login,
|
set_last_login,
|
||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
get_users_by_username_ci,
|
get_users_by_username_ci,
|
||||||
|
get_all_users,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
set_user_email,
|
set_user_email,
|
||||||
@@ -47,8 +48,15 @@ from ..security import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from ..security import create_stream_token
|
from ..security import create_stream_token
|
||||||
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
|
from ..auth import (
|
||||||
|
clear_auth_cookies,
|
||||||
|
get_current_user,
|
||||||
|
normalize_user_auth_provider,
|
||||||
|
resolve_user_auth_provider,
|
||||||
|
set_auth_cookies,
|
||||||
|
)
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from ..network_security import request_trusts_forwarded_headers
|
||||||
from ..services.user_cache import (
|
from ..services.user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
build_jellyseerr_candidate_map,
|
||||||
extract_jellyseerr_user_email,
|
extract_jellyseerr_user_email,
|
||||||
@@ -95,13 +103,33 @@ def _require_recipient_email(value: object) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_recipient_email(value: object) -> str | None:
|
||||||
|
if value is None or not str(value).strip():
|
||||||
|
return None
|
||||||
|
return _require_recipient_email(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_account_email(value: object) -> str | None:
|
||||||
|
if value is None or not str(value).strip():
|
||||||
|
return None
|
||||||
|
normalized = normalize_delivery_email(value)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Enter a valid email address.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _auth_client_ip(request: Request) -> str:
|
def _auth_client_ip(request: Request) -> str:
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
direct_host = request.client.host if request.client else None
|
||||||
if isinstance(forwarded, str) and forwarded.strip():
|
if request_trusts_forwarded_headers(direct_host):
|
||||||
return forwarded.split(",", 1)[0].strip()
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
real = request.headers.get("x-real-ip")
|
if isinstance(forwarded, str) and forwarded.strip():
|
||||||
if isinstance(real, str) and real.strip():
|
return forwarded.split(",", 1)[0].strip()
|
||||||
return real.strip()
|
real = request.headers.get("x-real-ip")
|
||||||
|
if isinstance(real, str) and real.strip():
|
||||||
|
return real.strip()
|
||||||
if request.client and request.client.host:
|
if request.client and request.client.host:
|
||||||
return str(request.client.host)
|
return str(request.client.host)
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -358,6 +386,15 @@ def _assert_user_can_login(user: dict | None) -> None:
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
|
||||||
|
set_auth_cookies(response, token)
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"token_type": "cookie",
|
||||||
|
"user": user_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"code": invite.get("code"),
|
"code": invite.get("code"),
|
||||||
@@ -580,7 +617,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
) -> dict:
|
||||||
_enforce_login_rate_limit(request, form_data.username)
|
_enforce_login_rate_limit(request, form_data.username)
|
||||||
logger.info(
|
logger.info(
|
||||||
"login attempt provider=local username=%s client=%s",
|
"login attempt provider=local username=%s client=%s",
|
||||||
@@ -629,15 +670,19 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
|||||||
user["role"],
|
user["role"],
|
||||||
_auth_client_ip(request),
|
_auth_client_ip(request),
|
||||||
)
|
)
|
||||||
return {
|
return _auth_success_response(
|
||||||
"access_token": token,
|
response,
|
||||||
"token_type": "bearer",
|
token,
|
||||||
"user": {"username": user["username"], "role": user["role"]},
|
{"username": user["username"], "role": user["role"]},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jellyfin/login")
|
@router.post("/jellyfin/login")
|
||||||
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def jellyfin_login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
) -> dict:
|
||||||
_enforce_login_rate_limit(request, form_data.username)
|
_enforce_login_rate_limit(request, form_data.username)
|
||||||
logger.info(
|
logger.info(
|
||||||
"login attempt provider=jellyfin username=%s client=%s",
|
"login attempt provider=jellyfin username=%s client=%s",
|
||||||
@@ -668,13 +713,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
|||||||
canonical_username,
|
canonical_username,
|
||||||
_auth_client_ip(request),
|
_auth_client_ip(request),
|
||||||
)
|
)
|
||||||
return {
|
return _auth_success_response(
|
||||||
"access_token": token,
|
response,
|
||||||
"token_type": "bearer",
|
token,
|
||||||
"user": {"username": canonical_username, "role": "user"},
|
{"username": canonical_username, "role": "user"},
|
||||||
}
|
)
|
||||||
try:
|
try:
|
||||||
response = await client.authenticate_by_name(username, password)
|
auth_response = await client.authenticate_by_name(username, password)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"login upstream error provider=jellyfin username=%s client=%s",
|
"login upstream error provider=jellyfin username=%s client=%s",
|
||||||
@@ -682,7 +727,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
|||||||
_auth_client_ip(request),
|
_auth_client_ip(request),
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
if not isinstance(response, dict) or not response.get("User"):
|
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||||
_record_login_failure(request, username)
|
_record_login_failure(request, username)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||||
if not preferred_match:
|
if not preferred_match:
|
||||||
@@ -724,16 +769,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
|||||||
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
|
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
|
||||||
_auth_client_ip(request),
|
_auth_client_ip(request),
|
||||||
)
|
)
|
||||||
return {
|
return _auth_success_response(
|
||||||
"access_token": token,
|
response,
|
||||||
"token_type": "bearer",
|
token,
|
||||||
"user": {"username": canonical_username, "role": "user"},
|
{"username": canonical_username, "role": "user"},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/seerr/login")
|
@router.post("/seerr/login")
|
||||||
@router.post("/jellyseerr/login")
|
@router.post("/jellyseerr/login")
|
||||||
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def jellyseerr_login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
) -> dict:
|
||||||
_enforce_login_rate_limit(request, form_data.username)
|
_enforce_login_rate_limit(request, form_data.username)
|
||||||
logger.info(
|
logger.info(
|
||||||
"login attempt provider=seerr username=%s client=%s",
|
"login attempt provider=seerr username=%s client=%s",
|
||||||
@@ -745,7 +794,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
|||||||
if not client.configured():
|
if not client.configured():
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
|
||||||
try:
|
try:
|
||||||
response = await client.login_local(form_data.username, form_data.password)
|
auth_response = await client.login_local(form_data.username, form_data.password)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"login upstream error provider=seerr username=%s client=%s",
|
"login upstream error provider=seerr username=%s client=%s",
|
||||||
@@ -753,11 +802,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
|||||||
_auth_client_ip(request),
|
_auth_client_ip(request),
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
if not isinstance(response, dict):
|
if not isinstance(auth_response, dict):
|
||||||
_record_login_failure(request, form_data.username)
|
_record_login_failure(request, form_data.username)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
||||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
||||||
jellyseerr_email = _extract_jellyseerr_response_email(response)
|
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
||||||
ci_matches = get_users_by_username_ci(form_data.username)
|
ci_matches = get_users_by_username_ci(form_data.username)
|
||||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||||
@@ -791,11 +840,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
_auth_client_ip(request),
|
_auth_client_ip(request),
|
||||||
)
|
)
|
||||||
return {
|
return _auth_success_response(
|
||||||
"access_token": token,
|
response,
|
||||||
"token_type": "bearer",
|
token,
|
||||||
"user": {"username": canonical_username, "role": "user"},
|
{"username": canonical_username, "role": "user"},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
@@ -803,6 +852,12 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(response: Response) -> dict:
|
||||||
|
clear_auth_cookies(response)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream-token")
|
@router.get("/stream-token")
|
||||||
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
token = create_stream_token(
|
token = create_stream_token(
|
||||||
@@ -832,7 +887,7 @@ async def invite_details(code: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/signup")
|
@router.post("/signup")
|
||||||
async def signup(payload: dict) -> dict:
|
async def signup(payload: dict, response: Response) -> dict:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
invite_code = str(payload.get("invite_code") or "").strip()
|
invite_code = str(payload.get("invite_code") or "").strip()
|
||||||
@@ -908,14 +963,14 @@ async def signup(payload: dict) -> dict:
|
|||||||
duplicate_like = status_code in {400, 409}
|
duplicate_like = status_code in {400, 409}
|
||||||
if duplicate_like:
|
if duplicate_like:
|
||||||
try:
|
try:
|
||||||
response = await jellyfin_client.authenticate_by_name(username, password_value)
|
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||||
except Exception as auth_exc:
|
except Exception as auth_exc:
|
||||||
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
||||||
) from exc
|
) from exc
|
||||||
if not isinstance(response, dict) or not response.get("User"):
|
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="Jellyfin account already exists for that username.",
|
detail="Jellyfin account already exists for that username.",
|
||||||
@@ -987,17 +1042,17 @@ async def signup(payload: dict) -> dict:
|
|||||||
created_user.get("profile_id") if created_user else None,
|
created_user.get("profile_id") if created_user else None,
|
||||||
invite.get("code"),
|
invite.get("code"),
|
||||||
)
|
)
|
||||||
return {
|
return _auth_success_response(
|
||||||
"access_token": token,
|
response,
|
||||||
"token_type": "bearer",
|
token,
|
||||||
"user": {
|
{
|
||||||
"username": username,
|
"username": username,
|
||||||
"role": role,
|
"role": role,
|
||||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||||
},
|
},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password/forgot")
|
@router.post("/password/forgot")
|
||||||
@@ -1123,6 +1178,37 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile/email")
|
||||||
|
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
username = str(current_user.get("username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
|
||||||
|
email = _optional_account_email(payload.get("email"))
|
||||||
|
if email:
|
||||||
|
duplicate = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in get_all_users()
|
||||||
|
if str(candidate.get("username") or "").casefold() != username.casefold()
|
||||||
|
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="That email address is already assigned to another account.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not set_user_email(username, email):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
logger.info("User updated profile contact email: username=%s email_set=%s", username, bool(email))
|
||||||
|
return {"status": "ok", "email": email}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile/invites")
|
@router.get("/profile/invites")
|
||||||
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
username = str(current_user.get("username") or "").strip()
|
username = str(current_user.get("username") or "").strip()
|
||||||
@@ -1174,8 +1260,10 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
|||||||
label = str(label).strip() or None
|
label = str(label).strip() or None
|
||||||
if description is not None:
|
if description is not None:
|
||||||
description = str(description).strip() or None
|
description = str(description).strip() or None
|
||||||
recipient_email = _require_recipient_email(recipient_email)
|
|
||||||
send_email = bool(payload.get("send_email"))
|
send_email = bool(payload.get("send_email"))
|
||||||
|
recipient_email = _optional_recipient_email(recipient_email)
|
||||||
|
if send_email and not recipient_email:
|
||||||
|
recipient_email = _require_recipient_email(recipient_email)
|
||||||
delivery_message = str(payload.get("message") or "").strip() or None
|
delivery_message = str(payload.get("message") or "").strip() or None
|
||||||
|
|
||||||
master_invite = _get_self_service_master_invite()
|
master_invite = _get_self_service_master_invite()
|
||||||
@@ -1264,8 +1352,10 @@ async def update_profile_invite(
|
|||||||
label = str(label).strip() or None
|
label = str(label).strip() or None
|
||||||
if description is not None:
|
if description is not None:
|
||||||
description = str(description).strip() or None
|
description = str(description).strip() or None
|
||||||
recipient_email = _require_recipient_email(recipient_email)
|
|
||||||
send_email = bool(payload.get("send_email"))
|
send_email = bool(payload.get("send_email"))
|
||||||
|
recipient_email = _optional_recipient_email(recipient_email)
|
||||||
|
if send_email and not recipient_email:
|
||||||
|
recipient_email = _require_recipient_email(recipient_email)
|
||||||
delivery_message = str(payload.get("message") or "").strip() or None
|
delivery_message = str(payload.get("message") or "").strip() or None
|
||||||
|
|
||||||
master_invite = _get_self_service_master_invite()
|
master_invite = _get_self_service_master_invite()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from fastapi.responses import StreamingResponse
|
|||||||
|
|
||||||
from ..auth import get_current_user_event_stream
|
from ..auth import get_current_user_event_stream
|
||||||
from . import requests as requests_router
|
from . import requests as requests_router
|
||||||
from .status import services_status
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
@@ -85,9 +84,7 @@ async def events_stream(
|
|||||||
async def event_generator():
|
async def event_generator():
|
||||||
yield "retry: 2000\n\n"
|
yield "retry: 2000\n\n"
|
||||||
last_recent_signature: Optional[str] = None
|
last_recent_signature: Optional[str] = None
|
||||||
last_services_signature: Optional[str] = None
|
|
||||||
next_recent_at = 0.0
|
next_recent_at = 0.0
|
||||||
next_services_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
heartbeat_counter = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -129,27 +126,6 @@ async def events_stream(
|
|||||||
yield _sse_json(payload)
|
yield _sse_json(payload)
|
||||||
sent_any = True
|
sent_any = True
|
||||||
|
|
||||||
if now >= next_services_at:
|
|
||||||
next_services_at = now + 30.0
|
|
||||||
try:
|
|
||||||
status_payload = await services_status()
|
|
||||||
payload = {
|
|
||||||
"type": "home_services",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"status": status_payload,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "home_services",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_services_signature:
|
|
||||||
last_services_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
if sent_any:
|
||||||
heartbeat_counter = 0
|
heartbeat_counter = 0
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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)])
|
||||||
@@ -17,6 +18,10 @@ async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(
|
|||||||
)
|
)
|
||||||
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"}:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from ..services.operation_progress import get_operation
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/operations",
|
||||||
|
tags=["operations"],
|
||||||
|
dependencies=[Depends(get_current_user)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{operation_id}")
|
||||||
|
async def operation_status(operation_id: str) -> dict:
|
||||||
|
operation = get_operation(operation_id)
|
||||||
|
if not operation:
|
||||||
|
raise HTTPException(status_code=404, detail="Operation not found")
|
||||||
|
return operation
|
||||||
File diff suppressed because it is too large
Load Diff
+1578
-168
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|||||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"showRequests": bool(runtime.site_nav_show_requests),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if include_changelog:
|
if include_changelog:
|
||||||
info["changelog"] = (CHANGELOG or "").strip()
|
info["changelog"] = (CHANGELOG or "").strip()
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ from typing import Any, Dict
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import require_admin
|
||||||
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(get_current_user)])
|
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
|
||||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||||
@@ -26,12 +27,42 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
|||||||
return {"name": name, "status": "down", "message": str(exc)}
|
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
|
||||||
@@ -60,6 +91,13 @@ 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(),
|
||||||
@@ -71,13 +109,7 @@ 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(
|
services.append(await _check_qbittorrent(qbittorrent))
|
||||||
await _check(
|
|
||||||
"qBittorrent",
|
|
||||||
qbittorrent.configured(),
|
|
||||||
qbittorrent.get_app_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Jellyfin",
|
"Jellyfin",
|
||||||
@@ -101,6 +133,7 @@ async def test_service(service: str) -> Dict[str, Any]:
|
|||||||
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
|
||||||
@@ -121,11 +154,18 @@ async def test_service(service: str) -> Dict[str, Any]:
|
|||||||
),
|
),
|
||||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||||
"radarr": ("Radarr", radarr.configured(), radarr.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),
|
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||||
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
|
|
||||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if service_key == "qbittorrent":
|
||||||
|
return await _check_qbittorrent(qbittorrent)
|
||||||
|
|
||||||
if service_key not in checks:
|
if service_key not in checks:
|
||||||
raise HTTPException(status_code=404, detail="Unknown service")
|
raise HTTPException(status_code=404, detail="Unknown service")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ _INT_FIELDS = {
|
|||||||
"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",
|
"magent_notify_email_smtp_port",
|
||||||
}
|
}
|
||||||
_BOOL_FIELDS = {
|
_BOOL_FIELDS = {
|
||||||
@@ -39,6 +41,7 @@ _BOOL_FIELDS = {
|
|||||||
"site_login_show_local_login",
|
"site_login_show_local_login",
|
||||||
"site_login_show_forgot_password",
|
"site_login_show_forgot_password",
|
||||||
"site_login_show_signup_link",
|
"site_login_show_signup_link",
|
||||||
|
"site_nav_show_requests",
|
||||||
}
|
}
|
||||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ def _create_token(
|
|||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
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")
|
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||||
@@ -55,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
|
|||||||
|
|
||||||
|
|
||||||
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])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from ..clients.radarr import RadarrClient
|
|||||||
from ..clients.sonarr import SonarrClient
|
from ..clients.sonarr import SonarrClient
|
||||||
from ..config import settings as env_settings
|
from ..config import settings as env_settings
|
||||||
from ..db import get_database_diagnostics
|
from ..db import get_database_diagnostics
|
||||||
|
from ..network_security import validate_notification_target_url
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
||||||
|
|
||||||
@@ -97,7 +98,12 @@ def _config_status(detail: str) -> str:
|
|||||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
||||||
return False, "Discord notifications are disabled."
|
return False, "Discord notifications are disabled."
|
||||||
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
|
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
||||||
|
if webhook_url:
|
||||||
|
try:
|
||||||
|
validate_notification_target_url(webhook_url)
|
||||||
|
except ValueError as exc:
|
||||||
|
return False, str(exc)
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
return False, "Discord webhook URL is required."
|
return False, "Discord webhook URL is required."
|
||||||
|
|
||||||
@@ -113,7 +119,12 @@ def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
|||||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
||||||
return False, "Generic webhook notifications are disabled."
|
return False, "Generic webhook notifications are disabled."
|
||||||
if _clean_text(runtime.magent_notify_webhook_url):
|
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
||||||
|
if webhook_url:
|
||||||
|
try:
|
||||||
|
validate_notification_target_url(webhook_url)
|
||||||
|
except ValueError as exc:
|
||||||
|
return False, str(exc)
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
return False, "Generic webhook URL is required."
|
return False, "Generic webhook URL is required."
|
||||||
|
|
||||||
@@ -123,11 +134,21 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
|||||||
return False, "Push notifications are disabled."
|
return False, "Push notifications are disabled."
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||||
if provider == "ntfy":
|
if provider == "ntfy":
|
||||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
|
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||||
|
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
||||||
|
try:
|
||||||
|
validate_notification_target_url(push_url)
|
||||||
|
except ValueError as exc:
|
||||||
|
return False, str(exc)
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
return False, "ntfy requires a base URL and topic."
|
return False, "ntfy requires a base URL and topic."
|
||||||
if provider == "gotify":
|
if provider == "gotify":
|
||||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
|
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||||
|
if push_url and _clean_text(runtime.magent_notify_push_token):
|
||||||
|
try:
|
||||||
|
validate_notification_target_url(push_url)
|
||||||
|
except ValueError as exc:
|
||||||
|
return False, str(exc)
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
return False, "Gotify requires a base URL and app token."
|
return False, "Gotify requires a base URL and app token."
|
||||||
if provider == "pushover":
|
if provider == "pushover":
|
||||||
@@ -135,7 +156,12 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
|||||||
return True, "ok"
|
return True, "ok"
|
||||||
return False, "Pushover requires an application token and user key."
|
return False, "Pushover requires an application token and user key."
|
||||||
if provider == "webhook":
|
if provider == "webhook":
|
||||||
if _clean_text(runtime.magent_notify_push_base_url):
|
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||||
|
if push_url:
|
||||||
|
try:
|
||||||
|
validate_notification_target_url(push_url)
|
||||||
|
except ValueError as exc:
|
||||||
|
return False, str(exc)
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
return False, "Webhook relay requires a target URL."
|
return False, "Webhook relay requires a target URL."
|
||||||
if provider == "telegram":
|
if provider == "telegram":
|
||||||
@@ -190,6 +216,7 @@ async def _run_http_post(
|
|||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
headers: Optional[Dict[str, str]] = None,
|
headers: Optional[Dict[str, str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
validate_notification_target_url(url)
|
||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
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 = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from email.policy import SMTP as SMTP_POLICY
|
|||||||
from email.utils import formataddr, formatdate, make_msgid
|
from email.utils import formataddr, formatdate, make_msgid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from ..build_info import BUILD_NUMBER
|
from ..build_info import BUILD_NUMBER
|
||||||
from ..config import settings as env_settings
|
from ..config import settings as env_settings
|
||||||
@@ -512,6 +513,40 @@ def _build_default_base_url() -> str:
|
|||||||
return f"http://localhost:{port}"
|
return f"http://localhost:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_mail_hostname(*, from_address: str) -> str:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
candidates = (
|
||||||
|
runtime.magent_application_url,
|
||||||
|
runtime.magent_proxy_base_url,
|
||||||
|
env_settings.cors_allow_origin,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
normalized = _normalize_display_text(candidate)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
|
||||||
|
hostname = _normalize_display_text(parsed.hostname)
|
||||||
|
if hostname and "." in hostname:
|
||||||
|
return hostname
|
||||||
|
domain = _normalize_display_text(from_address.split("@", 1)[1] if "@" in from_address else None)
|
||||||
|
if domain and "." in domain:
|
||||||
|
return domain
|
||||||
|
return "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_transactional_headers(
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
from_name: str,
|
||||||
|
from_address: str,
|
||||||
|
) -> None:
|
||||||
|
message["Reply-To"] = formataddr((from_name, from_address))
|
||||||
|
message["Organization"] = env_settings.app_name
|
||||||
|
message["X-Mailer"] = f"{env_settings.app_name}/{BUILD_NUMBER}"
|
||||||
|
message["Auto-Submitted"] = "auto-generated"
|
||||||
|
message["X-Auto-Response-Suppress"] = "All"
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_full_html_document(value: str) -> bool:
|
def _looks_like_full_html_document(value: str) -> bool:
|
||||||
probe = value.lstrip().lower()
|
probe = value.lstrip().lower()
|
||||||
return probe.startswith("<!doctype") or probe.startswith("<html") or "<body" in probe[:300]
|
return probe.startswith("<!doctype") or probe.startswith("<html") or "<body" in probe[:300]
|
||||||
@@ -918,8 +953,10 @@ def smtp_email_delivery_warning() -> Optional[str]:
|
|||||||
if host.endswith(".mail.protection.outlook.com") and not (username and password):
|
if host.endswith(".mail.protection.outlook.com") and not (username and password):
|
||||||
return (
|
return (
|
||||||
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
|
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
|
||||||
"confirm mailbox delivery. For reliable delivery, use smtp.office365.com:587 with "
|
"confirm mailbox delivery, and suspicious messages can still be filtered. For reliable "
|
||||||
"SMTP credentials or configure a verified Exchange relay connector."
|
"delivery, use smtp.office365.com:587 with SMTP credentials or configure a verified "
|
||||||
|
"Exchange relay connector and make sure SPF, DKIM, and DMARC are healthy for the "
|
||||||
|
"sender domain."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -986,8 +1023,9 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
delivery_warning = smtp_email_delivery_warning()
|
delivery_warning = smtp_email_delivery_warning()
|
||||||
if not host or not from_address:
|
if not host or not from_address:
|
||||||
raise RuntimeError("SMTP email settings are incomplete.")
|
raise RuntimeError("SMTP email settings are incomplete.")
|
||||||
|
local_hostname = _derive_mail_hostname(from_address=from_address)
|
||||||
logger.info(
|
logger.info(
|
||||||
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s",
|
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
|
||||||
recipient_email,
|
recipient_email,
|
||||||
from_address,
|
from_address,
|
||||||
host,
|
host,
|
||||||
@@ -996,6 +1034,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
use_ssl,
|
use_ssl,
|
||||||
bool(username and password),
|
bool(username and password),
|
||||||
subject,
|
subject,
|
||||||
|
local_hostname,
|
||||||
)
|
)
|
||||||
if delivery_warning:
|
if delivery_warning:
|
||||||
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
||||||
@@ -1009,6 +1048,11 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
|
message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
|
||||||
else:
|
else:
|
||||||
message["Message-ID"] = make_msgid()
|
message["Message-ID"] = make_msgid()
|
||||||
|
_add_transactional_headers(
|
||||||
|
message,
|
||||||
|
from_name=from_name,
|
||||||
|
from_address=from_address,
|
||||||
|
)
|
||||||
message.set_content(body_text or _strip_html_for_text(body_html))
|
message.set_content(body_text or _strip_html_for_text(body_html))
|
||||||
if body_html.strip():
|
if body_html.strip():
|
||||||
message.add_alternative(body_html, subtype="html")
|
message.add_alternative(body_html, subtype="html")
|
||||||
@@ -1027,7 +1071,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
)
|
)
|
||||||
|
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
|
with smtplib.SMTP_SSL(host, port, timeout=20, local_hostname=local_hostname) as smtp:
|
||||||
logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
|
logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
|
||||||
if username and password:
|
if username and password:
|
||||||
smtp.login(username, password)
|
smtp.login(username, password)
|
||||||
@@ -1047,7 +1091,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
)
|
)
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
with smtplib.SMTP(host, port, timeout=20) as smtp:
|
with smtplib.SMTP(host, port, timeout=20, local_hostname=local_hostname) as smtp:
|
||||||
logger.debug("smtp connection opened host=%s port=%s", host, port)
|
logger.debug("smtp connection opened host=%s port=%s", host, port)
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
if use_tls:
|
if use_tls:
|
||||||
@@ -1121,6 +1165,38 @@ async def send_templated_email(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_generic_email(
|
||||||
|
*,
|
||||||
|
recipient_email: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str = "",
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
ready, detail = smtp_email_config_ready()
|
||||||
|
if not ready:
|
||||||
|
raise RuntimeError(detail)
|
||||||
|
resolved_email = _normalize_email(recipient_email)
|
||||||
|
if not resolved_email:
|
||||||
|
raise RuntimeError("A valid recipient email is required.")
|
||||||
|
receipt = await asyncio.to_thread(
|
||||||
|
_send_email_sync,
|
||||||
|
recipient_email=resolved_email,
|
||||||
|
subject=subject.strip() or f"{env_settings.app_name} notification",
|
||||||
|
body_text=body_text.strip(),
|
||||||
|
body_html=body_html.strip(),
|
||||||
|
)
|
||||||
|
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
|
||||||
|
return {
|
||||||
|
"recipient_email": resolved_email,
|
||||||
|
"subject": subject.strip() or f"{env_settings.app_name} notification",
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in receipt.items()
|
||||||
|
if key in {"provider_message_id", "provider_internal_id", "data_response"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
|
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
|
||||||
ready, detail = smtp_email_config_ready()
|
ready, detail = smtp_email_config_ready()
|
||||||
if not ready:
|
if not ready:
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import settings as env_settings
|
||||||
|
from ..db import get_setting
|
||||||
|
from ..network_security import validate_notification_target_url
|
||||||
|
from ..runtime import get_runtime_settings
|
||||||
|
from .invite_email import send_generic_email
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any, fallback: str = "") -> str:
|
||||||
|
if value is None:
|
||||||
|
return fallback
|
||||||
|
if isinstance(value, str):
|
||||||
|
trimmed = value.strip()
|
||||||
|
return trimmed if trimmed else fallback
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_emails(value: str) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
||||||
|
return [entry for entry in parts if entry and "@" in entry]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_app_url() -> str:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
for candidate in (
|
||||||
|
runtime.magent_application_url,
|
||||||
|
runtime.magent_proxy_base_url,
|
||||||
|
env_settings.cors_allow_origin,
|
||||||
|
):
|
||||||
|
normalized = _clean_text(candidate)
|
||||||
|
if normalized:
|
||||||
|
return normalized.rstrip("/")
|
||||||
|
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
||||||
|
return f"http://localhost:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_item_url(item_id: int) -> str:
|
||||||
|
return f"{_resolve_app_url()}/portal?item={item_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
validate_notification_target_url(url)
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = response.text
|
||||||
|
return {"status_code": response.status_code, "body": body}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
||||||
|
runtime.discord_webhook_url
|
||||||
|
)
|
||||||
|
if not webhook:
|
||||||
|
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
||||||
|
data = {
|
||||||
|
"content": f"**{title}**\n{message}",
|
||||||
|
"embeds": [
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"description": message,
|
||||||
|
"fields": [
|
||||||
|
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
||||||
|
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
||||||
|
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
||||||
|
],
|
||||||
|
"url": _clean_text(payload.get("item_url")),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = await _http_post_json(webhook, data)
|
||||||
|
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
||||||
|
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
||||||
|
if not bot_token or not chat_id:
|
||||||
|
return {"status": "skipped", "detail": "Telegram is not configured."}
|
||||||
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||||
|
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
||||||
|
result = await _http_post_json(url, payload)
|
||||||
|
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
||||||
|
if not webhook:
|
||||||
|
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
||||||
|
result = await _http_post_json(webhook, payload)
|
||||||
|
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||||
|
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||||
|
token = _clean_text(runtime.magent_notify_push_token)
|
||||||
|
topic = _clean_text(runtime.magent_notify_push_topic)
|
||||||
|
if provider == "ntfy":
|
||||||
|
if not base_url or not topic:
|
||||||
|
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
||||||
|
validate_notification_target_url(base_url)
|
||||||
|
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
||||||
|
headers = {"Title": title, "Tags": "magent,portal"}
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
|
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
||||||
|
if provider == "gotify":
|
||||||
|
if not base_url or not token:
|
||||||
|
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
||||||
|
validate_notification_target_url(base_url)
|
||||||
|
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
||||||
|
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
||||||
|
result = await _http_post_json(url, body)
|
||||||
|
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
||||||
|
if provider == "pushover":
|
||||||
|
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
||||||
|
if not token or not user_key:
|
||||||
|
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
||||||
|
form = {"token": token, "user": user_key, "title": title, "message": message}
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
|
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
||||||
|
if provider == "discord":
|
||||||
|
return await _send_discord(title, message, payload)
|
||||||
|
if provider == "telegram":
|
||||||
|
return await _send_telegram(title, message)
|
||||||
|
if provider == "webhook":
|
||||||
|
return await _send_webhook(payload)
|
||||||
|
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
||||||
|
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
||||||
|
if fallback and fallback not in recipients:
|
||||||
|
recipients.append(fallback)
|
||||||
|
if not recipients:
|
||||||
|
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
||||||
|
|
||||||
|
body_text = (
|
||||||
|
f"{title}\n\n"
|
||||||
|
f"{message}\n\n"
|
||||||
|
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
||||||
|
f"Status: {_clean_text(payload.get('status'))}\n"
|
||||||
|
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
||||||
|
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
||||||
|
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
||||||
|
)
|
||||||
|
body_html = (
|
||||||
|
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
||||||
|
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
||||||
|
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
||||||
|
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
||||||
|
"</table>"
|
||||||
|
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
deliveries: list[Dict[str, Any]] = []
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
result = await send_generic_email(
|
||||||
|
recipient_email=recipient,
|
||||||
|
subject=title,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
)
|
||||||
|
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
||||||
|
except Exception as exc:
|
||||||
|
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
||||||
|
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
||||||
|
if successful:
|
||||||
|
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
||||||
|
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_portal_notification(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
item: Dict[str, Any],
|
||||||
|
actor_username: str,
|
||||||
|
actor_role: str,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
if not runtime.magent_notify_enabled:
|
||||||
|
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
||||||
|
|
||||||
|
item_id = int(item.get("id") or 0)
|
||||||
|
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
||||||
|
message_lines = [
|
||||||
|
f"Event: {event_type}",
|
||||||
|
f"Actor: {actor_username} ({actor_role})",
|
||||||
|
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
||||||
|
]
|
||||||
|
if note:
|
||||||
|
message_lines.append(f"Note: {note}")
|
||||||
|
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
||||||
|
message = "\n".join(message_lines)
|
||||||
|
payload = {
|
||||||
|
"type": "portal.notification",
|
||||||
|
"event": event_type,
|
||||||
|
"item_id": item_id,
|
||||||
|
"item_url": _portal_item_url(item_id),
|
||||||
|
"kind": _clean_text(item.get("kind")),
|
||||||
|
"status": _clean_text(item.get("status")),
|
||||||
|
"priority": _clean_text(item.get("priority")),
|
||||||
|
"requested_by": _clean_text(item.get("created_by_username")),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"actor_role": actor_role,
|
||||||
|
"note": note or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
channels: Dict[str, Dict[str, Any]] = {}
|
||||||
|
if runtime.magent_notify_discord_enabled:
|
||||||
|
try:
|
||||||
|
channels["discord"] = await _send_discord(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["discord"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_telegram_enabled:
|
||||||
|
try:
|
||||||
|
channels["telegram"] = await _send_telegram(title, message)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_webhook_enabled:
|
||||||
|
try:
|
||||||
|
channels["webhook"] = await _send_webhook(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_push_enabled:
|
||||||
|
try:
|
||||||
|
channels["push"] = await _send_push(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["push"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_email_enabled:
|
||||||
|
try:
|
||||||
|
channels["email"] = await _send_email(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["email"] = {"status": "error", "detail": str(exc)}
|
||||||
|
|
||||||
|
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
||||||
|
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
||||||
|
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
||||||
|
logger.info(
|
||||||
|
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
||||||
|
event_type,
|
||||||
|
item_id,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
)
|
||||||
|
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
||||||
|
if not channels:
|
||||||
|
overall = "skipped"
|
||||||
|
return {"status": overall, "channels": channels}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
||||||
|
_OPERATION_TTL_SECONDS = 15 * 60
|
||||||
|
_MAX_OPERATIONS = 500
|
||||||
|
_MAX_EVENTS = 60
|
||||||
|
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
||||||
|
"magent_operation_id", default=None
|
||||||
|
)
|
||||||
|
_operations: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_locked(now_monotonic: float) -> None:
|
||||||
|
expired = [
|
||||||
|
operation_id
|
||||||
|
for operation_id, operation in _operations.items()
|
||||||
|
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
||||||
|
]
|
||||||
|
for operation_id in expired:
|
||||||
|
_operations.pop(operation_id, None)
|
||||||
|
if len(_operations) <= _MAX_OPERATIONS:
|
||||||
|
return
|
||||||
|
oldest = sorted(
|
||||||
|
_operations,
|
||||||
|
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
||||||
|
)
|
||||||
|
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
||||||
|
_operations.pop(operation_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
now_iso = _now_iso()
|
||||||
|
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
||||||
|
with _lock:
|
||||||
|
_prune_locked(now_monotonic)
|
||||||
|
_operations[operation_id] = {
|
||||||
|
"id": operation_id,
|
||||||
|
"label": normalized_label,
|
||||||
|
"path": path,
|
||||||
|
"status": "running",
|
||||||
|
"started_at": now_iso,
|
||||||
|
"updated_at": now_iso,
|
||||||
|
"updated_monotonic": now_monotonic,
|
||||||
|
"duration_ms": None,
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"service": "Magent",
|
||||||
|
"state": "complete",
|
||||||
|
"message": "Magent received the action.",
|
||||||
|
"started_at": now_iso,
|
||||||
|
"finished_at": now_iso,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"status_code": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return _current_operation_id.set(operation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_operation(token: Token) -> None:
|
||||||
|
_current_operation_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
|
||||||
|
operation_id = _current_operation_id.get()
|
||||||
|
if not operation_id:
|
||||||
|
return None
|
||||||
|
event_id = uuid.uuid4().hex
|
||||||
|
now_iso = _now_iso()
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
operation = _operations.get(operation_id)
|
||||||
|
if not operation:
|
||||||
|
return None
|
||||||
|
operation["events"].append(
|
||||||
|
{
|
||||||
|
"id": event_id,
|
||||||
|
"service": service,
|
||||||
|
"state": "active",
|
||||||
|
"message": message or f"Contacting {service}…",
|
||||||
|
"started_at": now_iso,
|
||||||
|
"finished_at": None,
|
||||||
|
"duration_ms": None,
|
||||||
|
"status_code": None,
|
||||||
|
"started_monotonic": now_monotonic,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||||
|
operation["updated_at"] = now_iso
|
||||||
|
operation["updated_monotonic"] = now_monotonic
|
||||||
|
return event_id
|
||||||
|
|
||||||
|
|
||||||
|
def finish_remote_call(
|
||||||
|
event_id: Optional[str],
|
||||||
|
*,
|
||||||
|
success: bool,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
operation_id = _current_operation_id.get()
|
||||||
|
if not operation_id or not event_id:
|
||||||
|
return
|
||||||
|
now_iso = _now_iso()
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
operation = _operations.get(operation_id)
|
||||||
|
if not operation:
|
||||||
|
return
|
||||||
|
event = next(
|
||||||
|
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||||
|
event["state"] = "complete" if success else "error"
|
||||||
|
event["finished_at"] = now_iso
|
||||||
|
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||||
|
event["status_code"] = status_code
|
||||||
|
event["message"] = message or (
|
||||||
|
f"{event['service']} responded successfully."
|
||||||
|
if success
|
||||||
|
else f"{event['service']} returned an error."
|
||||||
|
)
|
||||||
|
operation["updated_at"] = now_iso
|
||||||
|
operation["updated_monotonic"] = now_monotonic
|
||||||
|
|
||||||
|
|
||||||
|
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
|
||||||
|
now_iso = _now_iso()
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
operation = _operations.get(operation_id)
|
||||||
|
if not operation:
|
||||||
|
return
|
||||||
|
for event in operation["events"]:
|
||||||
|
if event.get("state") == "active":
|
||||||
|
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||||
|
event["state"] = "error"
|
||||||
|
event["finished_at"] = now_iso
|
||||||
|
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||||
|
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
|
||||||
|
started = datetime.fromisoformat(str(operation["started_at"]))
|
||||||
|
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
|
||||||
|
operation["status"] = "complete" if success else "error"
|
||||||
|
operation["status_code"] = status_code
|
||||||
|
operation["duration_ms"] = round(duration_ms, 1)
|
||||||
|
operation["updated_at"] = now_iso
|
||||||
|
operation["updated_monotonic"] = now_monotonic
|
||||||
|
operation["events"].append(
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"service": "Magent",
|
||||||
|
"state": "complete" if success else "error",
|
||||||
|
"message": (
|
||||||
|
"Magent finished processing the action."
|
||||||
|
if success
|
||||||
|
else "Magent could not complete the action."
|
||||||
|
),
|
||||||
|
"started_at": now_iso,
|
||||||
|
"finished_at": now_iso,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"status_code": status_code,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||||
|
|
||||||
|
|
||||||
|
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
normalized = normalize_operation_id(operation_id)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
with _lock:
|
||||||
|
operation = _operations.get(normalized)
|
||||||
|
if not operation:
|
||||||
|
return None
|
||||||
|
result = deepcopy(operation)
|
||||||
|
result.pop("updated_monotonic", None)
|
||||||
|
for event in result.get("events", []):
|
||||||
|
event.pop("started_monotonic", None)
|
||||||
|
return result
|
||||||
@@ -15,8 +15,10 @@ from ..clients.qbittorrent import QBittorrentClient
|
|||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..db import (
|
from ..db import (
|
||||||
save_snapshot,
|
save_snapshot,
|
||||||
|
get_recent_actions,
|
||||||
get_request_cache_payload,
|
get_request_cache_payload,
|
||||||
get_request_cache_by_id,
|
get_request_cache_by_id,
|
||||||
|
get_request_download_evidence,
|
||||||
get_recent_snapshots,
|
get_recent_snapshots,
|
||||||
get_setting,
|
get_setting,
|
||||||
set_setting,
|
set_setting,
|
||||||
@@ -30,6 +32,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
||||||
_jellyfin_scan_key = "jellyfin_scan_last_at"
|
_jellyfin_scan_key = "jellyfin_scan_last_at"
|
||||||
|
REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
|
||||||
|
REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
|
||||||
|
|
||||||
|
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
@@ -58,6 +62,22 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None:
|
||||||
|
"""Use the collector's authoritative identity when cached Seerr metadata is sparse."""
|
||||||
|
if not isinstance(arr_item, dict):
|
||||||
|
return
|
||||||
|
if snapshot.title in {None, "", "Unknown"}:
|
||||||
|
title = arr_item.get("title") or arr_item.get("seriesTitle")
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
snapshot.title = title.strip()
|
||||||
|
if not snapshot.year:
|
||||||
|
year = arr_item.get("year")
|
||||||
|
try:
|
||||||
|
snapshot.year = int(year) if year else snapshot.year
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _normalize_media_title(value: Any) -> Optional[str]:
|
def _normalize_media_title(value: Any) -> Optional[str]:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return None
|
return None
|
||||||
@@ -206,7 +226,20 @@ async def _get_seerr_media_details(
|
|||||||
|
|
||||||
|
|
||||||
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
||||||
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
|
collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
|
||||||
|
collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
|
||||||
|
collector_has_file = bool(
|
||||||
|
isinstance(collector_item, dict)
|
||||||
|
and (
|
||||||
|
collector_item.get("hasFile")
|
||||||
|
or snapshot.request_type == RequestType.tv
|
||||||
|
and isinstance(collector_stats, dict)
|
||||||
|
and collector_stats.get("episodeFileCount")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
|
||||||
|
snapshot.state == NormalizedState.importing and collector_has_file
|
||||||
|
):
|
||||||
return
|
return
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
@@ -222,8 +255,9 @@ async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
|||||||
pass
|
pass
|
||||||
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
|
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
|
||||||
if previous:
|
if previous:
|
||||||
prev_state = previous[0].get("state")
|
previous_payload = previous[0].get("payload") or {}
|
||||||
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
|
previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
|
||||||
|
if previous_jellyfin.get("found"):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await client.refresh_library()
|
await client.refresh_library()
|
||||||
@@ -300,6 +334,43 @@ def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]:
|
|||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_availability(episodes: Any) -> Dict[str, Any]:
|
||||||
|
if not isinstance(episodes, list):
|
||||||
|
return {"available": 0, "missing": 0, "total": 0, "seasons": []}
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
season_rows: Dict[int, Dict[str, Any]] = {}
|
||||||
|
for episode in episodes:
|
||||||
|
if not isinstance(episode, dict) or not episode.get("monitored", True):
|
||||||
|
continue
|
||||||
|
air_date = episode.get("airDateUtc")
|
||||||
|
if isinstance(air_date, str):
|
||||||
|
try:
|
||||||
|
aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
aired_at = None
|
||||||
|
if aired_at and aired_at > now:
|
||||||
|
continue
|
||||||
|
season_number = episode.get("seasonNumber")
|
||||||
|
if not isinstance(season_number, int):
|
||||||
|
continue
|
||||||
|
row = season_rows.setdefault(
|
||||||
|
season_number,
|
||||||
|
{"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0},
|
||||||
|
)
|
||||||
|
row["total"] += 1
|
||||||
|
if episode.get("hasFile"):
|
||||||
|
row["available"] += 1
|
||||||
|
else:
|
||||||
|
row["missing"] += 1
|
||||||
|
seasons = [season_rows[key] for key in sorted(season_rows)]
|
||||||
|
return {
|
||||||
|
"available": sum(int(row["available"]) for row in seasons),
|
||||||
|
"missing": sum(int(row["missing"]) for row in seasons),
|
||||||
|
"total": sum(int(row["total"]) for row in seasons),
|
||||||
|
"seasons": seasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
if not torrents:
|
if not torrents:
|
||||||
return {"state": "idle", "message": "0 active downloads."}
|
return {"state": "idle", "message": "0 active downloads."}
|
||||||
@@ -344,6 +415,546 @@ def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[st
|
|||||||
return f"https://image.tmdb.org/t/p/{size}{path}"
|
return f"https://image.tmdb.org/t/p/{size}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
|
||||||
|
progress = torrent.get("progress")
|
||||||
|
try:
|
||||||
|
numeric = float(progress)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
numeric = -1
|
||||||
|
if 0 <= numeric <= 1:
|
||||||
|
return round(numeric * 100, 1)
|
||||||
|
try:
|
||||||
|
size = float(torrent.get("size"))
|
||||||
|
amount_left = float(torrent.get("amount_left"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if size <= 0:
|
||||||
|
return None
|
||||||
|
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_action_time(value: Any) -> Optional[datetime]:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
for action in get_recent_actions(request_id, 25):
|
||||||
|
if action.get("action_id") not in REPAIR_ACTION_IDS:
|
||||||
|
continue
|
||||||
|
created_at = _parse_action_time(action.get("created_at"))
|
||||||
|
if created_at is None:
|
||||||
|
continue
|
||||||
|
age_seconds = (current_time - created_at).total_seconds()
|
||||||
|
if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
|
||||||
|
return action
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_repair_activity(
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
action: Optional[Dict[str, Any]],
|
||||||
|
arr_state: str,
|
||||||
|
arr_details: Dict[str, Any],
|
||||||
|
download: Dict[str, Any],
|
||||||
|
jellyfin_found: bool,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if not action:
|
||||||
|
return None
|
||||||
|
|
||||||
|
action_id = str(action.get("action_id") or "")
|
||||||
|
collector = (
|
||||||
|
"Bazarr"
|
||||||
|
if action_id == "repair_subtitles"
|
||||||
|
else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
|
||||||
|
)
|
||||||
|
action_ok = str(action.get("status") or "").lower() == "ok"
|
||||||
|
action_message = str(action.get("message") or "The repair action was recorded.")
|
||||||
|
download_state = str(download.get("state") or "not_started")
|
||||||
|
download_visible = bool(download.get("visible"))
|
||||||
|
availability = arr_details.get("availability")
|
||||||
|
if not isinstance(availability, dict):
|
||||||
|
availability = {}
|
||||||
|
missing = int(availability.get("missing") or 0)
|
||||||
|
total = int(availability.get("total") or 0)
|
||||||
|
collection_complete = arr_state == "available" and (
|
||||||
|
snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
submitted_step = {
|
||||||
|
"id": "submitted",
|
||||||
|
"label": "Repair requested",
|
||||||
|
"state": "complete",
|
||||||
|
"detail": "Magent recorded the issue and started the selected repair.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not action_ok:
|
||||||
|
return {
|
||||||
|
"visible": True,
|
||||||
|
"actionId": action_id,
|
||||||
|
"state": "attention",
|
||||||
|
"headline": "Repair needs attention",
|
||||||
|
"message": action_message,
|
||||||
|
"service": collector,
|
||||||
|
"updatedAt": action.get("created_at"),
|
||||||
|
"steps": [
|
||||||
|
submitted_step,
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"label": f"{collector} hand-off",
|
||||||
|
"state": "attention",
|
||||||
|
"detail": action_message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if action_id == "repair_subtitles":
|
||||||
|
return {
|
||||||
|
"visible": True,
|
||||||
|
"actionId": action_id,
|
||||||
|
"state": "searching",
|
||||||
|
"headline": "Subtitle repair is running",
|
||||||
|
"message": (
|
||||||
|
f"{action_message} Bazarr is checking the configured subtitle providers; "
|
||||||
|
"the issue can be confirmed once the replacement track is available."
|
||||||
|
),
|
||||||
|
"service": collector,
|
||||||
|
"updatedAt": action.get("created_at"),
|
||||||
|
"steps": [
|
||||||
|
submitted_step,
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"label": "Bazarr accepted the search",
|
||||||
|
"state": "complete",
|
||||||
|
"detail": action_message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "result",
|
||||||
|
"label": "Subtitle result",
|
||||||
|
"state": "active",
|
||||||
|
"detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if collection_complete:
|
||||||
|
headline = "Repair collected"
|
||||||
|
message = (
|
||||||
|
f"{collector} now reports the replacement file as collected. "
|
||||||
|
+ (
|
||||||
|
"It is also available in Grizzlyflix."
|
||||||
|
if jellyfin_found
|
||||||
|
else "Grizzlyflix is indexing the updated file now."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state = "complete" if jellyfin_found else "indexing"
|
||||||
|
download_step_state = "complete"
|
||||||
|
download_step_detail = f"{collector} reports the replacement file as collected and imported."
|
||||||
|
available_step_state = "complete" if jellyfin_found else "active"
|
||||||
|
elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
|
||||||
|
state = {
|
||||||
|
"downloading": "downloading",
|
||||||
|
"completed": "importing",
|
||||||
|
"paused": "attention",
|
||||||
|
"error": "attention",
|
||||||
|
"missing": "attention",
|
||||||
|
}[download_state]
|
||||||
|
headline = {
|
||||||
|
"downloading": "Replacement download in progress",
|
||||||
|
"completed": "Replacement downloaded — waiting for import",
|
||||||
|
"paused": "Replacement download paused",
|
||||||
|
"error": "Replacement download cannot be checked",
|
||||||
|
"missing": "Replacement hand-off needs checking",
|
||||||
|
}[download_state]
|
||||||
|
message = {
|
||||||
|
"downloading": "The replacement is downloading now.",
|
||||||
|
"paused": "The replacement download is paused and needs attention.",
|
||||||
|
"completed": f"The download has finished and is waiting for {collector} to import it.",
|
||||||
|
"error": "Magent cannot currently read the replacement download from qBittorrent.",
|
||||||
|
"missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
|
||||||
|
}[download_state]
|
||||||
|
download_step_state = "active" if download_state == "downloading" else (
|
||||||
|
"complete" if download_state == "completed" else "attention"
|
||||||
|
)
|
||||||
|
download_step_detail = str(
|
||||||
|
download.get("summary") or "Magent found the replacement download in qBittorrent."
|
||||||
|
)
|
||||||
|
available_step_state = "waiting"
|
||||||
|
else:
|
||||||
|
state = "searching"
|
||||||
|
headline = "Replacement search in progress"
|
||||||
|
message = (
|
||||||
|
f"{action_message} {collector} has accepted the search, but no replacement download "
|
||||||
|
"has been selected yet. Magent will keep checking."
|
||||||
|
)
|
||||||
|
download_step_state = "waiting"
|
||||||
|
download_step_detail = "Waiting for a suitable release to be selected."
|
||||||
|
available_step_state = "waiting"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"visible": True,
|
||||||
|
"actionId": action_id,
|
||||||
|
"state": state,
|
||||||
|
"headline": headline,
|
||||||
|
"message": message,
|
||||||
|
"service": collector,
|
||||||
|
"updatedAt": action.get("created_at"),
|
||||||
|
"steps": [
|
||||||
|
submitted_step,
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"label": f"{collector} accepted the search",
|
||||||
|
"state": "complete",
|
||||||
|
"detail": action_message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "download",
|
||||||
|
"label": "Replacement download",
|
||||||
|
"state": download_step_state,
|
||||||
|
"detail": download_step_detail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "available",
|
||||||
|
"label": "Updated media available",
|
||||||
|
"state": available_step_state,
|
||||||
|
"detail": (
|
||||||
|
"The repaired title is available in Grizzlyflix."
|
||||||
|
if jellyfin_found and collection_complete
|
||||||
|
else (
|
||||||
|
"The media server is indexing the replacement."
|
||||||
|
if collection_complete
|
||||||
|
else "Waiting for download and import to finish."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_presentation(
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
approved: bool,
|
||||||
|
arr_state: str,
|
||||||
|
arr_details: Dict[str, Any],
|
||||||
|
prowlarr_state: str,
|
||||||
|
download: Dict[str, Any],
|
||||||
|
jellyfin_found: bool,
|
||||||
|
jellyfin_link: Optional[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
|
||||||
|
noun = "episode" if snapshot.request_type == RequestType.tv else "movie"
|
||||||
|
availability = arr_details.get("availability")
|
||||||
|
if not isinstance(availability, dict):
|
||||||
|
availability = {"available": 0, "missing": 0, "total": 0, "seasons": []}
|
||||||
|
available = int(availability.get("available") or 0)
|
||||||
|
missing = int(availability.get("missing") or 0)
|
||||||
|
total = int(availability.get("total") or 0)
|
||||||
|
partial = available > 0 and missing > 0
|
||||||
|
jellyfin_partial = bool(
|
||||||
|
jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0
|
||||||
|
)
|
||||||
|
fully_available = bool(jellyfin_found and not jellyfin_partial)
|
||||||
|
download_visible = bool(download.get("visible"))
|
||||||
|
download_state = str(download.get("state") or "not_started")
|
||||||
|
|
||||||
|
if snapshot.state == NormalizedState.requested:
|
||||||
|
status_label = "Waiting for approval"
|
||||||
|
meaning = "This request has been received, but it must be approved before collection can begin."
|
||||||
|
elif snapshot.state == NormalizedState.needs_add:
|
||||||
|
status_label = "Approved, but not yet in the library queue"
|
||||||
|
meaning = (
|
||||||
|
f"The request was approved, but it has not reached the {collector} collector yet. "
|
||||||
|
"Adding it to the library queue is the next step."
|
||||||
|
)
|
||||||
|
elif jellyfin_partial:
|
||||||
|
status_label = f"Partially available — {available} of {total} episodes collected"
|
||||||
|
meaning = (
|
||||||
|
f"Some of this request is ready to watch. {collector} is still looking for "
|
||||||
|
f"{missing} missing episode{'s' if missing != 1 else ''}."
|
||||||
|
)
|
||||||
|
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||||
|
status_label = "Available to watch"
|
||||||
|
meaning = "Collection is complete and the title is available on the media server."
|
||||||
|
elif download_visible and download_state == "paused":
|
||||||
|
status_label = "Download paused"
|
||||||
|
meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed."
|
||||||
|
elif download_visible and download_state == "missing":
|
||||||
|
status_label = "Download attempt is no longer visible"
|
||||||
|
meaning = (
|
||||||
|
"A download was previously queued for this request, but qBittorrent no longer reports it. "
|
||||||
|
"A fresh release search may be required."
|
||||||
|
)
|
||||||
|
elif download_visible and download_state == "error":
|
||||||
|
status_label = "Unable to read the current download"
|
||||||
|
meaning = (
|
||||||
|
"A download attempt exists, but Magent cannot currently read its progress from qBittorrent."
|
||||||
|
)
|
||||||
|
elif snapshot.state == NormalizedState.downloading:
|
||||||
|
status_label = "Download in progress"
|
||||||
|
meaning = "A release has been collected and is currently downloading."
|
||||||
|
elif snapshot.state == NormalizedState.importing:
|
||||||
|
if arr_state == "available" and not jellyfin_found:
|
||||||
|
status_label = "Collected — waiting for the media server"
|
||||||
|
meaning = (
|
||||||
|
f"{collector} has collected and imported this title, but it is not visible on "
|
||||||
|
"the media server yet."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status_label = "Downloaded — waiting for library import"
|
||||||
|
meaning = f"The download has finished and {collector} is preparing it for the media server."
|
||||||
|
elif arr_state == "error":
|
||||||
|
status_label = "Unable to read the library queue"
|
||||||
|
meaning = (
|
||||||
|
f"The request is approved, but Magent could not read its current state from {collector}. "
|
||||||
|
"The service may be temporarily unavailable."
|
||||||
|
)
|
||||||
|
elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total:
|
||||||
|
if partial:
|
||||||
|
status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing"
|
||||||
|
meaning = (
|
||||||
|
f"The request was approved and sent to {collector}. {available} of {total} aired "
|
||||||
|
f"episodes have been collected; {missing} still need a matching release."
|
||||||
|
)
|
||||||
|
elif missing:
|
||||||
|
status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}"
|
||||||
|
meaning = (
|
||||||
|
f"The request was approved and sent to the {collector} collector, but none of the "
|
||||||
|
f"{total} aired episodes have been collected yet."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status_label = "Added to library queue"
|
||||||
|
meaning = f"The request was approved and sent to the {collector} collector."
|
||||||
|
elif arr_state in {"added", "searching"}:
|
||||||
|
status_label = "Added to library queue — waiting for a matching release"
|
||||||
|
meaning = (
|
||||||
|
f"The request was approved and sent to the {collector} collector, but a usable release "
|
||||||
|
"has not been collected yet."
|
||||||
|
)
|
||||||
|
elif snapshot.state == NormalizedState.failed:
|
||||||
|
status_label = "This request needs attention"
|
||||||
|
meaning = snapshot.state_reason or "Magent could not determine the next stage for this request."
|
||||||
|
else:
|
||||||
|
status_label = "Approved — preparing collection" if approved else "Request received"
|
||||||
|
meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process."
|
||||||
|
|
||||||
|
action_ids = [action.id for action in snapshot.actions]
|
||||||
|
if fully_available:
|
||||||
|
next_title = "Ready to watch"
|
||||||
|
next_description = "Collection is complete. Open the title on the media server when you are ready."
|
||||||
|
recommended = []
|
||||||
|
elif "resume_torrent" in action_ids:
|
||||||
|
next_title = "Resume the interrupted download"
|
||||||
|
next_description = "The download exists but is not currently progressing. Resume it to continue collection."
|
||||||
|
recommended = ["resume_torrent"]
|
||||||
|
elif "readd_to_arr" in action_ids:
|
||||||
|
next_title = "Add this request to the library queue"
|
||||||
|
next_description = f"Send the approved request to {collector} so collection can begin."
|
||||||
|
recommended = ["readd_to_arr"]
|
||||||
|
elif "search_auto" in action_ids or "search_releases" in action_ids:
|
||||||
|
if snapshot.request_type == RequestType.tv and missing:
|
||||||
|
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
|
||||||
|
else:
|
||||||
|
target = f"a matching {noun} release"
|
||||||
|
next_title = f"Search for {target}"
|
||||||
|
next_description = (
|
||||||
|
"Run an automatic search, or review the available releases and choose one manually."
|
||||||
|
)
|
||||||
|
recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids]
|
||||||
|
elif download_state == "downloading":
|
||||||
|
next_title = "Let the current download finish"
|
||||||
|
next_description = "Magent is tracking the active download; no action is needed right now."
|
||||||
|
recommended = []
|
||||||
|
elif snapshot.state == NormalizedState.importing and arr_state == "available":
|
||||||
|
next_title = "Wait for the media server to index this title"
|
||||||
|
next_description = (
|
||||||
|
f"{collector} has completed its work. Use Recheck request to see whether the title "
|
||||||
|
"has appeared on the media server."
|
||||||
|
)
|
||||||
|
recommended = []
|
||||||
|
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||||
|
next_title = "Ready to watch"
|
||||||
|
next_description = "Collection is complete. Open the title on the media server when you are ready."
|
||||||
|
recommended = []
|
||||||
|
elif snapshot.state == NormalizedState.requested:
|
||||||
|
next_title = "Wait for approval"
|
||||||
|
next_description = "An administrator must approve this request before collection can start."
|
||||||
|
recommended = []
|
||||||
|
else:
|
||||||
|
next_title = "Magent is checking the next step"
|
||||||
|
next_description = "No safe action is available until the current service state is known."
|
||||||
|
recommended = []
|
||||||
|
|
||||||
|
requested_stage = {
|
||||||
|
"id": "requested",
|
||||||
|
"label": "Requested",
|
||||||
|
"state": "complete",
|
||||||
|
"summary": "Request received",
|
||||||
|
}
|
||||||
|
approved_stage = {
|
||||||
|
"id": "approved",
|
||||||
|
"label": "Approved",
|
||||||
|
"state": "complete" if approved else "active",
|
||||||
|
"summary": "Approved for collection" if approved else "Waiting for approval",
|
||||||
|
}
|
||||||
|
if arr_state == "missing":
|
||||||
|
library_state, library_summary = "attention", "Not yet added to the collector"
|
||||||
|
elif arr_state == "error":
|
||||||
|
library_state, library_summary = "attention", f"Unable to read {collector}"
|
||||||
|
elif partial:
|
||||||
|
library_state, library_summary = "partial", f"{available} of {total} episodes collected"
|
||||||
|
elif arr_state == "available":
|
||||||
|
library_state, library_summary = "complete", "Collection complete"
|
||||||
|
elif arr_state in {"added", "searching"}:
|
||||||
|
library_state = "active" if missing or not available else "complete"
|
||||||
|
library_summary = (
|
||||||
|
f"{missing} episode{'s' if missing != 1 else ''} still missing"
|
||||||
|
if snapshot.request_type == RequestType.tv and missing
|
||||||
|
else "In the library queue"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
library_state, library_summary = "waiting", "Waiting for collector information"
|
||||||
|
|
||||||
|
if fully_available:
|
||||||
|
search_state, search_summary = "complete", "No further search needed"
|
||||||
|
elif arr_state == "available":
|
||||||
|
search_state, search_summary = "complete", "A release was collected"
|
||||||
|
elif download_visible:
|
||||||
|
search_state = "complete"
|
||||||
|
search_summary = "A release was found"
|
||||||
|
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
|
||||||
|
search_state = "active" if prowlarr_state == "ok" else "attention"
|
||||||
|
search_summary = (
|
||||||
|
f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}"
|
||||||
|
if snapshot.request_type == RequestType.tv and missing
|
||||||
|
else "Ready to search for a release"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
search_state, search_summary = "waiting", "Search has not started"
|
||||||
|
|
||||||
|
completed_download_summary = (
|
||||||
|
"The requested content has been collected and is available to watch. "
|
||||||
|
"No further action is needed."
|
||||||
|
)
|
||||||
|
if fully_available:
|
||||||
|
download_stage_state, download_summary = "complete", completed_download_summary
|
||||||
|
pipeline_download_visible = False
|
||||||
|
pipeline_torrents: List[Dict[str, Any]] = []
|
||||||
|
elif arr_state == "available":
|
||||||
|
download_stage_state = "complete"
|
||||||
|
download_summary = f"{collector} has imported the collected file"
|
||||||
|
pipeline_download_visible = False
|
||||||
|
pipeline_torrents = []
|
||||||
|
elif download_visible:
|
||||||
|
download_stage_state = {
|
||||||
|
"downloading": "active",
|
||||||
|
"paused": "attention",
|
||||||
|
"completed": "complete",
|
||||||
|
"missing": "attention",
|
||||||
|
"error": "attention",
|
||||||
|
}.get(download_state, "waiting")
|
||||||
|
download_summary = str(download.get("summary") or "A prior download attempt was found")
|
||||||
|
pipeline_download_visible = True
|
||||||
|
pipeline_torrents = download.get("torrents") or []
|
||||||
|
else:
|
||||||
|
download_stage_state, download_summary = "waiting", "No download attempt yet"
|
||||||
|
pipeline_download_visible = False
|
||||||
|
pipeline_torrents = []
|
||||||
|
|
||||||
|
if jellyfin_partial:
|
||||||
|
available_label = "Partially available"
|
||||||
|
available_state = "partial"
|
||||||
|
available_state_label = "Partly ready"
|
||||||
|
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
|
||||||
|
elif jellyfin_found:
|
||||||
|
available_label = "Available to watch"
|
||||||
|
available_state = "complete"
|
||||||
|
available_state_label = "Ready"
|
||||||
|
available_summary = "This title is ready to watch in Grizzlyflix."
|
||||||
|
elif arr_state == "available":
|
||||||
|
available_label = "Adding to Grizzlyflix"
|
||||||
|
available_state = "active"
|
||||||
|
available_state_label = "Indexing"
|
||||||
|
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
|
||||||
|
else:
|
||||||
|
available_label = "Media server"
|
||||||
|
available_state = "waiting"
|
||||||
|
available_state_label = "Waiting"
|
||||||
|
available_summary = "This title has not reached Grizzlyflix yet."
|
||||||
|
|
||||||
|
display_download = dict(download)
|
||||||
|
if fully_available:
|
||||||
|
display_download.update(
|
||||||
|
{
|
||||||
|
"visible": False,
|
||||||
|
"state": "completed",
|
||||||
|
"summary": completed_download_summary,
|
||||||
|
"torrents": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": {"label": status_label, "meaning": meaning},
|
||||||
|
"download": display_download,
|
||||||
|
"nextStep": {
|
||||||
|
"title": next_title,
|
||||||
|
"description": next_description,
|
||||||
|
"actionIds": recommended,
|
||||||
|
},
|
||||||
|
"pipeline": [
|
||||||
|
requested_stage,
|
||||||
|
approved_stage,
|
||||||
|
{
|
||||||
|
"id": "library",
|
||||||
|
"label": "Library collection",
|
||||||
|
"state": library_state,
|
||||||
|
"summary": library_summary,
|
||||||
|
"available": available,
|
||||||
|
"missing": missing,
|
||||||
|
"total": total,
|
||||||
|
"seasons": availability.get("seasons") or [],
|
||||||
|
"missingEpisodes": arr_details.get("missingEpisodes") or {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "search",
|
||||||
|
"label": "Release search",
|
||||||
|
"state": search_state,
|
||||||
|
"summary": search_summary,
|
||||||
|
"actionIds": [] if fully_available else [
|
||||||
|
action_id
|
||||||
|
for action_id in ("search_auto", "search_releases")
|
||||||
|
if action_id in action_ids
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "download",
|
||||||
|
"label": "Download complete" if fully_available else "Download",
|
||||||
|
"state": download_stage_state,
|
||||||
|
"summary": download_summary,
|
||||||
|
"visible": pipeline_download_visible,
|
||||||
|
"torrents": pipeline_torrents,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "available",
|
||||||
|
"label": available_label,
|
||||||
|
"state": available_state,
|
||||||
|
"stateLabel": available_state_label,
|
||||||
|
"summary": available_summary,
|
||||||
|
"link": jellyfin_link,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def build_snapshot(request_id: str) -> Snapshot:
|
async def build_snapshot(request_id: str) -> Snapshot:
|
||||||
timeline = []
|
timeline = []
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
@@ -449,7 +1060,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
poster_path = media.get("posterPath") or media.get("poster_path")
|
poster_path = media.get("posterPath") or media.get("poster_path")
|
||||||
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
|
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
|
||||||
|
|
||||||
if snapshot.title in {None, "", "Unknown"} and allow_remote:
|
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
|
||||||
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
||||||
if tmdb_id:
|
if tmdb_id:
|
||||||
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
|
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
|
||||||
@@ -533,6 +1144,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
|
arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
|
||||||
arr_details["queue"] = arr_queue
|
arr_details["queue"] = arr_queue
|
||||||
episodes = await sonarr.get_episodes(series_id)
|
episodes = await sonarr.get_episodes(series_id)
|
||||||
|
arr_details["availability"] = _episode_availability(episodes)
|
||||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||||
if missing_by_season:
|
if missing_by_season:
|
||||||
arr_details["missingEpisodes"] = missing_by_season
|
arr_details["missingEpisodes"] = missing_by_season
|
||||||
@@ -581,6 +1193,12 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
arr_state = "added"
|
arr_state = "added"
|
||||||
else:
|
else:
|
||||||
arr_state = "missing"
|
arr_state = "missing"
|
||||||
|
arr_details["availability"] = {
|
||||||
|
"available": 1 if arr_item and arr_item.get("hasFile") else 0,
|
||||||
|
"missing": 0 if arr_item and arr_item.get("hasFile") else 1,
|
||||||
|
"total": 1,
|
||||||
|
"seasons": [],
|
||||||
|
}
|
||||||
if arr_item and isinstance(arr_item.get("id"), int):
|
if arr_item and isinstance(arr_item.get("id"), int):
|
||||||
arr_queue = await radarr.get_queue(int(arr_item["id"]))
|
arr_queue = await radarr.get_queue(int(arr_item["id"]))
|
||||||
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
|
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
|
||||||
@@ -592,15 +1210,20 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
if arr_state is None:
|
if arr_state is None:
|
||||||
arr_state = "unknown"
|
arr_state = "unknown"
|
||||||
|
|
||||||
|
_apply_arr_identity(snapshot, arr_item)
|
||||||
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
||||||
|
|
||||||
|
prowlarr_state = "unknown"
|
||||||
try:
|
try:
|
||||||
prowlarr_health = await prowlarr.get_health()
|
prowlarr_health = await prowlarr.get_health()
|
||||||
if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
|
if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
|
||||||
|
prowlarr_state = "issues"
|
||||||
timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
|
timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
|
||||||
else:
|
else:
|
||||||
|
prowlarr_state = "ok"
|
||||||
timeline.append(TimelineHop(service="Prowlarr", status="ok"))
|
timeline.append(TimelineHop(service="Prowlarr", status="ok"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
prowlarr_state = "error"
|
||||||
timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
|
timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
|
||||||
|
|
||||||
jellyfin_available = False
|
jellyfin_available = False
|
||||||
@@ -668,34 +1291,66 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
qbit_state = None
|
qbit_state = "not_started"
|
||||||
qbit_message = None
|
qbit_message = "No download attempt has been observed."
|
||||||
|
download_ids = _download_ids(_queue_records(arr_queue))
|
||||||
|
download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100)
|
||||||
|
torrent_list: List[Dict[str, Any]] = []
|
||||||
|
download_visible = bool(download_ids) or bool(download_history.get("observed"))
|
||||||
|
qbit_error = None
|
||||||
try:
|
try:
|
||||||
download_ids = _download_ids(_queue_records(arr_queue))
|
|
||||||
torrent_list: List[Dict[str, Any]] = []
|
|
||||||
if qbittorrent.configured():
|
if qbittorrent.configured():
|
||||||
if download_ids:
|
if download_ids:
|
||||||
torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
|
torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
|
||||||
torrent_list = torrents if isinstance(torrents, list) else []
|
torrent_list = torrents if isinstance(torrents, list) else []
|
||||||
else:
|
else:
|
||||||
category = f"magent-{request_id}"
|
request_tag = f"magent-{request_id}"
|
||||||
torrents = await qbittorrent.get_torrents_by_category(category)
|
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
|
||||||
torrent_list = torrents if isinstance(torrents, list) else []
|
torrent_list = torrents if isinstance(torrents, list) else []
|
||||||
summary = _summarize_qbit(torrent_list)
|
for torrent in torrent_list:
|
||||||
qbit_state = summary.get("state")
|
if isinstance(torrent, dict):
|
||||||
qbit_message = summary.get("message")
|
torrent["progressPercent"] = _torrent_progress(torrent)
|
||||||
timeline.append(
|
if torrent_list:
|
||||||
TimelineHop(
|
download_visible = True
|
||||||
service="qBittorrent",
|
summary = _summarize_qbit(torrent_list)
|
||||||
status=summary["state"],
|
qbit_state = str(summary.get("state") or "idle")
|
||||||
details={
|
qbit_message = str(summary.get("message") or "Download found in qBittorrent.")
|
||||||
"summary": summary["message"],
|
elif download_ids:
|
||||||
"torrents": torrent_list,
|
qbit_state = "missing"
|
||||||
},
|
qbit_message = (
|
||||||
|
"The collector queued a download, but it is no longer visible in qBittorrent."
|
||||||
|
)
|
||||||
|
elif download_history.get("observed"):
|
||||||
|
qbit_state = "missing"
|
||||||
|
qbit_message = (
|
||||||
|
"A previous download was observed, but it is not currently visible in qBittorrent."
|
||||||
)
|
)
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
timeline.append(TimelineHop(service="qBittorrent", status="error", details={"error": str(exc)}))
|
qbit_error = str(exc)
|
||||||
|
if download_visible:
|
||||||
|
qbit_state = "error"
|
||||||
|
qbit_message = (
|
||||||
|
"A download attempt exists, but Magent cannot currently read its state from qBittorrent."
|
||||||
|
)
|
||||||
|
|
||||||
|
download_presentation = {
|
||||||
|
"visible": download_visible,
|
||||||
|
"observed": download_visible,
|
||||||
|
"state": qbit_state,
|
||||||
|
"summary": qbit_message,
|
||||||
|
"torrents": torrent_list,
|
||||||
|
"lastSeenAt": download_history.get("last_seen_at"),
|
||||||
|
}
|
||||||
|
timeline.append(
|
||||||
|
TimelineHop(
|
||||||
|
service="qBittorrent",
|
||||||
|
status=qbit_state,
|
||||||
|
details={
|
||||||
|
**download_presentation,
|
||||||
|
"error": qbit_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
status_code = None
|
status_code = None
|
||||||
try:
|
try:
|
||||||
@@ -720,8 +1375,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
snapshot.state_reason = qbit_message
|
snapshot.state_reason = qbit_message
|
||||||
elif qbit_state == "completed":
|
elif qbit_state == "completed":
|
||||||
if arr_state == "available":
|
if arr_state == "available":
|
||||||
snapshot.state = NormalizedState.completed
|
snapshot.state = NormalizedState.importing
|
||||||
snapshot.state_reason = "In your library and ready to watch."
|
snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
|
||||||
else:
|
else:
|
||||||
snapshot.state = NormalizedState.importing
|
snapshot.state = NormalizedState.importing
|
||||||
snapshot.state_reason = "Download finished. Waiting for library import."
|
snapshot.state_reason = "Download finished. Waiting for library import."
|
||||||
@@ -737,8 +1392,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
snapshot.state = NormalizedState.searching
|
snapshot.state = NormalizedState.searching
|
||||||
snapshot.state_reason = "Searching for a matching release."
|
snapshot.state_reason = "Searching for a matching release."
|
||||||
elif arr_state == "available":
|
elif arr_state == "available":
|
||||||
snapshot.state = NormalizedState.completed
|
snapshot.state = NormalizedState.importing
|
||||||
snapshot.state_reason = "In your library and ready to watch."
|
snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
|
||||||
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
|
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
|
||||||
snapshot.state = NormalizedState.added_to_arr
|
snapshot.state = NormalizedState.added_to_arr
|
||||||
snapshot.state_reason = "Item is present in Sonarr/Radarr"
|
snapshot.state_reason = "Item is present in Sonarr/Radarr"
|
||||||
@@ -766,23 +1421,32 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
actions.append(
|
actions.append(
|
||||||
ActionOption(
|
ActionOption(
|
||||||
id="readd_to_arr",
|
id="readd_to_arr",
|
||||||
label="Push to Sonarr/Radarr",
|
label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}",
|
||||||
risk="medium",
|
risk="medium",
|
||||||
|
description="Send this approved request to the library collector.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif arr_item and arr_state != "available":
|
elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}:
|
||||||
|
missing_count = int((arr_details.get("availability") or {}).get("missing") or 0)
|
||||||
|
automatic_label = (
|
||||||
|
f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}"
|
||||||
|
if snapshot.request_type == RequestType.tv and missing_count
|
||||||
|
else "Search automatically for a release"
|
||||||
|
)
|
||||||
actions.append(
|
actions.append(
|
||||||
ActionOption(
|
ActionOption(
|
||||||
id="search_auto",
|
id="search_auto",
|
||||||
label="Search and auto-download",
|
label=automatic_label,
|
||||||
risk="low",
|
risk="low",
|
||||||
|
description="Ask the library collector to find and download the best permitted match.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
actions.append(
|
actions.append(
|
||||||
ActionOption(
|
ActionOption(
|
||||||
id="search_releases",
|
id="search_releases",
|
||||||
label="Search and choose a download",
|
label="Review available releases",
|
||||||
risk="low",
|
risk="low",
|
||||||
|
description="Search the configured indexers and choose a release yourself.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -793,18 +1457,28 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
id="resume_torrent",
|
id="resume_torrent",
|
||||||
label="Resume the download",
|
label="Resume the download",
|
||||||
risk="low",
|
risk="low",
|
||||||
|
description="Resume the existing qBittorrent job if it is paused or stalled.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot.actions = actions
|
snapshot.actions = actions
|
||||||
jellyfin_link = None
|
jellyfin_link = None
|
||||||
if runtime.jellyfin_public_url and snapshot.state in {
|
if runtime.jellyfin_public_url and jellyfin_available:
|
||||||
NormalizedState.available,
|
|
||||||
NormalizedState.completed,
|
|
||||||
}:
|
|
||||||
base_url = runtime.jellyfin_public_url.rstrip("/")
|
base_url = runtime.jellyfin_public_url.rstrip("/")
|
||||||
query = quote(snapshot.title or "")
|
jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
|
||||||
jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
|
if jellyfin_item_id:
|
||||||
|
jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}"
|
||||||
|
else:
|
||||||
|
query = quote(snapshot.title or "")
|
||||||
|
jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
|
||||||
|
availability = arr_details.get("availability") or {}
|
||||||
|
is_partial = bool(
|
||||||
|
jellyfin_available
|
||||||
|
and snapshot.request_type == RequestType.tv
|
||||||
|
and int(availability.get("missing") or 0) > 0
|
||||||
|
)
|
||||||
|
if jellyfin_available and not is_partial:
|
||||||
|
snapshot.actions = []
|
||||||
snapshot.raw = {
|
snapshot.raw = {
|
||||||
"jellyseerr": jelly_request,
|
"jellyseerr": jelly_request,
|
||||||
"arr": {
|
"arr": {
|
||||||
@@ -813,15 +1487,47 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
},
|
},
|
||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"publicUrl": runtime.jellyfin_public_url,
|
"publicUrl": runtime.jellyfin_public_url,
|
||||||
"available": snapshot.state in {
|
"found": jellyfin_available,
|
||||||
|
"available": jellyfin_available and snapshot.state in {
|
||||||
NormalizedState.available,
|
NormalizedState.available,
|
||||||
NormalizedState.completed,
|
NormalizedState.completed,
|
||||||
},
|
},
|
||||||
|
"partial": is_partial,
|
||||||
"link": jellyfin_link,
|
"link": jellyfin_link,
|
||||||
"item": jellyfin_item,
|
"item": jellyfin_item,
|
||||||
},
|
},
|
||||||
|
"qbittorrent": {
|
||||||
|
**download_presentation,
|
||||||
|
"downloadIds": download_ids,
|
||||||
|
"error": qbit_error,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snapshot.presentation = _build_presentation(
|
||||||
|
snapshot,
|
||||||
|
approved=derived_approved,
|
||||||
|
arr_state=arr_state,
|
||||||
|
arr_details=arr_details,
|
||||||
|
prowlarr_state=prowlarr_state,
|
||||||
|
download=download_presentation,
|
||||||
|
jellyfin_found=jellyfin_available,
|
||||||
|
jellyfin_link=jellyfin_link,
|
||||||
|
)
|
||||||
|
repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
|
||||||
|
repair_activity = _build_repair_activity(
|
||||||
|
snapshot,
|
||||||
|
action=repair_action,
|
||||||
|
arr_state=arr_state,
|
||||||
|
arr_details=arr_details,
|
||||||
|
download=download_presentation,
|
||||||
|
jellyfin_found=jellyfin_available,
|
||||||
|
)
|
||||||
|
if repair_activity:
|
||||||
|
snapshot.presentation["repairActivity"] = repair_activity
|
||||||
|
status_presentation = snapshot.presentation.get("status")
|
||||||
|
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
|
||||||
|
snapshot.state_reason = str(status_presentation["meaning"])
|
||||||
|
|
||||||
await _maybe_refresh_jellyfin(snapshot)
|
await _maybe_refresh_jellyfin(snapshot)
|
||||||
await asyncio.to_thread(save_snapshot, snapshot)
|
await asyncio.to_thread(save_snapshot, snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ fastapi==0.134.0
|
|||||||
uvicorn==0.41.0
|
uvicorn==0.41.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
pydantic-settings==2.13.1
|
pydantic-settings==2.14.2
|
||||||
PyJWT==2.11.0
|
PyJWT==2.13.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.31
|
||||||
Pillow==12.1.1
|
Pillow==12.3.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
name: magent-beta
|
||||||
|
|
||||||
|
services:
|
||||||
|
magent:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
environment:
|
||||||
|
APP_NAME: Magent Beta
|
||||||
|
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
|
||||||
|
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
|
||||||
|
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
|
||||||
|
AUTH_COOKIE_NAME: magent_beta_auth
|
||||||
|
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
||||||
|
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
||||||
|
SQLITE_PATH: /app/data/magent.db
|
||||||
|
LOG_FILE: /app/data/magent.log
|
||||||
|
SITE_BANNER_ENABLED: "true"
|
||||||
|
SITE_BANNER_MESSAGE: "Beta environment"
|
||||||
|
SITE_BANNER_TONE: warning
|
||||||
|
ports:
|
||||||
|
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
|
||||||
|
- "127.0.0.1:8100:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
+456
-149
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import AdminShell from '../ui/AdminShell'
|
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../lib/auth'
|
||||||
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
|
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
|
||||||
|
import AdminShell from '../ui/AdminShell'
|
||||||
|
|
||||||
type AdminSetting = {
|
type AdminSetting = {
|
||||||
key: string
|
key: string
|
||||||
@@ -19,6 +19,12 @@ type ServiceOptions = {
|
|||||||
qualityProfiles: { id: number; name: string; label: string }[]
|
qualityProfiles: { id: number; name: string; label: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceStatus = {
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
const SECTION_LABELS: Record<string, string> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
magent: 'Magent',
|
magent: 'Magent',
|
||||||
general: 'General',
|
general: 'General',
|
||||||
@@ -27,14 +33,17 @@ const SECTION_LABELS: Record<string, string> = {
|
|||||||
jellyseerr: 'Seerr',
|
jellyseerr: 'Seerr',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
artwork: 'Artwork cache',
|
artwork: 'Artwork cache',
|
||||||
cache: 'Cache Control',
|
cache: 'Request cache',
|
||||||
sonarr: 'Sonarr',
|
sonarr: 'Sonarr',
|
||||||
radarr: 'Radarr',
|
radarr: 'Radarr',
|
||||||
|
bazarr: 'Bazarr',
|
||||||
prowlarr: 'Prowlarr',
|
prowlarr: 'Prowlarr',
|
||||||
qbittorrent: 'qBittorrent',
|
qbittorrent: 'qBittorrent',
|
||||||
log: 'Activity log',
|
logs: 'Activity log',
|
||||||
requests: 'Request sync',
|
maintenance: 'Maintenance',
|
||||||
site: 'Site',
|
requests: 'Request pipeline',
|
||||||
|
'issue-workflow': 'Issue workflow',
|
||||||
|
site: 'Site & login',
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOOL_SETTINGS = new Set([
|
const BOOL_SETTINGS = new Set([
|
||||||
@@ -44,6 +53,7 @@ const BOOL_SETTINGS = new Set([
|
|||||||
'site_login_show_local_login',
|
'site_login_show_local_login',
|
||||||
'site_login_show_forgot_password',
|
'site_login_show_forgot_password',
|
||||||
'site_login_show_signup_link',
|
'site_login_show_signup_link',
|
||||||
|
'site_nav_show_requests',
|
||||||
'magent_proxy_enabled',
|
'magent_proxy_enabled',
|
||||||
'magent_proxy_trust_forwarded_headers',
|
'magent_proxy_trust_forwarded_headers',
|
||||||
'magent_ssl_bind_enabled',
|
'magent_ssl_bind_enabled',
|
||||||
@@ -74,6 +84,7 @@ const URL_SETTINGS = new Set([
|
|||||||
'jellyfin_public_url',
|
'jellyfin_public_url',
|
||||||
'sonarr_base_url',
|
'sonarr_base_url',
|
||||||
'radarr_base_url',
|
'radarr_base_url',
|
||||||
|
'bazarr_base_url',
|
||||||
'prowlarr_base_url',
|
'prowlarr_base_url',
|
||||||
'qbittorrent_base_url',
|
'qbittorrent_base_url',
|
||||||
])
|
])
|
||||||
@@ -87,6 +98,8 @@ const NUMBER_SETTINGS = new Set([
|
|||||||
'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',
|
||||||
])
|
])
|
||||||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||||||
|
|
||||||
@@ -99,15 +112,18 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
'Notification providers and delivery channel settings used by Magent messaging features.',
|
'Notification providers and delivery channel settings used by Magent messaging features.',
|
||||||
seerr: 'Connect Seerr where users submit content requests.',
|
seerr: 'Connect Seerr where users submit content requests.',
|
||||||
jellyseerr: 'Connect Seerr where users submit content requests.',
|
jellyseerr: 'Connect Seerr where users submit content requests.',
|
||||||
jellyfin: 'Control Jellyfin login and availability checks.',
|
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
|
||||||
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
||||||
cache: 'Manage saved requests cache and refresh behavior.',
|
cache: 'Manage saved requests cache and refresh behavior.',
|
||||||
sonarr: 'TV automation settings.',
|
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
|
||||||
radarr: 'Movie automation settings.',
|
radarr: 'Radarr connection and the default profile and library location for movie requests.',
|
||||||
prowlarr: 'Indexer search settings.',
|
bazarr: 'Bazarr connection used to find and replace movie and episode subtitles.',
|
||||||
qbittorrent: 'Downloader connection settings.',
|
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
|
||||||
|
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
||||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||||
log: 'Activity log for troubleshooting.',
|
'issue-workflow': 'Control reporter confirmation, reminder timing, and automatic issue closure.',
|
||||||
|
logs: 'Control log output and inspect recent application activity for troubleshooting.',
|
||||||
|
maintenance: 'Repair cached data, clean historical records, and run recovery operations.',
|
||||||
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
|
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +137,11 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
|||||||
artwork: null,
|
artwork: null,
|
||||||
sonarr: 'sonarr',
|
sonarr: 'sonarr',
|
||||||
radarr: 'radarr',
|
radarr: 'radarr',
|
||||||
|
bazarr: 'bazarr',
|
||||||
prowlarr: 'prowlarr',
|
prowlarr: 'prowlarr',
|
||||||
qbittorrent: 'qbittorrent',
|
qbittorrent: 'qbittorrent',
|
||||||
requests: 'requests',
|
requests: 'requests',
|
||||||
|
'issue-workflow': 'issue',
|
||||||
cache: null,
|
cache: null,
|
||||||
logs: 'log',
|
logs: 'log',
|
||||||
maintenance: null,
|
maintenance: null,
|
||||||
@@ -226,10 +244,14 @@ const MAGENT_SECTION_GROUPS: Array<{
|
|||||||
'magent_notify_push_token',
|
'magent_notify_push_token',
|
||||||
'magent_notify_push_user_key',
|
'magent_notify_push_user_key',
|
||||||
'magent_notify_push_device',
|
'magent_notify_push_device',
|
||||||
'magent_notify_webhook_enabled',
|
|
||||||
'magent_notify_webhook_url',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-notify-webhook',
|
||||||
|
title: 'Generic Webhook',
|
||||||
|
description: 'Send notifications to a custom automation or integration endpoint.',
|
||||||
|
keys: ['magent_notify_webhook_enabled', 'magent_notify_webhook_url'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||||||
@@ -240,6 +262,7 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
|||||||
'magent-notify-discord',
|
'magent-notify-discord',
|
||||||
'magent-notify-telegram',
|
'magent-notify-telegram',
|
||||||
'magent-notify-push',
|
'magent-notify-push',
|
||||||
|
'magent-notify-webhook',
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,9 +289,165 @@ const SITE_SECTION_GROUPS: Array<{
|
|||||||
'site_login_show_signup_link',
|
'site_login_show_signup_link',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'site-navigation',
|
||||||
|
title: 'Beta Navigation',
|
||||||
|
description: 'Temporarily show or hide beta navigation entries while new request pipelines are built.',
|
||||||
|
keys: ['site_nav_show_requests'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const STANDARD_SECTION_GROUPS: Record<
|
||||||
|
string,
|
||||||
|
Array<{ key: string; title: string; description: string; keys: string[] }>
|
||||||
|
> = {
|
||||||
|
seerr: [
|
||||||
|
{
|
||||||
|
key: 'seerr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
|
||||||
|
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
jellyseerr: [
|
||||||
|
{
|
||||||
|
key: 'seerr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
|
||||||
|
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
jellyfin: [
|
||||||
|
{
|
||||||
|
key: 'jellyfin-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Internal Jellyfin endpoint and administrator API credential used for lookups and user sync.',
|
||||||
|
keys: ['jellyfin_base_url', 'jellyfin_api_key'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jellyfin-playback',
|
||||||
|
title: 'Playback Links',
|
||||||
|
description: 'Public address used when a viewer opens an available title from Magent.',
|
||||||
|
keys: ['jellyfin_public_url'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jellyfin-users',
|
||||||
|
title: 'Library and User Sync',
|
||||||
|
description: 'Control cross-service library reconciliation and manually import Jellyfin users.',
|
||||||
|
keys: ['jellyfin_sync_to_arr'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sonarr: [
|
||||||
|
{
|
||||||
|
key: 'sonarr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Sonarr endpoint and API credential used for TV collection operations.',
|
||||||
|
keys: ['sonarr_base_url', 'sonarr_api_key'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sonarr-library',
|
||||||
|
title: 'TV Collection Defaults',
|
||||||
|
description: 'Default quality profile and destination folder used for TV requests.',
|
||||||
|
keys: ['sonarr_quality_profile_id', 'sonarr_root_folder'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
radarr: [
|
||||||
|
{
|
||||||
|
key: 'radarr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Radarr endpoint and API credential used for movie collection operations.',
|
||||||
|
keys: ['radarr_base_url', 'radarr_api_key'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'radarr-library',
|
||||||
|
title: 'Movie Collection Defaults',
|
||||||
|
description: 'Default quality profile and destination folder used for movie requests.',
|
||||||
|
keys: ['radarr_quality_profile_id', 'radarr_root_folder'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bazarr: [
|
||||||
|
{
|
||||||
|
key: 'bazarr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Bazarr endpoint, API credential, and default language used by subtitle issue repairs.',
|
||||||
|
keys: ['bazarr_base_url', 'bazarr_api_key', 'bazarr_default_language'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prowlarr: [
|
||||||
|
{
|
||||||
|
key: 'prowlarr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Prowlarr endpoint and API credential used for indexer health and release discovery.',
|
||||||
|
keys: ['prowlarr_base_url', 'prowlarr_api_key'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
qbittorrent: [
|
||||||
|
{
|
||||||
|
key: 'qbittorrent-connection',
|
||||||
|
title: 'Connection and Sign-in',
|
||||||
|
description: 'qBittorrent Web UI endpoint and credentials used for live download progress and recovery.',
|
||||||
|
keys: ['qbittorrent_base_url', 'qbittorrent_username', 'qbittorrent_password'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
key: 'requests-sync',
|
||||||
|
title: 'Synchronization Schedule',
|
||||||
|
description: 'Control incremental checks and the scheduled full request-cache rebuild.',
|
||||||
|
keys: [
|
||||||
|
'requests_poll_interval_seconds',
|
||||||
|
'requests_delta_sync_interval_minutes',
|
||||||
|
'requests_full_sync_time',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'requests-retention',
|
||||||
|
title: 'History Retention',
|
||||||
|
description: 'Choose when old status history is cleaned up and how long it is retained.',
|
||||||
|
keys: ['requests_cleanup_time', 'requests_cleanup_days'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'issue-workflow': [
|
||||||
|
{
|
||||||
|
key: 'issues-resolution-confirmation',
|
||||||
|
title: 'Resolution confirmation',
|
||||||
|
description: 'Choose how often Magent asks a reporter to confirm a fix before the issue is closed automatically.',
|
||||||
|
keys: [
|
||||||
|
'issue_confirmation_contact_attempts',
|
||||||
|
'issue_confirmation_interval_value',
|
||||||
|
'issue_confirmation_interval_unit',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
key: 'logs-output',
|
||||||
|
title: 'Log Output',
|
||||||
|
description: 'Set the default application verbosity and the active log-file destination.',
|
||||||
|
keys: ['log_level', 'log_file'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs-rotation',
|
||||||
|
title: 'File Rotation',
|
||||||
|
description: 'Limit log-file growth and choose how many historical files remain on disk.',
|
||||||
|
keys: ['log_file_max_bytes', 'log_file_backup_count'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs-components',
|
||||||
|
title: 'Component Verbosity',
|
||||||
|
description: 'Tune noisy outbound-service and scheduled-background messages independently.',
|
||||||
|
keys: ['log_http_client_level', 'log_background_sync_level'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
bazarr_base_url: 'Bazarr base URL',
|
||||||
|
bazarr_api_key: 'Bazarr API key',
|
||||||
|
bazarr_default_language: 'Default subtitle language',
|
||||||
|
issue_confirmation_contact_attempts: 'Confirmation emails before auto-close',
|
||||||
|
issue_confirmation_interval_value: 'Confirmation interval',
|
||||||
|
issue_confirmation_interval_unit: 'Interval unit',
|
||||||
jellyseerr_base_url: 'Seerr base URL',
|
jellyseerr_base_url: 'Seerr base URL',
|
||||||
jellyseerr_api_key: 'Seerr API key',
|
jellyseerr_api_key: 'Seerr API key',
|
||||||
magent_application_url: 'Application URL',
|
magent_application_url: 'Application URL',
|
||||||
@@ -309,10 +488,38 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
magent_notify_push_device: 'Device / target',
|
magent_notify_push_device: 'Device / target',
|
||||||
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
|
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
|
||||||
magent_notify_webhook_url: 'Generic webhook URL',
|
magent_notify_webhook_url: 'Generic webhook URL',
|
||||||
|
jellyfin_base_url: 'Internal server URL',
|
||||||
|
jellyfin_api_key: 'Administrator API key',
|
||||||
|
jellyfin_public_url: 'Public playback URL',
|
||||||
|
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
|
||||||
|
sonarr_base_url: 'Sonarr server URL',
|
||||||
|
sonarr_api_key: 'Sonarr API key',
|
||||||
|
sonarr_quality_profile_id: 'Default TV quality profile',
|
||||||
|
sonarr_root_folder: 'Default TV root folder',
|
||||||
|
radarr_base_url: 'Radarr server URL',
|
||||||
|
radarr_api_key: 'Radarr API key',
|
||||||
|
radarr_quality_profile_id: 'Default movie quality profile',
|
||||||
|
radarr_root_folder: 'Default movie root folder',
|
||||||
|
prowlarr_base_url: 'Prowlarr server URL',
|
||||||
|
prowlarr_api_key: 'Prowlarr API key',
|
||||||
|
qbittorrent_base_url: 'Web UI URL',
|
||||||
|
qbittorrent_username: 'Web UI username',
|
||||||
|
qbittorrent_password: 'Web UI password',
|
||||||
|
requests_sync_ttl_minutes: 'Request cache freshness (minutes)',
|
||||||
|
requests_poll_interval_seconds: 'Full-sync eligibility check (seconds)',
|
||||||
|
requests_delta_sync_interval_minutes: 'Recent-change sync interval (minutes)',
|
||||||
|
requests_full_sync_time: 'Daily full-sync time',
|
||||||
|
requests_cleanup_time: 'Daily history cleanup time',
|
||||||
|
requests_cleanup_days: 'History retention (days)',
|
||||||
|
requests_data_source: 'Request read source',
|
||||||
|
artwork_cache_mode: 'Artwork delivery mode',
|
||||||
|
log_level: 'Application log level',
|
||||||
|
log_file: 'Active log file',
|
||||||
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
|
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
|
||||||
site_login_show_local_login: 'Login page: local Magent sign-in',
|
site_login_show_local_login: 'Login page: local Magent sign-in',
|
||||||
site_login_show_forgot_password: 'Login page: forgot password',
|
site_login_show_forgot_password: 'Login page: forgot password',
|
||||||
site_login_show_signup_link: 'Login page: invite signup link',
|
site_login_show_signup_link: 'Login page: invite signup link',
|
||||||
|
site_nav_show_requests: 'Top navigation: New Requests',
|
||||||
log_file_max_bytes: 'Log file max size (bytes)',
|
log_file_max_bytes: 'Log file max size (bytes)',
|
||||||
log_file_backup_count: 'Rotated log files to keep',
|
log_file_backup_count: 'Rotated log files to keep',
|
||||||
log_http_client_level: 'Service HTTP log level',
|
log_http_client_level: 'Service HTTP log level',
|
||||||
@@ -343,6 +550,7 @@ const labelFromKey = (key: string) =>
|
|||||||
.replace('site banner enabled', 'Sitewide banner enabled')
|
.replace('site banner enabled', 'Sitewide banner enabled')
|
||||||
.replace('site banner message', 'Sitewide banner message')
|
.replace('site banner message', 'Sitewide banner message')
|
||||||
.replace('site banner tone', 'Sitewide banner tone')
|
.replace('site banner tone', 'Sitewide banner tone')
|
||||||
|
.replace('site nav show requests', 'Top navigation: New Requests')
|
||||||
.replace('site changelog', 'Changelog text')
|
.replace('site changelog', 'Changelog text')
|
||||||
|
|
||||||
const formatBytes = (value?: number | null) => {
|
const formatBytes = (value?: number | null) => {
|
||||||
@@ -375,12 +583,12 @@ type SectionFeedback = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
||||||
jellyseerr: 'seerr',
|
'seerr-connection': 'seerr',
|
||||||
jellyfin: 'jellyfin',
|
'jellyfin-connection': 'jellyfin',
|
||||||
sonarr: 'sonarr',
|
'sonarr-connection': 'sonarr',
|
||||||
radarr: 'radarr',
|
'radarr-connection': 'radarr',
|
||||||
prowlarr: 'prowlarr',
|
'prowlarr-connection': 'prowlarr',
|
||||||
qbittorrent: 'qbittorrent',
|
'qbittorrent-connection': 'qbittorrent',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||||
@@ -414,6 +622,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||||
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
||||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||||
|
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([])
|
||||||
|
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null)
|
||||||
const requestsSyncRef = useRef<any | null>(null)
|
const requestsSyncRef = useRef<any | null>(null)
|
||||||
const artworkPrefetchRef = useRef<any | null>(null)
|
const artworkPrefetchRef = useRef<any | null>(null)
|
||||||
const computeProgressPercent = (
|
const computeProgressPercent = (
|
||||||
@@ -506,7 +716,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not load artwork stats.'
|
: 'Could not load artwork stats.'
|
||||||
setArtworkSummaryStatus(message)
|
setArtworkSummaryStatus(message)
|
||||||
}
|
}
|
||||||
@@ -543,6 +753,21 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadServiceStatuses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/status/services`)
|
||||||
|
if (!response.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setServiceStatuses(Array.isArray(data?.services) ? data.services : [])
|
||||||
|
setServiceStatusCheckedAt(new Date().toISOString())
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
@@ -550,7 +775,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadSettings()
|
await Promise.all([loadSettings(), loadServiceStatuses()])
|
||||||
if (section === 'cache' || section === 'artwork') {
|
if (section === 'cache' || section === 'artwork') {
|
||||||
await loadArtworkPrefetchStatus()
|
await loadArtworkPrefetchStatus()
|
||||||
await loadArtworkSummary()
|
await loadArtworkSummary()
|
||||||
@@ -570,7 +795,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
if (section === 'radarr') {
|
if (section === 'radarr') {
|
||||||
void loadOptions('radarr')
|
void loadOptions('radarr')
|
||||||
}
|
}
|
||||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
|
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
|
||||||
|
|
||||||
const groupedSettings = useMemo(() => {
|
const groupedSettings = useMemo(() => {
|
||||||
const groups: Record<string, AdminSetting[]> = {}
|
const groups: Record<string, AdminSetting[]> = {}
|
||||||
@@ -583,115 +808,119 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
||||||
|
const statusNamesBySection: Record<string, string[]> = {
|
||||||
|
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
|
||||||
|
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
|
||||||
|
jellyfin: ['Jellyfin'],
|
||||||
|
sonarr: ['Sonarr'],
|
||||||
|
radarr: ['Radarr'],
|
||||||
|
prowlarr: ['Prowlarr'],
|
||||||
|
qbittorrent: ['qBittorrent', 'Qbittorrent'],
|
||||||
|
}
|
||||||
|
const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ''] ?? []
|
||||||
|
const currentServiceStatus = serviceStatuses.find((service) =>
|
||||||
|
statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase())
|
||||||
|
)
|
||||||
|
const currentServiceConfigured = currentServiceStatus
|
||||||
|
? currentServiceStatus.status !== 'not_configured'
|
||||||
|
: null
|
||||||
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||||
const isSiteGroupedSection = section === 'site'
|
const isSiteGroupedSection = section === 'site'
|
||||||
const visibleSections = settingsSection ? [settingsSection] : []
|
|
||||||
const isCacheSection = section === 'cache'
|
const isCacheSection = section === 'cache'
|
||||||
|
const isArtworkSection = section === 'artwork'
|
||||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||||
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
||||||
const generatedSettingKeys = new Set(['site_changelog'])
|
const generatedSettingKeys = new Set(['site_changelog'])
|
||||||
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
|
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
|
||||||
const requestSettingOrder = [
|
const obsoleteSettingKeys = new Set([
|
||||||
'requests_poll_interval_seconds',
|
'sonarr_qbittorrent_category',
|
||||||
'requests_delta_sync_interval_minutes',
|
'radarr_qbittorrent_category',
|
||||||
'requests_full_sync_time',
|
])
|
||||||
'requests_cleanup_time',
|
|
||||||
'requests_cleanup_days',
|
|
||||||
]
|
|
||||||
const siteSettingOrder = [
|
|
||||||
'site_banner_enabled',
|
|
||||||
'site_banner_message',
|
|
||||||
'site_banner_tone',
|
|
||||||
'site_login_show_jellyfin_login',
|
|
||||||
'site_login_show_local_login',
|
|
||||||
'site_login_show_forgot_password',
|
|
||||||
'site_login_show_signup_link',
|
|
||||||
]
|
|
||||||
const sortByOrder = (items: AdminSetting[], order: string[]) => {
|
|
||||||
const position = new Map(order.map((key, index) => [key, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
|
|
||||||
const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
|
|
||||||
if (aIndex !== bIndex) return aIndex - bIndex
|
|
||||||
return a.key.localeCompare(b.key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
|
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
|
||||||
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
||||||
|
const buildDefinedSections = (
|
||||||
|
definitions: Array<{ key: string; title: string; description: string; keys: string[] }>,
|
||||||
|
sourceItems: AdminSetting[],
|
||||||
|
includeUnassigned = true,
|
||||||
|
): SettingsSectionGroup[] => {
|
||||||
|
const byKey = new Map(sourceItems.map((item) => [item.key, item]))
|
||||||
|
const assignedKeys = new Set(definitions.flatMap((group) => group.keys))
|
||||||
|
const groups = definitions.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
title: group.title,
|
||||||
|
description: group.description,
|
||||||
|
items: group.keys
|
||||||
|
.map((key) => byKey.get(key))
|
||||||
|
.filter((item): item is AdminSetting => Boolean(item)),
|
||||||
|
}))
|
||||||
|
if (includeUnassigned) {
|
||||||
|
const unassigned = sourceItems.filter((item) => !assignedKeys.has(item.key))
|
||||||
|
if (unassigned.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
key: `${section}-additional`,
|
||||||
|
title: 'Additional Settings',
|
||||||
|
description: 'Settings returned by Magent that do not yet belong to a dedicated subsection.',
|
||||||
|
items: unassigned.sort((a, b) => a.key.localeCompare(b.key)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
const standardDefinitions = STANDARD_SECTION_GROUPS[section]
|
||||||
|
const standardItems = settingsSection
|
||||||
|
? (groupedSettings[settingsSection] ?? []).filter(
|
||||||
|
(setting) => !obsoleteSettingKeys.has(setting.key) && !hiddenSettingKeys.has(setting.key),
|
||||||
|
)
|
||||||
|
: []
|
||||||
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
||||||
? [
|
? [
|
||||||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
{
|
||||||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
key: 'cache',
|
||||||
|
title: 'Request Cache Strategy',
|
||||||
|
description: 'Choose where request pages read from and how long cached request records remain fresh.',
|
||||||
|
items: cacheSettings,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
: isArtworkSection
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'artwork',
|
||||||
|
title: 'Artwork Delivery and Storage',
|
||||||
|
description: 'Choose how posters and backdrops are delivered, then inspect or rebuild the local artwork cache.',
|
||||||
|
items: artworkSettings,
|
||||||
|
},
|
||||||
|
]
|
||||||
: isMagentGroupedSection
|
: isMagentGroupedSection
|
||||||
? (() => {
|
? (() => {
|
||||||
if (section === 'magent') {
|
if (section === 'magent') {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const magentItems = groupedSettings.magent ?? []
|
const magentItems = groupedSettings.magent ?? []
|
||||||
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
|
||||||
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
||||||
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
|
const definitions = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key))
|
||||||
allowedGroupKeys.has(group.key),
|
return buildDefinedSections(definitions, magentItems, false)
|
||||||
).map((group) => {
|
|
||||||
const items = group.keys
|
|
||||||
.map((key) => byKey.get(key))
|
|
||||||
.filter((item): item is AdminSetting => Boolean(item))
|
|
||||||
return {
|
|
||||||
key: group.key,
|
|
||||||
title: group.title,
|
|
||||||
description: group.description,
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return groups
|
|
||||||
})()
|
})()
|
||||||
: isSiteGroupedSection
|
: isSiteGroupedSection
|
||||||
? (() => {
|
? buildDefinedSections(
|
||||||
const siteItems = groupedSettings.site ?? []
|
SITE_SECTION_GROUPS,
|
||||||
const byKey = new Map(siteItems.map((item) => [item.key, item]))
|
(groupedSettings.site ?? []).filter((setting) => !hiddenSettingKeys.has(setting.key)),
|
||||||
return SITE_SECTION_GROUPS.map((group) => {
|
)
|
||||||
const items = group.keys
|
: standardDefinitions
|
||||||
.map((key) => byKey.get(key))
|
? buildDefinedSections(standardDefinitions, standardItems)
|
||||||
.filter((item): item is AdminSetting => Boolean(item))
|
: []
|
||||||
return {
|
|
||||||
key: group.key,
|
|
||||||
title: group.title,
|
|
||||||
description: group.description,
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
: visibleSections.map((sectionKey) => ({
|
|
||||||
key: sectionKey,
|
|
||||||
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
|
||||||
items: (() => {
|
|
||||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
|
||||||
const filtered =
|
|
||||||
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
|
|
||||||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
|
||||||
: sectionItems
|
|
||||||
if (sectionKey === 'requests') {
|
|
||||||
return sortByOrder(filtered, requestSettingOrder)
|
|
||||||
}
|
|
||||||
if (sectionKey === 'site') {
|
|
||||||
return sortByOrder(filtered, siteSettingOrder)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
})(),
|
|
||||||
}))
|
|
||||||
const showLogs = section === 'logs'
|
const showLogs = section === 'logs'
|
||||||
const showMaintenance = section === 'maintenance'
|
const showMaintenance = section === 'maintenance'
|
||||||
const showRequestsExtras = section === 'requests'
|
const showRequestsExtras = section === 'requests'
|
||||||
const showArtworkExtras = section === 'cache'
|
const showArtworkExtras = section === 'artwork'
|
||||||
const showCacheExtras = section === 'cache'
|
const showCacheExtras = section === 'cache'
|
||||||
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
||||||
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
||||||
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
||||||
if (showCacheExtras && sectionGroup.key === 'cache') return true
|
if (showCacheExtras && sectionGroup.key === 'cache') return true
|
||||||
if (showRequestsExtras && sectionGroup.key === 'requests') return true
|
if (showRequestsExtras && sectionGroup.key === 'requests-sync') return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const renderedSettingsSections = settingsSections.filter(shouldRenderSection)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestsSyncRef.current = requestsSync
|
requestsSyncRef.current = requestsSync
|
||||||
@@ -702,6 +931,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [artworkPrefetch])
|
}, [artworkPrefetch])
|
||||||
|
|
||||||
const settingDescriptions: Record<string, string> = {
|
const settingDescriptions: Record<string, string> = {
|
||||||
|
issue_confirmation_contact_attempts:
|
||||||
|
'Number of confirmation emails sent after an issue is marked fixed. Set 0 to send none and close immediately.',
|
||||||
|
issue_confirmation_interval_value:
|
||||||
|
'Amount of time between confirmation emails, and the final waiting period before automatic closure.',
|
||||||
|
issue_confirmation_interval_unit:
|
||||||
|
'Unit used for the confirmation interval: days, weeks, or months.',
|
||||||
magent_application_url:
|
magent_application_url:
|
||||||
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
|
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
|
||||||
magent_application_port:
|
magent_application_port:
|
||||||
@@ -773,14 +1008,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
|
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
|
||||||
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
|
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
|
||||||
sonarr_api_key: 'API key for Sonarr.',
|
sonarr_api_key: 'API key for Sonarr.',
|
||||||
|
bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.',
|
||||||
|
bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.',
|
||||||
|
bazarr_default_language: 'Language code Bazarr should search for by default, such as en.',
|
||||||
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
|
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
|
||||||
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
|
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
|
||||||
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
|
|
||||||
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
|
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
|
||||||
radarr_api_key: 'API key for Radarr.',
|
radarr_api_key: 'API key for Radarr.',
|
||||||
radarr_quality_profile_id: 'Quality profile used when adding movies.',
|
radarr_quality_profile_id: 'Quality profile used when adding movies.',
|
||||||
radarr_root_folder: 'Root folder where Radarr stores movies.',
|
radarr_root_folder: 'Root folder where Radarr stores movies.',
|
||||||
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
|
|
||||||
prowlarr_base_url:
|
prowlarr_base_url:
|
||||||
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
|
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
|
||||||
prowlarr_api_key: 'API key for Prowlarr.',
|
prowlarr_api_key: 'API key for Prowlarr.',
|
||||||
@@ -814,6 +1050,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
site_login_show_local_login: 'Show the local Magent login button on the login page.',
|
site_login_show_local_login: 'Show the local Magent login button on the login page.',
|
||||||
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
|
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
|
||||||
site_login_show_signup_link: 'Show the invite signup link on the login page.',
|
site_login_show_signup_link: 'Show the invite signup link on the login page.',
|
||||||
|
site_nav_show_requests:
|
||||||
|
'Show the New Requests item in the top navigation. Disable it while request creation is unavailable.',
|
||||||
site_changelog: 'One update per line for the public changelog.',
|
site_changelog: 'One update per line for the public changelog.',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,6 +1085,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
||||||
jellyfin_public_url: 'https://jelly.example.com',
|
jellyfin_public_url: 'https://jelly.example.com',
|
||||||
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
|
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
|
||||||
|
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
|
||||||
|
bazarr_default_language: 'en',
|
||||||
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
|
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
|
||||||
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
|
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
|
||||||
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
|
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
|
||||||
@@ -875,7 +1115,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
|
|
||||||
const parseActionError = (err: unknown, fallback: string) => {
|
const parseActionError = (err: unknown, fallback: string) => {
|
||||||
if (err instanceof Error && err.message) {
|
if (err instanceof Error && err.message) {
|
||||||
return err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
return err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1303,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not import Jellyfin users.'
|
: 'Could not import Jellyfin users.'
|
||||||
setJellyfinSyncStatus(message)
|
setJellyfinSyncStatus(message)
|
||||||
}
|
}
|
||||||
@@ -1094,7 +1334,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not sync requests.'
|
: 'Could not sync requests.'
|
||||||
setRequestsSyncStatus(message)
|
setRequestsSyncStatus(message)
|
||||||
}
|
}
|
||||||
@@ -1125,7 +1365,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not run delta sync.'
|
: 'Could not run delta sync.'
|
||||||
setRequestsSyncStatus(message)
|
setRequestsSyncStatus(message)
|
||||||
}
|
}
|
||||||
@@ -1155,7 +1395,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not cache artwork.'
|
: 'Could not cache artwork.'
|
||||||
setArtworkPrefetchStatus(message)
|
setArtworkPrefetchStatus(message)
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1426,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not cache missing artwork.'
|
: 'Could not cache missing artwork.'
|
||||||
setArtworkPrefetchStatus(message)
|
setArtworkPrefetchStatus(message)
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1471,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
setLiveStreamConnected(true)
|
setLiveStreamConnected(true)
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const payload = JSON.parse(event.data)
|
||||||
if (!payload || payload.type !== 'admin_live_state') {
|
if (payload?.type !== 'admin_live_state') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,7 +1641,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not load logs.'
|
: 'Could not load logs.'
|
||||||
setLogsStatus(message)
|
setLogsStatus(message)
|
||||||
}
|
}
|
||||||
@@ -1443,7 +1683,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message
|
err instanceof Error && err.message
|
||||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
|
||||||
: 'Could not load cache.'
|
: 'Could not load cache.'
|
||||||
setCacheStatus(message)
|
setCacheStatus(message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1576,10 +1816,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<span>Maintenance job</span>
|
<span>Maintenance job</span>
|
||||||
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
|
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="cache-rail-metric">
|
|
||||||
<span>Live updates</span>
|
|
||||||
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="cache-rail-metric">
|
<div className="cache-rail-metric">
|
||||||
<span>Log lines in view</span>
|
<span>Log lines in view</span>
|
||||||
<strong>{logsLines.length}</strong>
|
<strong>{logsLines.length}</strong>
|
||||||
@@ -1593,8 +1829,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
const cacheRail = showCacheExtras ? (
|
const cacheRail = showCacheExtras ? (
|
||||||
<div className="admin-rail-stack">
|
<div className="admin-rail-card cache-rail-card">
|
||||||
<div className="admin-rail-card cache-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">Cache control</span>
|
<span className="admin-rail-eyebrow">Cache control</span>
|
||||||
<h2>Saved requests</h2>
|
<h2>Saved requests</h2>
|
||||||
<p>Load and inspect cached request entries from the right rail.</p>
|
<p>Load and inspect cached request entries from the right rail.</p>
|
||||||
@@ -1639,8 +1874,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-rail-card cache-rail-card">
|
) : undefined
|
||||||
|
const artworkRail = showArtworkExtras ? (
|
||||||
|
<div className="admin-rail-card cache-rail-card">
|
||||||
<span className="admin-rail-eyebrow">Artwork</span>
|
<span className="admin-rail-eyebrow">Artwork</span>
|
||||||
<h2>Cache stats</h2>
|
<h2>Cache stats</h2>
|
||||||
<div className="cache-rail-metrics">
|
<div className="cache-rail-metrics">
|
||||||
@@ -1661,7 +1898,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<strong>{artworkSummary?.cache_mode ?? '--'}</strong>
|
<strong>{artworkSummary?.cache_mode ?? '--'}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|
||||||
@@ -1673,7 +1909,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<AdminShell
|
<AdminShell
|
||||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||||
rail={maintenanceRail ?? cacheRail}
|
rail={maintenanceRail ?? cacheRail ?? artworkRail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
<button type="button" onClick={() => router.push('/admin')}>
|
||||||
Back to settings
|
Back to settings
|
||||||
@@ -1681,27 +1917,72 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status && <div className="error-banner">{status}</div>}
|
{status && <div className="error-banner">{status}</div>}
|
||||||
{settingsSections.length > 0 ? (
|
{currentServiceStatus ? (
|
||||||
|
<section className="admin-section admin-zone service-status-panel">
|
||||||
|
<div className="service-status-summary">
|
||||||
|
<span className={`system-dot system-dot-${currentServiceStatus.status}`} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Connection status</span>
|
||||||
|
<h2>{currentServiceStatus.name}</h2>
|
||||||
|
<p className="section-subtitle">
|
||||||
|
{currentServiceStatus.message ?? 'No service message was returned.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="service-status-grid">
|
||||||
|
<div>
|
||||||
|
<span>Status</span>
|
||||||
|
<strong>{currentServiceStatus.status.replaceAll('_', ' ')}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Configuration</span>
|
||||||
|
<strong>{currentServiceConfigured ? 'Configured' : 'Not configured'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Last checked</span>
|
||||||
|
<strong>
|
||||||
|
{serviceStatusCheckedAt ? new Date(serviceStatusCheckedAt).toLocaleString() : 'Not checked yet'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => void loadServiceStatuses()}>
|
||||||
|
Refresh status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{renderedSettingsSections.length > 1 ? (
|
||||||
|
<nav className="config-subsection-nav" aria-label={`${SECTION_LABELS[section] ?? 'Settings'} subsections`}>
|
||||||
|
<span>On this page</span>
|
||||||
|
<div>
|
||||||
|
{renderedSettingsSections.map((sectionGroup, index) => (
|
||||||
|
<a key={sectionGroup.key} href={`#config-${sectionGroup.key}`}>
|
||||||
|
<small>{String(index + 1).padStart(2, '0')}</small>
|
||||||
|
{sectionGroup.title}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
{renderedSettingsSections.length > 0 ? (
|
||||||
<div className="admin-form admin-zone-stack">
|
<div className="admin-form admin-zone-stack">
|
||||||
{settingsSections
|
{renderedSettingsSections.map((sectionGroup, sectionIndex) => (
|
||||||
.filter(shouldRenderSection)
|
<section id={`config-${sectionGroup.key}`} key={sectionGroup.key} className="admin-section admin-zone config-subsection">
|
||||||
.map((sectionGroup) => (
|
|
||||||
<section key={sectionGroup.key} className="admin-section admin-zone">
|
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>
|
<div className="config-subsection-heading">
|
||||||
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
|
<span className="section-kicker">Subsection {String(sectionIndex + 1).padStart(2, '0')}</span>
|
||||||
</h2>
|
<h2>{sectionGroup.title}</h2>
|
||||||
{sectionGroup.key === 'sonarr' && (
|
</div>
|
||||||
|
{sectionGroup.key === 'sonarr-library' && (
|
||||||
<button type="button" onClick={() => loadOptions('sonarr')}>
|
<button type="button" onClick={() => loadOptions('sonarr')}>
|
||||||
Refresh Sonarr options
|
Refresh Sonarr options
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'radarr' && (
|
{sectionGroup.key === 'radarr-library' && (
|
||||||
<button type="button" onClick={() => loadOptions('radarr')}>
|
<button type="button" onClick={() => loadOptions('radarr')}>
|
||||||
Refresh Radarr options
|
Refresh Radarr options
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'jellyfin' && (
|
{sectionGroup.key === 'jellyfin-users' && (
|
||||||
<button type="button" onClick={syncJellyfinUsers}>
|
<button type="button" onClick={syncJellyfinUsers}>
|
||||||
Import Jellyfin users
|
Import Jellyfin users
|
||||||
</button>
|
</button>
|
||||||
@@ -1720,7 +2001,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
|
||||||
<div className="sync-actions-block">
|
<div className="sync-actions-block">
|
||||||
<div className="sync-actions">
|
<div className="sync-actions">
|
||||||
<button type="button" onClick={syncRequests}>
|
<button type="button" onClick={syncRequests}>
|
||||||
@@ -1737,25 +2018,24 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (
|
||||||
(!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
|
<p className="section-subtitle">
|
||||||
<p className="section-subtitle">
|
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||||||
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
|
||||||
{section === 'general' && sectionGroup.key === 'magent-runtime' && (
|
{section === 'general' && sectionGroup.key === 'magent-runtime' && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Runtime host/port and SSL values are configuration settings. Container/process
|
Runtime host/port and SSL values are configuration settings. Container/process
|
||||||
restarts may still be required before bind/port changes take effect.
|
restarts may still be required before bind/port changes take effect.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'sonarr' && sonarrError && (
|
{sectionGroup.key === 'sonarr-library' && sonarrError && (
|
||||||
<div className="error-banner">{sonarrError}</div>
|
<div className="error-banner">{sonarrError}</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'radarr' && radarrError && (
|
{sectionGroup.key === 'radarr-library' && radarrError && (
|
||||||
<div className="error-banner">{radarrError}</div>
|
<div className="error-banner">{radarrError}</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
|
{sectionGroup.key === 'jellyfin-users' && jellyfinSyncStatus && (
|
||||||
<div className="status-banner">{jellyfinSyncStatus}</div>
|
<div className="status-banner">{jellyfinSyncStatus}</div>
|
||||||
)}
|
)}
|
||||||
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
|
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
|
||||||
@@ -1790,10 +2070,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSyncStatus && (
|
||||||
<div className="status-banner">{requestsSyncStatus}</div>
|
<div className="status-banner">{requestsSyncStatus}</div>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Full refresh checks only decide when to run a full refresh. The delta sync interval
|
Full refresh checks only decide when to run a full refresh. The delta sync interval
|
||||||
polls for new or updated requests.
|
polls for new or updated requests.
|
||||||
@@ -1825,7 +2105,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSync && (
|
||||||
<div className="sync-progress">
|
<div className="sync-progress">
|
||||||
<div className="sync-meta">
|
<div className="sync-meta">
|
||||||
<span>Status: {requestsSync.status}</span>
|
<span>Status: {requestsSync.status}</span>
|
||||||
@@ -2170,7 +2450,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<input
|
<input
|
||||||
name={setting.key}
|
name={setting.key}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={setting.key === 'issue_confirmation_contact_attempts' ? 0 : 1}
|
||||||
|
max={setting.key === 'issue_confirmation_contact_attempts' ? 10 : setting.key === 'issue_confirmation_interval_value' ? 365 : undefined}
|
||||||
step={1}
|
step={1}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -2183,6 +2464,32 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (setting.key === 'issue_confirmation_interval_unit') {
|
||||||
|
return (
|
||||||
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
<span className="label-row">
|
||||||
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
|
<span className="meta">
|
||||||
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name={setting.key}
|
||||||
|
value={value || 'days'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[setting.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="days">Days</option>
|
||||||
|
<option value="weeks">Weeks</option>
|
||||||
|
<option value="months">Months</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (setting.key === 'requests_data_source') {
|
if (setting.key === 'requests_data_source') {
|
||||||
return (
|
return (
|
||||||
<label key={setting.key} data-helper={helperText || undefined}>
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
@@ -2314,7 +2621,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
onClick={() => void saveSettingGroup(sectionGroup)}
|
onClick={() => void saveSettingGroup(sectionGroup)}
|
||||||
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
||||||
>
|
>
|
||||||
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
|
{sectionSaving[sectionGroup.key] ? 'Saving...' : `Save ${sectionGroup.title}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'artwork',
|
'artwork',
|
||||||
'sonarr',
|
'sonarr',
|
||||||
'radarr',
|
'radarr',
|
||||||
|
'bazarr',
|
||||||
'prowlarr',
|
'prowlarr',
|
||||||
'qbittorrent',
|
'qbittorrent',
|
||||||
'requests',
|
'requests',
|
||||||
|
'issue-workflow',
|
||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
|
|||||||
+535
-254
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
import PortalClient from '../../portal/PortalClient'
|
||||||
|
|
||||||
|
export default function AdminIssuesPage() {
|
||||||
|
return <PortalClient workspace="issue" />
|
||||||
|
}
|
||||||
+328
-7
@@ -1,24 +1,345 @@
|
|||||||
'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="Settings"
|
title="Admin overview"
|
||||||
subtitle="Choose what you want to manage."
|
subtitle="Service health, request movement, issue intake, and the controls that keep Magent running."
|
||||||
|
rail={rail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/')}>
|
<button type="button" onClick={() => router.push('/admin/diagnostics')}>
|
||||||
Back to requests
|
Run diagnostics
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
||||||
<div className="status-banner">
|
{error ? <div className="error-banner">{error}</div> : null}
|
||||||
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>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {
|
|||||||
<div className="system-guide-grid">
|
<div className="system-guide-grid">
|
||||||
<article className="system-guide-card">
|
<article className="system-guide-card">
|
||||||
<h3>Landing page</h3>
|
<h3>Landing page</h3>
|
||||||
<p>Recent requests and service summaries refresh live for signed-in users.</p>
|
<p>Recent request activity refreshes live for signed-in users.</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="system-guide-card">
|
<article className="system-guide-card">
|
||||||
<h3>Request pages</h3>
|
<h3>Request pages</h3>
|
||||||
@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {
|
|||||||
</article>
|
</article>
|
||||||
<article className="system-guide-card">
|
<article className="system-guide-card">
|
||||||
<h3>Admin views</h3>
|
<h3>Admin views</h3>
|
||||||
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
|
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||||
|
|
||||||
type Profile = {
|
type Profile = {
|
||||||
username?: string
|
username?: string
|
||||||
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearToken()
|
throw new Error('Could not load profile.')
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/feedback`, {
|
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -58,17 +60,16 @@ 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 {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import './ops-redesign.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import HeaderActions from './ui/HeaderActions'
|
|
||||||
import HeaderIdentity from './ui/HeaderIdentity'
|
|
||||||
import ThemeToggle from './ui/ThemeToggle'
|
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import BrandingLogo from './ui/BrandingLogo'
|
import BrandingLogo from './ui/BrandingLogo'
|
||||||
|
import HeaderActions from './ui/HeaderActions'
|
||||||
|
import HeaderIdentity from './ui/HeaderIdentity'
|
||||||
import SiteStatus from './ui/SiteStatus'
|
import SiteStatus from './ui/SiteStatus'
|
||||||
|
import UserViewBanner from './ui/UserViewBanner'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Magent',
|
title: 'Magent',
|
||||||
@@ -24,18 +25,19 @@ 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">Find and fix media requests fast.</div>
|
<div className="tagline">GrizzlyFlix media operations</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<ThemeToggle />
|
<span className="beta-chip" title="Beta environment">Beta</span>
|
||||||
<HeaderIdentity />
|
<HeaderIdentity />
|
||||||
</div>
|
</div>
|
||||||
<div className="header-nav">
|
<div className="header-nav">
|
||||||
<HeaderActions />
|
<HeaderActions />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<UserViewBanner />
|
||||||
<SiteStatus />
|
<SiteStatus />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+72
-12
@@ -1,27 +1,53 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
export const getToken = () => {
|
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof document === 'undefined') return
|
||||||
return window.localStorage.getItem('magent_token')
|
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setToken = (token: string) => {
|
const clearCookie = (name: string) => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
window.localStorage.setItem('magent_token', token)
|
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getToken = () => {
|
||||||
|
if (typeof document === 'undefined') return null
|
||||||
|
const cookies = document.cookie.split(';').map((entry) => entry.trim())
|
||||||
|
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
|
||||||
|
if (!marker) return null
|
||||||
|
const [, value] = marker.split('=', 2)
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setToken = (_token: string) => {
|
||||||
|
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearToken = () => {
|
export const clearToken = () => {
|
||||||
|
clearCookie(AUTH_STATE_COOKIE)
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
window.localStorage.removeItem('magent_token')
|
const baseUrl = getApiBase()
|
||||||
|
void fetch(`${baseUrl}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logout = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
clearCookie(AUTH_STATE_COOKIE)
|
||||||
|
await fetch(`${baseUrl}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const token = getToken()
|
|
||||||
const headers = new Headers(init?.headers || {})
|
const headers = new Headers(init?.headers || {})
|
||||||
if (token) {
|
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||||
headers.set('Authorization', `Bearer ${token}`)
|
|
||||||
}
|
|
||||||
return fetch(input, { ...init, headers })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEventStreamToken = async () => {
|
export const getEventStreamToken = async () => {
|
||||||
@@ -38,3 +64,37 @@ export const getEventStreamToken = async () => {
|
|||||||
}
|
}
|
||||||
return token
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
|
||||||
|
const USER_VIEW_EVENT = 'magent:user-view-change'
|
||||||
|
|
||||||
|
const readUserViewPreview = () => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDocumentMode = (enabled: boolean) => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setUserViewPreview = (enabled: boolean) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (enabled) {
|
||||||
|
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
|
||||||
|
} else {
|
||||||
|
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
applyDocumentMode(enabled)
|
||||||
|
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserViewPreview = () => {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const nextValue = readUserViewPreview()
|
||||||
|
applyDocumentMode(nextValue)
|
||||||
|
setEnabled(nextValue)
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
window.addEventListener(USER_VIEW_EVENT, sync)
|
||||||
|
window.addEventListener('storage', sync)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(USER_VIEW_EVENT, sync)
|
||||||
|
window.removeEventListener('storage', sync)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
@@ -42,13 +42,14 @@ 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?.access_token) {
|
if (data?.authenticated) {
|
||||||
setToken(data.access_token)
|
setToken('cookie')
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
return
|
return
|
||||||
@@ -107,10 +108,17 @@ export default function LoginPage() {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card auth-card">
|
<main className="auth-screen">
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<section className="auth-hero">
|
||||||
<h1>Sign in</h1>
|
<div className="auth-mark">
|
||||||
<p className="lede">{loginHelpText}</p>
|
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||||
|
</div>
|
||||||
|
<div className="auth-title-block">
|
||||||
|
<span className="section-kicker">Secure access</span>
|
||||||
|
<h1>Magent operational gateway</h1>
|
||||||
|
<p>{loginHelpText}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
if (!primaryMode) {
|
if (!primaryMode) {
|
||||||
@@ -120,23 +128,25 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
void submit(event, primaryMode)
|
void submit(event, primaryMode)
|
||||||
}}
|
}}
|
||||||
className="auth-form"
|
className="auth-form auth-panel"
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Username
|
<span>Username</span>
|
||||||
<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>
|
||||||
Password
|
<span>Password</span>
|
||||||
<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>}
|
||||||
@@ -170,6 +180,10 @@ export default function LoginPage() {
|
|||||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="auth-footnote">
|
||||||
|
<span className="live-dot" aria-hidden="true" />
|
||||||
|
Beta environment
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import NewRequestClient from './NewRequestClient'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'New Requests | Magent',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewRequestsPage() {
|
||||||
|
return <NewRequestClient />
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+141
-338
@@ -64,14 +64,6 @@ export default function HomePage() {
|
|||||||
const [recentDays, setRecentDays] = useState(90)
|
const [recentDays, setRecentDays] = useState(90)
|
||||||
const [recentStage, setRecentStage] = useState('all')
|
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 [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
|
||||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
|
||||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
|
||||||
|
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -84,61 +76,6 @@ export default function HomePage() {
|
|||||||
void runSearch(trimmed)
|
void runSearch(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
||||||
|
|
||||||
const updateServiceStatus = (name: string, status: string, message?: string) => {
|
|
||||||
setServicesStatus((prev) => {
|
|
||||||
if (!prev) return prev
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
services: prev.services.map((service) =>
|
|
||||||
service.name === name ? { ...service, status, message } : service
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const testService = async (name: string) => {
|
|
||||||
const slug = toServiceSlug(name)
|
|
||||||
setServiceTesting((prev) => ({ ...prev, [name]: true }))
|
|
||||||
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || `Service test failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const status = data?.status ?? 'unknown'
|
|
||||||
const message =
|
|
||||||
data?.message ||
|
|
||||||
(status === 'up'
|
|
||||||
? 'API OK'
|
|
||||||
: status === 'down'
|
|
||||||
? 'API unreachable'
|
|
||||||
: status === 'degraded'
|
|
||||||
? 'Health warnings'
|
|
||||||
: status === 'not_configured'
|
|
||||||
? 'Not configured'
|
|
||||||
: 'Unknown')
|
|
||||||
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
|
|
||||||
updateServiceStatus(name, status, data?.message)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
|
|
||||||
} finally {
|
|
||||||
setServiceTesting((prev) => ({ ...prev, [name]: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -198,45 +135,7 @@ export default function HomePage() {
|
|||||||
if (!authReady) {
|
if (!authReady) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const load = async () => {
|
|
||||||
setServicesLoading(true)
|
|
||||||
setServicesError(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/status/services`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Service status failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setServicesStatus(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
setServicesError('Service status is not available right now.')
|
|
||||||
} finally {
|
|
||||||
setServicesLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load()
|
|
||||||
if (liveStreamConnected) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const timer = setInterval(load, 30000)
|
|
||||||
return () => clearInterval(timer)
|
|
||||||
}, [authReady, liveStreamConnected, router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authReady) {
|
|
||||||
setLiveStreamConnected(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
setLiveStreamConnected(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
@@ -257,14 +156,8 @@ export default function HomePage() {
|
|||||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
||||||
source = new EventSource(streamUrl)
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
source.onopen = () => {
|
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
setLiveStreamConnected(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const payload = JSON.parse(event.data)
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
@@ -281,29 +174,14 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_services') {
|
|
||||||
if (payload.status && typeof payload.status === 'object') {
|
|
||||||
setServicesStatus(payload.status)
|
|
||||||
setServicesError(null)
|
|
||||||
setServicesLoading(false)
|
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
|
||||||
setServicesError('Service status is not available right now.')
|
|
||||||
setServicesLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.onerror = () => {
|
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setLiveStreamConnected(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +189,6 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true
|
closed = true
|
||||||
setLiveStreamConnected(false)
|
|
||||||
source?.close()
|
source?.close()
|
||||||
}
|
}
|
||||||
}, [authReady, recentDays, recentStage])
|
}, [authReady, recentDays, recentStage])
|
||||||
@@ -362,230 +239,156 @@ export default function HomePage() {
|
|||||||
return date.toLocaleString()
|
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">
|
<main className="card home-page">
|
||||||
<div className="layout-grid">
|
<section className="home-command">
|
||||||
<section className="recent centerpiece">
|
<div className="home-command-copy">
|
||||||
<div className="system-status">
|
<span className="section-kicker">Request lookup</span>
|
||||||
<div className="system-header">
|
<h1>My requests</h1>
|
||||||
<h2>System status</h2>
|
<p>
|
||||||
<span
|
Enter a title and year, or jump straight to a request using its request number.
|
||||||
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="home-search">
|
||||||
|
<label htmlFor="request-search">Title, year, or request number</label>
|
||||||
|
<div className="home-search-row">
|
||||||
|
<input
|
||||||
|
id="request-search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Dune 2021 or 1289"
|
||||||
|
/>
|
||||||
|
<button type="submit">Find request</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(searchError || searchResults.length > 0) && (
|
||||||
|
<section className="home-search-results" aria-live="polite">
|
||||||
|
<div className="home-section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Search results</span>
|
||||||
|
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => {
|
||||||
|
setSearchResults([])
|
||||||
|
setSearchError(null)
|
||||||
|
}}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{searchError ? (
|
||||||
|
<div className="error-banner">{searchError}</div>
|
||||||
|
) : (
|
||||||
|
<div className="home-result-grid">
|
||||||
|
{searchResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${item.title || 'Untitled'}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className="home-result-card"
|
||||||
|
disabled={!item.requestId}
|
||||||
|
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
||||||
>
|
>
|
||||||
{servicesLoading
|
<span>
|
||||||
? 'Checking services...'
|
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
|
||||||
: servicesError
|
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
|
||||||
? 'Status not available yet'
|
</span>
|
||||||
: servicesStatus?.overall === 'up'
|
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
|
||||||
? 'Services are up and running'
|
|
||||||
: servicesStatus?.overall === 'down'
|
|
||||||
? 'Something is down'
|
|
||||||
: 'Some services need attention'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="system-list">
|
|
||||||
{(() => {
|
|
||||||
const order = [
|
|
||||||
'Seerr',
|
|
||||||
'Sonarr',
|
|
||||||
'Radarr',
|
|
||||||
'Prowlarr',
|
|
||||||
'qBittorrent',
|
|
||||||
'Jellyfin',
|
|
||||||
]
|
|
||||||
const items = servicesStatus?.services ?? []
|
|
||||||
return order.map((name) => {
|
|
||||||
const item = items.find((entry) => entry.name === name)
|
|
||||||
const status = item?.status ?? 'unknown'
|
|
||||||
const testing = serviceTesting[name] ?? false
|
|
||||||
return (
|
|
||||||
<div key={name} className={`system-item system-${status}`}>
|
|
||||||
<span className="system-dot" />
|
|
||||||
<div className="system-meta">
|
|
||||||
<span className="system-name">{name}</span>
|
|
||||||
{serviceTestResults[name] && (
|
|
||||||
<span className="system-test-message">{serviceTestResults[name]}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="system-actions">
|
|
||||||
<span className="system-state">
|
|
||||||
{status === 'up'
|
|
||||||
? 'Up'
|
|
||||||
: status === 'down'
|
|
||||||
? 'Down'
|
|
||||||
: status === 'degraded'
|
|
||||||
? 'Needs attention'
|
|
||||||
: status === 'not_configured'
|
|
||||||
? 'Not configured'
|
|
||||||
: 'Unknown'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="system-test"
|
|
||||||
onClick={() => void testService(name)}
|
|
||||||
disabled={testing}
|
|
||||||
>
|
|
||||||
{testing ? 'Testing...' : 'Test'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="recent-header">
|
|
||||||
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
|
||||||
{authReady && (
|
|
||||||
<div className="recent-filter-group">
|
|
||||||
<label className="recent-filter">
|
|
||||||
<span>Show</span>
|
|
||||||
<select
|
|
||||||
value={recentDays}
|
|
||||||
onChange={(event) => setRecentDays(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
<option value={0}>All</option>
|
|
||||||
<option value={30}>30 days</option>
|
|
||||||
<option value={60}>60 days</option>
|
|
||||||
<option value={90}>90 days</option>
|
|
||||||
<option value={180}>180 days</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="recent-filter">
|
|
||||||
<span>Stage</span>
|
|
||||||
<select
|
|
||||||
value={recentStage}
|
|
||||||
onChange={(event) => setRecentStage(event.target.value)}
|
|
||||||
>
|
|
||||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="recent-grid">
|
|
||||||
{recentLoading ? (
|
|
||||||
<div className="loading-center">
|
|
||||||
<div className="spinner" aria-hidden="true" />
|
|
||||||
<span className="loading-text">Loading recent requests…</span>
|
|
||||||
</div>
|
|
||||||
) : recentError ? (
|
|
||||||
<button type="button" disabled>
|
|
||||||
{recentError}
|
|
||||||
</button>
|
</button>
|
||||||
) : recent.length === 0 ? (
|
))}
|
||||||
<button type="button" disabled>
|
|
||||||
No recent requests found
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
recent.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`/requests/${item.id}`)}
|
|
||||||
className="recent-card"
|
|
||||||
>
|
|
||||||
{item.artwork?.poster_url && (
|
|
||||||
<img
|
|
||||||
className="recent-poster"
|
|
||||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="recent-info">
|
|
||||||
<span className="recent-title">
|
|
||||||
{item.title || 'Untitled'}
|
|
||||||
{item.year ? ` (${item.year})` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="recent-meta">
|
|
||||||
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
|
|
||||||
{item.id}
|
|
||||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
<aside className="side-panel">
|
)}
|
||||||
<section className="main-panel find-panel">
|
|
||||||
<div className="find-header">
|
<section className="home-metric-strip" aria-label="Request summary">
|
||||||
<h1>Search all requests</h1>
|
<div><span>In view</span><strong>{recent.length}</strong></div>
|
||||||
<p className="lede">
|
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
|
||||||
Search any request by title + year or request number and see whether it already
|
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
|
||||||
exists in the system.
|
</section>
|
||||||
</p>
|
|
||||||
|
<section className="recent home-recent">
|
||||||
|
<div className="recent-header home-section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Request activity</span>
|
||||||
|
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
|
||||||
|
</div>
|
||||||
|
{authReady && (
|
||||||
|
<div className="recent-filter-group">
|
||||||
|
<label className="recent-filter">
|
||||||
|
<span>Period</span>
|
||||||
|
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
|
||||||
|
<option value={0}>All time</option>
|
||||||
|
<option value={30}>30 days</option>
|
||||||
|
<option value={60}>60 days</option>
|
||||||
|
<option value={90}>90 days</option>
|
||||||
|
<option value={180}>180 days</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="recent-filter">
|
||||||
|
<span>Stage</span>
|
||||||
|
<select value={recentStage} onChange={(event) => setRecentStage(event.target.value)}>
|
||||||
|
{REQUEST_STAGE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="find-controls">
|
)}
|
||||||
<form onSubmit={submit} className="search search-row">
|
</div>
|
||||||
<input
|
<div className="recent-grid home-recent-grid">
|
||||||
value={query}
|
{recentLoading ? (
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
<div className="loading-center">
|
||||||
placeholder="e.g. Dune 2021 or 1289"
|
<div className="spinner" aria-hidden="true" />
|
||||||
/>
|
<span className="loading-text">Loading recent requests...</span>
|
||||||
<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>
|
</div>
|
||||||
<section className="recent results-panel">
|
) : recentError ? (
|
||||||
<h2>Search results</h2>
|
<div className="error-banner">{recentError}</div>
|
||||||
<div className="recent-grid">
|
) : recent.length === 0 ? (
|
||||||
{searchError ? (
|
<div className="home-empty-state">
|
||||||
<button type="button" disabled>
|
<strong>No requests match these filters</strong>
|
||||||
{searchError}
|
<span>Try a wider period or a different stage.</span>
|
||||||
</button>
|
</div>
|
||||||
) : searchResults.length === 0 ? (
|
) : (
|
||||||
<button type="button" disabled>
|
recent.map((item) => (
|
||||||
No matches yet
|
<button
|
||||||
</button>
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/requests/${item.id}`)}
|
||||||
|
className="recent-card"
|
||||||
|
>
|
||||||
|
{item.artwork?.poster_url ? (
|
||||||
|
<img
|
||||||
|
className="recent-poster"
|
||||||
|
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
searchResults.map((item, index) => (
|
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
|
||||||
<button
|
|
||||||
key={`${item.title || 'Untitled'}-${index}`}
|
|
||||||
type="button"
|
|
||||||
disabled={!item.requestId}
|
|
||||||
onClick={() =>
|
|
||||||
item.requestId && router.push(`/requests/${item.requestId}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
|
|
||||||
{!item.requestId
|
|
||||||
? '- not requested'
|
|
||||||
: item.statusLabel
|
|
||||||
? `- ${item.statusLabel}`
|
|
||||||
: '- already requested'}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="recent-info">
|
||||||
</section>
|
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
|
||||||
</section>
|
<span className="recent-meta">
|
||||||
</aside>
|
{item.statusLabel || 'Status not available yet'} · Request {item.id}
|
||||||
</div>
|
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="recent-open-cue" aria-hidden="true">Open</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
import PortalClient from '../PortalClient'
|
||||||
|
|
||||||
|
export default function IssuePortalPage() {
|
||||||
|
return <PortalClient workspace="issue" />
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function PortalIndexPage() {
|
||||||
|
redirect('/new-requests')
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function RequestPortalPage() {
|
||||||
|
redirect('/new-requests')
|
||||||
|
}
|
||||||
@@ -1,137 +1,75 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, 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; role: string; invite_management_enabled?: boolean }
|
||||||
username: string
|
|
||||||
role: string
|
|
||||||
auth_provider: string
|
|
||||||
invite_management_enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileResponse = {
|
|
||||||
user: ProfileInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
type OwnedInvite = {
|
type OwnedInvite = {
|
||||||
id: number
|
id: number; code: string; label?: string | null; description?: string | null
|
||||||
code: string
|
recipient_email?: string | null; max_uses?: number | null; use_count: number
|
||||||
label?: string | null
|
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
|
||||||
description?: string | null
|
is_usable?: boolean; created_at?: string | null
|
||||||
recipient_email?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
use_count: number
|
|
||||||
remaining_uses?: number | null
|
|
||||||
enabled: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
is_expired?: boolean
|
|
||||||
is_usable?: boolean
|
|
||||||
created_at?: string | null
|
|
||||||
updated_at?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnedInvitesResponse = {
|
type OwnedInvitesResponse = {
|
||||||
invites?: OwnedInvite[]
|
invites?: OwnedInvite[]
|
||||||
count?: number
|
invite_access?: { enabled?: boolean; managed_by_master?: boolean }
|
||||||
invite_access?: {
|
master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null
|
||||||
enabled?: boolean
|
|
||||||
managed_by_master?: boolean
|
|
||||||
}
|
|
||||||
master_invite?: {
|
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
label?: string | null
|
|
||||||
description?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
enabled?: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
is_usable?: boolean
|
|
||||||
} | null
|
|
||||||
}
|
}
|
||||||
|
type InviteForm = {
|
||||||
type OwnedInviteForm = {
|
code: string; label: string; description: string; recipient_email: string
|
||||||
code: string
|
enabled: boolean; message: string
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
recipient_email: string
|
|
||||||
max_uses: string
|
|
||||||
expires_at: string
|
|
||||||
enabled: boolean
|
|
||||||
send_email: boolean
|
|
||||||
message: string
|
|
||||||
}
|
}
|
||||||
|
type DeliveryMethod = '' | 'manual' | 'email'
|
||||||
|
|
||||||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
const defaultInviteForm = (): InviteForm => ({
|
||||||
code: '',
|
code: '', label: '', description: '', recipient_email: '', enabled: true, message: '',
|
||||||
label: '',
|
|
||||||
description: '',
|
|
||||||
recipient_email: '',
|
|
||||||
max_uses: '',
|
|
||||||
expires_at: '',
|
|
||||||
enabled: true,
|
|
||||||
send_email: false,
|
|
||||||
message: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
const formatDate = (value?: string | null) => {
|
||||||
if (!value) return 'Never'
|
if (!value) return 'Never'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString()
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||||
|
|
||||||
export default function ProfileInvitesPage() {
|
export default function ProfileInvitesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
|
||||||
const [inviteError, setInviteError] = useState<string | null>(null)
|
|
||||||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||||||
const [inviteSaving, setInviteSaving] = useState(false)
|
|
||||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
|
||||||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
|
||||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [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(() => {
|
const signupBaseUrl = useMemo(() => {
|
||||||
if (typeof window === 'undefined') return '/signup'
|
if (typeof window === 'undefined') return '/signup'
|
||||||
return `${window.location.origin}/signup`
|
return `${window.location.origin}/signup`
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadInvites = async () => {
|
||||||
const baseUrl = getApiBase()
|
const response = await authFetch(`${getApiBase()}/auth/profile/invites`)
|
||||||
const [profileResponse, invitesResponse] = await Promise.all([
|
if (!response.ok) {
|
||||||
authFetch(`${baseUrl}/auth/profile`),
|
if (response.status === 401) {
|
||||||
authFetch(`${baseUrl}/auth/profile/invites`),
|
|
||||||
])
|
|
||||||
if (!profileResponse.ok || !invitesResponse.ok) {
|
|
||||||
if (profileResponse.status === 401 || invitesResponse.status === 401) {
|
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error('Could not load invite tools.')
|
throw new Error('Could not load your invite workspace.')
|
||||||
}
|
}
|
||||||
const [profileData, inviteData] = (await Promise.all([
|
const data = (await response.json()) as OwnedInvitesResponse
|
||||||
profileResponse.json(),
|
setInvites(Array.isArray(data.invites) ? data.invites : [])
|
||||||
invitesResponse.json(),
|
setInviteAccessEnabled(Boolean(data.invite_access?.enabled))
|
||||||
])) as [ProfileResponse, OwnedInvitesResponse]
|
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master))
|
||||||
const user = profileData?.user ?? {}
|
setMasterInvite(data.master_invite ?? null)
|
||||||
setProfile({
|
|
||||||
username: user?.username ?? 'Unknown',
|
|
||||||
role: user?.role ?? 'user',
|
|
||||||
auth_provider: user?.auth_provider ?? 'local',
|
|
||||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
|
||||||
})
|
|
||||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
|
||||||
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
|
||||||
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,10 +79,21 @@ export default function ProfileInvitesPage() {
|
|||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
await loadPage()
|
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`)
|
||||||
|
if (!profileResponse.ok) {
|
||||||
|
if (profileResponse.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('Could not load your profile.')
|
||||||
|
}
|
||||||
|
const profileData = await profileResponse.json()
|
||||||
|
setProfile(profileData?.user ?? null)
|
||||||
|
await loadInvites()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
|
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -152,80 +101,65 @@ export default function ProfileInvitesPage() {
|
|||||||
void load()
|
void load()
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
const resetInviteEditor = () => {
|
const resetFlow = () => {
|
||||||
setInviteEditingId(null)
|
setEditingId(null)
|
||||||
setInviteForm(defaultOwnedInviteForm())
|
setFlowStep(1)
|
||||||
|
setUseCustomCode(false)
|
||||||
|
setDeliveryMethod('')
|
||||||
|
setInviteForm(defaultInviteForm())
|
||||||
}
|
}
|
||||||
|
|
||||||
const editInvite = (invite: OwnedInvite) => {
|
const editInvite = (invite: OwnedInvite) => {
|
||||||
setInviteEditingId(invite.id)
|
setEditingId(invite.id)
|
||||||
setInviteError(null)
|
setCreatedInvite(null)
|
||||||
setInviteStatus(null)
|
setFlowStep(4)
|
||||||
|
setUseCustomCode(true)
|
||||||
|
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
|
||||||
setInviteForm({
|
setInviteForm({
|
||||||
code: invite.code ?? '',
|
code: invite.code,
|
||||||
label: invite.label ?? '',
|
label: invite.label ?? '',
|
||||||
description: invite.description ?? '',
|
description: invite.description ?? '',
|
||||||
recipient_email: invite.recipient_email ?? '',
|
recipient_email: invite.recipient_email ?? '',
|
||||||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
|
||||||
expires_at: invite.expires_at ?? '',
|
|
||||||
enabled: invite.enabled !== false,
|
enabled: invite.enabled !== false,
|
||||||
send_email: false,
|
|
||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
}
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
const reloadInvites = async () => {
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Invite refresh failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as OwnedInvitesResponse
|
|
||||||
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
|
||||||
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
|
||||||
setMasterInviteTemplate(data?.master_invite ?? null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
const inviteName = inviteForm.label.trim()
|
||||||
const recipientEmail = inviteForm.recipient_email.trim()
|
const recipientEmail = inviteForm.recipient_email.trim()
|
||||||
if (!recipientEmail) {
|
if (!inviteName) {
|
||||||
setInviteError('Recipient email is required.')
|
setError('Give this invite a name so you can recognise it later.')
|
||||||
setInviteStatus(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isValidEmail(recipientEmail)) {
|
if (!deliveryMethod) {
|
||||||
setInviteError('Recipient email must be valid.')
|
setError('Choose how you want to deliver the invite.')
|
||||||
setInviteStatus(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setInviteSaving(true)
|
if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
|
||||||
setInviteError(null)
|
setError('Enter a valid recipient email address.')
|
||||||
setInviteStatus(null)
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
const response = await authFetch(
|
||||||
inviteEditingId == null
|
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`,
|
||||||
? `${baseUrl}/auth/profile/invites`
|
|
||||||
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
|
||||||
{
|
{
|
||||||
method: inviteEditingId == null ? 'POST' : 'PUT',
|
method: editingId == null ? 'POST' : 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
code: inviteForm.code || null,
|
code: useCustomCode ? inviteForm.code || null : null,
|
||||||
label: inviteForm.label || null,
|
label: inviteName,
|
||||||
description: inviteForm.description || null,
|
description: inviteForm.description || null,
|
||||||
recipient_email: recipientEmail,
|
recipient_email: deliveryMethod === 'email' ? recipientEmail : null,
|
||||||
max_uses: inviteForm.max_uses || null,
|
|
||||||
expires_at: inviteForm.expires_at || null,
|
|
||||||
enabled: inviteForm.enabled,
|
enabled: inviteForm.enabled,
|
||||||
send_email: inviteForm.send_email,
|
send_email: editingId == null && deliveryMethod === 'email',
|
||||||
message: inviteForm.message || null,
|
message: inviteForm.message || null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -236,400 +170,136 @@ export default function ProfileInvitesPage() {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const text = await response.text()
|
throw new Error((await response.text()) || 'Could not save the invite.')
|
||||||
throw new Error(text || 'Invite save failed')
|
|
||||||
}
|
}
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json()
|
||||||
if (data?.email?.status === 'ok') {
|
const savedInvite = data?.invite as OwnedInvite | undefined
|
||||||
setInviteStatus(
|
setStatus(
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
data?.email?.status === 'ok'
|
||||||
)
|
? `Invite created and emailed to ${data.email.recipient_email}.`
|
||||||
} else if (data?.email?.status === 'error') {
|
: data?.email?.status === 'error'
|
||||||
setInviteStatus(
|
? `Invite created, but the email could not be sent: ${data.email.detail}`
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
: editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.'
|
||||||
)
|
)
|
||||||
} else {
|
resetFlow()
|
||||||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
if (editingId == null && savedInvite) setCreatedInvite(savedInvite)
|
||||||
}
|
await loadInvites()
|
||||||
resetInviteEditor()
|
|
||||||
await reloadInvites()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
setError(err instanceof Error ? err.message : 'Could not save the invite.')
|
||||||
} finally {
|
} finally {
|
||||||
setInviteSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteInvite = async (invite: OwnedInvite) => {
|
const deleteInvite = async (invite: OwnedInvite) => {
|
||||||
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return
|
||||||
setInviteError(null)
|
setError(null)
|
||||||
setInviteStatus(null)
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' })
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.')
|
||||||
method: 'DELETE',
|
if (editingId === invite.id) resetFlow()
|
||||||
})
|
setStatus(`Deleted ${invite.label || invite.code}.`)
|
||||||
if (!response.ok) {
|
await loadInvites()
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Invite delete failed')
|
|
||||||
}
|
|
||||||
if (inviteEditingId === invite.id) {
|
|
||||||
resetInviteEditor()
|
|
||||||
}
|
|
||||||
setInviteStatus(`Deleted invite ${invite.code}.`)
|
|
||||||
await reloadInvites()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
setError(err instanceof Error ? err.message : 'Could not delete the invite.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard?.writeText) {
|
await navigator.clipboard.writeText(url)
|
||||||
await navigator.clipboard.writeText(url)
|
setStatus(`Copied the link for ${invite.label || invite.code}.`)
|
||||||
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
} catch {
|
||||||
} else {
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
window.prompt('Copy invite link', url)
|
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 canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||||
|
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : ''
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return <main className="card">Loading invite workspace…</main>
|
||||||
return <main className="card">Loading invite tools...</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
<div className="user-directory-panel-header profile-page-header">
|
<div className="user-directory-panel-header profile-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>My invites</h1>
|
<span className="section-kicker">04 · Invites</span>
|
||||||
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
|
<h1>Invite someone to Grizzlyflix</h1>
|
||||||
</div>
|
<p className="lede">Create a secure invitation one simple decision at a time.</p>
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
|
|
||||||
Back to profile
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{profile ? (
|
{status && <div className="status-banner">{status}</div>}
|
||||||
<div className="status-banner">
|
|
||||||
Signed in as <strong>{profile.username}</strong> ({profile.role}).
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="profile-tabbar">
|
|
||||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
|
||||||
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
|
|
||||||
Overview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={false}
|
|
||||||
onClick={() => router.push('/profile?tab=activity')}
|
|
||||||
>
|
|
||||||
Activity
|
|
||||||
</button>
|
|
||||||
<button type="button" role="tab" aria-selected className="is-active">
|
|
||||||
My invites
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={false}
|
|
||||||
onClick={() => router.push('/profile?tab=security')}
|
|
||||||
>
|
|
||||||
Security
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
|
||||||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
|
||||||
|
|
||||||
{!canManageInvites ? (
|
{!canManageInvites ? (
|
||||||
<section className="profile-section profile-tab-panel">
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Invite access is disabled</h2>
|
<h2>Invites are not enabled for your account</h2>
|
||||||
<p className="lede">
|
<p className="lede">Ask an administrator if you need permission to invite someone.</p>
|
||||||
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
|
|
||||||
</p>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={() => router.push('/profile')}>
|
|
||||||
Return to profile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||||
<div className="user-directory-panel-header">
|
<div className="invite-flow-heading">
|
||||||
<div>
|
<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>
|
||||||
<h2>Invite workspace</h2>
|
{editingId != null && <button type="button" className="ghost-button" onClick={resetFlow}>Cancel edit</button>}
|
||||||
<p className="lede">
|
|
||||||
{inviteManagedByMaster
|
|
||||||
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
|
|
||||||
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="profile-invites-layout">
|
{createdInvite && editingId == null ? (
|
||||||
<div className="profile-invite-form-card">
|
<div className="invite-created-card" role="status">
|
||||||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
<span className="eyebrow">Invite ready</span><h3>{createdInvite.label || 'Your invite'}</h3>
|
||||||
<p className="meta profile-invite-form-lede">
|
<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>
|
||||||
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
|
<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>
|
||||||
</p>
|
<button type="button" className="ghost-button" onClick={() => { setCreatedInvite(null); resetFlow() }}>Create another invite</button>
|
||||||
{inviteManagedByMaster && masterInviteTemplate ? (
|
|
||||||
<div className="status-banner profile-invite-master-banner">
|
|
||||||
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
|
||||||
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Identity</span>
|
|
||||||
<small>Optional code and label for easier tracking.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-grid">
|
|
||||||
<label>
|
|
||||||
<span>Code (optional)</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.code}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Leave blank to auto-generate"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Label</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.label}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Family invite"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Description</span>
|
|
||||||
<small>Optional note shown on the signup page.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control">
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={inviteForm.description}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
description: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional note shown on the signup page"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Delivery</span>
|
|
||||||
<small>Recipient email is required. You can also send the invite immediately after saving.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label>
|
|
||||||
<span>Recipient email (required)</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={inviteForm.recipient_email}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
recipient_email: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Required recipient email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Delivery note</span>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={inviteForm.message}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
message: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional note to include in the email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.send_email}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
send_email: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Send "You have been invited" email after saving
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Limits</span>
|
|
||||||
<small>Usage cap and optional expiry date/time.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-grid">
|
|
||||||
<label>
|
|
||||||
<span>Max uses</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.max_uses}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
|
||||||
}
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="Blank = unlimited"
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Invite expiry (ISO datetime)</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.expires_at}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="2026-03-01T12:00:00+00:00"
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Status</span>
|
|
||||||
<small>Enable or disable this invite before sharing.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.enabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
enabled: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
Invite is enabled
|
|
||||||
</label>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="submit" disabled={inviteSaving}>
|
|
||||||
{inviteSaving
|
|
||||||
? 'Saving…'
|
|
||||||
: inviteEditingId == null
|
|
||||||
? 'Create invite'
|
|
||||||
: 'Save invite'}
|
|
||||||
</button>
|
|
||||||
{inviteEditingId != null && (
|
|
||||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
|
||||||
Cancel edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="meta profile-invite-hint">
|
|
||||||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={saveInvite} className="invite-flow-form">
|
||||||
|
<ol className="invite-flow-route" aria-label="Invite creation progress">
|
||||||
|
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
|
||||||
|
const step = index + 1
|
||||||
|
return <li key={label} className={step === flowStep ? 'is-active' : step < flowStep ? 'is-complete' : ''}><span>{String(step).padStart(2, '0')}</span><strong>{label}</strong></li>
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
<div className="profile-invites-list">
|
<section className={`invite-flow-step ${flowStep > 1 ? 'is-complete' : 'is-active'}`}>
|
||||||
{invites.length === 0 ? (
|
<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="status-banner">You have not created any invites yet.</div>
|
<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>
|
||||||
<div className="admin-list">
|
<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>
|
||||||
{invites.map((invite) => (
|
{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>}
|
||||||
<div key={invite.id} className="admin-list-item">
|
{flowStep === 1 && <div className="invite-flow-actions"><button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>Continue to description</button></div>}
|
||||||
<div className="admin-list-item-main">
|
|
||||||
<div className="admin-list-item-title-row">
|
|
||||||
<code className="invite-code">{invite.code}</code>
|
|
||||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
|
||||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
|
||||||
</span>
|
|
||||||
<span className="small-pill is-muted">
|
|
||||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
|
||||||
{invite.description && (
|
|
||||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
|
||||||
{invite.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="admin-meta-row">
|
|
||||||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
|
||||||
<span>
|
|
||||||
Uses: {invite.use_count}
|
|
||||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
|
||||||
</span>
|
|
||||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
|
||||||
<span>Created: {formatDate(invite.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => copyInviteLink(invite)}
|
|
||||||
>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => editInvite(invite)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</div>
|
|
||||||
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, 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
|
invite_management_enabled?: boolean
|
||||||
@@ -66,6 +67,8 @@ const formatDate = (value?: string | null) => {
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||||
|
|
||||||
const parseBrowser = (agent?: string | null) => {
|
const parseBrowser = (agent?: string | null) => {
|
||||||
if (!agent) return 'Unknown'
|
if (!agent) return 'Unknown'
|
||||||
const value = agent.toLowerCase()
|
const value = agent.toLowerCase()
|
||||||
@@ -81,6 +84,9 @@ export default function ProfilePage() {
|
|||||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
const [stats, setStats] = useState<ProfileStats | null>(null)
|
||||||
const [activity, setActivity] = useState<ProfileActivity | 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 [confirmPassword, setConfirmPassword] = useState('')
|
||||||
@@ -124,6 +130,7 @@ export default function ProfilePage() {
|
|||||||
const user = data?.user ?? {}
|
const user = data?.user ?? {}
|
||||||
setProfile({
|
setProfile({
|
||||||
username: user?.username ?? 'Unknown',
|
username: user?.username ?? 'Unknown',
|
||||||
|
email: user?.email ?? null,
|
||||||
role: user?.role ?? 'user',
|
role: user?.role ?? 'user',
|
||||||
auth_provider: user?.auth_provider ?? 'local',
|
auth_provider: user?.auth_provider ?? 'local',
|
||||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||||
@@ -133,6 +140,7 @@ export default function ProfilePage() {
|
|||||||
? user.password_provider
|
? user.password_provider
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
|
setEmail(user?.email ?? '')
|
||||||
setStats(data?.stats ?? null)
|
setStats(data?.stats ?? null)
|
||||||
setActivity(data?.activity ?? null)
|
setActivity(data?.activity ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -200,6 +208,57 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 authProvider = profile?.auth_provider ?? 'local'
|
||||||
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||||
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||||
@@ -265,11 +324,6 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
Activity
|
Activity
|
||||||
</button>
|
</button>
|
||||||
{canManageInvites ? (
|
|
||||||
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
|
|
||||||
My invites
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -284,6 +338,41 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<section className="profile-section profile-tab-panel">
|
<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 ? (
|
{canManageInvites ? (
|
||||||
<div className="profile-quick-link-card">
|
<div className="profile-quick-link-card">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+911
-592
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,7 @@ function SignupPageContent() {
|
|||||||
const response = await fetch(`${baseUrl}/auth/signup`, {
|
const response = await fetch(`${baseUrl}/auth/signup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
@@ -117,12 +118,12 @@ function SignupPageContent() {
|
|||||||
throw new Error(text || 'Sign-up failed')
|
throw new Error(text || 'Sign-up failed')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.access_token) {
|
if (data?.authenticated) {
|
||||||
setToken(data.access_token)
|
setToken('cookie')
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error('Sign-up did not return a token')
|
throw new Error('Sign-up did not complete')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
<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>
|
||||||
|
|||||||
@@ -4,35 +4,50 @@ import { usePathname } from 'next/navigation'
|
|||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
title: 'Services',
|
title: 'Configuration',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin', label: 'Config overview' },
|
||||||
|
{ href: '/admin/general', label: 'Application & proxy' },
|
||||||
|
{ href: '/admin/site', label: 'Site & login' },
|
||||||
|
{ href: '/admin/notifications', label: 'Notifications' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Media Services',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/general', label: 'General' },
|
|
||||||
{ href: '/admin/seerr', label: 'Seerr' },
|
{ 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: 'Requests',
|
title: 'Request Pipeline',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/requests', label: 'Request sync' },
|
{ href: '/admin/requests', label: 'Sync & retention' },
|
||||||
|
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
|
||||||
|
{ href: '/admin/cache', label: 'Request cache' },
|
||||||
|
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||||
{ href: '/admin/requests-all', label: 'All requests' },
|
{ href: '/admin/requests-all', label: 'All requests' },
|
||||||
{ href: '/admin/cache', label: 'Cache Control' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Admin',
|
title: 'Users & Access',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/notifications', label: 'Notifications' },
|
|
||||||
{ href: '/admin/system', label: 'How it works' },
|
|
||||||
{ href: '/admin/site', label: 'Site' },
|
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/invites', label: 'Invite management' },
|
{ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -49,7 +64,9 @@ export default function AdminSidebar() {
|
|||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== '/' && pathname.startsWith(item.href))
|
(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,14 +1,44 @@
|
|||||||
|
'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 (
|
||||||
<img
|
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
||||||
className={className}
|
{!failed ? (
|
||||||
src="/api/branding/logo.png"
|
<img
|
||||||
alt={alt}
|
className={loaded ? 'is-loaded' : undefined}
|
||||||
/>
|
src="/api/branding/logo.png"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!loaded ? (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#7ed7ff" />
|
||||||
|
<stop offset="100%" stopColor="#c6c1ff" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="12" fill="#0b1328" />
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
|
||||||
|
<path
|
||||||
|
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
|
||||||
|
fill="url(#magentLogoGlow)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
'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 [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 = await authFetch(`${baseUrl}/auth/me`)
|
const [response, siteResponse] = await Promise.all([
|
||||||
|
authFetch(`${baseUrl}/auth/me`),
|
||||||
|
fetch(`${baseUrl}/site/public`).catch(() => null),
|
||||||
|
])
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
setSignedIn(false)
|
setSignedIn(false)
|
||||||
|
setRole(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await response.json()
|
const data = await response.json()
|
||||||
|
setRole(data?.role ?? null)
|
||||||
|
if (siteResponse?.ok) {
|
||||||
|
const siteData = await siteResponse.json()
|
||||||
|
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
|
||||||
|
} else {
|
||||||
|
setShowRequestsNav(true)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
setShowRequestsNav(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
@@ -33,16 +50,68 @@ export default function HeaderActions() {
|
|||||||
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 (
|
||||||
<div className="header-actions">
|
<nav className="header-actions" aria-label="Primary">
|
||||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
{items.map((item, index) => {
|
||||||
<div className="header-actions-center">
|
const active = item.match(pathname)
|
||||||
<a href="/how-it-works">How it works</a>
|
return (
|
||||||
</div>
|
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
||||||
<div className="header-actions-right">
|
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||||
<a href="/">Requests</a>
|
{item.label}
|
||||||
<a href="/profile/invites">Invites</a>
|
</a>
|
||||||
</div>
|
)
|
||||||
</div>
|
})}
|
||||||
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
|
||||||
|
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
|
||||||
|
|
||||||
export default function HeaderIdentity() {
|
export default function HeaderIdentity() {
|
||||||
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
||||||
const [buildNumber, setBuildNumber] = 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()
|
||||||
@@ -27,6 +29,9 @@ 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({ username: data.username, role: data.role })
|
||||||
|
if (data.role !== 'admin') {
|
||||||
|
setUserViewPreview(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const siteResponse = await fetch(`${baseUrl}/site/public`)
|
const siteResponse = await fetch(`${baseUrl}/site/public`)
|
||||||
if (siteResponse.ok) {
|
if (siteResponse.ok) {
|
||||||
@@ -49,7 +54,9 @@ export default function HeaderIdentity() {
|
|||||||
|
|
||||||
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
||||||
const initial = identity.username.slice(0, 1).toUpperCase()
|
const initial = identity.username.slice(0, 1).toUpperCase()
|
||||||
const signOut = () => {
|
const signOut = async () => {
|
||||||
|
setUserViewPreview(false)
|
||||||
|
await logout().catch(() => undefined)
|
||||||
clearToken()
|
clearToken()
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
@@ -57,39 +64,54 @@ export default function HeaderIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signed-in-menu">
|
<div className="signed-in-context">
|
||||||
<button
|
{identity.role === 'admin' ? (
|
||||||
type="button"
|
<button
|
||||||
className="avatar-button"
|
type="button"
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
|
||||||
aria-haspopup="true"
|
aria-pressed={viewAsUser}
|
||||||
aria-expanded={open}
|
onClick={() => setUserViewPreview(!viewAsUser)}
|
||||||
title={label}
|
>
|
||||||
>
|
{viewAsUser ? 'Exit user view' : 'View as user'}
|
||||||
{initial}
|
</button>
|
||||||
</button>
|
) : null}
|
||||||
{open && (
|
<div className="signed-in-menu">
|
||||||
<div className="signed-in-dropdown">
|
<button
|
||||||
<div className="signed-in-header">Signed in as {label}</div>
|
type="button"
|
||||||
<div className="signed-in-actions">
|
className="avatar-button"
|
||||||
<a href="/profile" onClick={() => setOpen(false)}>
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
My profile
|
aria-haspopup="true"
|
||||||
</a>
|
aria-expanded={open}
|
||||||
{identity.role === 'admin' ? (
|
title={label}
|
||||||
<a href="/admin" onClick={() => setOpen(false)}>
|
>
|
||||||
Settings
|
{initial}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="signed-in-dropdown">
|
||||||
|
<div className="signed-in-header">
|
||||||
|
Signed in as {label}
|
||||||
|
{viewAsUser ? <span>Previewing user view</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="signed-in-actions">
|
||||||
|
<a href="/profile" onClick={() => setOpen(false)}>
|
||||||
|
My profile
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
{identity.role === 'admin' ? (
|
||||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
<a href="/admin" onClick={() => setOpen(false)}>
|
||||||
Changelog
|
Settings
|
||||||
</a>
|
</a>
|
||||||
<button type="button" className="signed-in-signout" onClick={signOut}>
|
) : null}
|
||||||
Sign out
|
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||||
</button>
|
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>
|
||||||
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
|
||||||
|
|
||||||
|
export default function UserViewBanner() {
|
||||||
|
const enabled = useUserViewPreview()
|
||||||
|
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-view-banner" role="status">
|
||||||
|
<div>
|
||||||
|
<strong>User view</strong>
|
||||||
|
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setUserViewPreview(false)}>
|
||||||
|
Exit user view
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -101,8 +101,10 @@ export default function UserDetailPage() {
|
|||||||
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
|
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
|
||||||
const [profileSelection, setProfileSelection] = useState('')
|
const [profileSelection, setProfileSelection] = useState('')
|
||||||
const [expiryInput, setExpiryInput] = useState('')
|
const [expiryInput, setExpiryInput] = useState('')
|
||||||
|
const [emailInput, setEmailInput] = useState('')
|
||||||
const [savingProfile, setSavingProfile] = useState(false)
|
const [savingProfile, setSavingProfile] = useState(false)
|
||||||
const [savingExpiry, setSavingExpiry] = useState(false)
|
const [savingExpiry, setSavingExpiry] = useState(false)
|
||||||
|
const [savingEmail, setSavingEmail] = useState(false)
|
||||||
const [systemActionBusy, setSystemActionBusy] = useState(false)
|
const [systemActionBusy, setSystemActionBusy] = useState(false)
|
||||||
const [actionStatus, setActionStatus] = useState<string | null>(null)
|
const [actionStatus, setActionStatus] = useState<string | null>(null)
|
||||||
const [lineage, setLineage] = useState<UserLineage>(null)
|
const [lineage, setLineage] = useState<UserLineage>(null)
|
||||||
@@ -165,6 +167,7 @@ export default function UserDetailPage() {
|
|||||||
: String(nextUser.profile_id)
|
: String(nextUser.profile_id)
|
||||||
)
|
)
|
||||||
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
|
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
|
||||||
|
setEmailInput(nextUser?.email ?? '')
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -218,6 +221,47 @@ export default function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveUserEmail = async (clear = false) => {
|
||||||
|
if (!user) return
|
||||||
|
const email = clear ? '' : emailInput.trim()
|
||||||
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
setError('Enter a valid email address.')
|
||||||
|
setActionStatus(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSavingEmail(true)
|
||||||
|
setError(null)
|
||||||
|
setActionStatus(null)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(
|
||||||
|
`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email || null }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const text = await response.text()
|
||||||
|
let data: any = null
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null
|
||||||
|
} catch {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.detail || text || 'Email update failed')
|
||||||
|
}
|
||||||
|
setEmailInput(data?.user?.email ?? '')
|
||||||
|
await loadUser()
|
||||||
|
setActionStatus(email ? 'Contact email saved.' : 'Contact email removed.')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not update the contact email.')
|
||||||
|
} finally {
|
||||||
|
setSavingEmail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateAutoSearchEnabled = async (enabled: boolean) => {
|
const updateAutoSearchEnabled = async (enabled: boolean) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
try {
|
try {
|
||||||
@@ -546,6 +590,53 @@ export default function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-detail-side-column">
|
<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="admin-panel user-detail-panel">
|
||||||
<div className="user-detail-panel-header">
|
<div className="user-detail-panel-header">
|
||||||
<h2>Access controls</h2>
|
<h2>Access controls</h2>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"app/**/*.{ts,tsx}",
|
||||||
|
"next.config.js",
|
||||||
|
"!node_modules",
|
||||||
|
"!.next"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended",
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noImgElement": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noArrayIndexKey": "off",
|
||||||
|
"noDocumentCookie": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
@@ -1,2 +1,6 @@
|
|||||||
/// <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
+490
-218
File diff suppressed because it is too large
Load Diff
+11
-9
@@ -1,26 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0403261321",
|
"version": "0803262237",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "biome lint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.2.12",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "5.9.3",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@types/node": "24.11.0",
|
"@types/node": "24.11.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3"
|
"@types/react-dom": "19.2.3",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"nanoid": "3.3.18",
|
||||||
|
"postcss": "8.5.25",
|
||||||
|
"sharp": "0.35.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(70 21.00012)">
|
||||||
|
<path d="M105.302 154.943L112.824 869.492C52.651 877.014 7.52158 846.927 7.52158 786.755L0 192.55C0 4.51106 172.996-40.6184 278.298 34.5974L812.33 342.982C887.546 395.633 902.589 493.413 864.981 561.107 857.46 508.456 834.895 478.37 789.765 448.284L188.039 109.813C142.91 79.7268 105.302 87.2484 105.302 154.943Z" fill="#24292E"/>
|
||||||
|
<path d="M0 376.079C45.1295 391.122 90.259 383.6 127.867 361.036L744.636 0C782.244 52.651 774.723 105.302 729.593 135.388L210.604 436.251C135.388 473.859 37.6079 436.251 0 376.079Z" transform="translate(60.17249 531.0214)" fill="#24292E"/>
|
||||||
|
<path d="M0 413.687L368.557 203.083 7.52157 0 0 413.687Z" transform="translate(240.6902 282.8092)" fill="#FFC230"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/>
|
||||||
|
<path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/>
|
||||||
|
<g clip-rule="evenodd">
|
||||||
|
<path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/>
|
||||||
|
<path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/>
|
||||||
|
<path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
python_bin="${PYTHON_BIN:-python3}"
|
||||||
|
|
||||||
|
echo "Installing backend Python requirements"
|
||||||
|
"$python_bin" -m pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
echo "Running Python dependency integrity check"
|
||||||
|
"$python_bin" -m pip check
|
||||||
|
|
||||||
|
echo "Running backend unit tests"
|
||||||
|
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
|
||||||
|
|
||||||
|
echo "Backend quality gate passed"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||||
|
deploy_user="${DEPLOY_USER:-zak}"
|
||||||
|
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||||
|
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||||
|
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
|
||||||
|
remote="${deploy_user}@${deploy_host}"
|
||||||
|
|
||||||
|
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
|
||||||
|
|
||||||
|
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||||
|
set -e
|
||||||
|
mkdir -p '${deploy_path}'
|
||||||
|
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
|
||||||
|
mkdir -p \"\${backup_root}\"
|
||||||
|
cd '${deploy_path}'
|
||||||
|
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||||
|
if [ -e \"\$path\" ]; then
|
||||||
|
cp -a \"\$path\" \"\${backup_root}/\"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
tar -xf - -C '${deploy_path}'
|
||||||
|
docker compose up -d --build
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Running remote smoke checks"
|
||||||
|
ssh ${ssh_opts} "${remote}" "
|
||||||
|
set -e
|
||||||
|
python3 - <<'PY'
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
('http://127.0.0.1:8000/health', 200),
|
||||||
|
('http://127.0.0.1:3000/login', 200),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url, expected in checks:
|
||||||
|
with request.urlopen(url, timeout=20) as response:
|
||||||
|
if response.status != expected:
|
||||||
|
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
||||||
|
print(url, response.status)
|
||||||
|
PY
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Deployment completed successfully"
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||||
|
deploy_user="${DEPLOY_USER:-zak}"
|
||||||
|
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||||
|
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
|
||||||
|
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
|
||||||
|
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||||
|
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
|
||||||
|
remote="${deploy_user}@${deploy_host}"
|
||||||
|
|
||||||
|
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
|
||||||
|
|
||||||
|
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||||
|
set -e
|
||||||
|
mkdir -p '${deploy_path}'
|
||||||
|
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
|
||||||
|
mkdir -p \"\${backup_root}\"
|
||||||
|
cd '${deploy_path}'
|
||||||
|
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||||
|
if [ -e \"\$path\" ]; then
|
||||||
|
cp -a \"\$path\" \"\${backup_root}/\"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
tar -xf - -C '${deploy_path}'
|
||||||
|
|
||||||
|
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
|
||||||
|
cp '${prod_path}/.env' '${deploy_path}/.env'
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p '${deploy_path}/data'
|
||||||
|
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
|
||||||
|
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd '${deploy_path}'
|
||||||
|
docker compose -p magent-beta -f docker-compose.beta.yml build
|
||||||
|
docker compose -p magent-beta -f docker-compose.beta.yml up -d
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Running remote beta smoke checks"
|
||||||
|
ssh ${ssh_opts} "${remote}" "
|
||||||
|
set -e
|
||||||
|
python3 - <<'PY'
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
('http://127.0.0.1:8100/health', 200),
|
||||||
|
('http://${beta_frontend_bind}:3100/login', 200),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url, expected in checks:
|
||||||
|
with request.urlopen(url, timeout=20) as response:
|
||||||
|
if response.status != expected:
|
||||||
|
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
||||||
|
print(url, response.status)
|
||||||
|
PY
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Beta deployment completed successfully"
|
||||||
@@ -2,6 +2,7 @@ $ErrorActionPreference = "Stop"
|
|||||||
|
|
||||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
$env:PYTHONIOENCODING = "utf-8"
|
||||||
|
|
||||||
function Assert-LastExitCode {
|
function Assert-LastExitCode {
|
||||||
param([Parameter(Mandatory = $true)][string]$CommandName)
|
param([Parameter(Mandatory = $true)][string]$CommandName)
|
||||||
|
|||||||
Reference in New Issue
Block a user