Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
615c4c1c29 | ||
|
|
38eee2407b | ||
|
|
cf4277d10c | ||
|
|
030480410b | ||
|
|
3d414b4aeb | ||
|
|
18bbcbf660 | ||
|
|
5fa3aa6665 | ||
|
|
52e3d680f7 | ||
|
|
00bccfa8b6 | ||
|
|
aa3532dd83 | ||
|
|
4ec2351241 | ||
|
|
6480478167 | ||
|
|
3739e11016 | ||
|
|
132e02e06e | ||
|
|
cc79685eaf | ||
|
|
b20cf0a9d2 | ||
|
|
eab212ea8d | ||
|
|
24685a5371 | ||
|
|
49e9ee771f | ||
|
|
69dc7febe2 | ||
|
|
7b8fc1d99b | ||
|
|
7a7d570852 | ||
|
|
3eb4b3f09f | ||
|
|
6425345c69 | ||
|
|
fe43a81175 |
@@ -1 +0,0 @@
|
|||||||
0803262237
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
*.ps1 text eol=crlf
|
|
||||||
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.ico binary
|
|
||||||
*.pdf binary
|
|
||||||
*.zip binary
|
|
||||||
*.gz binary
|
|
||||||
*.tgz binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
name: Magent CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- beta
|
|
||||||
- prod
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: magent-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run backend quality gate
|
|
||||||
run: bash scripts/ci_backend_quality_gate.sh
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
deploy-prod:
|
|
||||||
if: github.ref_name == 'prod'
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure SSH key
|
|
||||||
env:
|
|
||||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
|
||||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy to AMS-DEV01
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
|
||||||
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
|
||||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
|
||||||
run: bash scripts/deploy_ams_dev01.sh
|
|
||||||
|
|
||||||
deploy-beta:
|
|
||||||
if: github.ref_name == 'beta'
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure SSH key
|
|
||||||
env:
|
|
||||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
|
||||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy beta to AMS-DEV01
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
|
||||||
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
|
||||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
|
||||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
FROM node:24-slim AS frontend-builder
|
|
||||||
|
|
||||||
WORKDIR /frontend
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
|
|
||||||
NEXT_PUBLIC_API_BASE=/api
|
|
||||||
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
|
||||||
RUN npm ci --include=dev
|
|
||||||
|
|
||||||
COPY frontend/app ./app
|
|
||||||
COPY frontend/public ./public
|
|
||||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
|
||||||
COPY frontend/next.config.js ./next.config.js
|
|
||||||
COPY frontend/tsconfig.json ./tsconfig.json
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY backend/app ./app
|
|
||||||
COPY data/branding /app/data/branding
|
|
||||||
|
|
||||||
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
|
|
||||||
COPY --from=frontend-builder /frontend/public /app/frontend/public
|
|
||||||
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
|
||||||
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
|
||||||
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
|
||||||
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
|
|
||||||
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
|
||||||
|
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
|
||||||
|
|
||||||
EXPOSE 3000 8000
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Magent
|
# Magent
|
||||||
|
|
||||||
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1) Requests are pulled from Seerr and stored locally.
|
1) Requests are pulled from Jellyseerr and stored locally.
|
||||||
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
||||||
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
||||||
4) The UI renders a timeline and a central status box for each request.
|
4) The UI renders a timeline and a central status box for each request.
|
||||||
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
|
|||||||
|
|
||||||
- Request search by title/year or request ID.
|
- Request search by title/year or request ID.
|
||||||
- Recent requests list with posters and status.
|
- Recent requests list with posters and status.
|
||||||
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
||||||
- Central status box with clear reason + next steps.
|
- Central status box with clear reason + next steps.
|
||||||
- Safe action buttons (search, resume, re-add, etc.).
|
- Safe action buttons (search, resume, re-add, etc.).
|
||||||
- Admin settings for service URLs, API keys, profiles, and root folders.
|
- Admin settings for service URLs, API keys, profiles, and root folders.
|
||||||
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
|||||||
QBIT_USERNAME="..."
|
QBIT_USERNAME="..."
|
||||||
QBIT_PASSWORD="..."
|
QBIT_PASSWORD="..."
|
||||||
SQLITE_PATH="data/magent.db"
|
SQLITE_PATH="data/magent.db"
|
||||||
JWT_SECRET="replace-with-a-long-random-secret"
|
JWT_SECRET="change-me"
|
||||||
JWT_EXP_MINUTES="720"
|
JWT_EXP_MINUTES="720"
|
||||||
ADMIN_USERNAME="set-a-real-admin-username"
|
ADMIN_USERNAME="admin"
|
||||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
ADMIN_PASSWORD="adminadmin"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
|
|||||||
$env:QBIT_USERNAME="..."
|
$env:QBIT_USERNAME="..."
|
||||||
$env:QBIT_PASSWORD="..."
|
$env:QBIT_PASSWORD="..."
|
||||||
$env:SQLITE_PATH="data/magent.db"
|
$env:SQLITE_PATH="data/magent.db"
|
||||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
$env:JWT_SECRET="change-me"
|
||||||
$env:JWT_EXP_MINUTES="720"
|
$env:JWT_EXP_MINUTES="720"
|
||||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
$env:ADMIN_USERNAME="admin"
|
||||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
$env:ADMIN_PASSWORD="adminadmin"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (Next.js)
|
### Frontend (Next.js)
|
||||||
@@ -141,26 +141,6 @@ The frontend proxies `/api/*` to the backend container. Set:
|
|||||||
|
|
||||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||||
|
|
||||||
## Gitea CI/CD
|
|
||||||
|
|
||||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
|
||||||
|
|
||||||
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
|
|
||||||
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
|
|
||||||
|
|
||||||
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
|
||||||
|
|
||||||
- `http://127.0.0.1:8000/health`
|
|
||||||
- `http://127.0.0.1:3000/login`
|
|
||||||
|
|
||||||
Configure these Gitea Actions secrets before enabling the deploy job:
|
|
||||||
|
|
||||||
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
|
|
||||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
|
||||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
|
||||||
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
|
||||||
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
|
||||||
|
|
||||||
## History endpoints
|
## History endpoints
|
||||||
|
|
||||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||||
@@ -180,7 +160,7 @@ Configure these Gitea Actions secrets before enabling the deploy job:
|
|||||||
|
|
||||||
### No recent requests
|
### No recent requests
|
||||||
|
|
||||||
- Confirm Seerr credentials in Settings.
|
- Confirm Jellyseerr credentials in Settings.
|
||||||
- Run a full sync from Settings -> Requests.
|
- Run a full sync from Settings -> Requests.
|
||||||
|
|
||||||
### Docker images not updating
|
### Docker images not updating
|
||||||
|
|||||||
+4
-1
@@ -2,8 +2,11 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG BUILD_NUMBER=dev
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1 \
|
||||||
|
SITE_BUILD_NUMBER=${BUILD_NUMBER}
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
|||||||
|
|
||||||
if snapshot.state == NormalizedState.requested:
|
if snapshot.state == NormalizedState.requested:
|
||||||
root_cause = "approval"
|
root_cause = "approval"
|
||||||
summary = "The request is waiting for approval in Seerr."
|
summary = "The request is waiting for approval in Jellyseerr."
|
||||||
recommendations.append(
|
recommendations.append(
|
||||||
TriageRecommendation(
|
TriageRecommendation(
|
||||||
action_id="wait_for_approval",
|
action_id="wait_for_approval",
|
||||||
title="Ask an admin to approve the request",
|
title="Ask an admin to approve the request",
|
||||||
reason="Seerr has not marked this request as approved.",
|
reason="Jellyseerr has not marked this request as approved.",
|
||||||
risk="low",
|
risk="low",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
+6
-194
@@ -1,152 +1,19 @@
|
|||||||
from datetime import datetime, timezone
|
from typing import Dict, Any
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, Response, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
from .config import settings
|
from .db import get_user_by_username
|
||||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
from .security import safe_decode_token, TokenError
|
||||||
from .network_security import request_trusts_forwarded_headers
|
|
||||||
from .security import TokenError, safe_decode_token, verify_password
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(expires_at: str | None) -> bool:
|
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
|
||||||
if not isinstance(expires_at, str) or not expires_at.strip():
|
|
||||||
return False
|
|
||||||
candidate = expires_at.strip()
|
|
||||||
if candidate.endswith("Z"):
|
|
||||||
candidate = candidate[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed <= datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_client_ip(request: Request) -> str:
|
|
||||||
direct_host = request.client.host if request.client else None
|
|
||||||
if request_trusts_forwarded_headers(direct_host):
|
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if forwarded:
|
|
||||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
|
||||||
if parts:
|
|
||||||
return parts[0]
|
|
||||||
real_ip = request.headers.get("x-real-ip")
|
|
||||||
if real_ip:
|
|
||||||
return real_ip.strip()
|
|
||||||
if direct_host:
|
|
||||||
return direct_host
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _cookie_settings() -> dict[str, Any]:
|
|
||||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
|
||||||
if samesite not in {"lax", "strict", "none"}:
|
|
||||||
samesite = "lax"
|
|
||||||
return {
|
|
||||||
"secure": bool(settings.auth_cookie_secure),
|
|
||||||
"httponly": True,
|
|
||||||
"samesite": samesite,
|
|
||||||
"domain": settings.auth_cookie_domain or None,
|
|
||||||
"path": "/",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _state_cookie_settings() -> dict[str, Any]:
|
|
||||||
cookie = _cookie_settings()
|
|
||||||
cookie["httponly"] = False
|
|
||||||
return cookie
|
|
||||||
|
|
||||||
|
|
||||||
def set_auth_cookies(response: Response, token: str) -> None:
|
|
||||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
|
||||||
response.set_cookie(
|
|
||||||
settings.auth_cookie_name,
|
|
||||||
token,
|
|
||||||
max_age=max_age,
|
|
||||||
**_cookie_settings(),
|
|
||||||
)
|
|
||||||
response.set_cookie(
|
|
||||||
settings.auth_state_cookie_name,
|
|
||||||
"1",
|
|
||||||
max_age=max_age,
|
|
||||||
**_state_cookie_settings(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_auth_cookies(response: Response) -> None:
|
|
||||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
|
||||||
response.delete_cookie(
|
|
||||||
settings.auth_state_cookie_name,
|
|
||||||
path="/",
|
|
||||||
domain=settings.auth_cookie_domain or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
if auth_header.lower().startswith("bearer "):
|
|
||||||
return auth_header.split(" ", 1)[1].strip()
|
|
||||||
if oauth_token:
|
|
||||||
return oauth_token
|
|
||||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
|
||||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
|
||||||
return cookie_token.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return "local"
|
|
||||||
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
|
||||||
if provider != "local":
|
|
||||||
return provider
|
|
||||||
password_hash = user.get("password_hash")
|
|
||||||
if isinstance(password_hash, str) and password_hash:
|
|
||||||
if verify_password("jellyfin-user", password_hash):
|
|
||||||
return "jellyfin"
|
|
||||||
if verify_password("jellyseerr-user", password_hash):
|
|
||||||
return "jellyseerr"
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return {}
|
|
||||||
resolved_provider = resolve_user_auth_provider(user)
|
|
||||||
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
|
||||||
if resolved_provider != stored_provider:
|
|
||||||
username = str(user.get("username") or "").strip()
|
|
||||||
if username:
|
|
||||||
set_user_auth_provider(username, resolved_provider)
|
|
||||||
refreshed_user = get_user_by_username(username)
|
|
||||||
if refreshed_user:
|
|
||||||
user = refreshed_user
|
|
||||||
normalized = dict(user)
|
|
||||||
normalized["auth_provider"] = resolved_provider
|
|
||||||
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
|
|
||||||
normalized["password_provider"] = (
|
|
||||||
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
|
|
||||||
)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _load_current_user_from_token(
|
|
||||||
token: str,
|
|
||||||
request: Optional[Request] = None,
|
|
||||||
allowed_token_types: Optional[set[str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
payload = safe_decode_token(token)
|
payload = safe_decode_token(token)
|
||||||
except TokenError as exc:
|
except TokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||||
token_type = str(payload.get("typ") or "access").strip().lower()
|
|
||||||
if allowed_token_types and token_type not in allowed_token_types:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
@@ -157,70 +24,15 @@ def _load_current_user_from_token(
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
if user.get("is_blocked"):
|
if user.get("is_blocked"):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||||
if _is_expired(user.get("expires_at")):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
|
||||||
|
|
||||||
user = normalize_user_auth_provider(user)
|
|
||||||
|
|
||||||
if request is not None:
|
|
||||||
ip = _extract_client_ip(request)
|
|
||||||
user_agent = request.headers.get("user-agent", "unknown")
|
|
||||||
upsert_user_activity(user["username"], ip, user_agent)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"email": user.get("email"),
|
|
||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"auth_provider": user.get("auth_provider", "local"),
|
"auth_provider": user.get("auth_provider", "local"),
|
||||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
|
||||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
|
||||||
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
|
||||||
"profile_id": user.get("profile_id"),
|
|
||||||
"expires_at": user.get("expires_at"),
|
|
||||||
"is_expired": bool(user.get("is_expired", False)),
|
|
||||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
|
||||||
"password_provider": user.get("password_provider"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
request: Request,
|
|
||||||
token: Optional[str] = Depends(oauth2_scheme),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
resolved_token = _extract_access_token(request, token)
|
|
||||||
if not resolved_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
|
||||||
return _load_current_user_from_token(resolved_token, request)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_event_stream(
|
|
||||||
request: Request,
|
|
||||||
token: Optional[str] = Depends(oauth2_scheme),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
|
||||||
resolved_token = _extract_access_token(request, token)
|
|
||||||
stream_query_token = request.query_params.get("stream_token")
|
|
||||||
if resolved_token:
|
|
||||||
# Allow standard bearer tokens for non-browser EventSource clients.
|
|
||||||
return _load_current_user_from_token(resolved_token, None)
|
|
||||||
if not stream_query_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
|
||||||
return _load_current_user_from_token(
|
|
||||||
str(stream_query_token),
|
|
||||||
None,
|
|
||||||
allowed_token_types={"sse"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||||
if user.get("role") != "admin":
|
if user.get("role") != "admin":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_admin_event_stream(
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if user.get("role") != "admin":
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
||||||
return user
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+12
-393
@@ -1,261 +1,11 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..logging_config import sanitize_headers, sanitize_value
|
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
|
||||||
|
|
||||||
|
|
||||||
_SERVICE_NAMES = {
|
|
||||||
"JellyseerrClient": "Seerr",
|
|
||||||
"SonarrClient": "Sonarr",
|
|
||||||
"RadarrClient": "Radarr",
|
|
||||||
"BazarrClient": "Bazarr",
|
|
||||||
"ProwlarrClient": "Prowlarr",
|
|
||||||
"JellyfinClient": "Jellyfin",
|
|
||||||
"QBittorrentClient": "qBittorrent",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _result_items(result: Any, *keys: str) -> list[Any]:
|
|
||||||
if isinstance(result, list):
|
|
||||||
return result
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
return []
|
|
||||||
for key in keys:
|
|
||||||
value = result.get(key)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return value
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
|
||||||
candidates = result if isinstance(result, list) else [result]
|
|
||||||
for candidate in candidates:
|
|
||||||
if not isinstance(candidate, dict):
|
|
||||||
continue
|
|
||||||
title = str(candidate.get("title") or candidate.get("name") or "").strip()
|
|
||||||
if title:
|
|
||||||
return title
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
title = str(payload.get("title") or payload.get("name") or "").strip()
|
|
||||||
if title:
|
|
||||||
return title
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
|
|
||||||
noun = singular if count == 1 else (plural or f"{singular}s")
|
|
||||||
return f"{count} {noun}"
|
|
||||||
|
|
||||||
|
|
||||||
def _queue_result_message(service: str, result: Any) -> str:
|
|
||||||
records = _result_items(result, "records", "items")
|
|
||||||
total = result.get("totalRecords") if isinstance(result, dict) else None
|
|
||||||
count = int(total) if isinstance(total, int) else len(records)
|
|
||||||
if count == 0:
|
|
||||||
return f"{service} has no matching downloads in its queue."
|
|
||||||
first = next((item for item in records if isinstance(item, dict)), None)
|
|
||||||
progress_text = ""
|
|
||||||
if first:
|
|
||||||
size = first.get("size")
|
|
||||||
size_left = first.get("sizeleft")
|
|
||||||
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
|
|
||||||
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
|
|
||||||
progress_text = f" The first is {progress}% complete."
|
|
||||||
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
|
|
||||||
|
|
||||||
|
|
||||||
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
|
|
||||||
raw_name = str((payload or {}).get("name") or "").strip()
|
|
||||||
names = {
|
|
||||||
"MoviesSearch": "movie search",
|
|
||||||
"SeriesSearch": "series search",
|
|
||||||
"EpisodeSearch": "episode search",
|
|
||||||
"DownloadRelease": "release download",
|
|
||||||
"RefreshMovie": "movie refresh",
|
|
||||||
"RescanMovie": "movie rescan",
|
|
||||||
"RefreshSeries": "series refresh",
|
|
||||||
"RescanSeries": "series rescan",
|
|
||||||
}
|
|
||||||
return names.get(raw_name, "command")
|
|
||||||
|
|
||||||
|
|
||||||
def _operation_result_message(
|
|
||||||
service: str,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
result: Any,
|
|
||||||
*,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> str:
|
|
||||||
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
|
|
||||||
normalized_method = method.upper()
|
|
||||||
title = _result_title(result, payload)
|
|
||||||
title_text = f' "{title}"' if title else ""
|
|
||||||
|
|
||||||
if service == "Seerr":
|
|
||||||
if normalized_path.endswith("/request") and normalized_method == "POST":
|
|
||||||
request_id = result.get("id") if isinstance(result, dict) else None
|
|
||||||
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
|
|
||||||
return f"Seerr created the request{suffix} and passed it into the collection workflow."
|
|
||||||
if "/request/" in normalized_path and normalized_method == "GET":
|
|
||||||
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
|
|
||||||
status = result.get("status") if isinstance(result, dict) else None
|
|
||||||
status_text = status_names.get(status)
|
|
||||||
return (
|
|
||||||
f"Seerr found the request; it is currently {status_text}."
|
|
||||||
if status_text
|
|
||||||
else "Seerr found the request and returned its current status."
|
|
||||||
)
|
|
||||||
|
|
||||||
if service in {"Radarr", "Sonarr"}:
|
|
||||||
media_name = "movie" if service == "Radarr" else "series"
|
|
||||||
media_path = "/movie" if service == "Radarr" else "/series"
|
|
||||||
if "/queue" in normalized_path and normalized_method == "GET":
|
|
||||||
return _queue_result_message(service, result)
|
|
||||||
if "/command" in normalized_path and normalized_method == "POST":
|
|
||||||
return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started."
|
|
||||||
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, 'download quality setting')}."
|
|
||||||
if "/rootfolder" in normalized_path and normalized_method == "GET":
|
|
||||||
count = len(_result_items(result))
|
|
||||||
return f"{service} returned {_count_message(count, 'library folder')}."
|
|
||||||
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 "The download search sources are working normally."
|
|
||||||
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 download')}."
|
|
||||||
if results
|
|
||||||
else "Prowlarr did not find any possible downloads."
|
|
||||||
)
|
|
||||||
|
|
||||||
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} finished this check without reporting a problem."
|
|
||||||
if normalized_method == "POST":
|
|
||||||
return f"{service} received the request. Its result will be checked separately."
|
|
||||||
if normalized_method == "PUT":
|
|
||||||
return f"{service} saved the requested changes."
|
|
||||||
if normalized_method == "DELETE":
|
|
||||||
return f"{service} confirmed the item was removed."
|
|
||||||
return f"{service} completed the request successfully."
|
|
||||||
|
|
||||||
|
|
||||||
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
|
|
||||||
explanations = {
|
|
||||||
400: "rejected the request because some details were invalid",
|
|
||||||
401: "rejected Magent's login details",
|
|
||||||
403: "refused permission for this action",
|
|
||||||
404: "could not find the requested item",
|
|
||||||
409: "reported a conflict, usually because the item already exists",
|
|
||||||
422: "could not use the details Magent supplied",
|
|
||||||
429: "is busy and asked Magent to try again later",
|
|
||||||
500: "encountered an internal error while processing the request",
|
|
||||||
502: "could not reach one of its own dependent services",
|
|
||||||
503: "is temporarily unavailable",
|
|
||||||
504: "did not finish before the request timed out",
|
|
||||||
}
|
|
||||||
explanation = explanations.get(status_code)
|
|
||||||
if explanation:
|
|
||||||
return f"{service} {explanation}."
|
|
||||||
if status_code:
|
|
||||||
return f"{service} could not complete the request (response code {status_code})."
|
|
||||||
return f"Magent could not get a usable response from {service}."
|
|
||||||
|
|
||||||
|
|
||||||
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
|
|
||||||
normalized_path = path.lower()
|
|
||||||
normalized_method = method.upper()
|
|
||||||
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
|
|
||||||
return "Reading the request from Seerr…", "Seerr returned the current request record"
|
|
||||||
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
|
|
||||||
return "Checking Radarr for the movie…", "Radarr returned the movie record"
|
|
||||||
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
|
|
||||||
return "Checking Sonarr for the series…", "Sonarr returned the series record"
|
|
||||||
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
|
|
||||||
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
|
|
||||||
if service == "Sonarr" and "/episode" in normalized_path:
|
|
||||||
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
|
|
||||||
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
|
|
||||||
return f"Checking releases through {service}…", f"{service} returned release information"
|
|
||||||
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
|
|
||||||
if normalized_method == "GET":
|
|
||||||
return f"Checking {service}'s search activity…", f"{service} returned its current activity"
|
|
||||||
return f"Sending a command to {service}…", f"{service} accepted the command"
|
|
||||||
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
|
|
||||||
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
|
|
||||||
if service == "Prowlarr" and "/health" in normalized_path:
|
|
||||||
return "Checking whether the download search sources are working…", "Prowlarr returned its indexer health"
|
|
||||||
return f"Contacting {service}…", f"{service} responded"
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClient:
|
class ApiClient:
|
||||||
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
||||||
self.base_url = base_url.rstrip("/") if base_url else None
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
|
||||||
|
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url)
|
return bool(self.base_url)
|
||||||
@@ -263,151 +13,20 @@ class ApiClient:
|
|||||||
def headers(self) -> Dict[str, str]:
|
def headers(self) -> Dict[str, str]:
|
||||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||||
|
|
||||||
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
|
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if response is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = sanitize_value(response.json())
|
|
||||||
except ValueError:
|
|
||||||
payload = sanitize_value(response.text)
|
|
||||||
if isinstance(payload, str) and len(payload) > 500:
|
|
||||||
return f"{payload[:500]}..."
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def _send_request(
|
|
||||||
self,
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Dict[str, str],
|
|
||||||
params: Optional[Dict[str, Any]],
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
) -> httpx.Response:
|
|
||||||
return await client.request(
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
timeout_seconds: float = 10.0,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
response = await client.get(url, headers=self.headers(), params=params)
|
||||||
active_message, _ = _operation_messages(service_name, method, path)
|
response.raise_for_status()
|
||||||
operation_event_id = start_remote_call(service_name, active_message)
|
return response.json()
|
||||||
self.logger.debug(
|
|
||||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
sanitize_value(params),
|
|
||||||
sanitize_value(payload),
|
|
||||||
sanitize_headers(self.headers()),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
|
||||||
response = await self._send_request(
|
|
||||||
client,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=self.headers(),
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
self.logger.debug(
|
|
||||||
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
response.status_code,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
result = response.json() if response.content else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_operation_result_message(
|
|
||||||
service_name,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
result,
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
response = exc.response
|
|
||||||
status = response.status_code if response is not None else "unknown"
|
|
||||||
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
|
|
||||||
log_fn(
|
|
||||||
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
status,
|
|
||||||
duration_ms,
|
|
||||||
self._response_summary(response),
|
|
||||||
)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status if isinstance(status, int) else None,
|
|
||||||
message=_operation_error_message(
|
|
||||||
service_name,
|
|
||||||
status if isinstance(status, int) else None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
self.logger.exception(
|
|
||||||
"outbound request failed method=%s url=%s duration_ms=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
message=_operation_error_message(service_name, None),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
timeout_seconds: float = 10.0,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"GET", path, params=params, timeout_seconds=timeout_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
return await self._request("POST", path, payload=payload)
|
if not self.base_url:
|
||||||
|
return None
|
||||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
url = f"{self.base_url}{path}"
|
||||||
return await self._request("PUT", path, payload=payload)
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(url, headers=self.headers(), json=payload)
|
||||||
async def delete(
|
response.raise_for_status()
|
||||||
self,
|
return response.json()
|
||||||
path: str,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request("DELETE", path, params=params)
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from .base import ApiClient
|
|
||||||
|
|
||||||
|
|
||||||
class BazarrClient(ApiClient):
|
|
||||||
async def get_system_status(self) -> Optional[Any]:
|
|
||||||
return await self._request("GET", "/api/system/status")
|
|
||||||
|
|
||||||
async def search_movie_subtitles(
|
|
||||||
self,
|
|
||||||
radarr_id: int,
|
|
||||||
*,
|
|
||||||
language: str,
|
|
||||||
forced: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"PATCH",
|
|
||||||
"/api/movies/subtitles",
|
|
||||||
params={
|
|
||||||
"radarrid": radarr_id,
|
|
||||||
"language": language,
|
|
||||||
"forced": str(forced).lower(),
|
|
||||||
"hi": "false",
|
|
||||||
},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def search_episode_subtitles(
|
|
||||||
self,
|
|
||||||
series_id: int,
|
|
||||||
episode_id: int,
|
|
||||||
*,
|
|
||||||
language: str,
|
|
||||||
forced: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"PATCH",
|
|
||||||
"/api/episodes/subtitles",
|
|
||||||
params={
|
|
||||||
"seriesid": series_id,
|
|
||||||
"episodeid": episode_id,
|
|
||||||
"language": language,
|
|
||||||
"forced": str(forced).lower(),
|
|
||||||
"hi": "false",
|
|
||||||
},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
+12
-234
@@ -1,24 +1,6 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import time
|
from .base import ApiClient
|
||||||
from .base import ApiClient, _operation_error_message
|
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
|
||||||
|
|
||||||
|
|
||||||
def _availability_message(result: Any) -> str:
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
return "Jellyfin did not return any matching library items."
|
|
||||||
total = result.get("TotalRecordCount")
|
|
||||||
items = result.get("Items")
|
|
||||||
available = (
|
|
||||||
(isinstance(total, int) and total > 0)
|
|
||||||
or (isinstance(items, list) and len(items) > 0)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
|
|
||||||
if available
|
|
||||||
else "Grizzlyflix did not find this title in its library search."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JellyfinClient(ApiClient):
|
class JellyfinClient(ApiClient):
|
||||||
@@ -28,265 +10,61 @@ class JellyfinClient(ApiClient):
|
|||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url and self.api_key)
|
return bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
def _emby_headers(self) -> Dict[str, str]:
|
|
||||||
return {"X-Emby-Token": self.api_key} if self.api_key else {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_user_id(payload: Any) -> Optional[str]:
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
|
||||||
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
|
|
||||||
if not isinstance(candidate, dict):
|
|
||||||
return None
|
|
||||||
for key in ("Id", "id", "UserId", "userId"):
|
|
||||||
value = candidate.get(key)
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (str, int)):
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/Users"
|
url = f"{self.base_url}/Users"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/{user_id}"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.get(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
|
|
||||||
users = await self.get_users()
|
|
||||||
if not isinstance(users, list):
|
|
||||||
return None
|
|
||||||
target = username.strip().lower()
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
name = str(user.get("Name") or "").strip().lower()
|
|
||||||
if name and name == target:
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||||
payload = {"Username": username, "Pw": password}
|
payload = {"Username": username, "Pw": password}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/New"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
payload = {"Name": username}
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.content:
|
|
||||||
return None
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def set_user_password(self, user_id: str, password: str) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
headers = self._emby_headers()
|
|
||||||
payloads = [
|
|
||||||
{"CurrentPw": "", "NewPw": password},
|
|
||||||
{"CurrentPwd": "", "NewPw": password},
|
|
||||||
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
|
|
||||||
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
|
|
||||||
{"NewPw": password, "ResetPassword": False},
|
|
||||||
]
|
|
||||||
paths = [
|
|
||||||
f"/Users/{user_id}/Password",
|
|
||||||
f"/Users/{user_id}/EasyPassword",
|
|
||||||
]
|
|
||||||
last_error: Exception | None = None
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
for path in paths:
|
|
||||||
url = f"{self.base_url}{path}"
|
|
||||||
for payload in payloads:
|
|
||||||
try:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
return
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
if last_error:
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
user = await self.get_user(user_id)
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
raise RuntimeError("Jellyfin user details not available")
|
|
||||||
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
|
|
||||||
payload = {**policy, "IsDisabled": bool(disabled)}
|
|
||||||
url = f"{self.base_url}/Users/{user_id}/Policy"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: str) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/{user_id}"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.delete(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
||||||
created = await self.create_user(username)
|
|
||||||
user_id = self._extract_user_id(created)
|
|
||||||
if not user_id:
|
|
||||||
users = await self.get_users()
|
|
||||||
if isinstance(users, list):
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
name = str(user.get("Name") or "").strip()
|
|
||||||
if name.lower() == username.strip().lower():
|
|
||||||
created = user
|
|
||||||
user_id = self._extract_user_id(user)
|
|
||||||
break
|
|
||||||
if not user_id:
|
|
||||||
raise RuntimeError("Jellyfin user created but user ID was not returned")
|
|
||||||
await self.set_user_password(user_id, password)
|
|
||||||
return created
|
|
||||||
|
|
||||||
async def search_items(
|
async def search_items(
|
||||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
|
||||||
url = f"{self.base_url}/Items"
|
url = f"{self.base_url}/Items"
|
||||||
params = {
|
params = {
|
||||||
"SearchTerm": term,
|
"SearchTerm": term,
|
||||||
"IncludeItemTypes": ",".join(item_types or []),
|
"IncludeItemTypes": ",".join(item_types or []),
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"Fields": "Path,MediaSources",
|
|
||||||
"Limit": limit,
|
"Limit": limit,
|
||||||
}
|
}
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
try:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response = await client.get(url, headers=headers, params=params)
|
||||||
response = await client.get(url, headers=headers, params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_availability_message(result),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("Jellyfin", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key or not str(series_id).strip():
|
|
||||||
return []
|
|
||||||
url = f"{self.base_url}/Items"
|
|
||||||
params = {
|
|
||||||
"ParentId": str(series_id).strip(),
|
|
||||||
"IncludeItemTypes": "Episode",
|
|
||||||
"Recursive": "true",
|
|
||||||
"Fields": "Path,ProviderIds,MediaSources",
|
|
||||||
"Limit": 10000,
|
|
||||||
}
|
|
||||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
||||||
response = await client.get(url, headers=self._emby_headers(), params=params)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
return response.json()
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return []
|
|
||||||
items = payload.get("Items") or payload.get("items") or []
|
|
||||||
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
|
||||||
|
|
||||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/System/Info"
|
url = f"{self.base_url}/System/Info"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Sessions"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.get(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
return payload if isinstance(payload, list) else []
|
|
||||||
|
|
||||||
async def refresh_library(self, recursive: bool = True) -> None:
|
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 = {"X-Emby-Token": self.api_key}
|
||||||
params = {"Recursive": "true" if recursive else "false"}
|
params = {"Recursive": "true" if recursive else "false"}
|
||||||
try:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response = await client.post(url, headers=headers, params=params)
|
||||||
response = await client.post(url, headers=headers, params=params)
|
response.raise_for_status()
|
||||||
response.raise_for_status()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("Jellyfin", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,44 +1,8 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from urllib.parse import quote, unquote, urlsplit
|
|
||||||
import httpx
|
|
||||||
from .base import ApiClient
|
from .base import ApiClient
|
||||||
|
|
||||||
|
|
||||||
class JellyseerrClient(ApiClient):
|
class JellyseerrClient(ApiClient):
|
||||||
async def _send_request(
|
|
||||||
self,
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Dict[str, str],
|
|
||||||
params: Optional[Dict[str, Any]],
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
) -> httpx.Response:
|
|
||||||
request_headers = dict(headers)
|
|
||||||
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
|
|
||||||
# Seerr's optional CSRF protection also applies to API-key writes.
|
|
||||||
# Seed its secret/token cookie pair, then echo the readable token in
|
|
||||||
# the header Seerr's own web client uses.
|
|
||||||
csrf_response = await client.get(
|
|
||||||
f"{self.base_url}/api/v1/auth/me",
|
|
||||||
headers=self.headers(),
|
|
||||||
)
|
|
||||||
csrf_response.raise_for_status()
|
|
||||||
csrf_token = client.cookies.get("XSRF-TOKEN")
|
|
||||||
if csrf_token:
|
|
||||||
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
|
|
||||||
parsed_base = urlsplit(self.base_url)
|
|
||||||
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
|
||||||
return await super()._send_request(
|
|
||||||
client,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=request_headers,
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v1/status")
|
return await self.get("/api/v1/status")
|
||||||
|
|
||||||
@@ -54,6 +18,9 @@ class JellyseerrClient(ApiClient):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
return await self.get(f"/api/v1/media/{media_id}")
|
||||||
|
|
||||||
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
||||||
|
|
||||||
@@ -61,65 +28,10 @@ class JellyseerrClient(ApiClient):
|
|||||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||||
|
|
||||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||||
# Seerr rejects the `+` encoding that standard query builders use for
|
|
||||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
|
||||||
# percent-encoded values.
|
|
||||||
encoded_query = quote(query, safe="")
|
|
||||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
|
||||||
|
|
||||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
|
||||||
service = "sonarr" if media_type == "tv" else "radarr"
|
|
||||||
return await self.get(f"/api/v1/settings/{service}")
|
|
||||||
|
|
||||||
async def create_request(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
media_type: str,
|
|
||||||
media_id: int,
|
|
||||||
seasons: Optional[list[int]] = None,
|
|
||||||
is_4k: Optional[bool] = None,
|
|
||||||
server_id: Optional[int] = None,
|
|
||||||
profile_id: Optional[int] = None,
|
|
||||||
root_folder: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"mediaType": media_type,
|
|
||||||
"mediaId": media_id,
|
|
||||||
}
|
|
||||||
if isinstance(seasons, list) and seasons:
|
|
||||||
payload["seasons"] = seasons
|
|
||||||
if isinstance(is_4k, bool):
|
|
||||||
payload["is4k"] = is_4k
|
|
||||||
if isinstance(server_id, int):
|
|
||||||
payload["serverId"] = server_id
|
|
||||||
if isinstance(profile_id, int):
|
|
||||||
payload["profileId"] = profile_id
|
|
||||||
if isinstance(root_folder, str) and root_folder.strip():
|
|
||||||
payload["rootFolder"] = root_folder.strip()
|
|
||||||
return await self.post("/api/v1/request", payload=payload)
|
|
||||||
|
|
||||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/api/v1/user",
|
"/api/v1/search",
|
||||||
params={
|
params={
|
||||||
"take": take,
|
"query": query,
|
||||||
"skip": skip,
|
"page": page,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v1/user/{user_id}")
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.delete(f"/api/v1/user/{user_id}")
|
|
||||||
|
|
||||||
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
|
||||||
payload = {"email": email, "password": password}
|
|
||||||
try:
|
|
||||||
return await self.post("/api/v1/auth/local", payload=payload)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
# Backward compatibility for older Seerr/Overseerr deployments
|
|
||||||
# that still expose /auth/login instead of /auth/local.
|
|
||||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
|
||||||
return await self.post("/api/v1/auth/login", payload=payload)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,65 +1,7 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
import time
|
from .base import ApiClient
|
||||||
from .base import ApiClient, _operation_error_message
|
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
|
||||||
|
|
||||||
|
|
||||||
def _torrent_state_text(state: Any) -> str:
|
|
||||||
normalized = str(state or "").strip().lower()
|
|
||||||
if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
|
|
||||||
return "finished"
|
|
||||||
if "pause" in normalized or normalized == "stoppeddl":
|
|
||||||
return "paused"
|
|
||||||
if "stall" in normalized:
|
|
||||||
return "waiting for data"
|
|
||||||
if normalized.startswith("queued"):
|
|
||||||
return "waiting in the queue"
|
|
||||||
if normalized == "metadl":
|
|
||||||
return "getting the download details"
|
|
||||||
if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
|
|
||||||
return "checking the downloaded files"
|
|
||||||
if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
|
|
||||||
return "downloading"
|
|
||||||
if "upload" in normalized:
|
|
||||||
return "downloaded and sharing with others"
|
|
||||||
if normalized in {"completed", "missingfiles"}:
|
|
||||||
return "finished" if normalized == "completed" else "missing files"
|
|
||||||
if "error" in normalized:
|
|
||||||
return "unable to continue"
|
|
||||||
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:
|
|
||||||
progress = first.get("progress")
|
|
||||||
progress_text = (
|
|
||||||
f" — {max(0, min(100, round(progress * 100)))}% complete"
|
|
||||||
if isinstance(progress, (int, float))
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
state_text = _torrent_state_text(first.get("state"))
|
|
||||||
return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{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):
|
||||||
@@ -81,109 +23,34 @@ class QBittorrentClient(ApiClient):
|
|||||||
headers={"Referer": self.base_url},
|
headers={"Referer": self.base_url},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
text = response.text.strip().lower()
|
if response.text.strip().lower() != "ok.":
|
||||||
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
|
||||||
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
|
||||||
raise RuntimeError("qBittorrent login failed")
|
raise RuntimeError("qBittorrent login failed")
|
||||||
|
|
||||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
await self._login(client)
|
||||||
try:
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response.raise_for_status()
|
||||||
await self._login(client)
|
return response.json()
|
||||||
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
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
await self._login(client)
|
||||||
try:
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response.raise_for_status()
|
||||||
await self._login(client)
|
return response.text.strip()
|
||||||
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
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
await self._login(client)
|
||||||
try:
|
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response.raise_for_status()
|
||||||
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")
|
||||||
@@ -194,9 +61,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
return await self._get("/api/v2/torrents/info", params={"category": category})
|
||||||
|
|
||||||
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
|
|
||||||
return await self._get("/api/v2/torrents/info", params={"tag": tag})
|
|
||||||
|
|
||||||
async def get_app_version(self) -> Optional[Any]:
|
async def get_app_version(self) -> Optional[Any]:
|
||||||
return await self._get_text("/api/v2/app/version")
|
return await self._get_text("/api/v2/app/version")
|
||||||
|
|
||||||
@@ -209,9 +73,7 @@ class QBittorrentClient(ApiClient):
|
|||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def add_torrent_url(
|
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
||||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
url_host = None
|
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]
|
||||||
@@ -223,6 +85,4 @@ 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,13 +9,6 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||||
|
|
||||||
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
|
|
||||||
return result if isinstance(result, dict) else None
|
|
||||||
|
|
||||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
|
||||||
|
|
||||||
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/movie")
|
return await self.get("/api/v3/movie")
|
||||||
|
|
||||||
@@ -28,32 +21,12 @@ 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,
|
||||||
@@ -61,15 +34,9 @@ class RadarrClient(ApiClient):
|
|||||||
root_folder: str,
|
root_folder: str,
|
||||||
monitored: bool = True,
|
monitored: bool = True,
|
||||||
search_for_movie: bool = True,
|
search_for_movie: bool = True,
|
||||||
title: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
|
|
||||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
|
||||||
if not resolved_title:
|
|
||||||
raise ValueError("Radarr could not resolve a title for this TMDB ID")
|
|
||||||
payload = {
|
payload = {
|
||||||
"tmdbId": tmdb_id,
|
"tmdbId": tmdb_id,
|
||||||
"title": resolved_title,
|
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
@@ -77,9 +44,6 @@ class RadarrClient(ApiClient):
|
|||||||
}
|
}
|
||||||
return await self.post("/api/v3/movie", payload=payload)
|
return await self.post("/api/v3/movie", payload=payload)
|
||||||
|
|
||||||
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put("/api/v3/movie", payload=payload)
|
|
||||||
|
|
||||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||||
|
|
||||||
|
|||||||
@@ -9,23 +9,6 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||||
|
|
||||||
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
|
|
||||||
if not isinstance(result, list):
|
|
||||||
return None
|
|
||||||
for item in result:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if int(item.get("tvdbId")) == tvdb_id:
|
|
||||||
return item
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return next((item for item in result if isinstance(item, dict)), None)
|
|
||||||
|
|
||||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v3/series/{series_id}")
|
|
||||||
|
|
||||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/rootfolder")
|
return await self.get("/api/v3/rootfolder")
|
||||||
|
|
||||||
@@ -33,22 +16,7 @@ class SonarrClient(ApiClient):
|
|||||||
return await self.get("/api/v3/qualityprofile")
|
return await self.get("/api/v3/qualityprofile")
|
||||||
|
|
||||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
records = []
|
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
result = await self.get("/api/v3/queue", params={
|
|
||||||
"seriesIds": series_id, "includeEpisode": "true",
|
|
||||||
"page": page, "pageSize": 100,
|
|
||||||
})
|
|
||||||
if not isinstance(result, dict) or not isinstance(result.get("records"), list):
|
|
||||||
raise ValueError("Sonarr returned an invalid queue")
|
|
||||||
batch = result["records"]
|
|
||||||
records.extend(batch)
|
|
||||||
if not batch or len(records) >= int(result.get("totalRecords", len(records))):
|
|
||||||
return {**result, "records": records, "totalRecords": len(records)}
|
|
||||||
page += 1
|
|
||||||
if page > 100:
|
|
||||||
raise ValueError("Sonarr queue exceeded the safe paging limit")
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -56,36 +24,12 @@ 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,
|
||||||
@@ -95,24 +39,18 @@ class SonarrClient(ApiClient):
|
|||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
search_missing: bool = True,
|
search_missing: bool = True,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
|
|
||||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
|
||||||
if not resolved_title:
|
|
||||||
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
|
|
||||||
payload = {
|
payload = {
|
||||||
"tvdbId": tvdb_id,
|
"tvdbId": tvdb_id,
|
||||||
"title": resolved_title,
|
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
"seasonFolder": True,
|
"seasonFolder": True,
|
||||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||||
}
|
}
|
||||||
|
if title:
|
||||||
|
payload["title"] = title
|
||||||
return await self.post("/api/v3/series", payload=payload)
|
return await self.post("/api/v3/series", payload=payload)
|
||||||
|
|
||||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put("/api/v3/series", payload=payload)
|
|
||||||
|
|
||||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||||
|
|
||||||
|
|||||||
+8
-216
@@ -2,68 +2,18 @@ from typing import Optional
|
|||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from .build_info import BUILD_NUMBER, CHANGELOG
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_prefix="")
|
model_config = SettingsConfigDict(env_prefix="")
|
||||||
app_name: str = "Magent"
|
app_name: str = "Magent"
|
||||||
cors_allow_origin: str = "http://localhost:3000"
|
cors_allow_origin: str = "http://localhost:3000"
|
||||||
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
||||||
sqlite_journal_mode: str = Field(
|
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
|
||||||
)
|
|
||||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
|
||||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
|
||||||
auth_rate_limit_window_seconds: int = Field(
|
|
||||||
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
|
||||||
)
|
|
||||||
auth_rate_limit_max_attempts_ip: int = Field(
|
|
||||||
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
|
||||||
)
|
|
||||||
auth_rate_limit_max_attempts_user: int = Field(
|
|
||||||
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_window_seconds: int = Field(
|
|
||||||
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_max_attempts_ip: int = Field(
|
|
||||||
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_max_attempts_identifier: int = Field(
|
|
||||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
|
||||||
)
|
|
||||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||||
auth_cookie_name: str = Field(
|
|
||||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
|
||||||
)
|
|
||||||
auth_cookie_secure: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
|
||||||
)
|
|
||||||
auth_cookie_samesite: str = Field(
|
|
||||||
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
|
||||||
)
|
|
||||||
auth_cookie_domain: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
|
||||||
)
|
|
||||||
auth_state_cookie_name: str = Field(
|
|
||||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
|
||||||
)
|
|
||||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||||
log_file_max_bytes: int = Field(
|
|
||||||
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
|
||||||
)
|
|
||||||
log_file_backup_count: int = Field(
|
|
||||||
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
|
|
||||||
)
|
|
||||||
log_http_client_level: str = Field(
|
|
||||||
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
|
|
||||||
)
|
|
||||||
log_background_sync_level: str = Field(
|
|
||||||
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
|
|
||||||
)
|
|
||||||
requests_sync_ttl_minutes: int = Field(
|
requests_sync_ttl_minutes: int = Field(
|
||||||
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
||||||
)
|
)
|
||||||
@@ -85,19 +35,12 @@ class Settings(BaseSettings):
|
|||||||
requests_data_source: str = Field(
|
requests_data_source: str = Field(
|
||||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
||||||
)
|
)
|
||||||
issue_confirmation_contact_attempts: int = Field(
|
|
||||||
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
|
|
||||||
)
|
|
||||||
issue_confirmation_interval_value: int = Field(
|
|
||||||
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
|
|
||||||
)
|
|
||||||
issue_confirmation_interval_unit: str = Field(
|
|
||||||
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
|
|
||||||
)
|
|
||||||
artwork_cache_mode: str = Field(
|
artwork_cache_mode: str = Field(
|
||||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||||
)
|
)
|
||||||
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
|
site_build_number: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
|
||||||
|
)
|
||||||
site_banner_enabled: bool = Field(
|
site_banner_enabled: bool = Field(
|
||||||
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
||||||
)
|
)
|
||||||
@@ -107,149 +50,8 @@ class Settings(BaseSettings):
|
|||||||
site_banner_tone: str = Field(
|
site_banner_tone: str = Field(
|
||||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
||||||
)
|
)
|
||||||
site_login_show_jellyfin_login: bool = Field(
|
site_changelog: Optional[str] = Field(
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
|
||||||
)
|
|
||||||
site_login_show_local_login: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
|
|
||||||
)
|
|
||||||
site_login_show_forgot_password: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
|
|
||||||
)
|
|
||||||
site_login_show_signup_link: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
|
||||||
)
|
|
||||||
site_nav_show_requests: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
|
||||||
)
|
|
||||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
|
||||||
|
|
||||||
magent_application_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
|
|
||||||
)
|
|
||||||
magent_application_port: int = Field(
|
|
||||||
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
|
|
||||||
)
|
|
||||||
magent_api_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
|
|
||||||
)
|
|
||||||
magent_api_port: int = Field(
|
|
||||||
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
|
|
||||||
)
|
|
||||||
magent_bind_host: str = Field(
|
|
||||||
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
|
|
||||||
)
|
|
||||||
magent_proxy_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
|
|
||||||
)
|
|
||||||
magent_proxy_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
|
|
||||||
)
|
|
||||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
|
||||||
)
|
|
||||||
magent_proxy_trusted_proxies: str = Field(
|
|
||||||
default="127.0.0.1,::1",
|
|
||||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
|
||||||
)
|
|
||||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
|
||||||
)
|
|
||||||
magent_ssl_bind_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
|
|
||||||
)
|
|
||||||
magent_ssl_certificate_path: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
|
|
||||||
)
|
|
||||||
magent_ssl_private_key_path: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
|
|
||||||
)
|
|
||||||
magent_ssl_certificate_pem: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
|
|
||||||
)
|
|
||||||
magent_ssl_private_key_pem: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_email_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_host: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_port: int = Field(
|
|
||||||
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_username: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_password: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
|
|
||||||
)
|
|
||||||
magent_notify_email_from_address: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
|
|
||||||
)
|
|
||||||
magent_notify_email_from_name: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
|
|
||||||
)
|
|
||||||
magent_notify_email_use_tls: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
|
|
||||||
)
|
|
||||||
magent_notify_email_use_ssl: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_discord_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_discord_webhook_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_telegram_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_telegram_bot_token: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
|
|
||||||
)
|
|
||||||
magent_notify_telegram_chat_id: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_push_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_push_provider: Optional[str] = Field(
|
|
||||||
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
|
|
||||||
)
|
|
||||||
magent_notify_push_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
|
|
||||||
)
|
|
||||||
magent_notify_push_topic: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
|
|
||||||
)
|
|
||||||
magent_notify_push_token: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
|
|
||||||
)
|
|
||||||
magent_notify_push_user_key: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
|
|
||||||
)
|
|
||||||
magent_notify_push_device: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_webhook_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_webhook_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
|
||||||
)
|
|
||||||
magent_allow_private_notification_targets: bool = Field(
|
|
||||||
default=False,
|
|
||||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
jellyseerr_base_url: Optional[str] = Field(
|
jellyseerr_base_url: Optional[str] = Field(
|
||||||
@@ -305,16 +107,6 @@ 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")
|
||||||
)
|
)
|
||||||
@@ -333,7 +125,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
discord_webhook_url: Optional[str] = Field(
|
discord_webhook_url: Optional[str] = Field(
|
||||||
default=None,
|
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
||||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+122
-3262
File diff suppressed because it is too large
Load Diff
@@ -1,148 +1,10 @@
|
|||||||
import contextvars
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Any, Mapping, Optional
|
from typing import Optional
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
||||||
"magent_request_id", default="-"
|
|
||||||
)
|
|
||||||
|
|
||||||
_SENSITIVE_KEYWORDS = (
|
|
||||||
"api_key",
|
|
||||||
"authorization",
|
|
||||||
"cert",
|
|
||||||
"cookie",
|
|
||||||
"jwt",
|
|
||||||
"key",
|
|
||||||
"pass",
|
|
||||||
"password",
|
|
||||||
"pem",
|
|
||||||
"private",
|
|
||||||
"secret",
|
|
||||||
"session",
|
|
||||||
"signature",
|
|
||||||
"token",
|
|
||||||
)
|
|
||||||
_MAX_BODY_BYTES = 4096
|
|
||||||
|
|
||||||
|
|
||||||
class RequestContextFilter(logging.Filter):
|
def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
record.request_id = REQUEST_ID_CONTEXT.get("-")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
|
||||||
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
|
||||||
|
|
||||||
|
|
||||||
def reset_request_id(token: contextvars.Token[str]) -> None:
|
|
||||||
REQUEST_ID_CONTEXT.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def current_request_id() -> str:
|
|
||||||
return REQUEST_ID_CONTEXT.get("-")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_key(key: str) -> bool:
|
|
||||||
lowered = key.strip().lower()
|
|
||||||
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_scalar(value: Any) -> Any:
|
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
|
||||||
return value
|
|
||||||
text = str(value)
|
|
||||||
if len(text) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{text[:2]}***{text[-2:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
|
||||||
if key_hint and _is_sensitive_key(key_hint):
|
|
||||||
return _redact_scalar(value)
|
|
||||||
if value is None or isinstance(value, (bool, int, float)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return f"<bytes:{len(value)}>"
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value if len(value) <= 512 else f"{value[:509]}..."
|
|
||||||
if depth >= 3:
|
|
||||||
return f"<{type(value).__name__}>"
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {
|
|
||||||
str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
|
|
||||||
for key, item in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
|
|
||||||
if hasattr(value, "model_dump"):
|
|
||||||
try:
|
|
||||||
return sanitize_value(value.model_dump(), depth=depth + 1)
|
|
||||||
except Exception:
|
|
||||||
return f"<{type(value).__name__}>"
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
str(key).lower(): sanitize_value(value, key_hint=str(key))
|
|
||||||
for key, value in headers.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
normalized = (content_type or "").split(";")[0].strip().lower()
|
|
||||||
if normalized == "application/json":
|
|
||||||
preview = body[:_MAX_BODY_BYTES]
|
|
||||||
try:
|
|
||||||
payload = json.loads(preview.decode("utf-8"))
|
|
||||||
summary = sanitize_value(payload)
|
|
||||||
if len(body) > _MAX_BODY_BYTES:
|
|
||||||
return {"truncated": True, "bytes": len(body), "payload": summary}
|
|
||||||
return summary
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if normalized == "application/x-www-form-urlencoded":
|
|
||||||
try:
|
|
||||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
|
||||||
compact = {
|
|
||||||
key: value[0] if len(value) == 1 else value
|
|
||||||
for key, value in parsed.items()
|
|
||||||
}
|
|
||||||
return sanitize_value(compact)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if normalized.startswith("multipart/"):
|
|
||||||
return {"content_type": normalized, "bytes": len(body)}
|
|
||||||
preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
|
|
||||||
return {
|
|
||||||
"content_type": normalized or "unknown",
|
|
||||||
"bytes": len(body),
|
|
||||||
"preview": preview if len(body) <= 256 else f"{preview}...",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_level(level_name: Optional[str], fallback: int) -> int:
|
|
||||||
if not level_name:
|
|
||||||
return fallback
|
|
||||||
return getattr(logging, str(level_name).upper(), fallback)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(
|
|
||||||
log_level: Optional[str],
|
|
||||||
log_file: Optional[str],
|
|
||||||
*,
|
|
||||||
log_file_max_bytes: int = 20_000_000,
|
|
||||||
log_file_backup_count: int = 10,
|
|
||||||
log_http_client_level: Optional[str] = "INFO",
|
|
||||||
log_background_sync_level: Optional[str] = "INFO",
|
|
||||||
) -> None:
|
|
||||||
level_name = (log_level or "INFO").upper()
|
level_name = (log_level or "INFO").upper()
|
||||||
level = getattr(logging, level_name, logging.INFO)
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
@@ -156,20 +18,15 @@ def configure_logging(
|
|||||||
log_path = os.path.join(os.getcwd(), log_path)
|
log_path = os.path.join(os.getcwd(), log_path)
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
file_handler = RotatingFileHandler(
|
file_handler = RotatingFileHandler(
|
||||||
log_path,
|
log_path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
||||||
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
|
|
||||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
handlers.append(file_handler)
|
handlers.append(file_handler)
|
||||||
|
|
||||||
context_filter = RequestContextFilter()
|
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
handler.addFilter(context_filter)
|
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
@@ -181,10 +38,4 @@ def configure_logging(
|
|||||||
|
|
||||||
logging.getLogger("uvicorn").setLevel(level)
|
logging.getLogger("uvicorn").setLevel(level)
|
||||||
logging.getLogger("uvicorn.error").setLevel(level)
|
logging.getLogger("uvicorn.error").setLevel(level)
|
||||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
logging.getLogger("uvicorn.access").setLevel(level)
|
||||||
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
|
|
||||||
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
|
|
||||||
logging.getLogger("app.clients.base").setLevel(http_client_level)
|
|
||||||
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
|
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
||||||
|
|||||||
+13
-225
@@ -1,14 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import has_admin_user, init_db
|
from .db import init_db, set_setting
|
||||||
from .routers.requests import (
|
from .routers.requests import (
|
||||||
router as requests_router,
|
router as requests_router,
|
||||||
startup_warmup_requests_cache,
|
startup_warmup_requests_cache,
|
||||||
@@ -17,42 +13,17 @@ from .routers.requests import (
|
|||||||
run_daily_db_cleanup,
|
run_daily_db_cleanup,
|
||||||
)
|
)
|
||||||
from .routers.auth import router as auth_router
|
from .routers.auth import router as auth_router
|
||||||
from .routers.admin import router as admin_router, events_router as admin_events_router
|
from .routers.admin import router as admin_router
|
||||||
from .routers.images import router as images_router
|
from .routers.images import router as images_router
|
||||||
from .routers.branding import router as branding_router
|
from .routers.branding import router as branding_router
|
||||||
from .routers.status import router as status_router
|
from .routers.status import router as status_router
|
||||||
from .routers.feedback import router as feedback_router
|
from .routers.feedback import router as feedback_router
|
||||||
from .routers.site import router as site_router
|
from .routers.site import router as site_router
|
||||||
from .routers.events import router as events_router
|
|
||||||
from .routers.portal import router as portal_router
|
|
||||||
from .routers.operations import router as operations_router
|
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .services.issue_resolution import run_issue_confirmation_loop
|
from .logging_config import configure_logging
|
||||||
from .services.operation_progress import (
|
|
||||||
begin_operation,
|
|
||||||
finish_operation,
|
|
||||||
normalize_operation_id,
|
|
||||||
reset_operation,
|
|
||||||
)
|
|
||||||
from .logging_config import (
|
|
||||||
bind_request_id,
|
|
||||||
configure_logging,
|
|
||||||
reset_request_id,
|
|
||||||
sanitize_headers,
|
|
||||||
sanitize_value,
|
|
||||||
summarize_http_body,
|
|
||||||
)
|
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
app = FastAPI(title=settings.app_name)
|
||||||
_background_tasks: list[asyncio.Task[None]] = []
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title=settings.app_name,
|
|
||||||
docs_url="/docs" if settings.api_docs_enabled else None,
|
|
||||||
redoc_url=None,
|
|
||||||
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -63,212 +34,29 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
|
||||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
|
||||||
token = bind_request_id(request_id)
|
|
||||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
|
||||||
operation_token = None
|
|
||||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
|
||||||
operation_token = begin_operation(
|
|
||||||
operation_id,
|
|
||||||
label=request.headers.get("X-Magent-Operation-Label"),
|
|
||||||
path=request.url.path,
|
|
||||||
)
|
|
||||||
request.state.request_id = request_id
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
body = await request.body()
|
|
||||||
body_summary = summarize_http_body(body, request.headers.get("content-type"))
|
|
||||||
|
|
||||||
async def receive() -> dict:
|
|
||||||
return {"type": "http.request", "body": body, "more_body": False}
|
|
||||||
|
|
||||||
request._receive = receive
|
|
||||||
logger.info(
|
|
||||||
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
sanitize_value(dict(request.query_params)),
|
|
||||||
request.client.host if request.client else "-",
|
|
||||||
sanitize_headers(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in request.headers.items()
|
|
||||||
if key.lower()
|
|
||||||
in {
|
|
||||||
"content-type",
|
|
||||||
"content-length",
|
|
||||||
"user-agent",
|
|
||||||
"x-forwarded-for",
|
|
||||||
"x-forwarded-proto",
|
|
||||||
"x-request-id",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
body_summary,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
except Exception:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
logger.exception(
|
|
||||||
"request failed method=%s path=%s duration_ms=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
if operation_id and operation_token is not None:
|
|
||||||
finish_operation(operation_id, success=False, status_code=500)
|
|
||||||
reset_operation(operation_token)
|
|
||||||
reset_request_id(token)
|
|
||||||
raise
|
|
||||||
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
response.headers.setdefault("X-Request-ID", request_id)
|
|
||||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
||||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
||||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
||||||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
||||||
# Keep API responses non-executable and non-embeddable by default.
|
|
||||||
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
|
||||||
response.headers.setdefault(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
response.status_code,
|
|
||||||
duration_ms,
|
|
||||||
sanitize_headers(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in response.headers.items()
|
|
||||||
if key.lower() in {"content-type", "content-length", "x-request-id"}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if operation_id and operation_token is not None:
|
|
||||||
finish_operation(
|
|
||||||
operation_id,
|
|
||||||
success=response.status_code < 400,
|
|
||||||
status_code=response.status_code,
|
|
||||||
)
|
|
||||||
reset_operation(operation_token)
|
|
||||||
reset_request_id(token)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
async def _run_background_task(
|
|
||||||
name: str, coroutine_factory: Callable[[], Awaitable[None]]
|
|
||||||
) -> None:
|
|
||||||
token = bind_request_id(f"task-{name}")
|
|
||||||
logger.info("background task started task=%s", name)
|
|
||||||
try:
|
|
||||||
await coroutine_factory()
|
|
||||||
logger.warning("background task exited task=%s", name)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("background task cancelled task=%s", name)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("background task crashed task=%s", name)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
reset_request_id(token)
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
|
|
||||||
task = asyncio.create_task(
|
|
||||||
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
|
|
||||||
)
|
|
||||||
_background_tasks.append(task)
|
|
||||||
|
|
||||||
|
|
||||||
def _log_security_configuration_warnings() -> None:
|
|
||||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
|
||||||
if not jwt_secret or jwt_secret == "change-me":
|
|
||||||
logger.warning(
|
|
||||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
|
||||||
)
|
|
||||||
admin_password = str(settings.admin_password or "")
|
|
||||||
if not admin_password or admin_password == "adminadmin":
|
|
||||||
logger.warning(
|
|
||||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
|
||||||
)
|
|
||||||
if bool(settings.api_docs_enabled):
|
|
||||||
logger.warning(
|
|
||||||
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _enforce_secure_startup_configuration() -> None:
|
|
||||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
|
||||||
if not jwt_secret or jwt_secret == "change-me":
|
|
||||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
|
||||||
admin_password = str(settings.admin_password or "")
|
|
||||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
|
||||||
raise RuntimeError(
|
|
||||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
configure_logging(
|
|
||||||
settings.log_level,
|
|
||||||
settings.log_file,
|
|
||||||
log_file_max_bytes=settings.log_file_max_bytes,
|
|
||||||
log_file_backup_count=settings.log_file_backup_count,
|
|
||||||
log_http_client_level=settings.log_http_client_level,
|
|
||||||
log_background_sync_level=settings.log_background_sync_level,
|
|
||||||
)
|
|
||||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
|
||||||
_log_security_configuration_warnings()
|
|
||||||
init_db()
|
init_db()
|
||||||
_enforce_secure_startup_configuration()
|
if settings.site_build_number and settings.site_build_number.strip():
|
||||||
|
set_setting("site_build_number", settings.site_build_number.strip())
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
configure_logging(
|
configure_logging(runtime.log_level, runtime.log_file)
|
||||||
runtime.log_level,
|
asyncio.create_task(run_daily_jellyfin_sync())
|
||||||
runtime.log_file,
|
asyncio.create_task(startup_warmup_requests_cache())
|
||||||
log_file_max_bytes=runtime.log_file_max_bytes,
|
asyncio.create_task(run_requests_delta_loop())
|
||||||
log_file_backup_count=runtime.log_file_backup_count,
|
asyncio.create_task(run_daily_requests_full_sync())
|
||||||
log_http_client_level=runtime.log_http_client_level,
|
asyncio.create_task(run_daily_db_cleanup())
|
||||||
log_background_sync_level=runtime.log_background_sync_level,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
|
||||||
runtime.log_level,
|
|
||||||
runtime.log_file,
|
|
||||||
runtime.log_file_max_bytes,
|
|
||||||
runtime.log_file_backup_count,
|
|
||||||
runtime.log_http_client_level,
|
|
||||||
runtime.log_background_sync_level,
|
|
||||||
runtime.requests_data_source,
|
|
||||||
)
|
|
||||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
|
||||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
|
||||||
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
|
||||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
|
||||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
|
||||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
|
||||||
logger.info("startup complete")
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(requests_router)
|
app.include_router(requests_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(admin_events_router)
|
|
||||||
app.include_router(images_router)
|
app.include_router(images_router)
|
||||||
app.include_router(branding_router)
|
app.include_router(branding_router)
|
||||||
app.include_router(status_router)
|
app.include_router(status_router)
|
||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
app.include_router(events_router)
|
|
||||||
app.include_router(portal_router)
|
|
||||||
app.include_router(operations_router)
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class ActionOption(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
label: str
|
label: str
|
||||||
risk: str
|
risk: str
|
||||||
description: Optional[str] = None
|
|
||||||
requires_confirmation: bool = True
|
requires_confirmation: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ class Snapshot(BaseModel):
|
|||||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||||
actions: List[ActionOption] = Field(default_factory=list)
|
actions: List[ActionOption] = Field(default_factory=list)
|
||||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Iterable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
|
|
||||||
_METADATA_HOSTS = {
|
|
||||||
"169.254.169.254",
|
|
||||||
"metadata.google.internal",
|
|
||||||
"metadata.azure.internal",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text(value: object) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _split_csv(value: object) -> list[str]:
|
|
||||||
raw = _normalize_text(value)
|
|
||||||
if not raw:
|
|
||||||
return []
|
|
||||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
|
||||||
return bool(
|
|
||||||
ip_obj.is_loopback
|
|
||||||
or ip_obj.is_link_local
|
|
||||||
or ip_obj.is_multicast
|
|
||||||
or ip_obj.is_unspecified
|
|
||||||
or ip_obj.is_reserved
|
|
||||||
or ip_obj.is_private
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=256)
|
|
||||||
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
|
||||||
resolved: list[ipaddress._BaseAddress] = []
|
|
||||||
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
|
||||||
if family == socket.AF_INET:
|
|
||||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
|
||||||
elif family == socket.AF_INET6:
|
|
||||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
|
||||||
return tuple(resolved)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
|
||||||
candidate = _normalize_text(host)
|
|
||||||
if not candidate:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
host_ip = ipaddress.ip_address(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
|
||||||
|
|
||||||
for entry in trusted_proxies:
|
|
||||||
raw = _normalize_text(entry)
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if "/" in raw:
|
|
||||||
if host_ip in ipaddress.ip_network(raw, strict=False):
|
|
||||||
return True
|
|
||||||
elif host_ip == ipaddress.ip_address(raw):
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
|
||||||
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
|
||||||
return False
|
|
||||||
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
|
||||||
if not trusted:
|
|
||||||
return False
|
|
||||||
return _is_trusted_proxy_host(client_host or "", trusted)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_notification_target_url(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
allow_private: bool | None = None,
|
|
||||||
) -> str:
|
|
||||||
raw = _normalize_text(url)
|
|
||||||
if not raw:
|
|
||||||
raise ValueError("URL cannot be empty.")
|
|
||||||
|
|
||||||
parsed = urlparse(raw)
|
|
||||||
if parsed.scheme not in {"http", "https"}:
|
|
||||||
raise ValueError("URL must use http:// or https://.")
|
|
||||||
if parsed.username or parsed.password:
|
|
||||||
raise ValueError("URL must not embed credentials.")
|
|
||||||
hostname = _normalize_text(parsed.hostname).lower()
|
|
||||||
if not hostname:
|
|
||||||
raise ValueError("URL must include a valid host.")
|
|
||||||
|
|
||||||
allow_private_targets = (
|
|
||||||
settings.magent_allow_private_notification_targets
|
|
||||||
if allow_private is None
|
|
||||||
else bool(allow_private)
|
|
||||||
)
|
|
||||||
if hostname in _METADATA_HOSTS:
|
|
||||||
raise ValueError("Metadata service targets are not allowed.")
|
|
||||||
if hostname == "localhost" and not allow_private_targets:
|
|
||||||
raise ValueError("Local notification targets are not allowed.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
host_ip = ipaddress.ip_address(hostname)
|
|
||||||
except ValueError:
|
|
||||||
host_ip = None
|
|
||||||
|
|
||||||
if host_ip is not None:
|
|
||||||
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
|
||||||
raise ValueError("Private or local notification targets are not allowed.")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved_ips = _resolve_host_ips(hostname)
|
|
||||||
except socket.gaierror as exc:
|
|
||||||
raise ValueError("Host could not be resolved.") from exc
|
|
||||||
if not resolved_ips:
|
|
||||||
raise ValueError("Host could not be resolved.")
|
|
||||||
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
|
||||||
raise ValueError("Private or local notification targets are not allowed.")
|
|
||||||
return raw
|
|
||||||
+67
-1777
File diff suppressed because it is too large
Load Diff
+54
-1443
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,6 @@ router = APIRouter(prefix="/branding", tags=["branding"])
|
|||||||
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
||||||
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
||||||
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
||||||
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
|
|
||||||
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
|
||||||
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
|
||||||
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_branding_dir() -> None:
|
def _ensure_branding_dir() -> None:
|
||||||
@@ -45,18 +41,6 @@ def _ensure_default_branding() -> None:
|
|||||||
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
||||||
return
|
return
|
||||||
_ensure_branding_dir()
|
_ensure_branding_dir()
|
||||||
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
|
|
||||||
try:
|
|
||||||
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
|
|
||||||
target.write(source.read())
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
|
||||||
try:
|
|
||||||
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
|
|
||||||
target.write(source.read())
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if not os.path.exists(_LOGO_PATH):
|
if not os.path.exists(_LOGO_PATH):
|
||||||
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
@@ -81,32 +65,24 @@ def _ensure_default_branding() -> None:
|
|||||||
favicon.save(_FAVICON_PATH, format="ICO")
|
favicon.save(_FAVICON_PATH, format="ICO")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_branding_paths() -> tuple[str, str]:
|
|
||||||
if _BRANDING_SOURCE == "data":
|
|
||||||
_ensure_default_branding()
|
|
||||||
return _LOGO_PATH, _FAVICON_PATH
|
|
||||||
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
|
||||||
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
|
|
||||||
_ensure_default_branding()
|
|
||||||
return _LOGO_PATH, _FAVICON_PATH
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logo.png")
|
@router.get("/logo.png")
|
||||||
async def branding_logo() -> FileResponse:
|
async def branding_logo() -> FileResponse:
|
||||||
logo_path, _ = _resolve_branding_paths()
|
if not os.path.exists(_LOGO_PATH):
|
||||||
if not os.path.exists(logo_path):
|
_ensure_default_branding()
|
||||||
|
if not os.path.exists(_LOGO_PATH):
|
||||||
raise HTTPException(status_code=404, detail="Logo not found")
|
raise HTTPException(status_code=404, detail="Logo not found")
|
||||||
headers = {"Cache-Control": "no-store"}
|
headers = {"Cache-Control": "public, max-age=300"}
|
||||||
return FileResponse(logo_path, media_type="image/png", headers=headers)
|
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/favicon.ico")
|
@router.get("/favicon.ico")
|
||||||
async def branding_favicon() -> FileResponse:
|
async def branding_favicon() -> FileResponse:
|
||||||
_, favicon_path = _resolve_branding_paths()
|
if not os.path.exists(_FAVICON_PATH):
|
||||||
if not os.path.exists(favicon_path):
|
_ensure_default_branding()
|
||||||
|
if not os.path.exists(_FAVICON_PATH):
|
||||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||||
headers = {"Cache-Control": "no-store"}
|
headers = {"Cache-Control": "public, max-age=300"}
|
||||||
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
|
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from ..auth import get_current_user_event_stream
|
|
||||||
from . import requests as requests_router
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
|
||||||
|
|
||||||
|
|
||||||
def _sse_json(payload: Dict[str, Any]) -> str:
|
|
||||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _jsonable(value: Any) -> Any:
|
|
||||||
if hasattr(value, "model_dump"):
|
|
||||||
try:
|
|
||||||
return value.model_dump(mode="json")
|
|
||||||
except TypeError:
|
|
||||||
return value.model_dump()
|
|
||||||
if hasattr(value, "dict"):
|
|
||||||
try:
|
|
||||||
return value.dict()
|
|
||||||
except TypeError:
|
|
||||||
return value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
return []
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"request_id": entry.get("request_id"),
|
|
||||||
"state": entry.get("state"),
|
|
||||||
"state_reason": entry.get("state_reason"),
|
|
||||||
"created_at": entry.get("created_at"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
return []
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"request_id": entry.get("request_id"),
|
|
||||||
"action_id": entry.get("action_id"),
|
|
||||||
"label": entry.get("label"),
|
|
||||||
"status": entry.get("status"),
|
|
||||||
"message": entry.get("message"),
|
|
||||||
"created_at": entry.get("created_at"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def events_stream(
|
|
||||||
request: Request,
|
|
||||||
recent_days: int = 90,
|
|
||||||
recent_stage: str = "all",
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
recent_days = max(0, min(int(recent_days or 90), 3650))
|
|
||||||
recent_take = 50 if user.get("role") == "admin" else 6
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
yield "retry: 2000\n\n"
|
|
||||||
last_recent_signature: Optional[str] = None
|
|
||||||
next_recent_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
sent_any = False
|
|
||||||
|
|
||||||
if now >= next_recent_at:
|
|
||||||
next_recent_at = now + 15.0
|
|
||||||
try:
|
|
||||||
recent_payload = await requests_router.recent_requests(
|
|
||||||
take=recent_take,
|
|
||||||
skip=0,
|
|
||||||
days=recent_days,
|
|
||||||
stage=recent_stage,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
|
|
||||||
payload = {
|
|
||||||
"type": "home_recent",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"days": recent_days,
|
|
||||||
"stage": recent_stage,
|
|
||||||
"results": results if isinstance(results, list) else [],
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "home_recent",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"days": recent_days,
|
|
||||||
"stage": recent_stage,
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_recent_signature:
|
|
||||||
last_recent_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
|
||||||
heartbeat_counter = 0
|
|
||||||
else:
|
|
||||||
heartbeat_counter += 1
|
|
||||||
if heartbeat_counter >= 15:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
}
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}/stream")
|
|
||||||
async def request_events_stream(
|
|
||||||
request_id: str,
|
|
||||||
request: Request,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
request_id = str(request_id).strip()
|
|
||||||
if not request_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing request id")
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
yield "retry: 2000\n\n"
|
|
||||||
last_signature: Optional[str] = None
|
|
||||||
next_refresh_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
sent_any = False
|
|
||||||
|
|
||||||
if now >= next_refresh_at:
|
|
||||||
next_refresh_at = now + 2.0
|
|
||||||
try:
|
|
||||||
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
|
||||||
history_payload = await requests_router.request_history(
|
|
||||||
request_id=request_id, limit=5, user=user
|
|
||||||
)
|
|
||||||
actions_payload = await requests_router.request_actions(
|
|
||||||
request_id=request_id, limit=5, user=user
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"snapshot": _jsonable(snapshot),
|
|
||||||
"history": _request_history_brief(
|
|
||||||
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
|
||||||
),
|
|
||||||
"actions": _request_actions_brief(
|
|
||||||
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except HTTPException as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc.detail),
|
|
||||||
"status_code": int(exc.status_code),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_signature:
|
|
||||||
last_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
|
||||||
heartbeat_counter = 0
|
|
||||||
else:
|
|
||||||
heartbeat_counter += 1
|
|
||||||
if heartbeat_counter >= 15:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
}
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
|
||||||
@@ -3,7 +3,6 @@ import httpx
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||||
@@ -12,16 +11,9 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
webhook_url = (
|
webhook_url = runtime.discord_webhook_url
|
||||||
getattr(runtime, "magent_notify_discord_webhook_url", None)
|
|
||||||
or runtime.discord_webhook_url
|
|
||||||
)
|
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||||
try:
|
|
||||||
webhook_url = validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
feedback_type = str(payload.get("type") or "").strip().lower()
|
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||||
if feedback_type not in {"bug", "feature"}:
|
if feedback_type not in {"bug", "feature"}:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Response
|
from fastapi import APIRouter, HTTPException, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,7 +11,6 @@ router = APIRouter(prefix="/images", tags=["images"])
|
|||||||
|
|
||||||
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
||||||
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename(path: str) -> str:
|
def _safe_filename(path: str) -> str:
|
||||||
@@ -22,35 +19,23 @@ def _safe_filename(path: str) -> str:
|
|||||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
||||||
return safe or "image"
|
return safe or "image"
|
||||||
|
|
||||||
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
|
|
||||||
if not path or "://" in path or ".." in path:
|
|
||||||
return None
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = f"/{path}"
|
|
||||||
if size not in _ALLOWED_SIZES:
|
|
||||||
return None
|
|
||||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
|
||||||
return os.path.join(cache_dir, _safe_filename(path))
|
|
||||||
|
|
||||||
|
|
||||||
def is_tmdb_cached(path: str, size: str) -> bool:
|
|
||||||
file_path = tmdb_cache_path(path, size)
|
|
||||||
return bool(file_path and os.path.exists(file_path))
|
|
||||||
|
|
||||||
|
|
||||||
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||||
if not path or "://" in path or ".." in path:
|
if not path or "://" in path or ".." in path:
|
||||||
return False
|
return False
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = f"/{path}"
|
||||||
|
if size not in _ALLOWED_SIZES:
|
||||||
|
return False
|
||||||
|
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||||
if cache_mode != "cache":
|
if cache_mode != "cache":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file_path = tmdb_cache_path(path, size)
|
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||||
if not file_path:
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
return False
|
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -79,10 +64,9 @@ async def tmdb_image(path: str, size: str = "w342"):
|
|||||||
if cache_mode != "cache":
|
if cache_mode != "cache":
|
||||||
return RedirectResponse(url=url)
|
return RedirectResponse(url=url)
|
||||||
|
|
||||||
file_path = tmdb_cache_path(path, size)
|
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||||
if not file_path:
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
raise HTTPException(status_code=400, detail="Invalid image path")
|
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
||||||
headers = {"Cache-Control": "public, max-age=86400"}
|
headers = {"Cache-Control": "public, max-age=86400"}
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||||
@@ -93,8 +77,6 @@ async def tmdb_image(path: str, size: str = "w342"):
|
|||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||||
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
|
raise HTTPException(status_code=502, detail="Image cache failed")
|
||||||
except (httpx.HTTPError, OSError) as exc:
|
except httpx.HTTPError as exc:
|
||||||
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
|
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
|
||||||
|
|
||||||
return RedirectResponse(url=url)
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from ..auth import get_current_user
|
|
||||||
from ..services.operation_progress import get_operation
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/operations",
|
|
||||||
tags=["operations"],
|
|
||||||
dependencies=[Depends(get_current_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{operation_id}")
|
|
||||||
async def operation_status(operation_id: str) -> dict:
|
|
||||||
operation = get_operation(operation_id)
|
|
||||||
if not operation:
|
|
||||||
raise HTTPException(status_code=404, detail="Operation not found")
|
|
||||||
return operation
|
|
||||||
File diff suppressed because it is too large
Load Diff
+450
-2411
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ from typing import Any, Dict
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..build_info import BUILD_NUMBER, CHANGELOG
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/site", tags=["site"])
|
router = APIRouter(prefix="/site", tags=["site"])
|
||||||
@@ -18,24 +17,15 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|||||||
if tone not in _BANNER_TONES:
|
if tone not in _BANNER_TONES:
|
||||||
tone = "info"
|
tone = "info"
|
||||||
info = {
|
info = {
|
||||||
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
|
"buildNumber": (runtime.site_build_number or "").strip(),
|
||||||
"banner": {
|
"banner": {
|
||||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||||
"message": banner_message,
|
"message": banner_message,
|
||||||
"tone": tone,
|
"tone": tone,
|
||||||
},
|
},
|
||||||
"login": {
|
|
||||||
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
|
||||||
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
|
||||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
|
||||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"showRequests": bool(runtime.site_nav_show_requests),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if include_changelog:
|
if include_changelog:
|
||||||
info["changelog"] = (CHANGELOG or "").strip()
|
info["changelog"] = (runtime.site_changelog or "").strip()
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..auth import require_admin
|
from ..auth import get_current_user
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..clients.sonarr import SonarrClient
|
from ..clients.sonarr import SonarrClient
|
||||||
from ..clients.radarr import RadarrClient
|
from ..clients.radarr import RadarrClient
|
||||||
from ..clients.bazarr import BazarrClient
|
|
||||||
from ..clients.prowlarr import ProwlarrClient
|
from ..clients.prowlarr import ProwlarrClient
|
||||||
from ..clients.qbittorrent import QBittorrentClient
|
from ..clients.qbittorrent import QBittorrentClient
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
|
|
||||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
|
|
||||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||||
@@ -27,42 +26,12 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
|||||||
return {"name": name, "status": "down", "message": str(exc)}
|
return {"name": name, "status": "down", "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
|
||||||
if not qbittorrent.base_url:
|
|
||||||
return {"name": "qBittorrent", "status": "not_configured"}
|
|
||||||
if not qbittorrent.username or not qbittorrent.password:
|
|
||||||
reachable = await qbittorrent.is_webui_reachable()
|
|
||||||
return {
|
|
||||||
"name": "qBittorrent",
|
|
||||||
"status": "degraded" if reachable else "not_configured",
|
|
||||||
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result = await qbittorrent.get_app_version()
|
|
||||||
return {"name": "qBittorrent", "status": "up", "detail": result}
|
|
||||||
except RuntimeError as exc:
|
|
||||||
if "login failed" in str(exc).lower():
|
|
||||||
reachable = await qbittorrent.is_webui_reachable()
|
|
||||||
if reachable:
|
|
||||||
return {
|
|
||||||
"name": "qBittorrent",
|
|
||||||
"status": "degraded",
|
|
||||||
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
|
||||||
}
|
|
||||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
|
||||||
except Exception as exc:
|
|
||||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services")
|
@router.get("/services")
|
||||||
async def services_status() -> Dict[str, Any]:
|
async def services_status() -> Dict[str, Any]:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
|
||||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||||
qbittorrent = QBittorrentClient(
|
qbittorrent = QBittorrentClient(
|
||||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||||
@@ -72,7 +41,7 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
services = []
|
services = []
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Seerr",
|
"Jellyseerr",
|
||||||
jellyseerr.configured(),
|
jellyseerr.configured(),
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||||
)
|
)
|
||||||
@@ -91,13 +60,6 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
radarr.get_system_status,
|
radarr.get_system_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
services.append(
|
|
||||||
await _check(
|
|
||||||
"Bazarr",
|
|
||||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
|
||||||
bazarr.get_system_status,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
prowlarr_status = await _check(
|
prowlarr_status = await _check(
|
||||||
"Prowlarr",
|
"Prowlarr",
|
||||||
prowlarr.configured(),
|
prowlarr.configured(),
|
||||||
@@ -109,7 +71,13 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
prowlarr_status["status"] = "degraded"
|
prowlarr_status["status"] = "degraded"
|
||||||
prowlarr_status["message"] = "Health warnings"
|
prowlarr_status["message"] = "Health warnings"
|
||||||
services.append(prowlarr_status)
|
services.append(prowlarr_status)
|
||||||
services.append(await _check_qbittorrent(qbittorrent))
|
services.append(
|
||||||
|
await _check(
|
||||||
|
"qBittorrent",
|
||||||
|
qbittorrent.configured(),
|
||||||
|
qbittorrent.get_app_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Jellyfin",
|
"Jellyfin",
|
||||||
@@ -125,55 +93,3 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
overall = "degraded"
|
overall = "degraded"
|
||||||
|
|
||||||
return {"overall": overall, "services": services}
|
return {"overall": overall, "services": services}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/services/{service}/test")
|
|
||||||
async def test_service(service: str) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
||||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
|
||||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
|
||||||
qbittorrent = QBittorrentClient(
|
|
||||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
|
||||||
)
|
|
||||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
|
|
||||||
service_key = service.strip().lower()
|
|
||||||
checks = {
|
|
||||||
"seerr": (
|
|
||||||
"Seerr",
|
|
||||||
jellyseerr.configured(),
|
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
|
||||||
),
|
|
||||||
"jellyseerr": (
|
|
||||||
"Seerr",
|
|
||||||
jellyseerr.configured(),
|
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
|
||||||
),
|
|
||||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
|
||||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
|
||||||
"bazarr": (
|
|
||||||
"Bazarr",
|
|
||||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
|
||||||
bazarr.get_system_status,
|
|
||||||
),
|
|
||||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
|
||||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
|
||||||
}
|
|
||||||
|
|
||||||
if service_key == "qbittorrent":
|
|
||||||
return await _check_qbittorrent(qbittorrent)
|
|
||||||
|
|
||||||
if service_key not in checks:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown service")
|
|
||||||
|
|
||||||
name, configured, func = checks[service_key]
|
|
||||||
result = await _check(name, configured, func)
|
|
||||||
if name == "Prowlarr" and result.get("status") == "up":
|
|
||||||
health = result.get("detail")
|
|
||||||
if isinstance(health, list) and health:
|
|
||||||
result["status"] = "degraded"
|
|
||||||
result["message"] = "Health warnings"
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -2,48 +2,18 @@ from .config import settings
|
|||||||
from .db import get_settings_overrides
|
from .db import get_settings_overrides
|
||||||
|
|
||||||
_INT_FIELDS = {
|
_INT_FIELDS = {
|
||||||
"magent_application_port",
|
|
||||||
"magent_api_port",
|
|
||||||
"auth_rate_limit_window_seconds",
|
|
||||||
"auth_rate_limit_max_attempts_ip",
|
|
||||||
"auth_rate_limit_max_attempts_user",
|
|
||||||
"password_reset_rate_limit_window_seconds",
|
|
||||||
"password_reset_rate_limit_max_attempts_ip",
|
|
||||||
"password_reset_rate_limit_max_attempts_identifier",
|
|
||||||
"sonarr_quality_profile_id",
|
"sonarr_quality_profile_id",
|
||||||
"radarr_quality_profile_id",
|
"radarr_quality_profile_id",
|
||||||
"jwt_exp_minutes",
|
"jwt_exp_minutes",
|
||||||
"log_file_max_bytes",
|
|
||||||
"log_file_backup_count",
|
|
||||||
"requests_sync_ttl_minutes",
|
"requests_sync_ttl_minutes",
|
||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"issue_confirmation_contact_attempts",
|
|
||||||
"issue_confirmation_interval_value",
|
|
||||||
"magent_notify_email_smtp_port",
|
|
||||||
}
|
}
|
||||||
_BOOL_FIELDS = {
|
_BOOL_FIELDS = {
|
||||||
"magent_proxy_enabled",
|
|
||||||
"magent_proxy_trust_forwarded_headers",
|
|
||||||
"magent_ssl_bind_enabled",
|
|
||||||
"magent_notify_enabled",
|
|
||||||
"magent_notify_email_enabled",
|
|
||||||
"magent_notify_email_use_tls",
|
|
||||||
"magent_notify_email_use_ssl",
|
|
||||||
"magent_notify_discord_enabled",
|
|
||||||
"magent_notify_telegram_enabled",
|
|
||||||
"magent_notify_push_enabled",
|
|
||||||
"magent_notify_webhook_enabled",
|
|
||||||
"jellyfin_sync_to_arr",
|
"jellyfin_sync_to_arr",
|
||||||
"site_banner_enabled",
|
"site_banner_enabled",
|
||||||
"site_login_show_jellyfin_login",
|
|
||||||
"site_login_show_local_login",
|
|
||||||
"site_login_show_forgot_password",
|
|
||||||
"site_login_show_signup_link",
|
|
||||||
"site_nav_show_requests",
|
|
||||||
}
|
}
|
||||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_settings():
|
def get_runtime_settings():
|
||||||
@@ -52,8 +22,6 @@ def get_runtime_settings():
|
|||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
if key in _SKIP_OVERRIDE_FIELDS:
|
|
||||||
continue
|
|
||||||
if key in _INT_FIELDS:
|
if key in _INT_FIELDS:
|
||||||
try:
|
try:
|
||||||
update[key] = int(value)
|
update[key] = int(value)
|
||||||
|
|||||||
+4
-37
@@ -1,16 +1,13 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import jwt
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
_ALGORITHM = "HS256"
|
_ALGORITHM = "HS256"
|
||||||
MIN_PASSWORD_LENGTH = 8
|
|
||||||
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
@@ -21,44 +18,14 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||||||
return _pwd_context.verify(plain_password, hashed_password)
|
return _pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def validate_password_policy(password: str) -> str:
|
|
||||||
candidate = password.strip()
|
|
||||||
if len(candidate) < MIN_PASSWORD_LENGTH:
|
|
||||||
raise ValueError(PASSWORD_POLICY_MESSAGE)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _create_token(
|
|
||||||
subject: str,
|
|
||||||
role: str,
|
|
||||||
*,
|
|
||||||
expires_at: datetime,
|
|
||||||
token_type: str = "access",
|
|
||||||
) -> str:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"sub": subject,
|
|
||||||
"role": role,
|
|
||||||
"typ": token_type,
|
|
||||||
"exp": expires_at,
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||||
if not settings.jwt_secret:
|
|
||||||
raise ValueError("JWT_SECRET is not configured")
|
|
||||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||||
|
|
||||||
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
|
|
||||||
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="sse")
|
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Dict[str, Any]:
|
def decode_token(token: str) -> Dict[str, Any]:
|
||||||
if not settings.jwt_secret:
|
|
||||||
raise ValueError("JWT_SECRET is not configured")
|
|
||||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||||
|
|
||||||
|
|
||||||
@@ -69,5 +36,5 @@ class TokenError(Exception):
|
|||||||
def safe_decode_token(token: str) -> Dict[str, Any]:
|
def safe_decode_token(token: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return decode_token(token)
|
return decode_token(token)
|
||||||
except InvalidTokenError as exc:
|
except JWTError as exc:
|
||||||
raise TokenError("Invalid token") from exc
|
raise TokenError("Invalid token") from exc
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"""Read title-specific search activity without starting a search or changing monitoring."""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ..clients.base import ApiClient
|
|
||||||
from ..models import RequestType
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(values: Any) -> set[int]:
|
|
||||||
if not isinstance(values, list):
|
|
||||||
return set()
|
|
||||||
return {value for value in values if type(value) is int and value > 0}
|
|
||||||
|
|
||||||
|
|
||||||
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
|
|
||||||
"""Only a matching queued/started search is evidence of current activity.
|
|
||||||
|
|
||||||
Completed commands, RSS syncs and library-wide jobs do not establish that this
|
|
||||||
title is being searched. Episode searches are matched using Sonarr episode IDs.
|
|
||||||
"""
|
|
||||||
if not isinstance(commands, list):
|
|
||||||
return "unavailable"
|
|
||||||
episode_ids = _ids([
|
|
||||||
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
|
|
||||||
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
|
|
||||||
])
|
|
||||||
queued = False
|
|
||||||
for command in commands:
|
|
||||||
if not isinstance(command, dict):
|
|
||||||
continue
|
|
||||||
body = command.get("body")
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
continue
|
|
||||||
name = str(command.get("name") or body.get("name") or "").lower()
|
|
||||||
if request_type == RequestType.movie:
|
|
||||||
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
|
|
||||||
else:
|
|
||||||
matches = (
|
|
||||||
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
|
|
||||||
) or (
|
|
||||||
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
|
|
||||||
)
|
|
||||||
if not matches or command.get("ended"):
|
|
||||||
continue
|
|
||||||
status = str(command.get("status", "")).lower()
|
|
||||||
if status in {"started", "1"}:
|
|
||||||
return "searching"
|
|
||||||
if status in {"queued", "0"}:
|
|
||||||
queued = True
|
|
||||||
return "queued" if queued else "idle"
|
|
||||||
|
|
||||||
|
|
||||||
async def read_search_status(
|
|
||||||
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
|
|
||||||
except Exception:
|
|
||||||
# Search telemetry must not turn a healthy library record into an error.
|
|
||||||
return "unavailable"
|
|
||||||
return search_status(commands, request_type, item_id, episodes)
|
|
||||||
@@ -1,735 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from time import perf_counter
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
|
||||||
from ..clients.prowlarr import ProwlarrClient
|
|
||||||
from ..clients.qbittorrent import QBittorrentClient
|
|
||||||
from ..clients.radarr import RadarrClient
|
|
||||||
from ..clients.sonarr import SonarrClient
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import get_database_diagnostics
|
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
|
||||||
|
|
||||||
|
|
||||||
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DiagnosticCheck:
|
|
||||||
key: str
|
|
||||||
label: str
|
|
||||||
category: str
|
|
||||||
description: str
|
|
||||||
live_safe: bool
|
|
||||||
configured: bool
|
|
||||||
config_detail: str
|
|
||||||
target: Optional[str]
|
|
||||||
runner: DiagnosticRunner
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
|
||||||
if value is None:
|
|
||||||
return fallback
|
|
||||||
if isinstance(value, str):
|
|
||||||
trimmed = value.strip()
|
|
||||||
return trimmed if trimmed else fallback
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _url_target(url: Optional[str]) -> Optional[str]:
|
|
||||||
raw = _clean_text(url)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = urlparse(raw)
|
|
||||||
except Exception:
|
|
||||||
return raw
|
|
||||||
host = parsed.hostname or parsed.netloc or raw
|
|
||||||
if parsed.port:
|
|
||||||
host = f"{host}:{parsed.port}"
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
|
|
||||||
resolved_host = _clean_text(host)
|
|
||||||
if not resolved_host:
|
|
||||||
return None
|
|
||||||
if port is None:
|
|
||||||
return resolved_host
|
|
||||||
return f"{resolved_host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error_detail(exc: Exception) -> str:
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
|
||||||
response = exc.response
|
|
||||||
body = ""
|
|
||||||
try:
|
|
||||||
body = response.text.strip()
|
|
||||||
except Exception:
|
|
||||||
body = ""
|
|
||||||
if body:
|
|
||||||
return f"HTTP {response.status_code}: {body}"
|
|
||||||
return f"HTTP {response.status_code}"
|
|
||||||
return str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _config_status(detail: str) -> str:
|
|
||||||
lowered = detail.lower()
|
|
||||||
if "disabled" in lowered:
|
|
||||||
return "disabled"
|
|
||||||
return "not_configured"
|
|
||||||
|
|
||||||
|
|
||||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
|
||||||
return False, "Discord notifications are disabled."
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Discord webhook URL is required."
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
|
|
||||||
return False, "Telegram notifications are disabled."
|
|
||||||
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Telegram bot token and chat ID are required."
|
|
||||||
|
|
||||||
|
|
||||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
|
||||||
return False, "Generic webhook notifications are disabled."
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Generic webhook URL is required."
|
|
||||||
|
|
||||||
|
|
||||||
def _push_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
|
|
||||||
return False, "Push notifications are disabled."
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
if provider == "ntfy":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "ntfy requires a base URL and topic."
|
|
||||||
if provider == "gotify":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url and _clean_text(runtime.magent_notify_push_token):
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Gotify requires a base URL and app token."
|
|
||||||
if provider == "pushover":
|
|
||||||
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Pushover requires an application token and user key."
|
|
||||||
if provider == "webhook":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Webhook relay requires a target URL."
|
|
||||||
if provider == "telegram":
|
|
||||||
return _telegram_config_ready(runtime)
|
|
||||||
if provider == "discord":
|
|
||||||
return _discord_config_ready(runtime)
|
|
||||||
return False, f"Unsupported push provider: {provider or 'unknown'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
|
|
||||||
summary = {
|
|
||||||
"total": len(results),
|
|
||||||
"up": 0,
|
|
||||||
"down": 0,
|
|
||||||
"degraded": 0,
|
|
||||||
"not_configured": 0,
|
|
||||||
"disabled": 0,
|
|
||||||
}
|
|
||||||
for result in results:
|
|
||||||
status = str(result.get("status") or "").strip().lower()
|
|
||||||
if status in summary:
|
|
||||||
summary[status] += 1
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_json_get(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(url, headers=headers, params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
return {"response": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_text_get(url: str) -> Dict[str, Any]:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(url)
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.text
|
|
||||||
return {"response": body, "message": f"HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_post(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
json_payload: Optional[Dict[str, Any]] = None,
|
|
||||||
data_payload: Any = None,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
validate_notification_target_url(url)
|
|
||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
|
||||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.content:
|
|
||||||
return {"message": f"HTTP {response.status_code}"}
|
|
||||||
content_type = response.headers.get("content-type", "")
|
|
||||||
if "application/json" in content_type.lower():
|
|
||||||
try:
|
|
||||||
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_database_check() -> Dict[str, Any]:
|
|
||||||
detail = await asyncio.to_thread(get_database_diagnostics)
|
|
||||||
integrity = _clean_text(detail.get("integrity_check"), "unknown")
|
|
||||||
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
|
|
||||||
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
|
|
||||||
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
|
|
||||||
status = "up" if integrity == "ok" else "degraded"
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
|
|
||||||
"detail": detail,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
|
|
||||||
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
|
|
||||||
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
|
|
||||||
payload = result.get("response")
|
|
||||||
build_number = payload.get("build") if isinstance(payload, dict) else None
|
|
||||||
message = "Health endpoint responded"
|
|
||||||
if build_number:
|
|
||||||
message = f"Health endpoint responded (build {build_number})"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
|
|
||||||
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
|
|
||||||
result = await _run_http_text_get(base_url.rstrip("/"))
|
|
||||||
body = result.get("response")
|
|
||||||
if isinstance(body, str) and "<html" in body.lower():
|
|
||||||
return {"message": "Application page responded", "detail": "html"}
|
|
||||||
return {"status": "degraded", "message": "Application responded with unexpected content"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_seerr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
payload = await client.get_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Seerr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Seerr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
payload = await client.get_system_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Sonarr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Sonarr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_radarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
||||||
payload = await client.get_system_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Radarr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Radarr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
|
||||||
payload = await client.get_health()
|
|
||||||
if isinstance(payload, list) and payload:
|
|
||||||
return {
|
|
||||||
"status": "degraded",
|
|
||||||
"message": f"Prowlarr health warnings: {len(payload)}",
|
|
||||||
"detail": payload,
|
|
||||||
}
|
|
||||||
return {"message": "Prowlarr reported healthy", "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = QBittorrentClient(
|
|
||||||
runtime.qbittorrent_base_url,
|
|
||||||
runtime.qbittorrent_username,
|
|
||||||
runtime.qbittorrent_password,
|
|
||||||
)
|
|
||||||
version = await client.get_app_version()
|
|
||||||
message = "qBittorrent responded"
|
|
||||||
if isinstance(version, str) and version:
|
|
||||||
message = f"qBittorrent version {version}"
|
|
||||||
return {"message": message, "detail": version}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
payload = await client.get_system_info()
|
|
||||||
version = payload.get("Version") if isinstance(payload, dict) else None
|
|
||||||
message = "Jellyfin responded"
|
|
||||||
if version:
|
|
||||||
message = f"Jellyfin version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
result = await send_test_email(recipient_email=recipient_email)
|
|
||||||
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
|
|
||||||
warning = _clean_text(result.get("warning"))
|
|
||||||
if warning:
|
|
||||||
return {
|
|
||||||
"status": "degraded",
|
|
||||||
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
|
|
||||||
"detail": result,
|
|
||||||
}
|
|
||||||
return {"message": f"Test email sent to {recipient}", "detail": result}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_discord_check(runtime) -> Dict[str, Any]:
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
|
||||||
payload = {
|
|
||||||
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
|
||||||
}
|
|
||||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
|
||||||
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_telegram_check(runtime) -> Dict[str, Any]:
|
|
||||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
|
||||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
||||||
payload = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
|
||||||
}
|
|
||||||
result = await _run_http_post(url, json_payload=payload)
|
|
||||||
return {"message": "Telegram ping accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_webhook_check(runtime) -> Dict[str, Any]:
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
payload = {
|
|
||||||
"type": "diagnostics.ping",
|
|
||||||
"application": env_settings.app_name,
|
|
||||||
"build": env_settings.site_build_number,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
|
||||||
return {"message": "Webhook accepted ping", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_push_check(runtime) -> Dict[str, Any]:
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
message = f"{env_settings.app_name} diagnostics ping"
|
|
||||||
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
|
|
||||||
|
|
||||||
if provider == "ntfy":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
|
||||||
result = await _run_http_post(
|
|
||||||
f"{base_url.rstrip('/')}/{topic}",
|
|
||||||
data_payload=f"{message}\n{build_suffix}",
|
|
||||||
headers={"Content-Type": "text/plain; charset=utf-8"},
|
|
||||||
)
|
|
||||||
return {"message": "ntfy push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "gotify":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
result = await _run_http_post(
|
|
||||||
f"{base_url.rstrip('/')}/message",
|
|
||||||
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
|
|
||||||
params={"token": token},
|
|
||||||
)
|
|
||||||
return {"message": "Gotify push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "pushover":
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
|
||||||
device = _clean_text(runtime.magent_notify_push_device)
|
|
||||||
payload = {
|
|
||||||
"token": token,
|
|
||||||
"user": user_key,
|
|
||||||
"message": f"{message}\n{build_suffix}",
|
|
||||||
"title": env_settings.app_name,
|
|
||||||
}
|
|
||||||
if device:
|
|
||||||
payload["device"] = device
|
|
||||||
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
|
|
||||||
return {"message": "Pushover push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "webhook":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
payload = {
|
|
||||||
"type": "diagnostics.push",
|
|
||||||
"application": env_settings.app_name,
|
|
||||||
"build": env_settings.site_build_number,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
result = await _run_http_post(base_url, json_payload=payload)
|
|
||||||
return {"message": "Push webhook accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "telegram":
|
|
||||||
return await _run_telegram_check(runtime)
|
|
||||||
|
|
||||||
if provider == "discord":
|
|
||||||
return await _run_discord_check(runtime)
|
|
||||||
|
|
||||||
raise RuntimeError(f"Unsupported push provider: {provider}")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
seerr_target = _url_target(runtime.jellyseerr_base_url)
|
|
||||||
jellyfin_target = _url_target(runtime.jellyfin_base_url)
|
|
||||||
sonarr_target = _url_target(runtime.sonarr_base_url)
|
|
||||||
radarr_target = _url_target(runtime.radarr_base_url)
|
|
||||||
prowlarr_target = _url_target(runtime.prowlarr_base_url)
|
|
||||||
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
|
|
||||||
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
|
|
||||||
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
|
|
||||||
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
|
|
||||||
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
|
|
||||||
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
|
|
||||||
webhook_target = _url_target(runtime.magent_notify_webhook_url)
|
|
||||||
|
|
||||||
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
push_target = None
|
|
||||||
if push_provider == "pushover":
|
|
||||||
push_target = "api.pushover.net"
|
|
||||||
elif push_provider == "telegram":
|
|
||||||
push_target = telegram_target or "api.telegram.org"
|
|
||||||
elif push_provider == "discord":
|
|
||||||
push_target = discord_target or "discord.com"
|
|
||||||
else:
|
|
||||||
push_target = _url_target(runtime.magent_notify_push_base_url)
|
|
||||||
|
|
||||||
email_ready, email_detail = smtp_email_config_ready()
|
|
||||||
email_warning = smtp_email_delivery_warning()
|
|
||||||
discord_ready, discord_detail = _discord_config_ready(runtime)
|
|
||||||
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
|
||||||
push_ready, push_detail = _push_config_ready(runtime)
|
|
||||||
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
|
|
||||||
|
|
||||||
checks = [
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="magent-web",
|
|
||||||
label="Magent application",
|
|
||||||
category="Application",
|
|
||||||
description="Checks that the frontend application URL is responding.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target=application_target,
|
|
||||||
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="magent-api",
|
|
||||||
label="Magent API",
|
|
||||||
category="Application",
|
|
||||||
description="Checks the Magent API health endpoint.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target=api_target,
|
|
||||||
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="database",
|
|
||||||
label="SQLite database",
|
|
||||||
category="Application",
|
|
||||||
description="Runs SQLite integrity_check against the current Magent database.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target="sqlite",
|
|
||||||
runner=_run_database_check,
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="seerr",
|
|
||||||
label="Seerr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Seerr API reachability and version.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
|
|
||||||
config_detail="Seerr URL and API key are required.",
|
|
||||||
target=seerr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_seerr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="jellyfin",
|
|
||||||
label="Jellyfin",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Jellyfin system info with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
|
|
||||||
config_detail="Jellyfin URL and API key are required.",
|
|
||||||
target=jellyfin_target,
|
|
||||||
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="sonarr",
|
|
||||||
label="Sonarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Sonarr system status with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
|
|
||||||
config_detail="Sonarr URL and API key are required.",
|
|
||||||
target=sonarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="radarr",
|
|
||||||
label="Radarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Radarr system status with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
|
|
||||||
config_detail="Radarr URL and API key are required.",
|
|
||||||
target=radarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_radarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="prowlarr",
|
|
||||||
label="Prowlarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Prowlarr health and flags warnings as degraded.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
|
|
||||||
config_detail="Prowlarr URL and API key are required.",
|
|
||||||
target=prowlarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="qbittorrent",
|
|
||||||
label="qBittorrent",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks qBittorrent login and app version.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(
|
|
||||||
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
|
|
||||||
),
|
|
||||||
config_detail="qBittorrent URL, username, and password are required.",
|
|
||||||
target=qbittorrent_target,
|
|
||||||
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="email",
|
|
||||||
label="SMTP email",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test email using the configured SMTP provider.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=email_ready,
|
|
||||||
config_detail=email_warning or email_detail,
|
|
||||||
target=smtp_target,
|
|
||||||
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="discord",
|
|
||||||
label="Discord webhook",
|
|
||||||
category="Notifications",
|
|
||||||
description="Posts a live test message to the configured Discord webhook.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=discord_ready,
|
|
||||||
config_detail=discord_detail,
|
|
||||||
target=discord_target,
|
|
||||||
runner=lambda runtime=runtime: _run_discord_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="telegram",
|
|
||||||
label="Telegram",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test message to the configured Telegram chat.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=telegram_ready,
|
|
||||||
config_detail=telegram_detail,
|
|
||||||
target=telegram_target,
|
|
||||||
runner=lambda runtime=runtime: _run_telegram_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="push",
|
|
||||||
label="Push/mobile provider",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test message through the configured push provider.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=push_ready,
|
|
||||||
config_detail=push_detail,
|
|
||||||
target=push_target,
|
|
||||||
runner=lambda runtime=runtime: _run_push_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="webhook",
|
|
||||||
label="Generic webhook",
|
|
||||||
category="Notifications",
|
|
||||||
description="Posts a live test payload to the configured generic webhook.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=webhook_ready,
|
|
||||||
config_detail=webhook_detail,
|
|
||||||
target=webhook_target,
|
|
||||||
runner=lambda runtime=runtime: _run_webhook_check(runtime),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
return checks
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
|
|
||||||
if not check.configured:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": False,
|
|
||||||
"status": _config_status(check.config_detail),
|
|
||||||
"message": check.config_detail,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
"duration_ms": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
started = perf_counter()
|
|
||||||
checked_at = _now_iso()
|
|
||||||
try:
|
|
||||||
payload = await check.runner()
|
|
||||||
status = _clean_text(payload.get("status"), "up")
|
|
||||||
message = _clean_text(payload.get("message"), "Check passed")
|
|
||||||
detail = payload.get("detail")
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"detail": detail,
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": "down",
|
|
||||||
"message": _http_error_detail(exc),
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": "down",
|
|
||||||
"message": str(exc),
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_diagnostics_catalog() -> Dict[str, Any]:
|
|
||||||
checks = _build_diagnostic_checks()
|
|
||||||
items = []
|
|
||||||
for check in checks:
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"target": check.target,
|
|
||||||
"configured": check.configured,
|
|
||||||
"config_status": "configured" if check.configured else _config_status(check.config_detail),
|
|
||||||
"config_detail": "Ready to test." if check.configured else check.config_detail,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
categories = sorted({item["category"] for item in items})
|
|
||||||
return {
|
|
||||||
"checks": items,
|
|
||||||
"categories": categories,
|
|
||||||
"generated_at": _now_iso(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
checks = _build_diagnostic_checks(recipient_email=recipient_email)
|
|
||||||
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
|
|
||||||
if selected:
|
|
||||||
checks = [check for check in checks if check.key.lower() in selected]
|
|
||||||
results = await asyncio.gather(*(_execute_check(check) for check in checks))
|
|
||||||
return {
|
|
||||||
"results": results,
|
|
||||||
"summary": _summary_from_results(results),
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]:
|
|
||||||
"""Join by collector download ID, never by fuzzy title matching.
|
|
||||||
|
|
||||||
A pack shares one transfer percentage; do not pretend its episodes have
|
|
||||||
individually measured progress.
|
|
||||||
"""
|
|
||||||
records = queue.get("records", []) if isinstance(queue, dict) else queue
|
|
||||||
labels: dict[str, set[str]] = {}
|
|
||||||
for row in records if isinstance(records, list) else []:
|
|
||||||
episode = row.get("episode") or {}
|
|
||||||
season, number = episode.get("seasonNumber"), episode.get("episodeNumber")
|
|
||||||
if isinstance(season, int) and isinstance(number, int):
|
|
||||||
key = str(row.get("downloadId") or "").lower()
|
|
||||||
labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}")
|
|
||||||
for torrent in torrents:
|
|
||||||
episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set()))
|
|
||||||
torrent["episodeLabels"] = episodes
|
|
||||||
torrent["episodeLabel"] = (
|
|
||||||
" · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "")
|
|
||||||
if episodes else None
|
|
||||||
)
|
|
||||||
return torrents
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,477 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from html import escape
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import (
|
|
||||||
add_portal_item_activity,
|
|
||||||
get_portal_item,
|
|
||||||
get_user_by_username,
|
|
||||||
list_portal_item_activity,
|
|
||||||
list_portal_items,
|
|
||||||
update_portal_item,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import resolve_user_delivery_email, send_generic_email
|
|
||||||
from .snapshot import build_snapshot
|
|
||||||
from .media_repair import evaluate_media_repair
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
_SYSTEM_USER = "Magent"
|
|
||||||
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime(value: Any) -> Optional[datetime]:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
raw = item.get("metadata_json")
|
|
||||||
if not isinstance(raw, str) or not raw.strip():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {}
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
state = _metadata(item).get("resolutionConfirmation")
|
|
||||||
return dict(state) if isinstance(state, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
|
|
||||||
metadata = _metadata(item)
|
|
||||||
metadata["resolutionConfirmation"] = state
|
|
||||||
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _interval_delta(value: int, unit: str) -> timedelta:
|
|
||||||
safe_value = max(1, min(int(value), 365))
|
|
||||||
normalized_unit = str(unit or "days").strip().lower()
|
|
||||||
if normalized_unit == "weeks":
|
|
||||||
return timedelta(weeks=safe_value)
|
|
||||||
if normalized_unit == "months":
|
|
||||||
return timedelta(days=30 * safe_value)
|
|
||||||
return timedelta(days=safe_value)
|
|
||||||
|
|
||||||
|
|
||||||
def _workflow_settings() -> tuple[int, int, str]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
|
|
||||||
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
|
|
||||||
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
|
|
||||||
if interval_unit not in {"days", "weeks", "months"}:
|
|
||||||
interval_unit = "days"
|
|
||||||
return attempts, interval_value, interval_unit
|
|
||||||
|
|
||||||
|
|
||||||
def _app_url() -> str:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
|
|
||||||
candidate = str(value or "").strip()
|
|
||||||
if candidate:
|
|
||||||
return candidate.rstrip("/")
|
|
||||||
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _issue_url(item_id: int) -> str:
|
|
||||||
return f"{_app_url()}/portal/issues?item={item_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _activity(
|
|
||||||
item_id: int,
|
|
||||||
event_type: str,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
actor_username: str = _SYSTEM_USER,
|
|
||||||
actor_role: str = "system",
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> None:
|
|
||||||
add_portal_item_activity(
|
|
||||||
item_id,
|
|
||||||
event_type=event_type,
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
message=message,
|
|
||||||
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
raw = entry.get("metadata_json")
|
|
||||||
if not isinstance(raw, str) or not raw.strip():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {}
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
|
|
||||||
activity = list_portal_item_activity(item_id, limit=500)
|
|
||||||
for entry in reversed(activity):
|
|
||||||
# A rejected repair must not be proposed again simply because the same
|
|
||||||
# replacement file is still present. Wait for a NEW repair attempt.
|
|
||||||
if str(entry.get("event_type") or "") == "resolution_rejected":
|
|
||||||
return {}, activity
|
|
||||||
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
|
|
||||||
continue
|
|
||||||
tracking = _activity_metadata(entry).get("repairTracking")
|
|
||||||
if isinstance(tracking, dict):
|
|
||||||
return dict(tracking), activity
|
|
||||||
return {}, activity
|
|
||||||
|
|
||||||
|
|
||||||
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
|
|
||||||
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
|
|
||||||
jellyfin = dict(raw.get("jellyfin") or {})
|
|
||||||
jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
|
|
||||||
return await evaluate_media_repair(
|
|
||||||
tracking, (raw.get("arr") or {}).get("item"), jellyfin,
|
|
||||||
episodes=(raw.get("arr") or {}).get("episodes"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 = f"{_app_url()}/issues/confirm/{int(item['id'])}"
|
|
||||||
sent = False
|
|
||||||
delivery_error: Optional[str] = None
|
|
||||||
if recipient:
|
|
||||||
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
|
|
||||||
body_text = (
|
|
||||||
"Your repair looks ready to test.\n\n"
|
|
||||||
f"{item.get('title') or 'Your reported issue'}\n\n"
|
|
||||||
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
|
|
||||||
f"YES — it works: {issue_url}#yes\n"
|
|
||||||
f"NO — still broken: {issue_url}#no\n\n"
|
|
||||||
"Confirm your answer in Magent. You may need to sign in first.\n"
|
|
||||||
"Yes closes the report. No keeps it open for another look.\n\n"
|
|
||||||
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
|
|
||||||
)
|
|
||||||
body_html = (
|
|
||||||
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
|
|
||||||
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
|
|
||||||
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
|
|
||||||
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
|
|
||||||
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
|
|
||||||
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
|
|
||||||
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
|
|
||||||
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
|
|
||||||
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
|
|
||||||
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
|
|
||||||
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
|
|
||||||
'</td></tr></table></div>'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await send_generic_email(
|
|
||||||
recipient_email=recipient,
|
|
||||||
subject=subject,
|
|
||||||
body_text=body_text,
|
|
||||||
body_html=body_html,
|
|
||||||
)
|
|
||||||
sent = True
|
|
||||||
except Exception as exc:
|
|
||||||
delivery_error = str(exc)
|
|
||||||
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
|
|
||||||
else:
|
|
||||||
delivery_error = "No email address is stored for the reporter."
|
|
||||||
|
|
||||||
now = _now()
|
|
||||||
state.update(
|
|
||||||
{
|
|
||||||
"status": "awaiting_confirmation",
|
|
||||||
"attemptsSent": attempt_number,
|
|
||||||
"maximumAttempts": maximum,
|
|
||||||
"lastContactAt": now.isoformat(),
|
|
||||||
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
|
|
||||||
"intervalValue": interval_value,
|
|
||||||
"intervalUnit": interval_unit,
|
|
||||||
"lastDeliverySucceeded": sent,
|
|
||||||
"lastDeliveryError": delivery_error,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
updated = update_portal_item(
|
|
||||||
int(item["id"]),
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue confirmation schedule could not be saved")
|
|
||||||
if sent:
|
|
||||||
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
|
|
||||||
else:
|
|
||||||
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"confirmation_email_sent" if sent else "confirmation_email_failed",
|
|
||||||
message,
|
|
||||||
metadata={
|
|
||||||
"attempt": attempt_number,
|
|
||||||
"maximum": maximum,
|
|
||||||
"nextContactAt": state["nextContactAt"],
|
|
||||||
"deliveryError": delivery_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def begin_issue_confirmation(
|
|
||||||
item_id: int,
|
|
||||||
*,
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
item = get_portal_item(item_id)
|
|
||||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
|
||||||
raise ValueError("Issue not found")
|
|
||||||
now = _now().isoformat()
|
|
||||||
maximum, interval_value, interval_unit = _workflow_settings()
|
|
||||||
state = {
|
|
||||||
"status": "awaiting_confirmation",
|
|
||||||
"startedAt": now,
|
|
||||||
"attemptsSent": 0,
|
|
||||||
"maximumAttempts": maximum,
|
|
||||||
"lastContactAt": None,
|
|
||||||
"nextContactAt": now,
|
|
||||||
"intervalValue": interval_value,
|
|
||||||
"intervalUnit": interval_unit,
|
|
||||||
"confirmedAt": None,
|
|
||||||
"closedAt": None,
|
|
||||||
}
|
|
||||||
updated = update_portal_item(
|
|
||||||
item_id,
|
|
||||||
status="awaiting_confirmation",
|
|
||||||
issue_resolved_at=None,
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue confirmation workflow could not be started")
|
|
||||||
_activity(
|
|
||||||
item_id,
|
|
||||||
"resolution_proposed",
|
|
||||||
"The issue was marked fixed and sent to the reporter for confirmation.",
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
|
|
||||||
)
|
|
||||||
return await _contact_reporter(updated)
|
|
||||||
|
|
||||||
|
|
||||||
def respond_to_issue_confirmation(
|
|
||||||
item_id: int,
|
|
||||||
*,
|
|
||||||
resolved: bool,
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
item = get_portal_item(item_id)
|
|
||||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
|
||||||
raise ValueError("Issue not found")
|
|
||||||
if str(item.get("status") or "").lower() != "awaiting_confirmation":
|
|
||||||
raise ValueError("This issue is not waiting for resolution confirmation")
|
|
||||||
if resolved:
|
|
||||||
return _close_issue(
|
|
||||||
item,
|
|
||||||
reason="The reporter confirmed that the issue is fixed.",
|
|
||||||
confirmed=True,
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
)
|
|
||||||
|
|
||||||
now = _now().isoformat()
|
|
||||||
state = issue_resolution_state(item)
|
|
||||||
state.update(
|
|
||||||
{
|
|
||||||
"status": "reported_still_broken",
|
|
||||||
"reporterResponseAt": now,
|
|
||||||
"nextContactAt": None,
|
|
||||||
"closedAt": None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
updated = update_portal_item(
|
|
||||||
item_id,
|
|
||||||
status="in_progress",
|
|
||||||
issue_resolved_at=None,
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue could not be reopened")
|
|
||||||
_activity(
|
|
||||||
item_id,
|
|
||||||
"resolution_rejected",
|
|
||||||
"The reporter said the issue is still happening. The issue was returned to In progress.",
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def process_active_media_repairs() -> Dict[str, int]:
|
|
||||||
items = list_portal_items(kind="issue", status="in_progress", limit=500)
|
|
||||||
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
|
|
||||||
for item in items:
|
|
||||||
tracking, activity = _repair_tracking(int(item["id"]))
|
|
||||||
if not tracking:
|
|
||||||
continue
|
|
||||||
result["checked"] += 1
|
|
||||||
try:
|
|
||||||
evidence = await _media_repair_evidence(tracking)
|
|
||||||
if evidence.get("complete"):
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"repair_verified",
|
|
||||||
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
|
|
||||||
metadata={
|
|
||||||
"requestId": tracking.get("requestId"),
|
|
||||||
"actionId": tracking.get("actionId"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await begin_issue_confirmation(
|
|
||||||
int(item["id"]),
|
|
||||||
actor_username=_SYSTEM_USER,
|
|
||||||
actor_role="system",
|
|
||||||
)
|
|
||||||
result["completed"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result["waiting"] += 1
|
|
||||||
if evidence.get("phase") == "indexing" and not any(
|
|
||||||
str(entry.get("event_type") or "") == "repair_imported"
|
|
||||||
for entry in activity
|
|
||||||
):
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"repair_imported",
|
|
||||||
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
|
|
||||||
metadata={
|
|
||||||
"requestId": tracking.get("requestId"),
|
|
||||||
"actionId": tracking.get("actionId"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
result["failed"] += 1
|
|
||||||
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
|
|
||||||
current = (now or _now()).astimezone(timezone.utc)
|
|
||||||
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
|
|
||||||
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
|
|
||||||
maximum, _, _ = _workflow_settings()
|
|
||||||
for item in items:
|
|
||||||
state = issue_resolution_state(item)
|
|
||||||
due_at = _parse_datetime(state.get("nextContactAt"))
|
|
||||||
if due_at and due_at > current:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
|
||||||
if maximum <= 0 or attempts >= maximum:
|
|
||||||
_close_issue(
|
|
||||||
item,
|
|
||||||
reason=(
|
|
||||||
"Issue closed automatically because reporter confirmation emails are disabled."
|
|
||||||
if maximum <= 0
|
|
||||||
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
|
|
||||||
),
|
|
||||||
confirmed=False,
|
|
||||||
)
|
|
||||||
result["closed"] += 1
|
|
||||||
else:
|
|
||||||
await _contact_reporter(item)
|
|
||||||
result["contacted"] += 1
|
|
||||||
except Exception:
|
|
||||||
result["failed"] += 1
|
|
||||||
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def run_issue_confirmation_loop() -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
repair_result = await process_active_media_repairs()
|
|
||||||
if repair_result["completed"] or repair_result["failed"]:
|
|
||||||
logger.info("automatic media repair sweep complete result=%s", repair_result)
|
|
||||||
result = await process_due_issue_confirmations()
|
|
||||||
if result["contacted"] or result["closed"] or result["failed"]:
|
|
||||||
logger.info("issue confirmation sweep complete result=%s", result)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("issue confirmation sweep failed")
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
@@ -3,22 +3,8 @@ import logging
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import (
|
from ..db import create_user_if_missing
|
||||||
create_user_if_missing,
|
|
||||||
get_user_by_username,
|
|
||||||
set_user_email,
|
|
||||||
set_user_auth_provider,
|
|
||||||
set_user_jellyseerr_id,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .user_cache import (
|
|
||||||
build_jellyseerr_candidate_map,
|
|
||||||
extract_jellyseerr_user_email,
|
|
||||||
find_matching_jellyseerr_user,
|
|
||||||
get_cached_jellyseerr_users,
|
|
||||||
match_jellyseerr_user_id,
|
|
||||||
save_jellyfin_users_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,11 +17,6 @@ async def sync_jellyfin_users() -> int:
|
|||||||
users = await client.get_users()
|
users = await client.get_users()
|
||||||
if not isinstance(users, list):
|
if not isinstance(users, list):
|
||||||
return 0
|
return 0
|
||||||
save_jellyfin_users_cache(users)
|
|
||||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
|
||||||
# matched as enrichment when possible.
|
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
|
||||||
imported = 0
|
imported = 0
|
||||||
for user in users:
|
for user in users:
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
@@ -43,31 +24,8 @@ async def sync_jellyfin_users() -> int:
|
|||||||
name = user.get("Name")
|
name = user.get("Name")
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
|
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
|
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
|
||||||
created = create_user_if_missing(
|
|
||||||
name,
|
|
||||||
"jellyfin-user",
|
|
||||||
role="user",
|
|
||||||
email=matched_email,
|
|
||||||
auth_provider="jellyfin",
|
|
||||||
jellyseerr_user_id=matched_id,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
imported += 1
|
imported += 1
|
||||||
else:
|
|
||||||
existing = get_user_by_username(name)
|
|
||||||
if (
|
|
||||||
existing
|
|
||||||
and str(existing.get("role") or "user").strip().lower() != "admin"
|
|
||||||
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
|
||||||
):
|
|
||||||
set_user_auth_provider(name, "jellyfin")
|
|
||||||
if matched_id is not None:
|
|
||||||
set_user_jellyseerr_id(name, matched_id)
|
|
||||||
if matched_email:
|
|
||||||
set_user_email(name, matched_email)
|
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.sonarr import SonarrClient
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
|
|
||||||
|
|
||||||
def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
|
|
||||||
"""Old seeding jobs are not proof of a replacement download.
|
|
||||||
|
|
||||||
A same-hash retry is valid when it is downloading again or was added anew.
|
|
||||||
Without a completion/add timestamp, a completed legacy job cannot prove that.
|
|
||||||
"""
|
|
||||||
rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
|
|
||||||
if not cycle:
|
|
||||||
return rows
|
|
||||||
cutoff = datetime.fromisoformat(cycle).timestamp()
|
|
||||||
def belongs(item: Dict[str, Any]) -> bool:
|
|
||||||
try:
|
|
||||||
progress = float(item.get("progress", 0))
|
|
||||||
completed = float(item.get("completion_on") or 0)
|
|
||||||
added = float(item.get("added_on") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return progress < 1 or max(completed, added) >= cutoff
|
|
||||||
return [item for item in rows if belongs(item)]
|
|
||||||
|
|
||||||
|
|
||||||
def _positive_ints(value: Any) -> list[int]:
|
|
||||||
if not isinstance(value, list):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
int(item)
|
|
||||||
for item in value
|
|
||||||
if isinstance(item, int) and not isinstance(item, bool) and item > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _media_signature(item: Any) -> Dict[str, str]:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
return {}
|
|
||||||
result: Dict[str, str] = {}
|
|
||||||
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
|
|
||||||
value = item.get(key)
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
||||||
elif value is not None and str(value).strip():
|
|
||||||
result[key] = str(value).strip()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
|
|
||||||
previous = _media_signature(baseline)
|
|
||||||
if not previous:
|
|
||||||
return True
|
|
||||||
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
|
|
||||||
|
|
||||||
|
|
||||||
async def evaluate_media_repair(
|
|
||||||
tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
|
|
||||||
*, episodes: Any = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
request_id = str(tracking.get("requestId") or "").strip()
|
|
||||||
action_id = str(tracking.get("actionId") or "").strip()
|
|
||||||
media_type = str(tracking.get("mediaType") or "").strip().lower()
|
|
||||||
collector_id = tracking.get("collectorId")
|
|
||||||
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
|
|
||||||
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
|
|
||||||
|
|
||||||
jellyfin_item = jellyfin.get("item")
|
|
||||||
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
|
|
||||||
baselines = tracking.get("jellyfinBaseline")
|
|
||||||
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
|
|
||||||
found_at_start = tracking.get("jellyfinFoundAtStart") is True
|
|
||||||
|
|
||||||
if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
|
|
||||||
return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
|
|
||||||
|
|
||||||
if media_type == "movie":
|
|
||||||
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
|
|
||||||
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
|
|
||||||
imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
|
|
||||||
if not imported:
|
|
||||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
|
|
||||||
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
|
|
||||||
current_signature = _media_signature(jellyfin_item)
|
|
||||||
if action_id == "replace_media" and found_at_start:
|
|
||||||
if not baselines or not _signature_changed(current_signature, baselines[0]):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
|
|
||||||
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
|
|
||||||
|
|
||||||
target_rows = tracking.get("episodes")
|
|
||||||
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
|
|
||||||
target_ids = {
|
|
||||||
int(item["id"])
|
|
||||||
for item in targets
|
|
||||||
if isinstance(item.get("id"), int) and int(item["id"]) > 0
|
|
||||||
}
|
|
||||||
target_pairs = {
|
|
||||||
(int(item["seasonNumber"]), int(item["episodeNumber"]))
|
|
||||||
for item in targets
|
|
||||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
|
||||||
}
|
|
||||||
if not target_ids or not target_pairs:
|
|
||||||
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
|
|
||||||
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
if episodes is None:
|
|
||||||
episodes = await sonarr.get_episodes(collector_id)
|
|
||||||
episode_map = {
|
|
||||||
int(item["id"]): item
|
|
||||||
for item in episodes
|
|
||||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
|
||||||
} if isinstance(episodes, list) else {}
|
|
||||||
imported = all(
|
|
||||||
episode_id in episode_map
|
|
||||||
and episode_map[episode_id].get("hasFile") is not False
|
|
||||||
and (
|
|
||||||
episode_map[episode_id].get("hasFile") is True
|
|
||||||
or (
|
|
||||||
isinstance(episode_map[episode_id].get("episodeFileId"), int)
|
|
||||||
and episode_map[episode_id]["episodeFileId"] > 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
|
|
||||||
for episode_id in target_ids
|
|
||||||
)
|
|
||||||
if not imported:
|
|
||||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
|
|
||||||
|
|
||||||
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
|
|
||||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
|
|
||||||
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
|
|
||||||
current_by_pair = {
|
|
||||||
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
|
|
||||||
for item in jellyfin_episodes
|
|
||||||
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
|
|
||||||
}
|
|
||||||
if not all(pair in current_by_pair for pair in target_pairs):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
|
|
||||||
if action_id == "replace_media" and found_at_start:
|
|
||||||
baseline_by_pair = {
|
|
||||||
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
|
|
||||||
for item in baselines
|
|
||||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
|
||||||
}
|
|
||||||
if any(pair not in baseline_by_pair for pair in target_pairs):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
|
|
||||||
if not all(
|
|
||||||
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
|
|
||||||
for pair in target_pairs
|
|
||||||
):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
|
|
||||||
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import get_setting
|
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_generic_email
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
|
||||||
if value is None:
|
|
||||||
return fallback
|
|
||||||
if isinstance(value, str):
|
|
||||||
trimmed = value.strip()
|
|
||||||
return trimmed if trimmed else fallback
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_emails(value: str) -> list[str]:
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
|
||||||
return [entry for entry in parts if entry and "@" in entry]
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_app_url() -> str:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
for candidate in (
|
|
||||||
runtime.magent_application_url,
|
|
||||||
runtime.magent_proxy_base_url,
|
|
||||||
env_settings.cors_allow_origin,
|
|
||||||
):
|
|
||||||
normalized = _clean_text(candidate)
|
|
||||||
if normalized:
|
|
||||||
return normalized.rstrip("/")
|
|
||||||
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
|
||||||
return f"http://localhost:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _portal_item_url(item_id: int) -> str:
|
|
||||||
return f"{_resolve_app_url()}/portal?item={item_id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
validate_notification_target_url(url)
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except ValueError:
|
|
||||||
body = response.text
|
|
||||||
return {"status_code": response.status_code, "body": body}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
|
||||||
runtime.discord_webhook_url
|
|
||||||
)
|
|
||||||
if not webhook:
|
|
||||||
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
|
||||||
data = {
|
|
||||||
"content": f"**{title}**\n{message}",
|
|
||||||
"embeds": [
|
|
||||||
{
|
|
||||||
"title": title,
|
|
||||||
"description": message,
|
|
||||||
"fields": [
|
|
||||||
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
|
||||||
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
|
||||||
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
|
||||||
],
|
|
||||||
"url": _clean_text(payload.get("item_url")),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
result = await _http_post_json(webhook, data)
|
|
||||||
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
|
||||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
|
||||||
if not bot_token or not chat_id:
|
|
||||||
return {"status": "skipped", "detail": "Telegram is not configured."}
|
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
||||||
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
|
||||||
result = await _http_post_json(url, payload)
|
|
||||||
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
if not webhook:
|
|
||||||
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
|
||||||
result = await _http_post_json(webhook, payload)
|
|
||||||
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
|
||||||
if provider == "ntfy":
|
|
||||||
if not base_url or not topic:
|
|
||||||
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
|
||||||
validate_notification_target_url(base_url)
|
|
||||||
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
|
||||||
headers = {"Title": title, "Tags": "magent,portal"}
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
|
||||||
if provider == "gotify":
|
|
||||||
if not base_url or not token:
|
|
||||||
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
|
||||||
validate_notification_target_url(base_url)
|
|
||||||
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
|
||||||
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
|
||||||
result = await _http_post_json(url, body)
|
|
||||||
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
|
||||||
if provider == "pushover":
|
|
||||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
|
||||||
if not token or not user_key:
|
|
||||||
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
|
||||||
form = {"token": token, "user": user_key, "title": title, "message": message}
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
|
||||||
response.raise_for_status()
|
|
||||||
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
|
||||||
if provider == "discord":
|
|
||||||
return await _send_discord(title, message, payload)
|
|
||||||
if provider == "telegram":
|
|
||||||
return await _send_telegram(title, message)
|
|
||||||
if provider == "webhook":
|
|
||||||
return await _send_webhook(payload)
|
|
||||||
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
|
||||||
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
|
||||||
if fallback and fallback not in recipients:
|
|
||||||
recipients.append(fallback)
|
|
||||||
if not recipients:
|
|
||||||
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
|
||||||
|
|
||||||
body_text = (
|
|
||||||
f"{title}\n\n"
|
|
||||||
f"{message}\n\n"
|
|
||||||
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
|
||||||
f"Status: {_clean_text(payload.get('status'))}\n"
|
|
||||||
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
|
||||||
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
|
||||||
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
|
||||||
)
|
|
||||||
body_html = (
|
|
||||||
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
|
||||||
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
|
||||||
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
|
||||||
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
|
||||||
"</table>"
|
|
||||||
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
|
||||||
"</div>"
|
|
||||||
)
|
|
||||||
deliveries: list[Dict[str, Any]] = []
|
|
||||||
for recipient in recipients:
|
|
||||||
try:
|
|
||||||
result = await send_generic_email(
|
|
||||||
recipient_email=recipient,
|
|
||||||
subject=title,
|
|
||||||
body_text=body_text,
|
|
||||||
body_html=body_html,
|
|
||||||
)
|
|
||||||
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
|
||||||
except Exception as exc:
|
|
||||||
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
|
||||||
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
|
||||||
if successful:
|
|
||||||
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
|
||||||
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
|
||||||
|
|
||||||
|
|
||||||
async def send_portal_notification(
|
|
||||||
*,
|
|
||||||
event_type: str,
|
|
||||||
item: Dict[str, Any],
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
note: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
if not runtime.magent_notify_enabled:
|
|
||||||
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
|
||||||
|
|
||||||
item_id = int(item.get("id") or 0)
|
|
||||||
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
|
||||||
message_lines = [
|
|
||||||
f"Event: {event_type}",
|
|
||||||
f"Actor: {actor_username} ({actor_role})",
|
|
||||||
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
|
||||||
]
|
|
||||||
if note:
|
|
||||||
message_lines.append(f"Note: {note}")
|
|
||||||
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
|
||||||
message = "\n".join(message_lines)
|
|
||||||
payload = {
|
|
||||||
"type": "portal.notification",
|
|
||||||
"event": event_type,
|
|
||||||
"item_id": item_id,
|
|
||||||
"item_url": _portal_item_url(item_id),
|
|
||||||
"kind": _clean_text(item.get("kind")),
|
|
||||||
"status": _clean_text(item.get("status")),
|
|
||||||
"priority": _clean_text(item.get("priority")),
|
|
||||||
"requested_by": _clean_text(item.get("created_by_username")),
|
|
||||||
"actor_username": actor_username,
|
|
||||||
"actor_role": actor_role,
|
|
||||||
"note": note or "",
|
|
||||||
}
|
|
||||||
|
|
||||||
channels: Dict[str, Dict[str, Any]] = {}
|
|
||||||
if runtime.magent_notify_discord_enabled:
|
|
||||||
try:
|
|
||||||
channels["discord"] = await _send_discord(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["discord"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_telegram_enabled:
|
|
||||||
try:
|
|
||||||
channels["telegram"] = await _send_telegram(title, message)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_webhook_enabled:
|
|
||||||
try:
|
|
||||||
channels["webhook"] = await _send_webhook(payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_push_enabled:
|
|
||||||
try:
|
|
||||||
channels["push"] = await _send_push(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["push"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_email_enabled:
|
|
||||||
try:
|
|
||||||
channels["email"] = await _send_email(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["email"] = {"status": "error", "detail": str(exc)}
|
|
||||||
|
|
||||||
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
|
||||||
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
|
||||||
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
|
||||||
logger.info(
|
|
||||||
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
|
||||||
event_type,
|
|
||||||
item_id,
|
|
||||||
successful,
|
|
||||||
failed,
|
|
||||||
skipped,
|
|
||||||
)
|
|
||||||
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
|
||||||
if not channels:
|
|
||||||
overall = "skipped"
|
|
||||||
return {"status": overall, "channels": channels}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextvars import ContextVar, Token
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
|
||||||
_OPERATION_TTL_SECONDS = 15 * 60
|
|
||||||
_MAX_OPERATIONS = 500
|
|
||||||
_MAX_EVENTS = 60
|
|
||||||
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
|
||||||
"magent_operation_id", default=None
|
|
||||||
)
|
|
||||||
_operations: Dict[str, Dict[str, Any]] = {}
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_locked(now_monotonic: float) -> None:
|
|
||||||
expired = [
|
|
||||||
operation_id
|
|
||||||
for operation_id, operation in _operations.items()
|
|
||||||
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
|
||||||
]
|
|
||||||
for operation_id in expired:
|
|
||||||
_operations.pop(operation_id, None)
|
|
||||||
if len(_operations) <= _MAX_OPERATIONS:
|
|
||||||
return
|
|
||||||
oldest = sorted(
|
|
||||||
_operations,
|
|
||||||
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
|
||||||
)
|
|
||||||
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
|
||||||
_operations.pop(operation_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
now_iso = _now_iso()
|
|
||||||
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
|
||||||
with _lock:
|
|
||||||
_prune_locked(now_monotonic)
|
|
||||||
_operations[operation_id] = {
|
|
||||||
"id": operation_id,
|
|
||||||
"label": normalized_label,
|
|
||||||
"path": path,
|
|
||||||
"status": "running",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"updated_at": now_iso,
|
|
||||||
"updated_monotonic": now_monotonic,
|
|
||||||
"duration_ms": None,
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"id": uuid.uuid4().hex,
|
|
||||||
"service": "Magent",
|
|
||||||
"state": "complete",
|
|
||||||
"message": "Your action has been received. Magent is starting the checks.",
|
|
||||||
"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": (
|
|
||||||
"This action has finished. Check the request status for what happens next."
|
|
||||||
if success
|
|
||||||
else "This action could not be completed. Open the activity details to see which step needs attention."
|
|
||||||
),
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": now_iso,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
|
||||||
|
|
||||||
|
|
||||||
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
normalized = normalize_operation_id(operation_id)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(normalized)
|
|
||||||
if not operation:
|
|
||||||
return None
|
|
||||||
result = deepcopy(operation)
|
|
||||||
result.pop("updated_monotonic", None)
|
|
||||||
for event in result.get("events", []):
|
|
||||||
event.pop("started_monotonic", None)
|
|
||||||
return result
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
|
||||||
from ..db import (
|
|
||||||
create_password_reset_token,
|
|
||||||
delete_expired_password_reset_tokens,
|
|
||||||
get_password_reset_token,
|
|
||||||
get_user_by_jellyseerr_id,
|
|
||||||
get_user_by_username,
|
|
||||||
get_users_by_username_ci,
|
|
||||||
mark_password_reset_token_used,
|
|
||||||
set_user_auth_provider,
|
|
||||||
set_user_password,
|
|
||||||
sync_jellyfin_password_state,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_password_reset_email
|
|
||||||
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetUnavailableError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_handles(value: object) -> list[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return []
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
handles = [normalized]
|
|
||||||
if "@" in normalized:
|
|
||||||
handles.append(normalized.split("@", 1)[0])
|
|
||||||
return list(dict.fromkeys(handles))
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
|
|
||||||
if not users:
|
|
||||||
return None
|
|
||||||
requested = str(requested_identifier or "").strip().lower()
|
|
||||||
|
|
||||||
def _rank(user: dict) -> tuple[int, int, int, int]:
|
|
||||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
|
||||||
role = str(user.get("role") or "user").strip().lower()
|
|
||||||
username = str(user.get("username") or "").strip().lower()
|
|
||||||
return (
|
|
||||||
0 if role == "admin" else 1,
|
|
||||||
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
|
||||||
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
|
|
||||||
0 if username == requested else 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
return sorted(users, key=_rank)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
|
|
||||||
target_handles = set(_normalize_handles(identifier))
|
|
||||||
if not target_handles:
|
|
||||||
return None
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email"):
|
|
||||||
value = user.get(key)
|
|
||||||
if target_handles.intersection(_normalize_handles(value)):
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_all_seerr_users() -> list[dict]:
|
|
||||||
cached = get_cached_jellyseerr_users()
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
if not client.configured():
|
|
||||||
return []
|
|
||||||
users: list[dict] = []
|
|
||||||
take = 100
|
|
||||||
skip = 0
|
|
||||||
while True:
|
|
||||||
payload = await client.get_users(take=take, skip=skip)
|
|
||||||
if not payload:
|
|
||||||
break
|
|
||||||
if isinstance(payload, list):
|
|
||||||
batch = payload
|
|
||||||
elif isinstance(payload, dict):
|
|
||||||
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
|
|
||||||
else:
|
|
||||||
batch = None
|
|
||||||
if not isinstance(batch, list) or not batch:
|
|
||||||
break
|
|
||||||
users.extend([user for user in batch if isinstance(user, dict)])
|
|
||||||
if len(batch) < take:
|
|
||||||
break
|
|
||||||
skip += take
|
|
||||||
if users:
|
|
||||||
return save_jellyseerr_users_cache(users)
|
|
||||||
return users
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
|
|
||||||
if isinstance(local_user, dict):
|
|
||||||
stored_email = str(local_user.get("email") or "").strip()
|
|
||||||
if "@" in stored_email:
|
|
||||||
return stored_email
|
|
||||||
username = str(local_user.get("username") or "").strip()
|
|
||||||
if "@" in username:
|
|
||||||
return username
|
|
||||||
if isinstance(seerr_user, dict):
|
|
||||||
email = str(seerr_user.get("email") or "").strip()
|
|
||||||
if "@" in email:
|
|
||||||
return email
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
|
|
||||||
normalized_identifier = str(identifier or "").strip()
|
|
||||||
if not normalized_identifier:
|
|
||||||
return None
|
|
||||||
|
|
||||||
local_user = normalize_user_auth_provider(
|
|
||||||
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
|
|
||||||
)
|
|
||||||
seerr_users: list[dict] | None = None
|
|
||||||
seerr_user: dict | None = None
|
|
||||||
|
|
||||||
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
|
|
||||||
seerr_users = await _fetch_all_seerr_users()
|
|
||||||
seerr_user = next(
|
|
||||||
(
|
|
||||||
user
|
|
||||||
for user in seerr_users
|
|
||||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not local_user:
|
|
||||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
|
||||||
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
|
|
||||||
if seerr_user:
|
|
||||||
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
|
|
||||||
try:
|
|
||||||
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
seerr_user_id = None
|
|
||||||
if seerr_user_id is not None:
|
|
||||||
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
|
|
||||||
if not local_user:
|
|
||||||
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
|
|
||||||
if not isinstance(candidate, str) or not candidate.strip():
|
|
||||||
continue
|
|
||||||
local_user = normalize_user_auth_provider(
|
|
||||||
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
|
|
||||||
)
|
|
||||||
if local_user:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not local_user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
auth_provider = resolve_user_auth_provider(local_user)
|
|
||||||
username = str(local_user.get("username") or "").strip()
|
|
||||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
|
||||||
if not recipient_email:
|
|
||||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
|
||||||
if isinstance(local_user.get("jellyseerr_user_id"), int):
|
|
||||||
seerr_user = next(
|
|
||||||
(
|
|
||||||
user
|
|
||||||
for user in seerr_users
|
|
||||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not seerr_user:
|
|
||||||
seerr_user = _find_matching_seerr_user(username, seerr_users)
|
|
||||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
|
||||||
if not recipient_email:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if auth_provider == "jellyseerr":
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if jellyfin_client.configured():
|
|
||||||
try:
|
|
||||||
jellyfin_user = await jellyfin_client.find_user_by_name(username)
|
|
||||||
except Exception:
|
|
||||||
jellyfin_user = None
|
|
||||||
if isinstance(jellyfin_user, dict):
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
|
|
||||||
if auth_provider not in {"local", "jellyfin"}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"username": username,
|
|
||||||
"recipient_email": recipient_email,
|
|
||||||
"auth_provider": auth_provider,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _token_record_is_usable(record: Optional[dict]) -> bool:
|
|
||||||
if not isinstance(record, dict):
|
|
||||||
return False
|
|
||||||
if record.get("is_used"):
|
|
||||||
return False
|
|
||||||
if record.get("is_expired"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_email(email: str) -> str:
|
|
||||||
candidate = str(email or "").strip()
|
|
||||||
if "@" not in candidate:
|
|
||||||
return "valid reset link"
|
|
||||||
local_part, domain = candidate.split("@", 1)
|
|
||||||
if not local_part:
|
|
||||||
return f"***@{domain}"
|
|
||||||
if len(local_part) == 1:
|
|
||||||
return f"{local_part}***@{domain}"
|
|
||||||
return f"{local_part[0]}***{local_part[-1]}@{domain}"
|
|
||||||
|
|
||||||
|
|
||||||
async def request_password_reset(
|
|
||||||
identifier: str,
|
|
||||||
*,
|
|
||||||
requested_by_ip: Optional[str] = None,
|
|
||||||
requested_user_agent: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
target = await _resolve_reset_target(identifier)
|
|
||||||
if not target:
|
|
||||||
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
|
|
||||||
return {"status": "ok", "issued": False}
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
|
|
||||||
create_password_reset_token(
|
|
||||||
token,
|
|
||||||
target["username"],
|
|
||||||
target["recipient_email"],
|
|
||||||
target["auth_provider"],
|
|
||||||
expires_at,
|
|
||||||
requested_by_ip=requested_by_ip,
|
|
||||||
requested_user_agent=requested_user_agent,
|
|
||||||
)
|
|
||||||
await send_password_reset_email(
|
|
||||||
recipient_email=target["recipient_email"],
|
|
||||||
username=target["username"],
|
|
||||||
token=token,
|
|
||||||
expires_at=expires_at,
|
|
||||||
auth_provider=target["auth_provider"],
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"issued": True,
|
|
||||||
"username": target["username"],
|
|
||||||
"recipient_email": target["recipient_email"],
|
|
||||||
"auth_provider": target["auth_provider"],
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password_reset_token(token: str) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
record = get_password_reset_token(token)
|
|
||||||
if not _token_record_is_usable(record):
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
|
|
||||||
"auth_provider": record.get("auth_provider"),
|
|
||||||
"expires_at": record.get("expires_at"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
record = get_password_reset_token(token)
|
|
||||||
if not _token_record_is_usable(record):
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
username = str(record.get("username") or "").strip()
|
|
||||||
if not username:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
stored_user = normalize_user_auth_provider(get_user_by_username(username))
|
|
||||||
if not stored_user:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
auth_provider = resolve_user_auth_provider(stored_user)
|
|
||||||
if auth_provider == "jellyseerr":
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
|
|
||||||
if auth_provider == "local":
|
|
||||||
set_user_password(username, new_password)
|
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
|
|
||||||
set_user_auth_provider(username, "local")
|
|
||||||
mark_password_reset_token_used(token)
|
|
||||||
logger.info("password reset applied username=%s provider=local", username)
|
|
||||||
return {"status": "ok", "provider": "local", "username": username}
|
|
||||||
|
|
||||||
if auth_provider == "jellyfin":
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if not client.configured():
|
|
||||||
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
|
|
||||||
jellyfin_user = await client.find_user_by_name(username)
|
|
||||||
user_id = client._extract_user_id(jellyfin_user)
|
|
||||||
if not user_id:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
await client.set_user_password(user_id, new_password)
|
|
||||||
sync_jellyfin_password_state(username, new_password)
|
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
|
|
||||||
set_user_auth_provider(username, "jellyfin")
|
|
||||||
mark_password_reset_token_used(token)
|
|
||||||
logger.info("password reset applied username=%s provider=jellyfin", username)
|
|
||||||
return {"status": "ok", "provider": "jellyfin", "username": username}
|
|
||||||
|
|
||||||
raise ValueError("Password reset is not available for this sign-in provider.")
|
|
||||||
+92
-1137
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from ..db import get_setting, set_setting, delete_setting
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
|
|
||||||
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
|
|
||||||
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
|
|
||||||
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
|
|
||||||
parsed = _parse_iso(cached_at)
|
|
||||||
if not parsed:
|
|
||||||
return False
|
|
||||||
age = datetime.now(timezone.utc) - parsed
|
|
||||||
return age <= timedelta(minutes=max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cached_users(
|
|
||||||
cache_key: str, cache_at_key: str, max_age_minutes: int
|
|
||||||
) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
cached_at = get_setting(cache_at_key)
|
|
||||||
if not _cache_is_fresh(cached_at, max_age_minutes):
|
|
||||||
return None
|
|
||||||
raw = get_setting(cache_key)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [item for item in data if isinstance(item, dict)]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
|
|
||||||
payload = json.dumps(users, ensure_ascii=True)
|
|
||||||
set_setting(cache_key, payload)
|
|
||||||
set_setting(cache_at_key, _now_iso())
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_handles(value: Any) -> List[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return []
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
handles = [normalized]
|
|
||||||
if "@" in normalized:
|
|
||||||
handles.append(normalized.split("@", 1)[0])
|
|
||||||
return list(dict.fromkeys(handles))
|
|
||||||
|
|
||||||
|
|
||||||
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
|
|
||||||
candidate_to_id: Dict[str, int] = {}
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
|
||||||
try:
|
|
||||||
user_id = int(user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email", "displayName", "name"):
|
|
||||||
for handle in _normalized_handles(user.get(key)):
|
|
||||||
candidate_to_id.setdefault(handle, user_id)
|
|
||||||
return candidate_to_id
|
|
||||||
|
|
||||||
|
|
||||||
def find_matching_jellyseerr_user(
|
|
||||||
identifier: str, users: List[Dict[str, Any]]
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
target_handles = set(_normalized_handles(identifier))
|
|
||||||
if not target_handles:
|
|
||||||
return None
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email", "displayName", "name"):
|
|
||||||
if target_handles.intersection(_normalized_handles(user.get(key))):
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return None
|
|
||||||
value = user.get("email")
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
candidate = value.strip()
|
|
||||||
if not candidate or "@" not in candidate:
|
|
||||||
return None
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def match_jellyseerr_user_id(
|
|
||||||
username: str, candidate_map: Dict[str, int]
|
|
||||||
) -> Optional[int]:
|
|
||||||
for handle in _normalized_handles(username):
|
|
||||||
matched = candidate_map.get(handle)
|
|
||||||
if matched is not None:
|
|
||||||
return matched
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
normalized: List[Dict[str, Any]] = []
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": user.get("id") or user.get("userId") or user.get("Id"),
|
|
||||||
"email": user.get("email"),
|
|
||||||
"username": user.get("username"),
|
|
||||||
"displayName": user.get("displayName"),
|
|
||||||
"name": user.get("name"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
|
|
||||||
logger.debug("Cached Seerr users: %s", len(normalized))
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
normalized: List[Dict[str, Any]] = []
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": user.get("Id"),
|
|
||||||
"name": user.get("Name"),
|
|
||||||
"hasPassword": user.get("HasPassword"),
|
|
||||||
"lastLoginDate": user.get("LastLoginDate"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
|
|
||||||
logger.debug("Cached Jellyfin users: %s", len(normalized))
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_user_import_caches() -> Dict[str, int]:
|
|
||||||
cleared = 0
|
|
||||||
for key in (
|
|
||||||
JELLYSEERR_CACHE_KEY,
|
|
||||||
JELLYSEERR_CACHE_AT_KEY,
|
|
||||||
JELLYFIN_CACHE_KEY,
|
|
||||||
JELLYFIN_CACHE_AT_KEY,
|
|
||||||
):
|
|
||||||
delete_setting(key)
|
|
||||||
cleared += 1
|
|
||||||
logger.debug("Cleared user import cache keys: %s", cleared)
|
|
||||||
return {"settingsKeysCleared": cleared}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
fastapi==0.134.0
|
fastapi==0.115.0
|
||||||
uvicorn==0.41.0
|
uvicorn==0.30.6
|
||||||
httpx==0.28.1
|
httpx==0.27.2
|
||||||
pydantic==2.12.5
|
pydantic==2.9.2
|
||||||
pydantic-settings==2.14.2
|
pydantic-settings==2.5.2
|
||||||
PyJWT==2.13.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
python-multipart==0.0.31
|
python-multipart==0.0.9
|
||||||
Pillow==12.3.0
|
Pillow==10.4.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
|||||||
from contextlib import ExitStack
|
|
||||||
from types import SimpleNamespace
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
from backend.app.config import settings
|
|
||||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
|
||||||
from backend.app.services import snapshot as snapshot_service
|
|
||||||
from backend.app.services.collector_search import read_search_status, search_status
|
|
||||||
|
|
||||||
|
|
||||||
def command(name="MoviesSearch", status="started", **body):
|
|
||||||
return {"name": name, "status": status, "body": body}
|
|
||||||
|
|
||||||
|
|
||||||
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
|
|
||||||
def test_movie_search_is_scoped_to_the_movie(self):
|
|
||||||
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
|
|
||||||
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
|
|
||||||
|
|
||||||
def test_queued_search_and_running_search_priority(self):
|
|
||||||
queued = command(status="queued", movieIds=[12])
|
|
||||||
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
|
|
||||||
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
|
|
||||||
|
|
||||||
def test_terminal_commands_are_not_searching(self):
|
|
||||||
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
|
|
||||||
with self.subTest(state=state):
|
|
||||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
|
|
||||||
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
|
|
||||||
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
|
|
||||||
|
|
||||||
def test_numeric_statuses(self):
|
|
||||||
for state, expected in [(0, "queued"), (1, "searching")]:
|
|
||||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
|
|
||||||
|
|
||||||
def test_series_and_season_searches(self):
|
|
||||||
for name in ["SeriesSearch", "SeasonSearch"]:
|
|
||||||
with self.subTest(name=name):
|
|
||||||
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
|
|
||||||
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
|
|
||||||
|
|
||||||
def test_episode_search_uses_episode_ids_not_numbers(self):
|
|
||||||
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
|
|
||||||
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
|
|
||||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
|
|
||||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
|
|
||||||
|
|
||||||
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
|
|
||||||
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
|
|
||||||
with self.subTest(name=name):
|
|
||||||
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
|
|
||||||
|
|
||||||
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
|
|
||||||
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
|
|
||||||
for payload in [None, {}, {"error": "unavailable"}]:
|
|
||||||
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
|
|
||||||
|
|
||||||
async def test_check_is_read_only_with_a_short_timeout(self):
|
|
||||||
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
|
|
||||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
|
|
||||||
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
|
||||||
|
|
||||||
async def test_service_failure_is_unknown_not_idle(self):
|
|
||||||
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
|
|
||||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
|
|
||||||
|
|
||||||
|
|
||||||
class LibrarySearchPresentationTests(unittest.TestCase):
|
|
||||||
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
|
|
||||||
arr_state="added", download_state="not_started", jellyfin=False):
|
|
||||||
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
|
|
||||||
state=NormalizedState.added_to_arr)
|
|
||||||
return snapshot_service._build_presentation(
|
|
||||||
snapshot, approved=True, arr_state=arr_state,
|
|
||||||
arr_details={"search": {"state": search}, "availability": {
|
|
||||||
"available": available, "missing": missing, "total": available + missing,
|
|
||||||
}}, prowlarr_state="ok",
|
|
||||||
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
|
|
||||||
jellyfin_found=jellyfin, jellyfin_link=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def stage(self, presentation, stage_id="library"):
|
|
||||||
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
|
|
||||||
|
|
||||||
def test_card_uses_actual_search_state(self):
|
|
||||||
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
|
|
||||||
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
|
|
||||||
with self.subTest(state=state):
|
|
||||||
presentation = self.presentation(state)
|
|
||||||
library = self.stage(presentation)
|
|
||||||
self.assertEqual(library["stateLabel"], badge)
|
|
||||||
self.assertEqual(library["state"], style)
|
|
||||||
self.assertEqual(library["searchStatus"], state)
|
|
||||||
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
|
|
||||||
self.assertEqual(library["available"], 0)
|
|
||||||
self.assertEqual(library["missing"], 1)
|
|
||||||
|
|
||||||
def test_partial_tv_retains_counts_and_search_activity(self):
|
|
||||||
for state in ["idle", "searching", "queued", "unavailable"]:
|
|
||||||
with self.subTest(state=state):
|
|
||||||
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
|
|
||||||
library = self.stage(presentation)
|
|
||||||
self.assertEqual(library["state"], "partial")
|
|
||||||
self.assertEqual(library["searchStatus"], state)
|
|
||||||
self.assertIn("22 of 24 episodes collected", library["summary"])
|
|
||||||
self.assertNotIn("is still looking", presentation["status"]["meaning"])
|
|
||||||
|
|
||||||
def test_collected_titles_dont_look_stuck_searching(self):
|
|
||||||
for jellyfin in [True, False]:
|
|
||||||
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
|
|
||||||
self.assertEqual(library["state"], "complete")
|
|
||||||
self.assertIn("no search needed", library["summary"])
|
|
||||||
|
|
||||||
def test_download_has_its_own_state_without_claiming_searching(self):
|
|
||||||
library = self.stage(self.presentation("idle", download_state="downloading"))
|
|
||||||
self.assertEqual(library["stateLabel"], "Downloading")
|
|
||||||
self.assertIn("Not currently searching", library["summary"])
|
|
||||||
|
|
||||||
def test_an_old_missing_download_does_not_mark_search_complete(self):
|
|
||||||
search = self.stage(self.presentation("idle", download_state="missing"), "search")
|
|
||||||
self.assertEqual(search["state"], "waiting")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
|
||||||
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
|
|
||||||
for media_type in [RequestType.movie, RequestType.tv]:
|
|
||||||
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
|
|
||||||
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
|
|
||||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
|
|
||||||
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
|
|
||||||
collector = SimpleNamespace(
|
|
||||||
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
|
|
||||||
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
|
|
||||||
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
|
|
||||||
get_queue=AsyncMock(return_value={"records": []}),
|
|
||||||
get=AsyncMock(return_value=commands),
|
|
||||||
)
|
|
||||||
mocks = {
|
|
||||||
"get_runtime_settings": runtime,
|
|
||||||
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
|
|
||||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
|
||||||
"get_request_cache_by_id": None,
|
|
||||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
|
|
||||||
"JellyfinClient": SimpleNamespace(configured=lambda: False),
|
|
||||||
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
|
|
||||||
"SonarrClient": collector, "RadarrClient": collector,
|
|
||||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
|
||||||
"get_request_download_evidence": {}, "get_request_repairs": [], "_latest_repair_action": None, "save_snapshot": None,
|
|
||||||
}
|
|
||||||
for name, value in mocks.items():
|
|
||||||
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
|
|
||||||
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
|
||||||
snapshot = await snapshot_service.build_snapshot("12")
|
|
||||||
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
|
||||||
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
|
|
||||||
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
|
|
||||||
self.assertEqual(library["searchStatus"], expected)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import json
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
from backend.app import db
|
|
||||||
from backend.app.services import issue_resolution as service
|
|
||||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
|
||||||
|
|
||||||
|
|
||||||
class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
|
||||||
def issue(self):
|
|
||||||
item = db.create_portal_item(kind="issue", title="Broken <movie>", description="Repair",
|
|
||||||
created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
|
|
||||||
self.start(item["id"])
|
|
||||||
return item
|
|
||||||
|
|
||||||
def start(self, item_id):
|
|
||||||
db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
|
|
||||||
actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
|
|
||||||
|
|
||||||
async def test_importing_or_unverified_media_does_not_email_reporter(self):
|
|
||||||
self.issue()
|
|
||||||
for phase in ["collecting", "indexing", "unavailable"]:
|
|
||||||
with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
|
|
||||||
await service.process_active_media_repairs()
|
|
||||||
begin.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
|
|
||||||
item = self.issue()
|
|
||||||
with (
|
|
||||||
patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
|
|
||||||
patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
|
|
||||||
patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
|
|
||||||
patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
|
|
||||||
patch.object(service, "send_generic_email", new=AsyncMock()) as email,
|
|
||||||
):
|
|
||||||
await service.process_active_media_repairs()
|
|
||||||
await service.process_active_media_repairs()
|
|
||||||
self.assertEqual(email.await_count, 1)
|
|
||||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
|
|
||||||
content = email.await_args.kwargs
|
|
||||||
self.assertIn("YES — it works", content["body_html"])
|
|
||||||
self.assertIn("NO — still broken", content["body_html"])
|
|
||||||
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
|
|
||||||
self.assertIn("Broken <movie>", content["body_html"])
|
|
||||||
self.assertNotIn("<movie>", content["body_html"])
|
|
||||||
self.assertIn("Confirm your answer in Magent", content["body_text"])
|
|
||||||
service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
|
|
||||||
await service.process_active_media_repairs()
|
|
||||||
await service.process_due_issue_confirmations()
|
|
||||||
self.assertEqual(email.await_count, 1)
|
|
||||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
|
|
||||||
self.start(item["id"])
|
|
||||||
await service.process_active_media_repairs()
|
|
||||||
self.assertEqual(email.await_count, 2)
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
"""Replacement-cycle regressions. All collectors/downloads are fixtures."""
|
|
||||||
from contextlib import ExitStack
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
from backend.app import db
|
|
||||||
from backend.app.config import settings
|
|
||||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
|
||||||
from backend.app.routers import requests as requests_router
|
|
||||||
from backend.app.services import snapshot as service, media_repair
|
|
||||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
|
||||||
|
|
||||||
|
|
||||||
class RepairPipelineTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
self.cycle = datetime.now(timezone.utc).isoformat()
|
|
||||||
self.item = {"id": 12, "title": "Example", "hasFile": False}
|
|
||||||
self.jf = {"Id": "jf-1", "Name": "Example", "Type": "Movie", "ProviderIds": {"Tmdb": "123"}, "Etag": "old"}
|
|
||||||
self.episodes = [
|
|
||||||
{"id": 109, "seasonNumber": 5, "episodeNumber": 9, "hasFile": False, "episodeFileId": 0},
|
|
||||||
{"id": 110, "seasonNumber": 5, "episodeNumber": 10, "hasFile": True, "episodeFileId": 42},
|
|
||||||
]
|
|
||||||
self.torrents = []
|
|
||||||
self.queue = []
|
|
||||||
self.commands = []
|
|
||||||
self.jf_episodes = [{"Id": "ep9", "ParentIndexNumber": 5, "IndexNumber": 9, "Etag": "old"}]
|
|
||||||
self.media_type = RequestType.movie
|
|
||||||
self.fail_collector = False
|
|
||||||
|
|
||||||
def start(self, media_type=RequestType.movie):
|
|
||||||
self.media_type = media_type
|
|
||||||
if media_type == RequestType.tv:
|
|
||||||
self.jf.update(Type="Series", ProviderIds={"Tvdb": "456"})
|
|
||||||
tracking = {
|
|
||||||
"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media",
|
|
||||||
"collectorId": 12, "mediaType": media_type.value, "originalFileIds": [40],
|
|
||||||
"previousDownloadIds": ["old"],
|
|
||||||
"episodes": [{"id": 109, "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [],
|
|
||||||
"jellyfinFoundAtStart": True,
|
|
||||||
"jellyfinBaseline": [{"Id": "ep9", "Etag": "old", "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [{"Id": "jf-1", "Etag": "old"}],
|
|
||||||
}
|
|
||||||
db.start_request_repair(tracking)
|
|
||||||
return tracking
|
|
||||||
|
|
||||||
async def snapshot(self):
|
|
||||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache", "jellyfin_public_url": "https://media.test"})
|
|
||||||
lookup = AsyncMock(side_effect=RuntimeError("offline")) if self.fail_collector else AsyncMock(return_value=[self.item])
|
|
||||||
collector = SimpleNamespace(
|
|
||||||
get_movie_by_tmdb_id=lookup, get_series_by_tvdb_id=lookup,
|
|
||||||
get_episodes=AsyncMock(return_value=self.episodes), get_queue=AsyncMock(return_value={"records": self.queue}),
|
|
||||||
get=AsyncMock(return_value=self.commands),
|
|
||||||
)
|
|
||||||
jellyfin = SimpleNamespace(configured=lambda: True, search_items=AsyncMock(return_value={"Items": [self.jf]}),
|
|
||||||
get_series_episodes=AsyncMock(return_value=self.jf_episodes))
|
|
||||||
with ExitStack() as stack:
|
|
||||||
mocks = {
|
|
||||||
"get_runtime_settings": runtime,
|
|
||||||
"get_request_cache_payload": {"id": 12, "type": self.media_type.value, "status": 4,
|
|
||||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
|
||||||
"get_request_cache_by_id": None,
|
|
||||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False), "JellyfinClient": jellyfin,
|
|
||||||
"QBittorrentClient": SimpleNamespace(configured=lambda: True,
|
|
||||||
get_torrents_by_hashes=AsyncMock(return_value=self.torrents), get_torrents_by_tag=AsyncMock(return_value=self.torrents)),
|
|
||||||
"SonarrClient": collector, "RadarrClient": collector,
|
|
||||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
|
||||||
"_latest_repair_action": None,
|
|
||||||
}
|
|
||||||
for name, value in mocks.items():
|
|
||||||
stack.enter_context(patch.object(service, name, return_value=value))
|
|
||||||
stack.enter_context(patch.object(service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
|
||||||
stack.enter_context(patch.object(media_repair, "JellyfinClient", return_value=jellyfin))
|
|
||||||
return await service.build_snapshot("12")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def stage(snapshot, name):
|
|
||||||
return next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == name)
|
|
||||||
|
|
||||||
async def test_movie_old_catalog_and_completed_torrent_do_not_complete_repair(self):
|
|
||||||
self.start()
|
|
||||||
self.torrents = [{"hash": "old", "progress": 1, "state": "uploading", "added_on": 1, "completion_on": 2}]
|
|
||||||
self.queue = [{"movieId": 12, "downloadId": "old"}]
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending")
|
|
||||||
self.assertEqual(self.stage(snapshot, "available")["state"], "waiting")
|
|
||||||
self.assertEqual(snapshot.presentation["status"]["label"], "Waiting for a replacement")
|
|
||||||
self.assertFalse(snapshot.presentation["download"]["visible"])
|
|
||||||
self.assertTrue(snapshot.raw["jellyfin"]["catalogFound"])
|
|
||||||
self.assertFalse(snapshot.raw["jellyfin"]["available"])
|
|
||||||
self.assertIn("search_auto", [a.id for a in snapshot.actions])
|
|
||||||
for name in ["requested", "approved"]:
|
|
||||||
self.assertEqual(self.stage(snapshot, name)["state"], "complete")
|
|
||||||
|
|
||||||
async def test_movie_repair_search_download_import_index_and_complete(self):
|
|
||||||
self.start()
|
|
||||||
self.commands = [{"name": "MoviesSearch", "status": "started", "body": {"movieIds": [12]}}]
|
|
||||||
searching = await self.snapshot()
|
|
||||||
self.assertEqual(searching.presentation["status"]["label"], "Searching for a replacement")
|
|
||||||
self.commands = []
|
|
||||||
self.torrents = [{"hash": "new", "progress": .32, "state": "downloading"}]
|
|
||||||
downloading = await self.snapshot()
|
|
||||||
self.assertEqual(downloading.state, NormalizedState.downloading)
|
|
||||||
self.assertEqual(self.stage(downloading, "download")["torrents"][0]["progressPercent"], 32)
|
|
||||||
self.assertNotIn("resume_torrent", [a.id for a in downloading.actions])
|
|
||||||
self.item.update(hasFile=True, movieFile={"id": 41})
|
|
||||||
imported = await self.snapshot()
|
|
||||||
self.assertEqual(self.stage(imported, "available")["stateLabel"], "Indexing")
|
|
||||||
self.assertEqual(self.stage(imported, "download")["state"], "complete")
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
|
||||||
self.jf["Etag"] = "new"
|
|
||||||
self.torrents = []
|
|
||||||
completed = await self.snapshot()
|
|
||||||
self.assertEqual(completed.state, NormalizedState.completed)
|
|
||||||
self.assertEqual(db.get_request_repairs("12"), [])
|
|
||||||
self.assertEqual(completed.presentation["status"]["label"], "Available to watch")
|
|
||||||
|
|
||||||
async def test_old_queue_record_without_torrent_is_not_new_download_attempt(self):
|
|
||||||
self.start()
|
|
||||||
self.queue = [{"movieId": 12, "downloadId": "old"}]
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending")
|
|
||||||
self.assertFalse(snapshot.presentation["download"]["visible"])
|
|
||||||
|
|
||||||
async def test_same_original_file_cannot_confirm_replacement(self):
|
|
||||||
self.start()
|
|
||||||
self.item.update(hasFile=True, movieFile={"id": 40})
|
|
||||||
self.jf["Etag"] = "new"
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
|
||||||
self.assertEqual(self.stage(snapshot, "download")["state"], "waiting")
|
|
||||||
|
|
||||||
async def test_tv_preserves_unaffected_episodes_and_verifies_exact_replacement(self):
|
|
||||||
self.start(RequestType.tv)
|
|
||||||
self.item["statistics"] = {"episodeFileCount": 2, "totalEpisodeCount": 2} # stale summary
|
|
||||||
pending = await self.snapshot()
|
|
||||||
self.assertEqual(self.stage(pending, "library")["missing"], 1)
|
|
||||||
self.assertEqual(self.stage(pending, "available")["state"], "partial")
|
|
||||||
self.assertEqual(self.stage(pending, "download")["stateLabel"], "Pending")
|
|
||||||
self.episodes[0].update(hasFile=True, episodeFileId=43)
|
|
||||||
imported = await self.snapshot()
|
|
||||||
self.assertEqual(imported.state, NormalizedState.importing)
|
|
||||||
self.assertEqual(self.stage(imported, "available")["state"], "partial")
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
|
||||||
self.jf_episodes[0]["Etag"] = "new"
|
|
||||||
completed = await self.snapshot()
|
|
||||||
self.assertEqual(completed.state, NormalizedState.completed)
|
|
||||||
self.assertEqual(db.get_request_repairs("12"), [])
|
|
||||||
|
|
||||||
async def test_collector_outage_does_not_restore_old_availability(self):
|
|
||||||
self.start()
|
|
||||||
self.fail_collector = True
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
|
||||||
self.assertEqual(snapshot.presentation["status"]["label"], "Repair status temporarily unavailable")
|
|
||||||
|
|
||||||
async def test_external_movie_removal_reconciles_old_jellyfin_entry(self):
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
|
||||||
self.assertFalse(snapshot.raw["jellyfin"]["available"])
|
|
||||||
|
|
||||||
async def test_history_from_previous_cycle_and_late_old_poll_are_ignored(self):
|
|
||||||
old = Snapshot(request_id="12", title="Example", state=NormalizedState.completed,
|
|
||||||
timeline=[{"service": "qBittorrent", "status": "completed", "details": {"torrents": [{"hash": "old"}]}}])
|
|
||||||
db.save_snapshot(old)
|
|
||||||
self.start()
|
|
||||||
self.assertFalse(db.get_request_download_evidence("12")["observed"])
|
|
||||||
old.state_reason = "A pre-repair poll returned late"
|
|
||||||
db.save_snapshot(old)
|
|
||||||
self.assertFalse(db.get_request_download_evidence("12")["observed"])
|
|
||||||
self.torrents = [{"hash": "new", "progress": .5, "state": "downloading"}]
|
|
||||||
await self.snapshot()
|
|
||||||
self.assertTrue(db.get_request_download_evidence("12")["observed"])
|
|
||||||
|
|
||||||
def test_same_hash_redownload_and_new_completed_job_are_kept(self):
|
|
||||||
old = {"hash": "same", "progress": 1, "added_on": 1, "completion_on": 2}
|
|
||||||
retry = {**old, "progress": .3}
|
|
||||||
fresh = {**old, "completion_on": datetime.now(timezone.utc).timestamp() + 1}
|
|
||||||
self.assertEqual(media_repair.current_cycle_torrents([old, retry, fresh], self.cycle), [retry, fresh])
|
|
||||||
|
|
||||||
def test_repair_cycle_survives_restart_and_list_does_not_say_ready(self):
|
|
||||||
self.start()
|
|
||||||
db.init_db()
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
|
||||||
with patch.dict(requests_router._recent_cache, {"items": [{"request_id": 12, "status": 4, "requested_by_id": 7}]}):
|
|
||||||
self.assertEqual(requests_router._get_recent_from_cache(None, 7, 10, 0, None, [4]), [])
|
|
||||||
rows = requests_router._get_recent_from_cache(None, 7, 10, 0, None, [5])
|
|
||||||
self.assertEqual(rows[0]["status"], 5)
|
|
||||||
|
|
||||||
async def test_live_poll_ignores_old_completed_download(self):
|
|
||||||
self.start()
|
|
||||||
runtime = settings.model_copy(update={"jellyseerr_base_url": None, "jellyseerr_api_key": None})
|
|
||||||
qbit = SimpleNamespace(configured=lambda: True, get_torrents_by_tag=AsyncMock(return_value=[{"progress": 1, "hash": "old", "state": "uploading"}]))
|
|
||||||
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(requests_router, "QBittorrentClient", return_value=qbit):
|
|
||||||
progress = await requests_router.get_download_progress("12", {"role": "user"})
|
|
||||||
self.assertEqual(progress["state"], "not_started")
|
|
||||||
self.assertFalse(progress["visible"])
|
|
||||||
self.assertEqual(progress["repairCycle"], self.cycle)
|
|
||||||
|
|
||||||
async def test_failed_search_after_deletion_keeps_cycle_and_pending_pipeline(self):
|
|
||||||
before = Snapshot(request_id="12", title="Example", request_type=RequestType.movie,
|
|
||||||
raw={"arr": {"item": {"id": 12, "hasFile": True, "movieFile": {"id": 40}}},
|
|
||||||
"jellyfin": {"found": True, "item": self.jf}})
|
|
||||||
async def delete(_):
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1, "Must persist before removal")
|
|
||||||
radarr = SimpleNamespace(configured=lambda: True, monitor_movie=AsyncMock(),
|
|
||||||
delete_movie_file=AsyncMock(side_effect=delete), search=AsyncMock(side_effect=RuntimeError("search failed")))
|
|
||||||
with ExitStack() as stack:
|
|
||||||
for name, value in {"_user_can_use_search_auto": True, "_linked_issue_for_replacement": None,
|
|
||||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False), "RadarrClient": radarr}.items():
|
|
||||||
stack.enter_context(patch.object(requests_router, name, return_value=value))
|
|
||||||
stack.enter_context(patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=before)))
|
|
||||||
with self.assertRaises(requests_router.HTTPException):
|
|
||||||
await requests_router.action_replace_media("12", {"file_ids": [40], "confirmed": True}, {"role": "admin"})
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertEqual(self.stage(snapshot, "download")["state"], "waiting")
|
|
||||||
|
|
||||||
def test_existing_issue_tracking_is_migrated_once_and_survives_ticket_deletion(self):
|
|
||||||
tracking = {"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media", "collectorId": 12,
|
|
||||||
"mediaType": "movie", "originalFileIds": [40]}
|
|
||||||
issue = db.create_portal_item(kind="issue", title="Repair", description="Replace movie", status="in_progress",
|
|
||||||
created_by_username="reporter", created_by_id=None, issue_type="broken_media")
|
|
||||||
db.add_portal_item_activity(issue["id"], event_type="replacement_started", actor_username="reporter",
|
|
||||||
actor_role="user", message="Repair requested", metadata_json=json.dumps({"repairTracking": tracking}))
|
|
||||||
db.init_db()
|
|
||||||
db.init_db()
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
|
||||||
db.delete_portal_item(issue["id"])
|
|
||||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
|
||||||
|
|
||||||
async def test_multiple_repairs_wait_for_every_target_not_just_latest(self):
|
|
||||||
first = self.start(RequestType.tv)
|
|
||||||
second = {**first, "startedAt": (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat(),
|
|
||||||
"episodes": [{"id": 110, "seasonNumber": 5, "episodeNumber": 10}], "originalFileIds": [42],
|
|
||||||
"jellyfinFoundAtStart": False, "jellyfinBaseline": []}
|
|
||||||
db.start_request_repair(second)
|
|
||||||
self.episodes[1].update(episodeFileId=43)
|
|
||||||
self.jf_episodes.append({"Id": "ep10", "ParentIndexNumber": 5, "IndexNumber": 10})
|
|
||||||
snapshot = await self.snapshot()
|
|
||||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
|
||||||
self.assertEqual([r["originalFileIds"] for r in db.get_request_repairs("12")], [[40]])
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from backend.app.clients.sonarr import SonarrClient
|
|
||||||
from backend.app.services.download_labels import label_episode_downloads
|
|
||||||
from backend.app.routers import requests
|
|
||||||
|
|
||||||
|
|
||||||
class TvDownloadTrackingTests(unittest.IsolatedAsyncioTestCase):
|
|
||||||
async def test_queue_paginates_with_correct_filter(self):
|
|
||||||
client = SonarrClient('http://sonarr.test', 'test')
|
|
||||||
with patch.object(client, 'get', new=AsyncMock(side_effect=[
|
|
||||||
{'records': [{'id': 1}], 'totalRecords': 2},
|
|
||||||
{'records': [{'id': 2}], 'totalRecords': 2},
|
|
||||||
])) as get:
|
|
||||||
result = await client.get_queue(42)
|
|
||||||
self.assertEqual(len(result['records']), 2)
|
|
||||||
self.assertEqual(get.call_args_list[0].kwargs['params']['seriesIds'], 42)
|
|
||||||
self.assertEqual(get.call_args_list[1].kwargs['params']['page'], 2)
|
|
||||||
self.assertEqual(get.call_args.kwargs['params']['includeEpisode'], 'true')
|
|
||||||
|
|
||||||
async def test_live_poll_discovers_two_unseen_episode_downloads(self):
|
|
||||||
runtime = SimpleNamespace(jellyseerr_base_url=None, jellyseerr_api_key=None,
|
|
||||||
sonarr_base_url='http://sonarr.test', sonarr_api_key='test',
|
|
||||||
qbittorrent_base_url='http://qbit.test', qbittorrent_username='test', qbittorrent_password='test')
|
|
||||||
queue = {'records': [
|
|
||||||
{'seriesId': 42, 'downloadId': 'ABC', 'episode': {'seasonNumber': 5, 'episodeNumber': 9}},
|
|
||||||
{'seriesId': 42, 'downloadId': 'DEF', 'episode': {'seasonNumber': 5, 'episodeNumber': 10}},
|
|
||||||
{'seriesId': 99, 'downloadId': 'OTHER'},
|
|
||||||
]}
|
|
||||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
|
||||||
patch.object(requests, 'get_request_repairs', return_value=[]), \
|
|
||||||
patch.object(requests, 'get_request_download_evidence', return_value={'observed': True, 'torrents': []}), \
|
|
||||||
patch.object(requests, 'get_request_cache_payload', return_value={'type': 'tv', 'media': {'tvdbId': 123}}), \
|
|
||||||
patch.object(requests.SonarrClient, 'get_series_by_tvdb_id', new=AsyncMock(return_value=[{'id': 42}])), \
|
|
||||||
patch.object(requests.SonarrClient, 'get_queue', new=AsyncMock(return_value=queue)), \
|
|
||||||
patch.object(requests.QBittorrentClient, 'get_torrents_by_hashes', new=AsyncMock(return_value=[
|
|
||||||
{'hash': 'abc', 'progress': .25, 'state': 'downloading'},
|
|
||||||
{'hash': 'def', 'progress': .5, 'state': 'downloading'},
|
|
||||||
])) as torrents:
|
|
||||||
result = await requests.get_download_progress('12', {'username': 'viewer', 'role': 'user'})
|
|
||||||
torrents.assert_awaited_once_with('abc|def')
|
|
||||||
self.assertEqual(result['state'], 'downloading')
|
|
||||||
self.assertEqual(result['torrents'][0]['episodeLabel'], 'S05E09')
|
|
||||||
self.assertEqual(result['torrents'][1]['progressPercent'], 50)
|
|
||||||
|
|
||||||
def test_pack_does_not_claim_individual_episode_progress(self):
|
|
||||||
rows = [{'downloadId': 'PACK', 'episode': {'seasonNumber': 1, 'episodeNumber': n}} for n in [1, 2, 2]]
|
|
||||||
result = label_episode_downloads([{'hash': 'pack'}], rows)
|
|
||||||
self.assertEqual(result[0]['episodeLabel'], 'S01E01 · S01E02 — shared download progress')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: magent-beta
|
|
||||||
|
|
||||||
services:
|
|
||||||
magent:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
environment:
|
|
||||||
APP_NAME: Magent Beta
|
|
||||||
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
|
|
||||||
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
|
|
||||||
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
|
|
||||||
AUTH_COOKIE_NAME: magent_beta_auth
|
|
||||||
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
|
||||||
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
|
||||||
SQLITE_PATH: /app/data/magent.db
|
|
||||||
LOG_FILE: /app/data/magent.log
|
|
||||||
SITE_BANNER_ENABLED: "true"
|
|
||||||
SITE_BANNER_MESSAGE: "Beta environment"
|
|
||||||
SITE_BANNER_TONE: warning
|
|
||||||
ports:
|
|
||||||
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
|
|
||||||
- "127.0.0.1:8100:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
+12
-3
@@ -1,10 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
magent:
|
backend:
|
||||||
image: rephl3xnz/magent:latest
|
image: rephl3xnz/magent-backend:latest
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: rephl3xnz/magent-frontend:latest
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_BASE=/api
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|||||||
+16
-3
@@ -1,12 +1,25 @@
|
|||||||
services:
|
services:
|
||||||
magent:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_NUMBER: ${BUILD_NUMBER}
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_BASE=/api
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
logfile=/dev/null
|
|
||||||
logfile_maxbytes=0
|
|
||||||
pidfile=/tmp/supervisord.pid
|
|
||||||
|
|
||||||
[program:backend]
|
|
||||||
directory=/app
|
|
||||||
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
priority=10
|
|
||||||
|
|
||||||
[program:frontend]
|
|
||||||
directory=/app/frontend
|
|
||||||
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
|
|
||||||
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
priority=20
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Shared workspace layout
|
|
||||||
|
|
||||||
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
|
|
||||||
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
|
|
||||||
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
|
|
||||||
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
|
|
||||||
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
|
|
||||||
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
|
|
||||||
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
|
|
||||||
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
|
|
||||||
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
|
|
||||||
- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
|
|
||||||
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
|
|
||||||
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
|
|
||||||
|
|
||||||
## Browser checks
|
|
||||||
|
|
||||||
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
|
|
||||||
|
|
||||||
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
|
|
||||||
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
|
|
||||||
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
|
|
||||||
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
|
|
||||||
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
|
|
||||||
- `review_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
|
|
||||||
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
|
|
||||||
|
|
||||||
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/* Top-navigation workspace and streamlined account screens. */
|
|
||||||
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
|
|
||||||
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
|
|
||||||
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
|
|
||||||
.settings-top-navigation label { display: flex; align-items: center; gap: 12px; margin: 0; padding: 0; }
|
|
||||||
.settings-top-navigation label span { color: var(--ops-faint); font: 12px Inter, sans-serif; text-transform: none; }
|
|
||||||
.settings-top-navigation select { width: 260px; min-height: 42px; font: 13px Inter, sans-serif; padding: 10px 12px; border-radius: 8px; }
|
|
||||||
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
|
|
||||||
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
|
|
||||||
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
|
|
||||||
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
|
|
||||||
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
||||||
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
|
|
||||||
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
|
|
||||||
.account-identity > div > span { font-size: 12px; color: var(--ops-faint); }
|
|
||||||
.account-avatar { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; border: 1px solid #45434f; border-radius: 14px; background: #25242c; color: #dedaff; font: 600 20px "DM Sans", sans-serif; }
|
|
||||||
.account-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--ops-line-soft); margin-bottom: 24px; }
|
|
||||||
.account-tabs button { min-height: 46px; padding: 0 2px; border: 0; border-radius: 0 !important; border-bottom: 2px solid transparent; border-color: transparent !important; background: transparent !important; color: var(--ops-muted); font: 500 14px "DM Sans", sans-serif; box-shadow: none; text-transform: none; }
|
|
||||||
.account-tabs button[aria-selected=true] { color: #dedaff; border-bottom-color: #bcb3ff !important; }
|
|
||||||
.account-page [hidden] { display: none !important; }
|
|
||||||
.account-panel { border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); padding: 32px; animation: account-appear .18s ease-out; }
|
|
||||||
.account-section-intro { margin-bottom: 26px; }
|
|
||||||
.account-section-intro h2 { margin: 0 0 8px; font-size: 21px; }
|
|
||||||
.account-section-intro p { margin: 0; font-size: 13px; color: var(--ops-muted); line-height: 1.6; }
|
|
||||||
.account-form { display: grid; gap: 10px; max-width: 500px; }
|
|
||||||
.account-form fieldset { display: grid; gap: 10px; min-width: 0; padding: 0; margin: 0; border: 0; }
|
|
||||||
.account-form label { display: block; padding: 0; margin: 0; border: 0; background: none; color: var(--ops-text); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
|
||||||
.account-form input { display: block; width: 100%; min-width: 0; min-height: 46px; padding: 11px 13px; margin: 0; border: 1px solid var(--ops-line); border-radius: 8px; font: 14px Inter, sans-serif; }
|
|
||||||
.account-form fieldset label:not(:first-child) { margin-top: 10px; }
|
|
||||||
.account-form .account-hint { margin: 0; color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
|
|
||||||
.account-form-actions { display: flex; gap: 10px; margin-top: 14px; }
|
|
||||||
button.account-primary { min-height: 44px; padding: 11px 20px; border: 1px solid #c7bdff !important; border-radius: 8px; background: #c7bdff !important; color: #1c172c !important; font: 700 13px "DM Sans", sans-serif; text-transform: none; transition: background .15s, opacity .15s; }
|
|
||||||
button.account-primary:hover:not(:disabled) { background: #d8d1ff !important; }
|
|
||||||
button.account-primary:disabled { opacity: .4; cursor: not-allowed; }
|
|
||||||
button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px solid var(--ops-line); background: transparent !important; color: var(--ops-muted); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
|
||||||
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
||||||
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
|
|
||||||
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
|
|
||||||
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
|
|
||||||
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
|
|
||||||
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
|
|
||||||
.account-request-summary > div { display: grid; gap: 5px; }
|
|
||||||
.account-request-summary strong { font: 600 25px "DM Sans", sans-serif; }
|
|
||||||
.account-request-summary span { color: var(--ops-muted); font-size: 12px; }
|
|
||||||
.account-request-summary a { margin-left: auto; font-size: 13px; color: #d0c8ff; text-decoration: none; }
|
|
||||||
.account-list-heading { font-size: 14px; margin: 0 0 8px; }
|
|
||||||
.account-access-list { list-style: none; padding: 0; margin: 0; }
|
|
||||||
.account-access-list > li { padding: 18px 0; border-bottom: 1px solid var(--ops-line-soft); }
|
|
||||||
.account-access-list > li:last-child { border: 0; }
|
|
||||||
.account-access-summary { display: flex; justify-content: space-between; gap: 16px; }
|
|
||||||
.account-access-summary strong { font-size: 13px; font-weight: 500; }
|
|
||||||
.account-access-summary time { font-size: 12px; color: var(--ops-muted); text-align: right; }
|
|
||||||
.account-access-list details { margin-top: 8px; font-size: 12px; color: var(--ops-faint); }
|
|
||||||
.account-access-list summary { cursor: pointer; }
|
|
||||||
.account-access-list dl { display: grid; gap: 8px; margin-bottom: 0; }
|
|
||||||
.account-access-list dl > div { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
||||||
.account-access-list dd { margin: 0; color: var(--ops-muted); overflow-wrap: anywhere; }
|
|
||||||
.account-empty { color: var(--ops-muted); font-size: 14px; line-height: 1.6; padding: 16px 0; }
|
|
||||||
.account-empty button { margin-top: 8px; }
|
|
||||||
.page:has(> .login-page) { padding: 0; background: #121214; }
|
|
||||||
.page > main.login-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; width: 100%; max-width: none; margin: 0; padding: 40px 20px; background: radial-gradient(ellipse at 50% 15%, #24202e 0, transparent 60%); }
|
|
||||||
.login-card { width: 100%; max-width: 430px; border: 1px solid #3a3841; border-radius: 20px; padding: 32px; background: #1c1b1f; box-shadow: 0 22px 90px #0003; }
|
|
||||||
.login-brand { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 36px; }
|
|
||||||
.login-brand > a { display: flex; align-items: center; gap: 10px; color: #eeeaf5; text-decoration: none; font: 600 23px "DM Sans", sans-serif; }
|
|
||||||
.magent-mark { width: 38px; height: 38px; }
|
|
||||||
.login-beta { padding: 4px 8px; border: 1px solid #44404d; border-radius: 6px; font: 10px "JetBrains Mono", monospace; color: #bdb5cf; text-transform: uppercase; }
|
|
||||||
.login-card header { margin-bottom: 24px; }
|
|
||||||
.login-card h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.2; color: #f1edf6; }
|
|
||||||
.login-card header p { margin: 0; color: #aba5b7; font-size: 13px; }
|
|
||||||
.login-methods { display: flex; gap: 4px; padding: 4px; margin: 0 0 16px; background: #151417; border: 1px solid #3b3842; border-radius: 9px; }
|
|
||||||
.login-methods button { flex: 1; min-height: 36px; padding: 8px; border: 0; border-radius: 6px !important; background: transparent !important; color: #b3adbf; font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
|
||||||
.login-methods button[aria-pressed=true] { background: #36313f !important; color: #eee7ff; }
|
|
||||||
.login-method-help { font-size: 12px; line-height: 1.5; margin: 0 0 10px; color: #aba5b7; }
|
|
||||||
.login-form input { background: #151417 !important; border-color: #46414e !important; }
|
|
||||||
.login-password-label { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; }
|
|
||||||
.login-password-label a { color: #c4b9e5; font-size: 12px; text-decoration: none; }
|
|
||||||
.login-password-field { position: relative; }
|
|
||||||
.login-password-field input { padding-right: 48px; }
|
|
||||||
.login-password-field .password-visibility { position: absolute; top: 1px; right: 1px; width: 44px; height: calc(100% - 2px); min-height: 42px; padding: 12px; border: 0; background: transparent !important; color: #aba5b7; box-shadow: none; }
|
|
||||||
.password-visibility svg { width: 19px; height: 19px; display: block; }
|
|
||||||
.login-form .login-submit { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; min-height: 46px; }
|
|
||||||
.login-card footer { border-top: 1px solid #36323c; margin-top: 28px; padding-top: 22px; text-align: center; color: #aaa3b5; font-size: 12px; }
|
|
||||||
.login-card footer a { margin-left: 4px; color: #d4c7ff; text-decoration: none; font-weight: 500; }
|
|
||||||
.login-credit { color: #87818f; font-size: 11px; margin: 24px 0 0; }
|
|
||||||
.account-page :focus-visible, .login-page :focus-visible, .settings-top-navigation :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
|
|
||||||
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.account-identity strong { max-width: 130px; }
|
|
||||||
.account-avatar { display: none; }
|
|
||||||
.account-panel { padding: 22px 20px; }
|
|
||||||
.account-request-summary { gap: 24px; flex-wrap: wrap; }
|
|
||||||
.account-request-summary a { width: 100%; margin: 0; }
|
|
||||||
.account-access-summary { flex-direction: column; gap: 6px; }
|
|
||||||
.account-access-summary time { text-align: left; }
|
|
||||||
.settings-top-navigation { gap: 14px; flex-wrap: wrap; }
|
|
||||||
.settings-top-navigation label { flex: 1; min-width: 220px; }
|
|
||||||
.settings-top-navigation select { flex: 1; width: 100%; }
|
|
||||||
.login-card { padding: 26px 24px; }
|
|
||||||
.page > main.login-page { padding: 24px 16px; }
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
|
|
||||||
type Option = { value: string; label: string }
|
|
||||||
type Props = {
|
|
||||||
setting: AdminSetting
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
help?: string
|
|
||||||
placeholder?: string
|
|
||||||
boolean?: boolean
|
|
||||||
numeric?: boolean
|
|
||||||
multiline?: boolean
|
|
||||||
options?: Option[]
|
|
||||||
optionsUnavailable?: boolean
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SELECTS: Record<string, Option[]> = {
|
|
||||||
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })),
|
|
||||||
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({ value: String(index), label: index === 0 ? 'None — close when fixed' : String(index) })),
|
|
||||||
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
|
||||||
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }],
|
|
||||||
site_banner_tone: ['info', 'warning', 'error', 'maintenance'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
|
||||||
magent_notify_push_provider: ['ntfy', 'gotify', 'pushover', 'webhook', 'telegram', 'discord'].map((value) => ({ value, label: value })),
|
|
||||||
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingField(props: Props) {
|
|
||||||
const { setting, label, value, help, placeholder, onChange } = props
|
|
||||||
const id = `setting-${setting.key}`
|
|
||||||
const options = props.options ?? SELECTS[setting.key] ?? (setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ? SELECTS.log_level : undefined)
|
|
||||||
const selectedOptions = options && value && !options.some((option) => option.value === value)
|
|
||||||
? [{ value, label: `Current selection (${value})` }, ...options] : options
|
|
||||||
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time'
|
|
||||||
const zeroAllowed = setting.key === 'log_file_backup_count'
|
|
||||||
const minimum = zeroAllowed ? 0 : 1
|
|
||||||
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
|
|
||||||
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
|
|
||||||
|
|
||||||
if (props.boolean) {
|
|
||||||
return (
|
|
||||||
<div className="setting-field setting-switch">
|
|
||||||
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
|
|
||||||
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}>
|
|
||||||
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
|
|
||||||
{props.optionsUnavailable ? (
|
|
||||||
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
|
|
||||||
) : selectedOptions ? (
|
|
||||||
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
|
|
||||||
{!value && <option value="">Choose an option</option>}
|
|
||||||
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
||||||
</select>
|
|
||||||
) : props.multiline ? (
|
|
||||||
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
|
|
||||||
) : (
|
|
||||||
<input {...aria} type={setting.sensitive ? 'password' : props.numeric ? 'number' : isTime ? 'time' : 'text'}
|
|
||||||
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined}
|
|
||||||
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false}
|
|
||||||
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder}
|
|
||||||
onChange={(event) => onChange(event.target.value)} />
|
|
||||||
)}
|
|
||||||
{help && <p id={`${id}-help`}>{help}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+643
-1413
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react'
|
|
||||||
|
|
||||||
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) {
|
|
||||||
const [open, setOpen] = useState(!collapsed)
|
|
||||||
return (
|
|
||||||
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}>
|
|
||||||
{collapsed && <button type="button" className="config-region-toggle" aria-expanded={open} aria-controls={`${id}-content`} onClick={() => setOpen(!open)}><strong>{title}</strong><span>{open ? 'Hide' : 'Configure'} <b aria-hidden="true">{open ? '−' : '+'}</b></span></button>}
|
|
||||||
<div id={`${id}-content`} hidden={!open}>{children}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,34 +2,27 @@ import { notFound } from 'next/navigation'
|
|||||||
import SettingsPage from '../SettingsPage'
|
import SettingsPage from '../SettingsPage'
|
||||||
|
|
||||||
const ALLOWED_SECTIONS = new Set([
|
const ALLOWED_SECTIONS = new Set([
|
||||||
'seerr',
|
|
||||||
'jellyseerr',
|
'jellyseerr',
|
||||||
'jellyfin',
|
'jellyfin',
|
||||||
'artwork',
|
'artwork',
|
||||||
'sonarr',
|
'sonarr',
|
||||||
'radarr',
|
'radarr',
|
||||||
'bazarr',
|
|
||||||
'prowlarr',
|
'prowlarr',
|
||||||
'qbittorrent',
|
'qbittorrent',
|
||||||
'requests',
|
'requests',
|
||||||
'issue-workflow',
|
|
||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
'magent',
|
|
||||||
'general',
|
|
||||||
'notifications',
|
|
||||||
'site',
|
'site',
|
||||||
])
|
])
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ section: string }>
|
params: { section: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminSectionPage({ params }: PageProps) {
|
export default function AdminSectionPage({ params }: PageProps) {
|
||||||
const { section } = await params
|
if (!ALLOWED_SECTIONS.has(params.section)) {
|
||||||
if (!ALLOWED_SECTIONS.has(section)) {
|
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
return <SettingsPage section={section} />
|
return <SettingsPage section={params.section} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
/* Settings workspace. Shared Stitch tokens, compact controls and clear regions. */
|
|
||||||
.config-directory { display: grid; gap: 32px; max-width: 1120px; }
|
|
||||||
.config-directory-region { display: grid; gap: 16px; }
|
|
||||||
.config-directory-region header h2 { margin: 0 0 4px; font-size: 18px; }
|
|
||||||
.config-directory-region header p { margin: 0; color: var(--ops-muted); font-size: 13px; }
|
|
||||||
.config-directory-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
||||||
.config-directory-link { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); color: var(--ops-text); text-decoration: none; min-width: 0; transition: border-color .15s, background .15s; }
|
|
||||||
.config-directory-link:hover { border-color: var(--ops-primary-2); background: var(--ops-panel-2); }
|
|
||||||
.config-link-icon { flex: 0 0 34px; display: grid; place-items: center; height: 34px; border-radius: 8px; background: var(--ops-primary); color: var(--ops-primary-2); font: 11px "JetBrains Mono", monospace; }
|
|
||||||
.config-link-copy { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
|
||||||
.config-link-copy strong { font-size: 14px; }
|
|
||||||
.config-link-copy small { font-size: 12px; font-weight: 400; color: var(--ops-muted); line-height: 1.5; }
|
|
||||||
.config-link-arrow { color: var(--ops-faint); }
|
|
||||||
.config-connection-badge { flex-shrink: 0; font: 11px "JetBrains Mono", monospace; color: var(--ops-muted); }
|
|
||||||
.config-connection-badge::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
|
|
||||||
.config-connection-badge.is-up { color: var(--ops-green); }
|
|
||||||
.config-connection-badge.is-down { color: var(--ops-red); }
|
|
||||||
.config-connection-badge.is-degraded { color: var(--ops-warn); }
|
|
||||||
.config-advanced-directory { border: 1px solid var(--ops-line); border-radius: 10px; padding: 18px; }
|
|
||||||
.config-advanced-directory > summary { cursor: pointer; color: var(--ops-text); }
|
|
||||||
.config-advanced-directory > summary > span { margin-left: 12px; color: var(--ops-muted); font-size: 12px; }
|
|
||||||
.config-advanced-directory[open] > summary { margin-bottom: 18px; }
|
|
||||||
.config-sidebar-home { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px 20px; font: 600 20px "DM Sans", sans-serif; text-decoration: none; color: var(--ops-primary-2); }
|
|
||||||
.config-sidebar-back { display: block; margin-top: 20px; padding: 12px 10px; color: var(--ops-muted); font-size: 12px; }
|
|
||||||
.config-desktop-navigation { display: grid; gap: 18px; }
|
|
||||||
.admin-sidebar .admin-nav-links a { font: 13px Inter, sans-serif; padding: 8px 10px; min-height: 34px; }
|
|
||||||
.admin-sidebar .admin-nav-title { font-size: 10px; }
|
|
||||||
.config-nav-advanced > summary { cursor: pointer; padding: 8px 10px; font-size: 12px; color: var(--ops-muted); }
|
|
||||||
.config-mobile-picker { display: none; }
|
|
||||||
.admin-card { min-width: 0; }
|
|
||||||
.admin-shell { grid-template-areas: "nav main"; }
|
|
||||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main rail"; }
|
|
||||||
.admin-shell--no-rail > .admin-card { width: 100%; max-width: 1280px; }
|
|
||||||
.admin-card .admin-header { margin-bottom: 24px; }
|
|
||||||
.admin-card .admin-header .lede { max-width: 720px; margin: 8px 0 0; font-size: 14px; }
|
|
||||||
.admin-card .admin-header .section-kicker { font-size: 10px; }
|
|
||||||
.config-service-status { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 20px; font-size: 12px; color: var(--ops-muted); }
|
|
||||||
.config-service-status button { margin-left: auto; }
|
|
||||||
.admin-form.admin-zone-stack { gap: 16px; }
|
|
||||||
.admin-form .config-subsection { padding: 22px !important; }
|
|
||||||
.config-subsection form { display: grid; gap: 18px; min-width: 0; }
|
|
||||||
.config-subsection .section-header { margin: 0; padding: 0; border: 0; align-items: center; }
|
|
||||||
.config-subsection .section-header h2 { font-size: 18px; padding: 0; }
|
|
||||||
.config-subsection .section-header h2::after { display: none; }
|
|
||||||
.config-subsection .section-subtitle { margin: -10px 0 0; font-size: 12px; line-height: 1.6; }
|
|
||||||
.config-subsection .admin-grid { gap: 20px 24px; }
|
|
||||||
.config-subsection .setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
||||||
.config-subsection .setting-field > label, .config-subsection .setting-switch label { display: flex; align-items: center; gap: 10px; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0; background: none; color: var(--ops-text); font: 500 13px Inter, sans-serif; text-transform: none; letter-spacing: 0; }
|
|
||||||
.config-subsection .setting-field label small { color: var(--ops-green); font-size: 11px; font-weight: 400; }
|
|
||||||
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
|
|
||||||
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
|
|
||||||
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
|
|
||||||
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
|
|
||||||
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
|
|
||||||
.setting-switch > div { display: grid; gap: 6px; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: 0 0 38px; width: 38px; height: 22px; min-height: 22px; padding: 2px; margin: 0; background: var(--ops-panel-3) !important; border: 1px solid var(--ops-line); border-radius: 20px !important; cursor: pointer; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox]::before { content: ''; display: block; width: 16px; height: 16px; background: var(--ops-muted); border-radius: 50%; transition: transform .15s; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox]:checked { background: var(--ops-primary-2) !important; border-color: var(--ops-primary-2) !important; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox]:checked::before { background: var(--ops-primary); transform: translateX(16px); }
|
|
||||||
.config-subsection .settings-section-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding-top: 16px; border-top: 1px solid var(--ops-line); }
|
|
||||||
.config-subsection .settings-inline-field { padding: 0; border: 0; background: none; min-height: 0; flex: 1 1 230px; max-width: 330px; }
|
|
||||||
.config-subsection .settings-inline-field span { font: 500 12px Inter, sans-serif; text-transform: none; }
|
|
||||||
.config-subsection button { font: 600 12px "DM Sans", "Segoe UI", sans-serif; min-height: 38px; }
|
|
||||||
.config-subsection .config-unsaved { margin-right: auto; font-size: 12px; color: var(--ops-warn); }
|
|
||||||
.config-subsection .config-region-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; background: transparent !important; padding: 0; color: var(--ops-text); text-align: left; box-shadow: none; }
|
|
||||||
.config-region-toggle strong { font-size: 15px; }
|
|
||||||
.config-region-toggle > span { font-size: 12px; color: var(--ops-muted); }
|
|
||||||
.config-region-toggle + div:not([hidden]) { margin-top: 18px; }
|
|
||||||
.config-subsection [hidden] { display: none !important; }
|
|
||||||
.config-region-toggle + div .config-subsection-heading { display: none; }
|
|
||||||
.config-inline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
|
||||||
.config-inline-controls label { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.config-inline-controls select { width: auto; }
|
|
||||||
.admin-card .maintenance-layout { grid-template-columns: 1fr; }
|
|
||||||
.admin-card .cache-table, .admin-card .log-viewer { overflow-x: auto; max-width: 100%; }
|
|
||||||
.admin-card .cache-row { min-width: 650px; }
|
|
||||||
.config-tool-link { padding: 16px 0; color: var(--ops-primary-2); font-size: 13px; }
|
|
||||||
|
|
||||||
@media (max-width: 1250px) {
|
|
||||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main"; }
|
|
||||||
}
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.config-directory-links { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.admin-shell-nav .admin-sidebar { display: block; padding: 12px 18px; }
|
|
||||||
.config-desktop-navigation { display: none; }
|
|
||||||
.config-mobile-picker { display: flex; align-items: center; gap: 16px; margin: 0; }
|
|
||||||
.config-mobile-picker > span { font: 500 12px Inter, sans-serif; color: var(--ops-muted); }
|
|
||||||
.config-mobile-picker select { flex: 1; width: 100%; min-width: 0; padding: 10px; font: 13px Inter, sans-serif; }
|
|
||||||
}
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.admin-form .admin-grid { grid-template-columns: 1fr; }
|
|
||||||
.admin-form .config-subsection { padding: 16px !important; }
|
|
||||||
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
|
|
||||||
.config-link-copy { flex-basis: calc(100% - 62px); }
|
|
||||||
.config-directory-link .config-connection-badge { margin-left: 46px; }
|
|
||||||
.config-link-arrow { display: none; }
|
|
||||||
.config-advanced-directory > summary > span { display: block; margin: 8px 0 0; }
|
|
||||||
.admin-card .admin-header { align-items: flex-start; gap: 14px; flex-direction: column; }
|
|
||||||
.config-subsection .section-header { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
|
||||||
.config-subsection .section-subtitle { margin-top: 0; }
|
|
||||||
.config-subsection .settings-section-actions > button { flex-grow: 1; }
|
|
||||||
.config-subsection .settings-section-actions .config-unsaved { flex-basis: 100%; }
|
|
||||||
.config-service-status { gap: 10px; }
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
|
|
||||||
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
|
|
||||||
|
|
||||||
export const CONFIG_GROUPS: ConfigGroup[] = [
|
|
||||||
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
|
|
||||||
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
|
|
||||||
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
|
|
||||||
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
|
|
||||||
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
|
|
||||||
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
|
|
||||||
{ href: '/admin/prowlarr', label: 'Prowlarr', description: 'Search sources', symbol: 'PR', service: 'Prowlarr' },
|
|
||||||
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' },
|
|
||||||
]},
|
|
||||||
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
|
||||||
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options', symbol: '01' },
|
|
||||||
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates', symbol: '02' },
|
|
||||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
|
|
||||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
|
|
||||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
|
|
||||||
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
|
|
||||||
]},
|
|
||||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
|
||||||
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' },
|
|
||||||
{ href: '/admin/diagnostics', label: 'System health', description: 'Service checks and diagnostics' },
|
|
||||||
{ href: '/admin/logs', label: 'Logs', description: 'Recent activity and log settings' },
|
|
||||||
{ href: '/admin/cache', label: 'Request cache', description: 'Inspect saved request records' },
|
|
||||||
{ href: '/admin/artwork', label: 'Artwork cache', description: 'Poster storage and missing artwork' },
|
|
||||||
{ href: '/admin/maintenance', label: 'Recovery & cleanup', description: 'Database repair and history cleanup' },
|
|
||||||
]},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const serviceStatusLabel = (status?: string) => ({
|
|
||||||
up: 'Connected', down: 'Unavailable', degraded: 'Needs attention', not_configured: 'Not set up',
|
|
||||||
}[status ?? ''] ?? 'Not checked')
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
|
|
||||||
|
|
||||||
export default function AdminDiagnosticsPage() {
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="Diagnostics"
|
|
||||||
subtitle="Check connections and investigate service problems."
|
|
||||||
>
|
|
||||||
<AdminDiagnosticsPanel />
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
import PortalClient from '../../portal/PortalClient'
|
|
||||||
|
|
||||||
export default function AdminIssuesPage() {
|
|
||||||
return <PortalClient workspace="issue" />
|
|
||||||
}
|
|
||||||
+13
-64
@@ -1,77 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
|
|
||||||
|
|
||||||
type ServiceState = { name: string; status: string }
|
|
||||||
|
|
||||||
export default function AdminLandingPage() {
|
export default function AdminLandingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [services, setServices] = useState<ServiceState[]>([])
|
|
||||||
const [ready, setReady] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) { router.replace('/login'); return }
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/me`)
|
|
||||||
if (!response.ok) { router.replace('/login'); return }
|
|
||||||
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
|
|
||||||
if (!active) return
|
|
||||||
setReady(true)
|
|
||||||
const status = await authFetch(`${getApiBase()}/status/services`)
|
|
||||||
if (!status.ok) throw new Error('Status unavailable')
|
|
||||||
const data = await status.json()
|
|
||||||
if (active) setServices(Array.isArray(data.services) ? data.services : [])
|
|
||||||
} catch {
|
|
||||||
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => { active = false }
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
|
<AdminShell
|
||||||
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings…</p> : (
|
title="Settings"
|
||||||
<div className="config-directory">
|
subtitle="Choose what you want to manage."
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
actions={
|
||||||
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
|
<button type="button" onClick={() => router.push('/')}>
|
||||||
<section className="config-directory-region" key={group.title}>
|
Back to requests
|
||||||
<header><h2>{group.title}</h2><p>{group.description}</p></header>
|
</button>
|
||||||
<div className="config-directory-links">
|
}
|
||||||
{group.items.map((item) => {
|
>
|
||||||
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
|
<section className="admin-section">
|
||||||
return (
|
<div className="status-banner">
|
||||||
<a href={item.href} key={item.href} className="config-directory-link">
|
Pick a section from the left. Each page explains what it does and how it helps.
|
||||||
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
|
|
||||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
|
||||||
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
|
|
||||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
<details className="config-advanced-directory">
|
|
||||||
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary>
|
|
||||||
<div className="config-directory-links">
|
|
||||||
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => (
|
|
||||||
<a href={item.href} key={item.href} className="config-directory-link">
|
|
||||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
|
||||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function AdminProfilesRedirectPage() {
|
|
||||||
redirect('/admin/invites')
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
|
|
||||||
type RequestRow = {
|
|
||||||
id: number
|
|
||||||
title?: string | null
|
|
||||||
year?: number | null
|
|
||||||
type?: string | null
|
|
||||||
statusLabel?: string | null
|
|
||||||
requestedBy?: string | null
|
|
||||||
createdAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_STAGE_OPTIONS = [
|
|
||||||
{ value: 'all', label: 'All stages' },
|
|
||||||
{ value: 'pending', label: 'Waiting for approval' },
|
|
||||||
{ value: 'approved', label: 'Approved' },
|
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
|
||||||
{ value: 'working', label: 'Working on it' },
|
|
||||||
{ value: 'partial', label: 'Partially ready' },
|
|
||||||
{ value: 'ready', label: 'Ready to watch' },
|
|
||||||
{ value: 'declined', label: 'Declined' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return 'Unknown'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminRequestsAllPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [rows, setRows] = useState<RequestRow[]>([])
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [pageSize, setPageSize] = useState(50)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [stage, setStage] = useState('all')
|
|
||||||
|
|
||||||
const pageCount = useMemo(() => {
|
|
||||||
if (!total || pageSize <= 0) return 1
|
|
||||||
return Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
}, [total, pageSize])
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const skip = (page - 1) * pageSize
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
take: String(pageSize),
|
|
||||||
skip: String(skip),
|
|
||||||
})
|
|
||||||
if (stage !== 'all') {
|
|
||||||
params.set('stage', stage)
|
|
||||||
}
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/requests/all?${params.toString()}`
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Load failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setRows(Array.isArray(data?.results) ? data.results : [])
|
|
||||||
setTotal(Number(data?.total ?? 0))
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Unable to load requests.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [page, pageSize, stage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > pageCount) {
|
|
||||||
setPage(pageCount)
|
|
||||||
}
|
|
||||||
}, [pageCount, page])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
}, [stage])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="All requests"
|
|
||||||
subtitle="Paginated view of every cached request."
|
|
||||||
>
|
|
||||||
<section className="admin-section">
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<div className="admin-toolbar-info">
|
|
||||||
<span>{total.toLocaleString()} total</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-toolbar-actions">
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Stage</span>
|
|
||||||
<select value={stage} onChange={(e) => setStage(e.target.value)}>
|
|
||||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Per page</span>
|
|
||||||
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={200}>200</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="status-banner">Loading requests…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="error-banner">{error}</div>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<div className="status-banner">No requests found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table">
|
|
||||||
<div className="admin-table-head">
|
|
||||||
<span>Request</span>
|
|
||||||
<span>Status</span>
|
|
||||||
<span>Requested by</span>
|
|
||||||
<span>Created</span>
|
|
||||||
</div>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<button
|
|
||||||
key={row.id}
|
|
||||||
type="button"
|
|
||||||
className="admin-table-row"
|
|
||||||
onClick={() => router.push(`/requests/${row.id}`)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{row.title || `Request #${row.id}`}
|
|
||||||
{row.year ? ` (${row.year})` : ''}
|
|
||||||
</span>
|
|
||||||
<span>{row.statusLabel || 'Unknown'}</span>
|
|
||||||
<span>{row.requestedBy || 'Unknown'}</span>
|
|
||||||
<span>{formatDateTime(row.createdAt)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="admin-pagination">
|
|
||||||
<button type="button" onClick={() => setPage(1)} disabled={page <= 1}>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setPage(page - 1)} disabled={page <= 1}>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
Page {page} of {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(pageCount)}
|
|
||||||
disabled={page >= pageCount}
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
|
|
||||||
type FlowStage = {
|
|
||||||
title: string
|
|
||||||
input: string
|
|
||||||
action: string
|
|
||||||
output: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_FLOW: FlowStage[] = [
|
|
||||||
{
|
|
||||||
title: 'Identity + access',
|
|
||||||
input: 'Jellyfin/local login',
|
|
||||||
action: 'Magent validates credentials and role',
|
|
||||||
output: 'JWT token + user scope',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Request intake',
|
|
||||||
input: 'Seerr request ID',
|
|
||||||
action: 'Magent snapshots request + media metadata',
|
|
||||||
output: 'Unified request state',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Queue orchestration',
|
|
||||||
input: 'Approved request',
|
|
||||||
action: 'Sonarr/Radarr add/search operations',
|
|
||||||
output: 'Grab decision',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Download execution',
|
|
||||||
input: 'Selected release',
|
|
||||||
action: 'qBittorrent downloads + reports progress',
|
|
||||||
output: 'Import-ready payload',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Library import',
|
|
||||||
input: 'Completed download',
|
|
||||||
action: 'Sonarr/Radarr import and finalize',
|
|
||||||
output: 'Available media object',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Playback availability',
|
|
||||||
input: 'Imported media',
|
|
||||||
action: 'Jellyfin refresh + link resolution',
|
|
||||||
output: 'Ready-to-watch state',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function AdminSystemGuidePage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [authorized, setAuthorized] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const me = await response.json()
|
|
||||||
if (!active) return
|
|
||||||
if (me?.role !== 'admin') {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAuthorized(true)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
router.push('/')
|
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <main className="card">Loading system guide...</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authorized) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const rail = (
|
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">How it works</span>
|
|
||||||
<h2>Admin flow map</h2>
|
|
||||||
<p>Identity → Request intake → Queue orchestration → Download → Import → Playback.</p>
|
|
||||||
<span className="small-pill">Admin only</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="System guide"
|
|
||||||
subtitle="Service connections, controls, and recovery paths."
|
|
||||||
rail={rail}
|
|
||||||
>
|
|
||||||
<section className="admin-section system-guide">
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>End-to-end system flow</h2>
|
|
||||||
<p className="lede">
|
|
||||||
This is the runtime path the platform follows from authentication through to playback
|
|
||||||
availability.
|
|
||||||
</p>
|
|
||||||
<div className="system-flow-track">
|
|
||||||
{REQUEST_FLOW.map((stage, index) => (
|
|
||||||
<div key={stage.title} className="system-flow-segment">
|
|
||||||
<article className="system-flow-card">
|
|
||||||
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Input</span>
|
|
||||||
<strong>{stage.input}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Action</span>
|
|
||||||
<strong>{stage.action}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Output</span>
|
|
||||||
<strong>{stage.output}</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true">→</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>What each service is responsible for</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Magent</h3>
|
|
||||||
<p>
|
|
||||||
Handles authentication, request pages, live event updates, invite workflows,
|
|
||||||
diagnostics, notifications, and admin operations.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Seerr</h3>
|
|
||||||
<p>
|
|
||||||
Stores the request itself and remains the request-state source for approval and
|
|
||||||
media request metadata.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Jellyfin</h3>
|
|
||||||
<p>
|
|
||||||
Provides user sign-in identity and the final playback destination once content is
|
|
||||||
available.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Sonarr / Radarr</h3>
|
|
||||||
<p>
|
|
||||||
Control queue placement, quality-profile decisions, import handling, and release
|
|
||||||
monitoring.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Prowlarr</h3>
|
|
||||||
<p>Provides search/indexer coverage for Arr-side release searches.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>qBittorrent</h3>
|
|
||||||
<p>
|
|
||||||
Executes the download and exposes live progress, paused states, and queue
|
|
||||||
visibility.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Operational controls by area</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>General</h3>
|
|
||||||
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Users</h3>
|
|
||||||
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Invite management</h3>
|
|
||||||
<p>
|
|
||||||
Master template, profile assignment, invite access policy, invite emails, and trace
|
|
||||||
map lineage.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Requests + cache</h3>
|
|
||||||
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Maintenance + diagnostics</h3>
|
|
||||||
<p>
|
|
||||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
|
||||||
nuclear flush/resync operations.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>User and invite model</h2>
|
|
||||||
<ol className="system-decision-list">
|
|
||||||
<li>
|
|
||||||
Jellyfin is used for sign-in identity and user presence across the platform.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Seerr provides request ownership and request-state data for Magent request pages.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Invite links, invite profiles, blanket rules, and invite-access controls are managed
|
|
||||||
inside Magent.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
If invite tracing is enabled, the lineage view shows who invited whom and how the
|
|
||||||
chain branches.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Cross-system removal and ban flows are initiated from Magent admin controls.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Stall recovery path (decision flow)</h2>
|
|
||||||
<ol className="system-decision-list">
|
|
||||||
<li>
|
|
||||||
Request approved but not in Arr queue <span>→</span> run <strong>Re-add to Arr</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
In queue but no release found <span>→</span> run <strong>Search releases</strong> and inspect options.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Release exists and user should not pick manually <span>→</span> run <strong>Search + auto-download</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Download paused/stalled in qBittorrent <span>→</span> run <strong>Resume download</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Imported but not visible to user <span>→</span> validate Jellyfin visibility/link from request page.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Live update surfaces</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Landing page</h3>
|
|
||||||
<p>Recent request activity refreshes live for signed-in users.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Request pages</h3>
|
|
||||||
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Admin views</h3>
|
|
||||||
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
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 { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
@@ -10,42 +8,15 @@ type SiteInfo = {
|
|||||||
changelog?: string
|
changelog?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangelogGroup = {
|
const parseChangelog = (raw: string) =>
|
||||||
date: string
|
raw
|
||||||
entries: string[]
|
.split('\n')
|
||||||
}
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
||||||
|
|
||||||
const parseChangelog = (raw: string): ChangelogGroup[] => {
|
|
||||||
const groups: ChangelogGroup[] = []
|
|
||||||
for (const rawLine of raw.split('\n')) {
|
|
||||||
const line = rawLine.trim()
|
|
||||||
if (!line) continue
|
|
||||||
const [candidateDate, ...messageParts] = line.split('|')
|
|
||||||
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
|
|
||||||
const message = messageParts.join('|').trim()
|
|
||||||
if (!message) continue
|
|
||||||
const currentGroup = groups[groups.length - 1]
|
|
||||||
if (currentGroup?.date === candidateDate) {
|
|
||||||
currentGroup.entries.push(message)
|
|
||||||
} else {
|
|
||||||
groups.push({ date: candidateDate, entries: [message] })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (groups.length === 0) {
|
|
||||||
groups.push({ date: 'Updates', entries: [line] })
|
|
||||||
} else {
|
|
||||||
groups[groups.length - 1].entries.push(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChangelogPage() {
|
export default function ChangelogPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [groups, setGroups] = useState<ChangelogGroup[]>([])
|
const [entries, setEntries] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,11 +40,11 @@ export default function ChangelogPage() {
|
|||||||
}
|
}
|
||||||
const data: SiteInfo = await response.json()
|
const data: SiteInfo = await response.json()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setGroups(parseChangelog(data?.changelog ?? ''))
|
setEntries(parseChangelog(data?.changelog ?? ''))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setGroups([])
|
setEntries([])
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoading(false)
|
if (active) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -88,29 +59,27 @@ export default function ChangelogPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading-text">Loading changelog...</div>
|
return <div className="loading-text">Loading changelog...</div>
|
||||||
}
|
}
|
||||||
if (groups.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <div className="meta">No updates posted yet.</div>
|
return <div className="meta">No updates posted yet.</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="changelog-groups">
|
<ul className="changelog-list">
|
||||||
{groups.map((group) => (
|
{entries.map((entry, index) => (
|
||||||
<section key={group.date} className="changelog-group">
|
<li key={`${entry}-${index}`}>{entry}</li>
|
||||||
<h2>{group.date}</h2>
|
|
||||||
<ul className="changelog-list">
|
|
||||||
{group.entries.map((entry, index) => (
|
|
||||||
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)
|
)
|
||||||
}, [groups, loading])
|
}, [entries, loading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card changelog-page">
|
<div className="page">
|
||||||
<PageHeading title="Changelog" description="What’s new and improved in Magent." />
|
<section className="card changelog-card">
|
||||||
{content}
|
<div className="changelog-header">
|
||||||
</main>
|
<h1>Changelog</h1>
|
||||||
|
<p className="lede">Latest updates and release notes.</p>
|
||||||
|
</div>
|
||||||
|
{content}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
type Profile = {
|
type Profile = {
|
||||||
username?: string
|
username?: string
|
||||||
@@ -26,17 +24,15 @@ export default function FeedbackPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Could not load profile.')
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setProfile({ username: data?.username })
|
setProfile({ username: data?.username })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +49,7 @@ export default function FeedbackPage() {
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -62,16 +58,17 @@ export default function FeedbackPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
throw new Error(text || `Request failed: ${response.status}`)
|
throw new Error(text || `Request failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setStatus('Thanks! Your message has been sent.')
|
setStatus('Thanks! Your message has been sent.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setStatus('That did not send. Please try again.')
|
setStatus('That did not send. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,10 +77,16 @@ export default function FeedbackPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card feedback-page">
|
<main className="card">
|
||||||
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
|
<header className="how-hero">
|
||||||
|
<p className="eyebrow">Send feedback</p>
|
||||||
|
<h1>Help us improve Magent</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Found a problem or have an idea? Send it here and we will see it right away.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<form className="account-panel account-form feedback-form" onSubmit={submit}>
|
<form className="auth-form" onSubmit={submit}>
|
||||||
<label htmlFor="feedback-user">Your username</label>
|
<label htmlFor="feedback-user">Your username</label>
|
||||||
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import AuthLayout from '../ui/AuthLayout'
|
|
||||||
import { getApiBase } from '../lib/auth'
|
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [identifier, setIdentifier] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!identifier.trim()) {
|
|
||||||
setError('Enter your username or email.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/auth/password/forgot`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ identifier: identifier.trim() }),
|
|
||||||
})
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.')
|
|
||||||
}
|
|
||||||
setStatus(
|
|
||||||
typeof data?.message === 'string'
|
|
||||||
? data.message
|
|
||||||
: 'If an account exists for that username or email, a password reset link has been sent.',
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to send reset link.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
|
|
||||||
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
|
|
||||||
<label>
|
|
||||||
Username or email
|
|
||||||
<input
|
|
||||||
value={identifier}
|
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
|
||||||
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit" className="account-primary" disabled={loading}>
|
|
||||||
{loading ? 'Sending reset link…' : 'Send reset link'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</AuthLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+33
-5980
File diff suppressed because it is too large
Load Diff
+102
-140
@@ -1,179 +1,141 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
|
||||||
|
|
||||||
export default function HowItWorksPage() {
|
export default function HowItWorksPage() {
|
||||||
return (
|
return (
|
||||||
<main className="card how-page">
|
<main className="card how-page">
|
||||||
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
|
<header className="how-hero">
|
||||||
|
<p className="eyebrow">How this works</p>
|
||||||
|
<h1>Your request, step by step</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
|
||||||
|
your request is and what you can safely do next.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-grid">
|
||||||
<h2>What Magent is for</h2>
|
<article className="how-card">
|
||||||
<div className="how-grid">
|
<h2>Jellyseerr</h2>
|
||||||
<article className="how-card">
|
<p className="how-title">The request box</p>
|
||||||
<h3>Track requests</h3>
|
<p>
|
||||||
<p>
|
This is where you ask for a movie or show. It keeps the request and whether it is
|
||||||
Search by title, year, or request number to open the request page and see where an
|
approved.
|
||||||
item is up to.
|
</p>
|
||||||
</p>
|
</article>
|
||||||
</article>
|
<article className="how-card">
|
||||||
<article className="how-card">
|
<h2>Sonarr / Radarr</h2>
|
||||||
<h3>See live progress</h3>
|
<p className="how-title">The library manager</p>
|
||||||
<p>
|
<p>
|
||||||
Request status, timeline events, and download progress update live while you are
|
These add the request to the library list and decide what quality to look for.
|
||||||
viewing the page.
|
</p>
|
||||||
</p>
|
</article>
|
||||||
</article>
|
<article className="how-card">
|
||||||
<article className="how-card">
|
<h2>Prowlarr</h2>
|
||||||
<h3>Know when it is ready</h3>
|
<p className="how-title">The search helper</p>
|
||||||
<p>
|
<p>
|
||||||
When the request is fully imported and available, Magent shows it as ready and links
|
This checks your search sources and reports back what it finds.
|
||||||
you through to Jellyfin.
|
</p>
|
||||||
</p>
|
</article>
|
||||||
</article>
|
<article className="how-card">
|
||||||
</div>
|
<h2>qBittorrent</h2>
|
||||||
|
<p className="how-title">The downloader</p>
|
||||||
|
<p>
|
||||||
|
This downloads the file. Magent can tell if it is downloading, paused, or finished.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h2>Jellyfin</h2>
|
||||||
|
<p className="how-title">The place you watch</p>
|
||||||
|
<p>
|
||||||
|
When the file is ready, Jellyfin shows it in your library so you can watch it.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-flow">
|
||||||
<h2>The request pipeline</h2>
|
<h2>The pipeline in plain English</h2>
|
||||||
<ol className="how-steps">
|
<ol className="how-steps">
|
||||||
<li>
|
<li>
|
||||||
<strong>You request a movie or show</strong> through Seerr.
|
<strong>You request a title</strong> in Jellyseerr.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Magent picks up the request</strong> and shows its current state.
|
<strong>Sonarr/Radarr adds it</strong> to the library list.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>The automation stack searches and downloads it</strong> if it can find a valid
|
<strong>Prowlarr looks for sources</strong> and sends results back.
|
||||||
release.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>The file is imported into the library</strong>.
|
<strong>qBittorrent downloads</strong> the match.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Jellyfin serves it</strong> once it is ready to watch.
|
<strong>Sonarr/Radarr imports</strong> it into your library.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Jellyfin shows it</strong> when it is ready to watch.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-flow">
|
||||||
<h2>What the statuses usually mean</h2>
|
<h2>Steps and fixes (simple and visual)</h2>
|
||||||
<div className="how-grid">
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Pending</h3>
|
|
||||||
<p>The request exists, but it is still waiting for approval or the next step.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Approved / Processing</h3>
|
|
||||||
<p>The request has been accepted and the automation tools are working on it.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Downloading</h3>
|
|
||||||
<p>Magent can show live progress while the content is still being downloaded.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Ready</h3>
|
|
||||||
<p>The item has been imported and should now be available in Jellyfin.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Partial / Waiting</h3>
|
|
||||||
<p>
|
|
||||||
Part of the workflow completed, but the request is still waiting on another service or
|
|
||||||
on content becoming available.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Declined</h3>
|
|
||||||
<p>The request was rejected or cannot proceed in its current form.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>Live updates you can expect</h2>
|
|
||||||
<div className="how-step-grid">
|
<div className="how-step-grid">
|
||||||
<article className="how-step-card step-seerr">
|
<article className="how-step-card step-jellyseerr">
|
||||||
<div className="step-badge">1</div>
|
<div className="step-badge">1</div>
|
||||||
<h3>Recent requests refresh automatically</h3>
|
<h3>Request sent</h3>
|
||||||
<p className="step-note">
|
<p className="step-note">Jellyseerr holds your request and approval.</p>
|
||||||
Your request list and landing-page activity update automatically while you are signed
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
in.
|
<ul className="step-fix-list">
|
||||||
</p>
|
<li>Add to library queue (if it was approved but never added)</li>
|
||||||
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article className="how-step-card step-qbit">
|
|
||||||
|
<article className="how-step-card step-arr">
|
||||||
<div className="step-badge">2</div>
|
<div className="step-badge">2</div>
|
||||||
<h3>Request pages update in real time</h3>
|
<h3>Added to the library list</h3>
|
||||||
<p className="step-note">
|
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
|
||||||
State changes, timeline steps, and downloader progress are pushed to the page live.
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
</p>
|
<ul className="step-fix-list">
|
||||||
|
<li>Search for releases (see options)</li>
|
||||||
|
<li>Search and auto-download (let it pick for you)</li>
|
||||||
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article className="how-step-card step-jellyfin">
|
|
||||||
|
<article className="how-step-card step-prowlarr">
|
||||||
<div className="step-badge">3</div>
|
<div className="step-badge">3</div>
|
||||||
<h3>Ready state appears as soon as the import completes</h3>
|
<h3>Searching for sources</h3>
|
||||||
<p className="step-note">
|
<p className="step-note">Prowlarr checks your torrent providers.</p>
|
||||||
Once the content is actually available, Magent updates the request page without a hard
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
refresh.
|
<ul className="step-fix-list">
|
||||||
</p>
|
<li>Search for releases (show a list to choose)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="how-step-card step-qbit">
|
||||||
|
<div className="step-badge">4</div>
|
||||||
|
<h3>Downloading the file</h3>
|
||||||
|
<p className="step-note">qBittorrent downloads the selected match.</p>
|
||||||
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Resume download (only if it already exists there)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="how-step-card step-jellyfin">
|
||||||
|
<div className="step-badge">5</div>
|
||||||
|
<h3>Ready to watch</h3>
|
||||||
|
<p className="step-note">Jellyfin shows it in your library.</p>
|
||||||
|
<div className="step-fix-title">What to do next</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Open in Jellyfin (watch it)</li>
|
||||||
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>User actions you may see</h2>
|
|
||||||
<div className="how-grid">
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Open request</h3>
|
|
||||||
<p>Jump into the full request page to inspect the current state and activity.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Open in Jellyfin</h3>
|
|
||||||
<p>Appears when the request is ready and Magent can link you through for playback.</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>Search + auto-download</h3>
|
|
||||||
<p>
|
|
||||||
Only appears for accounts that have been granted self-service download access by the
|
|
||||||
admin team.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="how-card">
|
|
||||||
<h3>My invites</h3>
|
|
||||||
<p>
|
|
||||||
If your account is allowed to invite others, you can create and manage invite links
|
|
||||||
from your profile.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
|
||||||
<h2>Invites and signup</h2>
|
|
||||||
<ol className="how-steps">
|
|
||||||
<li>
|
|
||||||
<strong>You receive an invite link</strong> by email or directly from the person who
|
|
||||||
invited you.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>You sign up through Magent</strong> and your account is linked into the media
|
|
||||||
stack.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Your account defaults apply</strong> based on the invite or your assigned
|
|
||||||
profile.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>You sign in and track requests</strong> from the landing page and your request
|
|
||||||
pages.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="how-callout">
|
<section className="how-callout">
|
||||||
<h2>If a request looks stuck</h2>
|
<h2>Why Magent sometimes says "waiting"</h2>
|
||||||
<p>
|
<p>
|
||||||
A waiting request usually means no usable release has been found yet, the download is
|
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
|
||||||
still in progress, or the import has not completed. Magent will keep updating as the
|
That does not mean it is broken. It usually means the release is not available yet.
|
||||||
underlying services move forward.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, getApiBase, clearToken } from '../../../lib/auth'
|
|
||||||
import ResolutionChoice from '../../../ui/ResolutionChoice'
|
|
||||||
|
|
||||||
type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } }
|
|
||||||
|
|
||||||
export default function ConfirmIssuePage() {
|
|
||||||
const { id } = useParams<{ id: string }>()
|
|
||||||
const router = useRouter()
|
|
||||||
const [item, setItem] = useState<Issue | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [result, setResult] = useState('')
|
|
||||||
const login = () => {
|
|
||||||
clearToken()
|
|
||||||
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`)
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController()
|
|
||||||
setLoading(true); setItem(null); setError(''); setResult('')
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' })
|
|
||||||
if (response.status === 401) { login(); return }
|
|
||||||
if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.')
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.')
|
|
||||||
setItem(data.item)
|
|
||||||
} catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') }
|
|
||||||
finally { if (!controller.signal.aborted) setLoading(false) }
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => controller.abort()
|
|
||||||
// The confirmation link identifies one issue. Never submit an answer on GET.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
const answer = async (resolved: boolean) => {
|
|
||||||
if (busy) return
|
|
||||||
setBusy(true); setError('')
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }),
|
|
||||||
})
|
|
||||||
if (response.status === 401) { login(); return }
|
|
||||||
if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.')
|
|
||||||
setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.')
|
|
||||||
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') }
|
|
||||||
finally { setBusy(false) }
|
|
||||||
}
|
|
||||||
return <main className="resolution-response-page">
|
|
||||||
{error && <p role="alert" className="status-banner">{error}</p>}
|
|
||||||
{loading ? <p role="status">Loading your issue…</p> : result ? <section className="resolution-choice" role="status"><h2>{result}</h2><a href="/portal/issues">Back to issues</a></section> : item ? (
|
|
||||||
item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution
|
|
||||||
? <ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
|
|
||||||
: <section className="resolution-choice"><h2>{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}</h2><p>{item.title}</p><a href={`/portal/issues?item=${item.id}`}>View issue</a></section>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
+24
-7
@@ -1,12 +1,11 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import './ops-redesign.css'
|
|
||||||
import './admin/config.css'
|
|
||||||
import './account.css'
|
|
||||||
import './workspace.css'
|
|
||||||
import './portal/issue-flow.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 ApplicationChrome from './ui/ApplicationChrome'
|
import BrandingLogo from './ui/BrandingLogo'
|
||||||
|
import SiteStatus from './ui/SiteStatus'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Magent',
|
title: 'Magent',
|
||||||
@@ -19,7 +18,25 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<body>
|
<body>
|
||||||
<BrandingFavicon />
|
<BrandingFavicon />
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<ApplicationChrome />
|
<header className="header">
|
||||||
|
<div className="header-left">
|
||||||
|
<a className="brand-link" href="/">
|
||||||
|
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||||
|
<div className="brand-stack">
|
||||||
|
<div className="brand">Magent</div>
|
||||||
|
<div className="tagline">Find and fix media requests fast.</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<HeaderIdentity />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="header-nav">
|
||||||
|
<HeaderActions />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<SiteStatus />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+10
-85
@@ -1,100 +1,25 @@
|
|||||||
const AUTH_STATE_COOKIE = 'magent_logged_in'
|
|
||||||
|
|
||||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
||||||
|
|
||||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCookie = (name: string) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getToken = () => {
|
export const getToken = () => {
|
||||||
if (typeof document === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
const cookies = document.cookie.split(';').map((entry) => entry.trim())
|
return window.localStorage.getItem('magent_token')
|
||||||
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
|
|
||||||
if (!marker) return null
|
|
||||||
const [, value] = marker.split('=', 2)
|
|
||||||
return value || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setToken = (_token: string) => {
|
export const setToken = (token: string) => {
|
||||||
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem('magent_token', token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearToken = () => {
|
export const clearToken = () => {
|
||||||
clearCookie(AUTH_STATE_COOKIE)
|
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const baseUrl = getApiBase()
|
window.localStorage.removeItem('magent_token')
|
||||||
void fetch(`${baseUrl}/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logout = async () => {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
clearCookie(AUTH_STATE_COOKIE)
|
|
||||||
await fetch(`${baseUrl}/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const token = getToken()
|
||||||
const headers = new Headers(init?.headers || {})
|
const headers = new Headers(init?.headers || {})
|
||||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
if (token) {
|
||||||
}
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
export const getEventStreamToken = async () => {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/stream-token`)
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || `Stream token request failed: ${response.status}`)
|
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
return fetch(input, { ...init, headers })
|
||||||
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Stream token not returned')
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Unauthorized')
|
|
||||||
this.name = 'UnauthorizedError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ForbiddenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Forbidden')
|
|
||||||
this.name = 'ForbiddenError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const response = await authFetch(input, init)
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
throw new UnauthorizedError()
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
throw new ForbiddenError()
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readResponseText = async (response: Response) => {
|
|
||||||
try {
|
|
||||||
return (await response.text()).trim()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
|
|
||||||
const USER_VIEW_EVENT = 'magent:user-view-change'
|
|
||||||
|
|
||||||
const readUserViewPreview = () => {
|
|
||||||
if (typeof window === 'undefined') return false
|
|
||||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyDocumentMode = (enabled: boolean) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setUserViewPreview = (enabled: boolean) => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
if (enabled) {
|
|
||||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
|
|
||||||
} else {
|
|
||||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
|
|
||||||
}
|
|
||||||
applyDocumentMode(enabled)
|
|
||||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUserViewPreview = () => {
|
|
||||||
const [enabled, setEnabled] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sync = () => {
|
|
||||||
const nextValue = readUserViewPreview()
|
|
||||||
applyDocumentMode(nextValue)
|
|
||||||
setEnabled(nextValue)
|
|
||||||
}
|
|
||||||
sync()
|
|
||||||
window.addEventListener(USER_VIEW_EVENT, sync)
|
|
||||||
window.addEventListener('storage', sync)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(USER_VIEW_EVENT, sync)
|
|
||||||
window.removeEventListener('storage', sync)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
+69
-85
@@ -1,107 +1,91 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useRouter } from 'next/navigation'
|
||||||
import { getApiBase, setToken } from '../lib/auth'
|
import { useState } from 'react'
|
||||||
import AuthLayout from '../ui/AuthLayout'
|
import { getApiBase, setToken, clearToken } from '../lib/auth'
|
||||||
|
import BrandingLogo from '../ui/BrandingLogo'
|
||||||
type LoginMode = 'jellyfin' | 'local'
|
|
||||||
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
|
|
||||||
const DEFAULT_OPTIONS: LoginOptions = { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [mode, setMode] = useState<LoginMode>('jellyfin')
|
|
||||||
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
|
|
||||||
const [optionsReady, setOptionsReady] = useState(false)
|
|
||||||
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
|
|
||||||
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin'
|
|
||||||
|
|
||||||
useEffect(() => {
|
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
|
||||||
const controller = new AbortController()
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal })
|
|
||||||
if (!response.ok) throw new Error('Options unavailable')
|
|
||||||
const data = await response.json()
|
|
||||||
if (controller.signal.aborted) return
|
|
||||||
setOptions({
|
|
||||||
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
|
|
||||||
showLocalLogin: data?.login?.showLocalLogin !== false,
|
|
||||||
showForgotPassword: data?.login?.showForgotPassword !== false,
|
|
||||||
showSignupLink: data?.login?.showSignupLink !== false,
|
|
||||||
})
|
|
||||||
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
|
|
||||||
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep the normal sign-in methods available during a settings outage.
|
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) setOptionsReady(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => controller.abort()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (loading || !canSignIn || !optionsReady) return
|
setError(null)
|
||||||
setError('')
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, {
|
clearToken()
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
|
||||||
|
const body = new URLSearchParams({ username, password })
|
||||||
|
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({ username: username.trim(), password }),
|
body,
|
||||||
credentials: 'include',
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(response.status === 429 ? 'Too many attempts. Please wait a moment and try again.'
|
throw new Error('Login failed')
|
||||||
: response.status >= 500 ? 'Sign-in is temporarily unavailable. Please try again shortly.'
|
|
||||||
: response.status === 403 ? 'This account cannot sign in. Please contact an administrator.'
|
|
||||||
: 'Check your username and password, then try again.')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
if (data?.access_token) {
|
||||||
setToken('cookie')
|
setToken(data.access_token)
|
||||||
const next = new URLSearchParams(window.location.search).get('next') || ''
|
if (typeof window !== 'undefined') {
|
||||||
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
|
window.location.href = '/'
|
||||||
} catch {
|
return
|
||||||
setError('Could not reach Magent. Check your connection and try again.')
|
}
|
||||||
} finally { setLoading(false) }
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('Login failed')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Invalid username or password.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
|
<main className="card auth-card">
|
||||||
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></>
|
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||||
}>
|
<h1>Sign in</h1>
|
||||||
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p>
|
||||||
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
|
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
|
||||||
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
|
<label>
|
||||||
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
|
Username
|
||||||
</div>}
|
<input
|
||||||
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in…</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
|
value={username}
|
||||||
<form className="account-form login-form" onSubmit={submit}>
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
|
autoComplete="username"
|
||||||
<label htmlFor="login-username">Username</label>
|
/>
|
||||||
<input id="login-username" name="username" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" autoCapitalize="none" spellCheck={false} required disabled={loading} />
|
</label>
|
||||||
<div className="login-password-label"><label htmlFor="login-password">Password</label>{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}</div>
|
<label>
|
||||||
<div className="login-password-field">
|
Password
|
||||||
<input id="login-password" name="password" type={showPassword ? 'text' : 'password'} value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required disabled={loading} />
|
<input
|
||||||
<button type="button" className="password-visibility" aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} onClick={() => setShowPassword(!showPassword)}>
|
type="password"
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />{showPassword && <path d="m3 3 18 18" />}</svg>
|
value={password}
|
||||||
</button>
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
</div>
|
autoComplete="current-password"
|
||||||
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
/>
|
||||||
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true">→</span></button>
|
</label>
|
||||||
</form>
|
{error && <div className="error-banner">{error}</div>}
|
||||||
)}
|
<div className="auth-actions">
|
||||||
</AuthLayout>
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={(event) => submit(event, 'local')}
|
||||||
|
>
|
||||||
|
Sign in with Magent account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|
||||||
|
|
||||||
type MediaType = 'movie' | 'tv'
|
|
||||||
|
|
||||||
type DiscoveryResult = {
|
|
||||||
title: string
|
|
||||||
year?: number | null
|
|
||||||
type: MediaType
|
|
||||||
tmdbId: number
|
|
||||||
requestId?: number | null
|
|
||||||
statusLabel?: string | null
|
|
||||||
overview?: string | null
|
|
||||||
posterPath?: string | null
|
|
||||||
backdropPath?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type RequestOptions = {
|
|
||||||
media: DiscoveryResult & {
|
|
||||||
seasons: Array<{
|
|
||||||
seasonNumber: number
|
|
||||||
name: string
|
|
||||||
episodeCount: number
|
|
||||||
airDate?: string | null
|
|
||||||
}>
|
|
||||||
existingRequestId?: number | null
|
|
||||||
}
|
|
||||||
destination: {
|
|
||||||
collector: 'Sonarr' | 'Radarr'
|
|
||||||
serverName: string
|
|
||||||
defaultProfileId: number
|
|
||||||
profiles: Array<{ id: number; name: string }>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OperationEvent = {
|
|
||||||
id: string
|
|
||||||
service: string
|
|
||||||
state: 'active' | 'complete' | 'error'
|
|
||||||
message: string
|
|
||||||
duration_ms?: number | null
|
|
||||||
status_code?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type OperationProgress = {
|
|
||||||
status: 'running' | 'complete' | 'error'
|
|
||||||
duration_ms?: number | null
|
|
||||||
events: OperationEvent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaChoices: Array<{
|
|
||||||
type: MediaType
|
|
||||||
eyebrow: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
collector: 'Radarr' | 'Sonarr'
|
|
||||||
icon: string
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
type: 'movie',
|
|
||||||
eyebrow: 'Film',
|
|
||||||
title: 'Movie',
|
|
||||||
description: 'Find a film and send it through Seerr to Radarr.',
|
|
||||||
collector: 'Radarr',
|
|
||||||
icon: '/service-icons/radarr.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tv',
|
|
||||||
eyebrow: 'Series',
|
|
||||||
title: 'TV show',
|
|
||||||
description: 'Choose a series, the seasons you want, and send it to Sonarr.',
|
|
||||||
collector: 'Sonarr',
|
|
||||||
icon: '/service-icons/sonarr.svg',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const artworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w342') => {
|
|
||||||
if (!path) return null
|
|
||||||
return `https://image.tmdb.org/t/p/${size}${path.startsWith('/') ? path : `/${path}`}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiError = async (response: Response, fallback: string) => {
|
|
||||||
try {
|
|
||||||
const payload = await response.json()
|
|
||||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
|
|
||||||
if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message
|
|
||||||
} catch {
|
|
||||||
// The upstream response was not JSON. Use the friendly fallback below.
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (milliseconds?: number | null) => {
|
|
||||||
if (milliseconds == null) return null
|
|
||||||
if (milliseconds < 1000) return `${Math.round(milliseconds)} ms`
|
|
||||||
return `${(milliseconds / 1000).toFixed(1)} s`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewRequestClient() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchSectionRef = useRef<HTMLElement | null>(null)
|
|
||||||
const resultsSectionRef = useRef<HTMLElement | null>(null)
|
|
||||||
const configureSectionRef = useRef<HTMLElement | null>(null)
|
|
||||||
const [mediaType, setMediaType] = useState<MediaType | null>(null)
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [searching, setSearching] = useState(false)
|
|
||||||
const [searchAttempted, setSearchAttempted] = useState(false)
|
|
||||||
const [results, setResults] = useState<DiscoveryResult[]>([])
|
|
||||||
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
|
|
||||||
const [options, setOptions] = useState<RequestOptions | null>(null)
|
|
||||||
const [loadingOptions, setLoadingOptions] = useState(false)
|
|
||||||
const [profileId, setProfileId] = useState<number | null>(null)
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!getToken()) router.push('/login')
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const resetAfterType = (nextType: MediaType) => {
|
|
||||||
setMediaType(nextType)
|
|
||||||
setQuery('')
|
|
||||||
setResults([])
|
|
||||||
setSearchAttempted(false)
|
|
||||||
setSelected(null)
|
|
||||||
setOptions(null)
|
|
||||||
setProfileId(null)
|
|
||||||
setSelectedSeasons([])
|
|
||||||
setOperation(null)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
const runSearch = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!mediaType) return
|
|
||||||
const term = query.trim()
|
|
||||||
if (!term) {
|
|
||||||
setError('Enter a title to search for.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSearching(true)
|
|
||||||
setSearchAttempted(true)
|
|
||||||
setSelected(null)
|
|
||||||
setOptions(null)
|
|
||||||
setOperation(null)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const params = new URLSearchParams({ query: term, media_type: mediaType })
|
|
||||||
const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`)
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`))
|
|
||||||
const payload = await response.json()
|
|
||||||
const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
|
|
||||||
? payload.results
|
|
||||||
.filter((item: any) => item?.type === mediaType && Number(item?.tmdbId) > 0)
|
|
||||||
.map((item: any) => ({
|
|
||||||
title: String(item?.title || 'Untitled'),
|
|
||||||
year: typeof item?.year === 'number' ? item.year : null,
|
|
||||||
type: mediaType,
|
|
||||||
tmdbId: Number(item.tmdbId),
|
|
||||||
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
|
||||||
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
|
|
||||||
overview: typeof item?.overview === 'string' ? item.overview : null,
|
|
||||||
posterPath: item?.posterPath ?? null,
|
|
||||||
backdropPath: item?.backdropPath ?? null,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
setResults(mapped)
|
|
||||||
window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
|
||||||
} catch (caught) {
|
|
||||||
setResults([])
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Search is unavailable right now.')
|
|
||||||
} finally {
|
|
||||||
setSearching(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectResult = async (item: DiscoveryResult) => {
|
|
||||||
setSelected(item)
|
|
||||||
setOptions(null)
|
|
||||||
setProfileId(null)
|
|
||||||
setSelectedSeasons([])
|
|
||||||
setOperation(null)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
if (item.requestId) {
|
|
||||||
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingOptions(true)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) })
|
|
||||||
const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`)
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!response.ok) throw new Error(await apiError(response, `Could not load request options (${response.status}).`))
|
|
||||||
const payload = (await response.json()) as RequestOptions
|
|
||||||
const refreshedSelection: DiscoveryResult = {
|
|
||||||
...item,
|
|
||||||
title: payload.media.title || item.title,
|
|
||||||
year: payload.media.year ?? item.year,
|
|
||||||
overview: payload.media.overview || item.overview,
|
|
||||||
posterPath: payload.media.posterPath || item.posterPath,
|
|
||||||
backdropPath: payload.media.backdropPath || item.backdropPath,
|
|
||||||
requestId: payload.media.existingRequestId || item.requestId,
|
|
||||||
statusLabel: payload.media.existingRequestId ? 'Already requested' : item.statusLabel,
|
|
||||||
}
|
|
||||||
setSelected(refreshedSelection)
|
|
||||||
if (payload.media.existingRequestId) {
|
|
||||||
setResults((current) => current.map((result) => result.tmdbId === item.tmdbId && result.type === item.type
|
|
||||||
? refreshedSelection
|
|
||||||
: result))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setOptions(payload)
|
|
||||||
setProfileId(payload.destination.defaultProfileId)
|
|
||||||
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
|
|
||||||
} finally {
|
|
||||||
setLoadingOptions(false)
|
|
||||||
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollOperation = async (operationId: string) => {
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/operations/${operationId}`)
|
|
||||||
if (response.ok) setOperation((await response.json()) as OperationProgress)
|
|
||||||
} catch {
|
|
||||||
// The request response remains authoritative if a progress poll is interrupted.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitRequest = async () => {
|
|
||||||
if (!selected || !options || !profileId) return
|
|
||||||
if (selected.type === 'tv' && selectedSeasons.length === 0) {
|
|
||||||
setError('Select at least one season.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`
|
|
||||||
setOperation({ status: 'running', events: [] })
|
|
||||||
const interval = window.setInterval(() => void pollOperation(operationId), 500)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/requests/create`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Magent-Operation-ID': operationId,
|
|
||||||
'X-Magent-Operation-Label': `Requesting ${selected.title}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
mediaType: selected.type,
|
|
||||||
tmdbId: selected.tmdbId,
|
|
||||||
profileId,
|
|
||||||
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await pollOperation(operationId)
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`))
|
|
||||||
const payload = await response.json()
|
|
||||||
const requestId = typeof payload?.requestId === 'number' ? payload.requestId : null
|
|
||||||
setSelected((current) => current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current)
|
|
||||||
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
|
|
||||||
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
|
|
||||||
: item))
|
|
||||||
setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.')
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
|
|
||||||
} finally {
|
|
||||||
window.clearInterval(interval)
|
|
||||||
await pollOperation(operationId)
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setEverySeason = (checked: boolean) => {
|
|
||||||
setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : [])
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedPoster = artworkUrl(selected?.posterPath, 'w185')
|
|
||||||
const currentFlowStep = success
|
|
||||||
? 5
|
|
||||||
: selected
|
|
||||||
? 4
|
|
||||||
: searchAttempted
|
|
||||||
? 3
|
|
||||||
: mediaType
|
|
||||||
? 2
|
|
||||||
: 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="card request-portal-page">
|
|
||||||
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
|
|
||||||
|
|
||||||
<ol className="request-master-stepper" aria-label="New request progress">
|
|
||||||
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
|
|
||||||
const step = index + 1
|
|
||||||
return (
|
|
||||||
<li key={label} className={step === currentFlowStep ? 'is-active' : step < currentFlowStep ? 'is-complete' : ''}>
|
|
||||||
<span>{step < currentFlowStep ? '✓' : step}</span>
|
|
||||||
<strong>{label}</strong>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
{error && <div className="error-banner request-flow-alert">{error}</div>}
|
|
||||||
{success && <div className="status-banner request-flow-alert">{success}</div>}
|
|
||||||
|
|
||||||
<section className="request-flow-stage is-current">
|
|
||||||
<div className="request-flow-heading">
|
|
||||||
<span className="request-flow-number">01</span>
|
|
||||||
<div><span>Start here</span><h2>What are you looking for?</h2></div>
|
|
||||||
</div>
|
|
||||||
<div className="request-type-grid">
|
|
||||||
{mediaChoices.map((choice) => (
|
|
||||||
<button
|
|
||||||
key={choice.type}
|
|
||||||
type="button"
|
|
||||||
className={`request-type-card ${mediaType === choice.type ? 'is-selected' : ''}`}
|
|
||||||
onClick={() => resetAfterType(choice.type)}
|
|
||||||
aria-pressed={mediaType === choice.type}
|
|
||||||
>
|
|
||||||
<span className="request-type-card-body">
|
|
||||||
<span className="request-service-icon">
|
|
||||||
<img src={choice.icon} alt={`${choice.collector} logo`} />
|
|
||||||
</span>
|
|
||||||
<span className="request-type-card-copy">
|
|
||||||
<span>{choice.eyebrow}</span>
|
|
||||||
<strong>{choice.title}</strong>
|
|
||||||
<span className="request-type-description">{choice.description}</span>
|
|
||||||
<b>{mediaType === choice.type ? 'Selected' : `Choose ${choice.title.toLowerCase()}`}</b>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{mediaType && (
|
|
||||||
<section ref={searchSectionRef} className="request-flow-stage is-current">
|
|
||||||
<div className="request-flow-heading">
|
|
||||||
<span className="request-flow-number">02</span>
|
|
||||||
<div><span>{mediaType === 'tv' ? 'TV show selected' : 'Movie selected'}</span><h2>Search for the title</h2></div>
|
|
||||||
</div>
|
|
||||||
<form className="request-flow-search" onSubmit={runSearch}>
|
|
||||||
<label htmlFor="request-title-search">Title</label>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
id="request-title-search"
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder={mediaType === 'tv' ? 'Search TV shows' : 'Search movies'}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={searching}>{searching ? 'Searching…' : 'Search Seerr'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mediaType && searchAttempted && !searching && (
|
|
||||||
<section ref={resultsSectionRef} className="request-flow-stage is-current">
|
|
||||||
<div className="request-flow-heading">
|
|
||||||
<span className="request-flow-number">03</span>
|
|
||||||
<div><span>Search results</span><h2>{results.length ? 'Select the right title' : 'No matches found'}</h2></div>
|
|
||||||
</div>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="request-flow-empty">
|
|
||||||
<strong>Nothing matched “{query.trim()}”.</strong>
|
|
||||||
<p>Check the spelling or try a shorter title.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="request-result-grid">
|
|
||||||
{results.map((item) => {
|
|
||||||
const poster = artworkUrl(item.posterPath)
|
|
||||||
const isSelected = selected?.tmdbId === item.tmdbId && selected.type === item.type
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${item.type}:${item.tmdbId}`}
|
|
||||||
type="button"
|
|
||||||
className={`request-result-card ${isSelected ? 'is-selected' : ''}`}
|
|
||||||
onClick={() => void selectResult(item)}
|
|
||||||
>
|
|
||||||
<span className="request-result-poster">
|
|
||||||
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
|
|
||||||
</span>
|
|
||||||
<span className="request-result-copy">
|
|
||||||
<small>{item.type === 'tv' ? 'TV show' : 'Movie'}{item.year ? ` · ${item.year}` : ''}</small>
|
|
||||||
<strong>{item.title}</strong>
|
|
||||||
<p>{item.overview || 'Select this title to view the available request options.'}</p>
|
|
||||||
<b>{item.requestId ? item.statusLabel || 'Already requested' : isSelected ? 'Selected' : 'Select title'}</b>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<section ref={configureSectionRef} className="request-flow-stage is-current request-configure-stage">
|
|
||||||
<div className="request-flow-heading">
|
|
||||||
<span className="request-flow-number">04</span>
|
|
||||||
<div><span>Final step</span><h2>{selected.requestId ? 'This title is already in the pipeline' : 'Configure your request'}</h2></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="request-selection-summary">
|
|
||||||
<span className="request-selection-poster">
|
|
||||||
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<small>{selected.type === 'tv' ? 'TV show' : 'Movie'}{selected.year ? ` · ${selected.year}` : ''}</small>
|
|
||||||
<h3>{selected.title}</h3>
|
|
||||||
<p>{selected.overview || 'Ready to configure.'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selected.requestId ? (
|
|
||||||
<div className="request-existing-state">
|
|
||||||
<div><span>Current status</span><strong>{selected.statusLabel || 'Already requested'}</strong><p>Request #{selected.requestId} is already being tracked by Magent.</p></div>
|
|
||||||
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Open request</button>
|
|
||||||
</div>
|
|
||||||
) : loadingOptions ? (
|
|
||||||
<div className="request-flow-empty"><strong>Checking Seerr and {selected.type === 'tv' ? 'Sonarr' : 'Radarr'}…</strong><p>Loading valid profiles and request choices.</p></div>
|
|
||||||
) : options ? (
|
|
||||||
<div className="request-options-layout">
|
|
||||||
{selected.type === 'tv' && (
|
|
||||||
<fieldset className="request-season-picker">
|
|
||||||
<legend>Which seasons?</legend>
|
|
||||||
<div className="request-season-actions">
|
|
||||||
<button type="button" onClick={() => setEverySeason(true)}>Select all</button>
|
|
||||||
<button type="button" onClick={() => setEverySeason(false)}>Clear</button>
|
|
||||||
</div>
|
|
||||||
<div className="request-season-grid">
|
|
||||||
{options.media.seasons.map((season) => (
|
|
||||||
<label key={season.seasonNumber} className={selectedSeasons.includes(season.seasonNumber) ? 'is-selected' : ''}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedSeasons.includes(season.seasonNumber)}
|
|
||||||
onChange={(event) => setSelectedSeasons((current) => event.target.checked
|
|
||||||
? [...current, season.seasonNumber].sort((a, b) => a - b)
|
|
||||||
: current.filter((value) => value !== season.seasonNumber))}
|
|
||||||
/>
|
|
||||||
<span><strong>{season.name}</strong><small>{season.episodeCount} episode{season.episodeCount === 1 ? '' : 's'}</small></span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="request-profile-field">
|
|
||||||
<span>Quality profile</span>
|
|
||||||
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
|
|
||||||
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="request-submit-bar">
|
|
||||||
<div><span>Delivery route</span><strong>Seerr → {options.destination.collector} → Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
|
|
||||||
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
|
||||||
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{operation && (
|
|
||||||
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
|
|
||||||
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
|
|
||||||
<div>
|
|
||||||
{operation.events.map((event) => (
|
|
||||||
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
|
|
||||||
))}
|
|
||||||
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request…</span></p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && selected.requestId && (
|
|
||||||
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import NewRequestClient from './NewRequestClient'
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'New Requests | Magent',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewRequestsPage() {
|
|
||||||
return <NewRequestClient />
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import PageHeading from './ui/PageHeading'
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<main className="card">
|
|
||||||
<PageHeading title="Page not found" description="This link may have moved or no longer be available." />
|
|
||||||
<p><Link href="/">← Back to my requests</Link></p>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+243
-278
@@ -1,40 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from './ui/PageHeading'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
||||||
|
|
||||||
const normalizeRecentResults = (items: any[]) =>
|
|
||||||
items
|
|
||||||
.filter((item: any) => item?.id)
|
|
||||||
.map((item: any) => {
|
|
||||||
const id = item.id
|
|
||||||
const rawTitle = item.title
|
|
||||||
const placeholder =
|
|
||||||
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
|
||||||
year: item.year,
|
|
||||||
type: item.type,
|
|
||||||
statusLabel: item.statusLabel,
|
|
||||||
artwork: item.artwork,
|
|
||||||
createdAt: item.createdAt ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const REQUEST_STAGE_OPTIONS = [
|
|
||||||
{ value: 'all', label: 'All stages' },
|
|
||||||
{ value: 'pending', label: 'Waiting' },
|
|
||||||
{ value: 'approved', label: 'Approved' },
|
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
|
||||||
{ value: 'working', label: 'Working' },
|
|
||||||
{ value: 'partial', label: 'Partial' },
|
|
||||||
{ value: 'ready', label: 'Ready' },
|
|
||||||
{ value: 'declined', label: 'Declined' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -44,30 +12,24 @@ export default function HomePage() {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
year?: number
|
year?: number
|
||||||
type?: string
|
|
||||||
statusLabel?: string
|
statusLabel?: string
|
||||||
artwork?: { poster_url?: string; backdrop_url?: string }
|
artwork?: { poster_url?: string }
|
||||||
createdAt?: string | null
|
|
||||||
}[]
|
}[]
|
||||||
>([])
|
>([])
|
||||||
const [recentError, setRecentError] = useState<string | null>(null)
|
const [recentError, setRecentError] = useState<string | null>(null)
|
||||||
const [recentLoading, setRecentLoading] = useState(false)
|
const [recentLoading, setRecentLoading] = useState(false)
|
||||||
const [searchResults, setSearchResults] = useState<
|
const [searchResults, setSearchResults] = useState<
|
||||||
{
|
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[]
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
type?: string
|
|
||||||
requestId?: number
|
|
||||||
statusLabel?: string
|
|
||||||
requestedBy?: string | null
|
|
||||||
accessible?: boolean
|
|
||||||
}[]
|
|
||||||
>([])
|
>([])
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
const [role, setRole] = useState<string | null>(null)
|
const [role, setRole] = useState<string | null>(null)
|
||||||
const [recentDays, setRecentDays] = useState(90)
|
const [recentDays, setRecentDays] = useState(90)
|
||||||
const [recentStage, setRecentStage] = useState('all')
|
|
||||||
const [authReady, setAuthReady] = useState(false)
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
const [servicesStatus, setServicesStatus] = useState<
|
||||||
|
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
|
||||||
|
>(null)
|
||||||
|
const [servicesLoading, setServicesLoading] = useState(false)
|
||||||
|
const [servicesError, setServicesError] = useState<string | null>(null)
|
||||||
|
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -104,14 +66,9 @@ export default function HomePage() {
|
|||||||
setRole(userRole)
|
setRole(userRole)
|
||||||
setAuthReady(true)
|
setAuthReady(true)
|
||||||
const take = userRole === 'admin' ? 50 : 6
|
const take = userRole === 'admin' ? 50 : 6
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(
|
||||||
take: String(take),
|
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
|
||||||
days: String(recentDays),
|
)
|
||||||
})
|
|
||||||
if (recentStage !== 'all') {
|
|
||||||
params.set('stage', recentStage)
|
|
||||||
}
|
|
||||||
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -122,7 +79,24 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (Array.isArray(data?.results)) {
|
if (Array.isArray(data?.results)) {
|
||||||
setRecent(normalizeRecentResults(data.results))
|
setRecent(
|
||||||
|
data.results
|
||||||
|
.filter((item: any) => item?.id)
|
||||||
|
.map((item: any) => {
|
||||||
|
const id = item.id
|
||||||
|
const rawTitle = item.title
|
||||||
|
const placeholder =
|
||||||
|
typeof rawTitle === 'string' &&
|
||||||
|
rawTitle.trim().toLowerCase() === `request ${id}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||||
|
year: item.year,
|
||||||
|
statusLabel: item.statusLabel,
|
||||||
|
artwork: item.artwork,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -133,69 +107,40 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load()
|
load()
|
||||||
}, [recentDays, recentStage])
|
}, [recentDays])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authReady) {
|
if (!authReady) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!getToken()) {
|
const load = async () => {
|
||||||
return
|
setServicesLoading(true)
|
||||||
}
|
setServicesError(null)
|
||||||
const baseUrl = getApiBase()
|
|
||||||
let closed = false
|
|
||||||
let source: EventSource | null = null
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
try {
|
try {
|
||||||
const streamToken = await getEventStreamToken()
|
const baseUrl = getApiBase()
|
||||||
if (closed) return
|
const response = await authFetch(`${baseUrl}/status/services`)
|
||||||
const params = new URLSearchParams({
|
if (!response.ok) {
|
||||||
stream_token: streamToken,
|
if (response.status === 401) {
|
||||||
recent_days: String(recentDays),
|
clearToken()
|
||||||
})
|
router.push('/login')
|
||||||
if (recentStage !== 'all') {
|
return
|
||||||
params.set('recent_stage', recentStage)
|
|
||||||
}
|
|
||||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
|
||||||
source = new EventSource(streamUrl)
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
if (closed) return
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data)
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (payload.type === 'home_recent') {
|
|
||||||
if (Array.isArray(payload.results)) {
|
|
||||||
setRecent(normalizeRecentResults(payload.results))
|
|
||||||
setRecentError(null)
|
|
||||||
setRecentLoading(false)
|
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
|
||||||
setRecentError('Recent requests are not available right now.')
|
|
||||||
setRecentLoading(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
}
|
||||||
|
throw new Error(`Service status failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setServicesStatus(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (closed) return
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setServicesError('Service status is not available right now.')
|
||||||
|
} finally {
|
||||||
|
setServicesLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void connect()
|
load()
|
||||||
|
const timer = setInterval(load, 30000)
|
||||||
return () => {
|
return () => clearInterval(timer)
|
||||||
closed = true
|
}, [authReady, router])
|
||||||
source?.close()
|
|
||||||
}
|
|
||||||
}, [authReady, recentDays, recentStage])
|
|
||||||
|
|
||||||
const runSearch = async (term: string) => {
|
const runSearch = async (term: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -212,16 +157,14 @@ export default function HomePage() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (Array.isArray(data?.results)) {
|
if (Array.isArray(data?.results)) {
|
||||||
setSearchResults(
|
setSearchResults(
|
||||||
data.results.map((item: any) => ({
|
data.results.map((item: any) => ({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
year: item.year,
|
year: item.year,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
requestId: item.requestId,
|
requestId: item.requestId,
|
||||||
statusLabel: item.statusLabel,
|
statusLabel: item.statusLabel,
|
||||||
requestedBy: item.requestedBy ?? null,
|
}))
|
||||||
accessible: Boolean(item.accessible),
|
)
|
||||||
}))
|
|
||||||
)
|
|
||||||
setSearchError(null)
|
setSearchError(null)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -236,172 +179,194 @@ export default function HomePage() {
|
|||||||
return url.startsWith('http') ? url : `${getApiBase()}${url}`
|
return url.startsWith('http') ? url : `${getApiBase()}${url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatRequestTime = (value?: string | null) => {
|
|
||||||
if (!value) return null
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeRecentCount = recent.filter((item) => {
|
|
||||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
|
||||||
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
|
|
||||||
}).length
|
|
||||||
const readyRecentCount = recent.filter((item) => {
|
|
||||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
|
||||||
return label.includes('ready') || label.includes('available')
|
|
||||||
}).length
|
|
||||||
|
|
||||||
const requestCardState = (value?: string) => {
|
|
||||||
const label = String(value ?? '').toLowerCase()
|
|
||||||
if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 }
|
|
||||||
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
|
|
||||||
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
|
|
||||||
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
|
|
||||||
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card home-page">
|
<main className="card">
|
||||||
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
|
<div className="layout-grid">
|
||||||
<form onSubmit={submit} className="home-search">
|
<section className="recent centerpiece">
|
||||||
<label htmlFor="request-search">Title, year, or request number</label>
|
<div className="system-status">
|
||||||
<div className="home-search-row">
|
<div className="system-header">
|
||||||
<input
|
<h2>System status</h2>
|
||||||
id="request-search"
|
<span
|
||||||
value={query}
|
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="Dune 2021 or 1289"
|
|
||||||
/>
|
|
||||||
<button type="submit">Find request</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{(searchError || searchResults.length > 0) && (
|
|
||||||
<section className="home-search-results" aria-live="polite">
|
|
||||||
<div className="home-section-heading">
|
|
||||||
<div>
|
|
||||||
<span className="section-kicker">Search results</span>
|
|
||||||
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => {
|
|
||||||
setSearchResults([])
|
|
||||||
setSearchError(null)
|
|
||||||
}}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{searchError ? (
|
|
||||||
<div className="error-banner">{searchError}</div>
|
|
||||||
) : (
|
|
||||||
<div className="home-result-grid">
|
|
||||||
{searchResults.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={`${item.title || 'Untitled'}-${index}`}
|
|
||||||
type="button"
|
|
||||||
className="home-result-card"
|
|
||||||
disabled={!item.requestId}
|
|
||||||
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
|
||||||
>
|
>
|
||||||
<span>
|
{servicesLoading
|
||||||
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
|
? 'Checking services...'
|
||||||
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
|
: servicesError
|
||||||
</span>
|
? 'Status not available yet'
|
||||||
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
|
: servicesStatus?.overall === 'up'
|
||||||
</button>
|
? 'Services are up and running'
|
||||||
))}
|
: servicesStatus?.overall === 'down'
|
||||||
</div>
|
? 'Something is down'
|
||||||
)}
|
: 'Some services need attention'}
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="home-metric-strip" aria-label="Request summary">
|
|
||||||
<div><span>In view</span><strong>{recent.length}</strong></div>
|
|
||||||
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
|
|
||||||
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="recent home-recent">
|
|
||||||
<div className="recent-header home-section-heading">
|
|
||||||
<div>
|
|
||||||
<span className="section-kicker">Request activity</span>
|
|
||||||
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
|
|
||||||
</div>
|
|
||||||
{authReady && (
|
|
||||||
<div className="recent-filter-group">
|
|
||||||
<label className="recent-filter">
|
|
||||||
<span>Period</span>
|
|
||||||
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
|
|
||||||
<option value={0}>All time</option>
|
|
||||||
<option value={30}>30 days</option>
|
|
||||||
<option value={60}>60 days</option>
|
|
||||||
<option value={90}>90 days</option>
|
|
||||||
<option value={180}>180 days</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{authReady && (
|
|
||||||
<div className="request-filter-chips" aria-label="Filter requests by stage">
|
|
||||||
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={option.value}
|
|
||||||
className={recentStage === option.value ? 'is-active' : undefined}
|
|
||||||
onClick={() => setRecentStage(option.value)}
|
|
||||||
>
|
|
||||||
{option.value === 'working' ? <i aria-hidden="true" /> : null}
|
|
||||||
{option.value === 'all' ? 'All' : option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="recent-grid home-recent-grid">
|
|
||||||
{recentLoading ? (
|
|
||||||
<div className="loading-center">
|
|
||||||
<div className="spinner" aria-hidden="true" />
|
|
||||||
<span className="loading-text">Loading recent requests...</span>
|
|
||||||
</div>
|
|
||||||
) : recentError ? (
|
|
||||||
<div className="error-banner">{recentError}</div>
|
|
||||||
) : recent.length === 0 ? (
|
|
||||||
<div className="home-empty-state">
|
|
||||||
<strong>No requests match these filters</strong>
|
|
||||||
<span>Try a wider period or a different stage.</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
recent.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`/requests/${item.id}`)}
|
|
||||||
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
|
|
||||||
>
|
|
||||||
{item.artwork?.poster_url ? (
|
|
||||||
<img
|
|
||||||
className="recent-poster"
|
|
||||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
|
|
||||||
)}
|
|
||||||
<span className="recent-info">
|
|
||||||
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
|
|
||||||
<span className="recent-meta">
|
|
||||||
{item.statusLabel || 'Status not available yet'} · Request {item.id}
|
|
||||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="recent-open-cue" aria-hidden="true">Open</span>
|
</div>
|
||||||
</button>
|
<div className="system-list">
|
||||||
))
|
{(() => {
|
||||||
)}
|
const order = [
|
||||||
</div>
|
'Jellyseerr',
|
||||||
</section>
|
'Sonarr',
|
||||||
|
'Radarr',
|
||||||
|
'Prowlarr',
|
||||||
|
'qBittorrent',
|
||||||
|
'Jellyfin',
|
||||||
|
]
|
||||||
|
const items = servicesStatus?.services ?? []
|
||||||
|
return order.map((name) => {
|
||||||
|
const item = items.find((entry) => entry.name === name)
|
||||||
|
const status = item?.status ?? 'unknown'
|
||||||
|
return (
|
||||||
|
<div key={name} className={`system-item system-${status}`}>
|
||||||
|
<span className="system-dot" />
|
||||||
|
<span className="system-name">{name}</span>
|
||||||
|
<span className="system-state">
|
||||||
|
{status === 'up'
|
||||||
|
? 'Up'
|
||||||
|
: status === 'down'
|
||||||
|
? 'Down'
|
||||||
|
: status === 'degraded'
|
||||||
|
? 'Needs attention'
|
||||||
|
: status === 'not_configured'
|
||||||
|
? 'Not configured'
|
||||||
|
: 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-header">
|
||||||
|
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||||
|
{authReady && (
|
||||||
|
<label className="recent-filter">
|
||||||
|
<span>Show last</span>
|
||||||
|
<select
|
||||||
|
value={recentDays}
|
||||||
|
onChange={(event) => setRecentDays(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
<option value={30}>30 days</option>
|
||||||
|
<option value={60}>60 days</option>
|
||||||
|
<option value={90}>90 days</option>
|
||||||
|
<option value={180}>180 days</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="recent-grid">
|
||||||
|
{recentLoading ? (
|
||||||
|
<div className="loading-center">
|
||||||
|
<div className="spinner" aria-hidden="true" />
|
||||||
|
<span className="loading-text">Loading recent requests…</span>
|
||||||
|
</div>
|
||||||
|
) : recentError ? (
|
||||||
|
<button type="button" disabled>
|
||||||
|
{recentError}
|
||||||
|
</button>
|
||||||
|
) : recent.length === 0 ? (
|
||||||
|
<button type="button" disabled>
|
||||||
|
No recent requests found
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
recent.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/requests/${item.id}`)}
|
||||||
|
className="recent-card"
|
||||||
|
>
|
||||||
|
{item.artwork?.poster_url && (
|
||||||
|
<img
|
||||||
|
className="recent-poster"
|
||||||
|
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="recent-info">
|
||||||
|
<span className="recent-title">
|
||||||
|
{item.title || 'Untitled'}
|
||||||
|
{item.year ? ` (${item.year})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="recent-meta">
|
||||||
|
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
|
||||||
|
{item.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside className="side-panel">
|
||||||
|
<section className="main-panel find-panel">
|
||||||
|
<div className="find-header">
|
||||||
|
<h1>Find my request</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Search by title + year, paste a request number, or pick from your recent requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="find-controls">
|
||||||
|
<form onSubmit={submit} className="search search-row">
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="e.g. Dune 2021 or 1289"
|
||||||
|
/>
|
||||||
|
<button type="submit">Check status</button>
|
||||||
|
</form>
|
||||||
|
<div className="filters filters-compact">
|
||||||
|
<div className="filter">
|
||||||
|
<span>Type</span>
|
||||||
|
<div className="pill-group">
|
||||||
|
<button type="button">TV</button>
|
||||||
|
<button type="button">Movie</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filter">
|
||||||
|
<span>Status</span>
|
||||||
|
<div className="pill-group">
|
||||||
|
<button type="button">Pending</button>
|
||||||
|
<button type="button">Approved</button>
|
||||||
|
<button type="button">Processing</button>
|
||||||
|
<button type="button">Failed</button>
|
||||||
|
<button type="button">Available</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="recent results-panel">
|
||||||
|
<h2>Search results</h2>
|
||||||
|
<div className="recent-grid">
|
||||||
|
{searchError ? (
|
||||||
|
<button type="button" disabled>
|
||||||
|
{searchError}
|
||||||
|
</button>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<button type="button" disabled>
|
||||||
|
No matches yet
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
searchResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${item.title || 'Untitled'}-${index}`}
|
||||||
|
type="button"
|
||||||
|
disabled={!item.requestId}
|
||||||
|
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
||||||
|
>
|
||||||
|
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
|
||||||
|
{!item.requestId
|
||||||
|
? '- not requested'
|
||||||
|
: item.statusLabel
|
||||||
|
? `- ${item.statusLabel}`
|
||||||
|
: ''}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useRef, type ReactNode } from 'react'
|
|
||||||
|
|
||||||
export default function IssueFlowStep({
|
|
||||||
number, title, summary, active, complete, onEdit, children,
|
|
||||||
}: {
|
|
||||||
number: number
|
|
||||||
title: string
|
|
||||||
summary: string
|
|
||||||
active: boolean
|
|
||||||
complete: boolean
|
|
||||||
onEdit: () => void
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
const heading = useRef<HTMLHeadingElement>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active || number === 1) return
|
|
||||||
heading.current?.focus({ preventScroll: true })
|
|
||||||
heading.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
|
|
||||||
}, [active, number])
|
|
||||||
|
|
||||||
if (!active && !complete) return null
|
|
||||||
return (
|
|
||||||
<section className={`issue-procedure-step ${active ? 'is-current' : 'is-complete'}`} aria-label={title}>
|
|
||||||
{active ? (
|
|
||||||
<>
|
|
||||||
<div className="issue-flow-heading">
|
|
||||||
<span className="issue-step-number" aria-hidden="true">{String(number).padStart(2, '0')}</span>
|
|
||||||
<h2 ref={heading} tabIndex={-1}>{title}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="issue-procedure-content">{children}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button type="button" className="issue-step-summary" onClick={onEdit} aria-label={`Change ${title}: ${summary}`}>
|
|
||||||
<span className="issue-step-number" aria-hidden="true">✓</span>
|
|
||||||
<span className="issue-step-summary-copy"><small>{title}</small><strong>{summary}</strong></span>
|
|
||||||
<span className="issue-step-change">Change</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
|||||||
/* One expanded procedure at a time; completed steps become editable summaries. */
|
|
||||||
.issue-flow-progressive .issue-guided-form { padding: 0; border: 0; }
|
|
||||||
.issue-wizard-fields { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
|
||||||
.issue-procedure-step { min-width: 0; border-bottom: 1px solid var(--ops-line-soft); }
|
|
||||||
.issue-procedure-step:last-child { border-bottom: 0; }
|
|
||||||
.issue-procedure-step.is-current { padding: 16px 0 8px; }
|
|
||||||
.issue-procedure-step .issue-flow-heading { align-items: center; margin-bottom: 18px; }
|
|
||||||
.issue-procedure-step h2 { scroll-margin-top: 130px; }
|
|
||||||
.issue-procedure-content { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; animation: issue-step-enter 150ms ease-out; }
|
|
||||||
.issue-flow-progressive .issue-category-card { min-height: 122px; gap: 6px; padding: 14px; }
|
|
||||||
.issue-flow-progressive .issue-media-finder { padding: 0; border: 0; background: transparent; }
|
|
||||||
.page .issue-flow-progressive .issue-step-summary {
|
|
||||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
|
|
||||||
gap: 12px; width: 100%; padding: 10px 0; border: 0 !important;
|
|
||||||
background: transparent !important; text-align: left; color: var(--ops-text) !important;
|
|
||||||
box-shadow: none; text-transform: none;
|
|
||||||
}
|
|
||||||
.issue-step-summary .issue-step-number { width: 28px; height: 28px; color: var(--ops-primary-2); }
|
|
||||||
.issue-step-summary-copy { display: grid; gap: 3px; min-width: 0; }
|
|
||||||
.issue-step-summary-copy small { color: var(--ops-muted); font-size: 11px; font-weight: 500; }
|
|
||||||
.issue-step-summary-copy strong { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.issue-step-change { color: var(--ops-primary-2); font-size: 12px; }
|
|
||||||
.issue-step-summary:hover .issue-step-change { text-decoration: underline; }
|
|
||||||
.issue-procedure-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 10px; }
|
|
||||||
.issue-procedure-actions button { min-height: 44px; }
|
|
||||||
.page .issue-procedure-actions > button:not(.ghost-button) {
|
|
||||||
background: #c7bdff !important; border-color: #c7bdff !important; color: #1c172c !important;
|
|
||||||
}
|
|
||||||
.issue-procedure-actions button:disabled { opacity: .4; }
|
|
||||||
.issue-selection-count { margin-right: auto; color: var(--ops-muted); font-size: 12px; }
|
|
||||||
.issue-flow-progressive .issue-choice-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.issue-flow-progressive .issue-choice-row button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; min-height: 48px; }
|
|
||||||
.issue-device-check { display: grid; place-items: center; width: 22px; height: 22px; flex: 0 0 22px; border: 1px solid currentColor; border-radius: 6px; }
|
|
||||||
/* Legacy global button colours are !important; scoped overrides keep toggles visible. */
|
|
||||||
.page .issue-flow-progressive button[aria-pressed='true'] {
|
|
||||||
border-color: #c7bdff !important; background: #373147 !important; color: #f5f0ff !important;
|
|
||||||
box-shadow: inset 0 0 0 1px #c7bdff;
|
|
||||||
}
|
|
||||||
.issue-device-feedback { margin: 4px 0 0; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
|
|
||||||
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr) auto; padding: 0; border: 0; }
|
|
||||||
.issue-flow-progressive .issue-resolution-card h3 { margin: 0; font-size: 19px; line-height: 1.4; }
|
|
||||||
.issue-flow-progressive .issue-resolution-card p { margin-top: 8px; font-size: 13px; }
|
|
||||||
.issue-flow-progressive .status-banner { display: grid; gap: 10px; }
|
|
||||||
.issue-flow-progressive .status-banner button { justify-self: start; }
|
|
||||||
@keyframes issue-step-enter { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.issue-flow-progressive .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.issue-flow-progressive .issue-category-card { min-height: 112px; padding: 12px; }
|
|
||||||
.issue-flow-progressive .issue-category-card p { display: none; }
|
|
||||||
.issue-flow-progressive .issue-category-card strong { font-size: 13px; }
|
|
||||||
.issue-procedure-step .issue-flow-heading h2 { font-size: 19px; }
|
|
||||||
.issue-flow-progressive .issue-step-summary { gap: 8px; }
|
|
||||||
.issue-flow-progressive .issue-choice-row button { font-size: 12px; padding: 10px; }
|
|
||||||
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) { .issue-procedure-content { animation: none; } }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import PortalClient from '../PortalClient'
|
|
||||||
|
|
||||||
export default function IssuePortalPage() {
|
|
||||||
return <PortalClient workspace="issue" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function PortalIndexPage() {
|
|
||||||
redirect('/new-requests')
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function RequestPortalPage() {
|
|
||||||
redirect('/new-requests')
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import InviteDeliveryChoice from '../../ui/InviteDeliveryChoice'
|
|
||||||
|
|
||||||
import PageHeading from '../../ui/PageHeading'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
|
|
||||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
|
|
||||||
type OwnedInvite = {
|
|
||||||
id: number; code: string; label?: string | null; description?: string | null
|
|
||||||
recipient_email?: string | null; max_uses?: number | null; use_count: number
|
|
||||||
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
|
|
||||||
is_usable?: boolean; created_at?: string | null
|
|
||||||
}
|
|
||||||
type OwnedInvitesResponse = {
|
|
||||||
invites?: OwnedInvite[]
|
|
||||||
invite_access?: { enabled?: boolean; managed_by_master?: boolean }
|
|
||||||
master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null
|
|
||||||
}
|
|
||||||
type InviteForm = {
|
|
||||||
code: string; label: string; description: string; recipient_email: string
|
|
||||||
enabled: boolean; message: string
|
|
||||||
}
|
|
||||||
type DeliveryMethod = '' | 'manual' | 'email'
|
|
||||||
|
|
||||||
const defaultInviteForm = (): InviteForm => ({
|
|
||||||
code: '', label: '', description: '', recipient_email: '', enabled: true, message: '',
|
|
||||||
})
|
|
||||||
const formatDate = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
|
||||||
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString()
|
|
||||||
}
|
|
||||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
|
||||||
|
|
||||||
export default function ProfileInvitesPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
|
||||||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
|
||||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
|
||||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
|
||||||
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse['master_invite']>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
|
||||||
const [flowStep, setFlowStep] = useState(1)
|
|
||||||
const [useCustomCode, setUseCustomCode] = useState(false)
|
|
||||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>('')
|
|
||||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
|
|
||||||
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null)
|
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
|
||||||
if (typeof window === 'undefined') return '/signup'
|
|
||||||
return `${window.location.origin}/signup`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadInvites = async () => {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Could not load your invite workspace.')
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as OwnedInvitesResponse
|
|
||||||
setInvites(Array.isArray(data.invites) ? data.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(data.invite_access?.enabled))
|
|
||||||
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master))
|
|
||||||
setMasterInvite(data.master_invite ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`)
|
|
||||||
if (!profileResponse.ok) {
|
|
||||||
if (profileResponse.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Could not load your profile.')
|
|
||||||
}
|
|
||||||
const profileData = await profileResponse.json()
|
|
||||||
setProfile(profileData?.user ?? null)
|
|
||||||
await loadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const resetFlow = () => {
|
|
||||||
setEditingId(null)
|
|
||||||
setFlowStep(1)
|
|
||||||
setUseCustomCode(false)
|
|
||||||
setDeliveryMethod('')
|
|
||||||
setInviteForm(defaultInviteForm())
|
|
||||||
}
|
|
||||||
|
|
||||||
const editInvite = (invite: OwnedInvite) => {
|
|
||||||
setEditingId(invite.id)
|
|
||||||
setCreatedInvite(null)
|
|
||||||
setFlowStep(4)
|
|
||||||
setUseCustomCode(true)
|
|
||||||
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
|
|
||||||
setInviteForm({
|
|
||||||
code: invite.code,
|
|
||||||
label: invite.label ?? '',
|
|
||||||
description: invite.description ?? '',
|
|
||||||
recipient_email: invite.recipient_email ?? '',
|
|
||||||
enabled: invite.enabled !== false,
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const inviteName = inviteForm.label.trim()
|
|
||||||
const recipientEmail = inviteForm.recipient_email.trim()
|
|
||||||
if (!inviteName) {
|
|
||||||
setError('Give this invite a name so you can recognise it later.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!deliveryMethod) {
|
|
||||||
setError('Choose how you want to deliver the invite.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
|
|
||||||
setError('Enter a valid recipient email address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(
|
|
||||||
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`,
|
|
||||||
{
|
|
||||||
method: editingId == null ? 'POST' : 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: useCustomCode ? inviteForm.code || null : null,
|
|
||||||
label: inviteName,
|
|
||||||
description: inviteForm.description || null,
|
|
||||||
recipient_email: deliveryMethod === 'email' ? recipientEmail : null,
|
|
||||||
enabled: inviteForm.enabled,
|
|
||||||
send_email: editingId == null && deliveryMethod === 'email',
|
|
||||||
message: inviteForm.message || null,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error((await response.text()) || 'Could not save the invite.')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const savedInvite = data?.invite as OwnedInvite | undefined
|
|
||||||
setStatus(
|
|
||||||
data?.email?.status === 'ok'
|
|
||||||
? `Invite created and emailed to ${data.email.recipient_email}.`
|
|
||||||
: data?.email?.status === 'error'
|
|
||||||
? `Invite created, but the email could not be sent: ${data.email.detail}`
|
|
||||||
: editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.'
|
|
||||||
)
|
|
||||||
resetFlow()
|
|
||||||
if (editingId == null && savedInvite) setCreatedInvite(savedInvite)
|
|
||||||
await loadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not save the invite.')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteInvite = async (invite: OwnedInvite) => {
|
|
||||||
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' })
|
|
||||||
if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.')
|
|
||||||
if (editingId === invite.id) resetFlow()
|
|
||||||
setStatus(`Deleted ${invite.label || invite.code}.`)
|
|
||||||
await loadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not delete the invite.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
|
||||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url)
|
|
||||||
setStatus(`Copied the link for ${invite.label || invite.code}.`)
|
|
||||||
} catch {
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
|
|
||||||
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6))
|
|
||||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
|
||||||
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : ''
|
|
||||||
|
|
||||||
if (loading) return <main className="card">Loading invite workspace…</main>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="card invites-page">
|
|
||||||
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
{status && <div className="status-banner">{status}</div>}
|
|
||||||
|
|
||||||
{!canManageInvites ? (
|
|
||||||
<section className="profile-section profile-tab-panel">
|
|
||||||
<h2>Invites are not enabled for your account</h2>
|
|
||||||
<p className="lede">Ask an administrator if you need permission to invite someone.</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
|
||||||
<div className="invite-flow-heading">
|
|
||||||
<div><span className="eyebrow">Invite flow</span><h2>{editingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2><p className="lede">Set up the invite one decision at a time.</p></div>
|
|
||||||
{editingId != null && <button type="button" className="ghost-button" onClick={resetFlow}>Cancel edit</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createdInvite && editingId == null ? (
|
|
||||||
<div className="invite-created-card" role="status">
|
|
||||||
<span className="eyebrow">Invite ready</span><h3>{createdInvite.label || 'Your invite'}</h3>
|
|
||||||
<p>{createdInvite.recipient_email ? `The invite was emailed to ${createdInvite.recipient_email}.` : 'Copy this link and send it to the person you are inviting.'}</p>
|
|
||||||
<div className="invite-created-link"><input value={createdInviteUrl} readOnly aria-label="Created invite link" /><button type="button" onClick={() => void copyInviteLink(createdInvite)}>Copy link</button></div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => { setCreatedInvite(null); resetFlow() }}>Create another invite</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={saveInvite} className="invite-flow-form">
|
|
||||||
<ol className="invite-flow-route" aria-label="Invite creation progress">
|
|
||||||
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
|
|
||||||
const step = index + 1
|
|
||||||
return <li key={label} className={step === flowStep ? 'is-active' : step < flowStep ? 'is-complete' : ''}><span>{String(step).padStart(2, '0')}</span><strong>{label}</strong></li>
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<section className={`invite-flow-step ${flowStep > 1 ? 'is-complete' : 'is-active'}`}>
|
|
||||||
<header><span className="invite-flow-number">01</span><div><span className="eyebrow">Identity</span><h3>Who is this invite for?</h3><p>Give it a name that will make sense when you return later.</p></div></header>
|
|
||||||
<div className="invite-flow-fields">
|
|
||||||
<label><span>Invite name</span><input value={inviteForm.label} onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))} placeholder="Family, that guy from work, the neighbour" /></label>
|
|
||||||
<label className="invite-flow-choice-line"><input type="checkbox" checked={useCustomCode} disabled={editingId != null} onChange={(event) => { setUseCustomCode(event.target.checked); if (!event.target.checked) setInviteForm((current) => ({ ...current, code: '' })) }} /><span><strong>Choose a custom invite code</strong><small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small></span></label>
|
|
||||||
{useCustomCode && <label><span>Custom code</span><input value={inviteForm.code} disabled={editingId != null} onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))} placeholder="At least 6 letters or numbers" /><small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small></label>}
|
|
||||||
{flowStep === 1 && <div className="invite-flow-actions"><button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>Continue to description</button></div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{flowStep >= 2 && <section className={`invite-flow-step ${flowStep > 2 ? 'is-complete' : 'is-active'}`}>
|
|
||||||
<header><span className="invite-flow-number">02</span><div><span className="eyebrow">Description</span><h3>Add a welcome note</h3><p>This optional message is shown on the sign-up page.</p></div></header>
|
|
||||||
<div className="invite-flow-fields"><label><span>Welcome note (optional)</span><textarea rows={3} value={inviteForm.description} onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))} placeholder="Welcome to Grizzlyflix. Use this link to create your account." /></label>{flowStep === 2 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(1)}>Back</button><button type="button" className="ghost-button" onClick={() => { setInviteForm((current) => ({ ...current, description: '' })); setFlowStep(3) }}>Skip</button><button type="button" onClick={() => setFlowStep(3)}>Continue</button></div>}</div>
|
|
||||||
</section>}
|
|
||||||
|
|
||||||
{flowStep >= 3 && <section className={`invite-flow-step ${flowStep > 3 ? 'is-complete' : 'is-active'}`}>
|
|
||||||
<header><span className="invite-flow-number">03</span><div><span className="eyebrow">Access</span><h3>Account access is applied automatically</h3><p>Magent uses the safe invite policy configured by an administrator.</p></div></header>
|
|
||||||
<div className="invite-flow-fields"><div className="invite-policy-note"><strong>Standard user access</strong><span>{inviteManagedByMaster && masterInvite ? `Using the “${masterInvite.label || masterInvite.code}” invite policy. Usage and expiry limits are automatic.` : 'This invite creates a standard user account using your configured defaults.'}</span></div>{flowStep === 3 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(2)}>Back</button><button type="button" onClick={() => setFlowStep(4)}>Continue to delivery</button></div>}</div>
|
|
||||||
</section>}
|
|
||||||
|
|
||||||
{flowStep >= 4 && <section className="invite-flow-step is-active">
|
|
||||||
<header><span className="invite-flow-number">04</span><div><span className="eyebrow">Delivery</span><h3>How will they receive it?</h3><p>Copy the link yourself, or let Magent email it directly.</p></div></header>
|
|
||||||
<div className="invite-flow-fields">
|
|
||||||
<InviteDeliveryChoice value={deliveryMethod} onChange={(method) => { setDeliveryMethod(method); if (method === 'manual') setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }} />
|
|
||||||
{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 invite-delivery-fields"><label><span>Recipient email</span><input type="email" value={inviteForm.recipient_email} onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))} placeholder="person@example.com" /></label><label><span>Email note (optional)</span><textarea rows={3} value={inviteForm.message} onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))} placeholder="A short personal message" /></label></div>}
|
|
||||||
{editingId != null && <label className="invite-status-control"><input type="checkbox" checked={inviteForm.enabled} onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))} /><span><strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong><small>Disable this existing invite to stop its link from accepting sign-ups.</small></span></label>}
|
|
||||||
<div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(3)}>Back</button><button type="submit" disabled={saving || !deliveryMethod || (deliveryMethod === 'email' && !isValidEmail(inviteForm.recipient_email))}>{saving ? 'Saving…' : editingId != null ? 'Save invite' : deliveryMethod === 'email' ? 'Create and email invite' : 'Create invite link'}</button></div>
|
|
||||||
</div>
|
|
||||||
</section>}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="profile-invites-list">
|
|
||||||
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
|
|
||||||
{invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+94
-202
@@ -1,234 +1,126 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
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
|
||||||
password_change_supported?: boolean
|
|
||||||
password_provider?: 'local' | 'jellyfin' | null
|
|
||||||
}
|
|
||||||
type ActivityEntry = {
|
|
||||||
ip: string
|
|
||||||
user_agent: string
|
|
||||||
first_seen_at: string
|
|
||||||
last_seen_at: string
|
|
||||||
}
|
|
||||||
type ProfileResponse = {
|
|
||||||
user: ProfileInfo
|
|
||||||
stats?: { total: number; ready: number; in_progress: number }
|
|
||||||
activity?: { recent: ActivityEntry[] }
|
|
||||||
}
|
|
||||||
type Notice = { tone: 'status' | 'error'; message: string } | null
|
|
||||||
type ProfileTab = 'overview' | 'security' | 'activity'
|
|
||||||
const TABS: { key: ProfileTab; label: string }[] = [
|
|
||||||
{ key: 'overview', label: 'Account' },
|
|
||||||
{ key: 'security', label: 'Security' },
|
|
||||||
{ key: 'activity', label: 'Activity' },
|
|
||||||
]
|
|
||||||
const normalizeTab = (value: string | null): ProfileTab => value === 'security' || value === 'activity' ? value : 'overview'
|
|
||||||
const formatDate = (value?: string) => {
|
|
||||||
if (!value) return 'Not recorded'
|
|
||||||
const date = new Date(value)
|
|
||||||
return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
|
||||||
}
|
|
||||||
const deviceName = (agent: string) => {
|
|
||||||
const value = (agent || '').toLowerCase()
|
|
||||||
const browser = value.includes('edg/') ? 'Edge' : value.includes('firefox/') || value.includes('fxios/') ? 'Firefox'
|
|
||||||
: value.includes('chrome/') || value.includes('crios/') ? 'Chrome' : value.includes('safari/') ? 'Safari' : 'Browser'
|
|
||||||
const device = /iphone|ipad/.test(value) ? 'iOS' : value.includes('android') ? 'Android'
|
|
||||||
: value.includes('windows') ? 'Windows' : value.includes('macintosh') ? 'Mac' : value.includes('linux') ? 'Linux' : ''
|
|
||||||
return device ? `${browser} on ${device}` : browser
|
|
||||||
}
|
|
||||||
const responseMessage = async (response: Response, fallback: string) => {
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
return typeof data?.detail === 'string' && data.detail.trim() ? data.detail : fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [data, setData] = useState<ProfileResponse | null>(null)
|
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [loadError, setLoadError] = useState('')
|
|
||||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [emailSaving, setEmailSaving] = useState(false)
|
|
||||||
const [emailNotice, setEmailNotice] = useState<Notice>(null)
|
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
const [loading, setLoading] = useState(true)
|
||||||
const [passwordNotice, setPasswordNotice] = useState<Notice>(null)
|
|
||||||
const [showAllActivity, setShowAllActivity] = useState(false)
|
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (!getToken()) { router.replace('/login'); return }
|
if (!getToken()) {
|
||||||
setLoading(true)
|
router.push('/login')
|
||||||
setLoadError('')
|
return
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile`)
|
|
||||||
if (response.status === 401) { clearToken(); router.replace('/login'); return }
|
|
||||||
if (!response.ok) throw new Error('Could not load your profile. Please try again.')
|
|
||||||
const profile = await response.json() as ProfileResponse
|
|
||||||
setData(profile)
|
|
||||||
setEmail(profile.user.email ?? '')
|
|
||||||
} catch {
|
|
||||||
setLoadError('Could not load your profile. Please try again.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
|
if (!response.ok) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setProfile({
|
||||||
|
username: data?.username ?? 'Unknown',
|
||||||
|
role: data?.role ?? 'user',
|
||||||
|
auth_provider: data?.auth_provider ?? 'local',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setStatus('Could not load your profile.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
useEffect(() => { void loadProfile() }, [loadProfile])
|
const submit = async (event: React.FormEvent) => {
|
||||||
useEffect(() => {
|
|
||||||
const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get('tab')))
|
|
||||||
syncTab()
|
|
||||||
window.addEventListener('popstate', syncTab)
|
|
||||||
return () => window.removeEventListener('popstate', syncTab)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const selectTab = (tab: ProfileTab) => {
|
|
||||||
setActiveTab(tab)
|
|
||||||
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`, { scroll: false })
|
|
||||||
}
|
|
||||||
const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
||||||
let next = index
|
|
||||||
if (event.key === 'ArrowRight') next = (index + 1) % TABS.length
|
|
||||||
else if (event.key === 'ArrowLeft') next = (index + TABS.length - 1) % TABS.length
|
|
||||||
else if (event.key === 'Home') next = 0
|
|
||||||
else if (event.key === 'End') next = TABS.length - 1
|
|
||||||
else return
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
selectTab(TABS[next].key)
|
setStatus(null)
|
||||||
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus()
|
if (!currentPassword || !newPassword) {
|
||||||
}
|
setStatus('Enter your current password and a new password.')
|
||||||
|
|
||||||
const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (emailSaving) return
|
|
||||||
setEmailSaving(true)
|
|
||||||
setEmailNotice(null)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: email.trim() || null }),
|
|
||||||
})
|
|
||||||
if (response.status === 401) { clearToken(); router.replace('/login'); return }
|
|
||||||
if (!response.ok) throw new Error(await responseMessage(response, 'Could not save your email. Please try again.'))
|
|
||||||
const result = await response.json()
|
|
||||||
const saved = typeof result.email === 'string' ? result.email : ''
|
|
||||||
setData((current) => current ? { ...current, user: { ...current.user, email: saved || null } } : current)
|
|
||||||
setEmail(saved)
|
|
||||||
setEmailNotice({ tone: 'status', message: saved ? 'Email saved.' : 'Email removed.' })
|
|
||||||
} catch (error) {
|
|
||||||
setEmailNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not save your email.' })
|
|
||||||
} finally { setEmailSaving(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const savePassword = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (passwordSaving) return
|
|
||||||
setPasswordNotice(null)
|
|
||||||
if (newPassword.trim().length < 8) {
|
|
||||||
setPasswordNotice({ tone: 'error', message: 'Use at least 8 characters for your new password.' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setPasswordNotice({ tone: 'error', message: 'The new passwords do not match.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPasswordSaving(true)
|
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${getApiBase()}/auth/password`, {
|
const baseUrl = getApiBase()
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
const response = await authFetch(`${baseUrl}/auth/password`, {
|
||||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(await responseMessage(response, 'Could not change your password. Please try again.'))
|
if (!response.ok) {
|
||||||
const result = await response.json()
|
const text = await response.text()
|
||||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
throw new Error(text || 'Update failed')
|
||||||
setPasswordNotice({ tone: 'status', message: result.provider === 'jellyfin'
|
}
|
||||||
? 'Password updated for Grizzlyflix and Magent. Seerr uses the same password.'
|
setCurrentPassword('')
|
||||||
: 'Password updated.' })
|
setNewPassword('')
|
||||||
} catch (error) {
|
setStatus('Password updated.')
|
||||||
setPasswordNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not change your password.' })
|
} catch (err) {
|
||||||
} finally { setPasswordSaving(false) }
|
console.error(err)
|
||||||
|
setStatus('Could not update password. Check your current password.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = data?.user
|
if (loading) {
|
||||||
const passwordProvider = user?.password_provider ?? (user?.auth_provider === 'jellyfin' ? 'jellyfin' : 'local')
|
return <main className="card">Loading profile...</main>
|
||||||
const canChangePassword = user?.password_change_supported ?? ['local', 'jellyfin'].includes(user?.auth_provider ?? '')
|
}
|
||||||
const emailChanged = email.trim() !== (user?.email ?? '')
|
|
||||||
const recent = data?.activity?.recent ?? []
|
|
||||||
const notice = (value: Notice) => value && <p className={`account-notice is-${value.tone}`} role={value.tone === 'error' ? 'alert' : 'status'}>{value.message}</p>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="account-page">
|
<main className="card">
|
||||||
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={
|
<h1>My profile</h1>
|
||||||
user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>
|
{profile && (
|
||||||
} />
|
<div className="status-banner">
|
||||||
|
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
||||||
{loading ? <p className="account-empty" role="status">Loading your profile…</p> : loadError ? (
|
{profile.auth_provider}.
|
||||||
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
|
|
||||||
) : user && <>
|
|
||||||
<div className="account-tabs" role="tablist" aria-label="Profile sections">
|
|
||||||
{TABS.map((tab, index) => <button key={tab.key} id={`profile-tab-${tab.key}`} type="button" role="tab"
|
|
||||||
aria-selected={activeTab === tab.key} aria-controls={`profile-panel-${tab.key}`} tabIndex={activeTab === tab.key ? 0 : -1}
|
|
||||||
onKeyDown={(event) => tabKeyDown(event, index)} onClick={() => selectTab(tab.key)}>{tab.label}</button>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<section className="account-panel" id="profile-panel-overview" role="tabpanel" aria-labelledby="profile-tab-overview" hidden={activeTab !== 'overview'}>
|
{profile?.auth_provider !== 'local' ? (
|
||||||
<div className="account-section-intro"><h2>Contact email</h2><p>For password recovery and updates on your reported issues.</p></div>
|
<div className="status-banner">
|
||||||
<form className="account-form" onSubmit={saveEmail}>
|
Password changes are only available for local Magent accounts.
|
||||||
<label htmlFor="profile-email">Email address</label>
|
</div>
|
||||||
<input id="profile-email" name="email" type="email" autoComplete="email" placeholder="you@example.com" value={email} disabled={emailSaving}
|
) : (
|
||||||
onChange={(event) => { setEmail(event.target.value); setEmailNotice(null) }} />
|
<form onSubmit={submit} className="auth-form">
|
||||||
{!user.email && <p className="account-hint">Add an email so we can let you know when a fix is ready.</p>}
|
<label>
|
||||||
{user.email && !email.trim() && <p className="account-hint">Saving without an email stops account and issue emails.</p>}
|
Current password
|
||||||
{notice(emailNotice)}
|
<input
|
||||||
<div className="account-form-actions">
|
type="password"
|
||||||
<button type="submit" className="account-primary" disabled={emailSaving || !emailChanged}>{emailSaving ? 'Saving…' : 'Save email'}</button>
|
value={currentPassword}
|
||||||
{emailChanged && <button type="button" className="account-secondary" disabled={emailSaving} onClick={() => { setEmail(user.email ?? ''); setEmailNotice(null) }}>Discard</button>}
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
</div>
|
autoComplete="current-password"
|
||||||
</form>
|
/>
|
||||||
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
|
</label>
|
||||||
</section>
|
<label>
|
||||||
|
New password
|
||||||
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
|
<input
|
||||||
<div className="account-section-intro"><h2>Change password</h2><p>{passwordProvider === 'jellyfin' ? 'One password for Grizzlyflix, Seerr and Magent.' : 'Keep your Magent account secure.'}</p></div>
|
type="password"
|
||||||
{canChangePassword ? <form className="account-form" onSubmit={savePassword}>
|
value={newPassword}
|
||||||
<fieldset disabled={passwordSaving}>
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
<label htmlFor="profile-current-password">Current password</label>
|
autoComplete="new-password"
|
||||||
<input id="profile-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required />
|
/>
|
||||||
<label htmlFor="profile-new-password">New password</label>
|
</label>
|
||||||
<input id="profile-new-password" type="password" autoComplete="new-password" aria-describedby="password-length" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={8} required />
|
{status && <div className="status-banner">{status}</div>}
|
||||||
<p id="password-length" className="account-hint">At least 8 characters.</p>
|
<div className="auth-actions">
|
||||||
<label htmlFor="profile-confirm-password">Confirm new password</label>
|
<button type="submit">Update password</button>
|
||||||
<input id="profile-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} minLength={8} required />
|
</div>
|
||||||
</fieldset>
|
</form>
|
||||||
{notice(passwordNotice)}
|
)}
|
||||||
<div className="account-form-actions"><button type="submit" className="account-primary" disabled={passwordSaving}>{passwordSaving ? 'Updating…' : 'Update password'}</button></div>
|
|
||||||
</form> : <p className="account-empty">Password changes are managed by your sign-in provider. Contact an administrator for help.</p>}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="account-panel" id="profile-panel-activity" role="tabpanel" aria-labelledby="profile-tab-activity" hidden={activeTab !== 'activity'}>
|
|
||||||
<div className="account-section-intro"><h2>Your activity</h2><p>Your requests and recent account access.</p></div>
|
|
||||||
{data?.stats && <div className="account-request-summary"><div><strong>{data.stats.total}</strong><span>Requests</span></div><div><strong>{data.stats.ready}</strong><span>Ready to watch</span></div><a href="/">View my requests <span aria-hidden="true">↗</span></a></div>}
|
|
||||||
<h3 className="account-list-heading">Recent account access</h3>
|
|
||||||
{recent.length ? <ul className="account-access-list">
|
|
||||||
{(showAllActivity ? recent : recent.slice(0, 5)).map((entry, index) => <li key={`${entry.ip}-${entry.last_seen_at}-${index}`}>
|
|
||||||
<div className="account-access-summary"><strong>{deviceName(entry.user_agent)}</strong><time dateTime={entry.last_seen_at}>{formatDate(entry.last_seen_at)}</time></div>
|
|
||||||
<details><summary>Connection details</summary><dl><div><dt>IP address</dt><dd>{entry.ip || 'Not recorded'}</dd></div><div><dt>First seen</dt><dd>{formatDate(entry.first_seen_at)}</dd></div></dl></details>
|
|
||||||
</li>)}
|
|
||||||
</ul> : <p className="account-empty">No recent activity yet.</p>}
|
|
||||||
{recent.length > 5 && <button className="account-secondary" type="button" onClick={() => setShowAllActivity(!showAllActivity)}>{showAllActivity ? 'Show less' : 'Show all activity'}</button>}
|
|
||||||
</section>
|
|
||||||
</>}
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import './latest-activity.css'
|
|
||||||
|
|
||||||
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
|
|
||||||
type Operation = { id: string; label: string; status: string; events: Event[] }
|
|
||||||
|
|
||||||
export default function LatestActivity({ operation, besideDownload, onDismiss }: {
|
|
||||||
operation: Operation; besideDownload: boolean; onDismiss: () => void
|
|
||||||
}) {
|
|
||||||
const dialog = useRef<HTMLDialogElement>(null)
|
|
||||||
const trigger = useRef<HTMLButtonElement>(null)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const latest = [...operation.events].sort((a, b) =>
|
|
||||||
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
|
|
||||||
).at(-1)
|
|
||||||
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const element = dialog.current
|
|
||||||
element?.showModal()
|
|
||||||
const previous = document.body.style.overflow
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
return () => {
|
|
||||||
element?.close()
|
|
||||||
document.body.style.overflow = previous
|
|
||||||
trigger.current?.focus()
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`request-overview-block latest-activity ${besideDownload ? 'beside-download' : 'full-row'}`}>
|
|
||||||
<button ref={trigger} type="button" className="latest-activity-trigger" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}>
|
|
||||||
<span className="latest-activity-heading"><span className="request-overview-label">Latest activity</span><span className={`latest-activity-badge is-${operation.status}`}>{status}</span></span>
|
|
||||||
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
|
|
||||||
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true">↗</span></span>
|
|
||||||
</button>
|
|
||||||
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
|
|
||||||
<div className="activity-dialog-content">
|
|
||||||
<header>
|
|
||||||
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
|
|
||||||
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
|
|
||||||
</header>
|
|
||||||
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
|
|
||||||
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
|
|
||||||
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
|
|
||||||
<div><strong>{event.service}</strong><p>{event.message}</p></div>
|
|
||||||
</li>)}
|
|
||||||
</ol>
|
|
||||||
{operation.status !== 'running' && <footer><button type="button" onClick={() => { setOpen(false); onDismiss() }}>Dismiss activity</button></footer>}
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
|
|
||||||
.latest-activity.full-row { grid-column: 1 / -1; }
|
|
||||||
.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
|
|
||||||
.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
|
|
||||||
.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
|
||||||
.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
|
|
||||||
.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
|
|
||||||
.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
|
|
||||||
.latest-activity-badge.is-error { color: #ff9b9b; }
|
|
||||||
.latest-activity-badge.is-complete { color: #55dec0; }
|
|
||||||
.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
|
|
||||||
.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
|
|
||||||
.activity-dialog-content { padding: 1.25rem; }
|
|
||||||
.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
|
||||||
.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
|
|
||||||
.activity-dialog small { color: var(--ops-muted, #bbb); }
|
|
||||||
.activity-dialog-events { list-style: none; padding: 0; margin: 1.25rem 0 0; display: grid; gap: .65rem; }
|
|
||||||
.activity-dialog-events li { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: .8rem; padding: .9rem; border: 1px solid var(--ops-border, #444); border-radius: 10px; }
|
|
||||||
.activity-dialog-events strong { font-size: .8rem; }
|
|
||||||
.activity-dialog-events p { margin: .3rem 0 0; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; }
|
|
||||||
.activity-event-state { font-size: .7rem; color: #55dec0; }
|
|
||||||
.is-error > .activity-event-state { color: #ff9b9b; }
|
|
||||||
.is-active > .activity-event-state { color: #83d7f7; }
|
|
||||||
.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
|
|
||||||
.activity-dialog-events li { grid-template-columns: 1fr; gap: .4rem; }
|
|
||||||
}
|
|
||||||
+559
-930
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user