Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f830fc1296 | ||
|
|
3989e90a9a | ||
|
|
4e2b902760 | ||
|
|
494b79ed26 | ||
|
|
d30a2473ce | ||
|
|
4e64f79e64 | ||
|
|
c6bc31f27e | ||
|
|
1ad4823830 | ||
|
|
caa6aa76d6 | ||
|
|
d80b1e5e4f | ||
|
|
1ff54690fc | ||
|
|
4f2b5e0922 | ||
|
|
96333c0d85 | ||
|
|
bac96c7db3 | ||
|
|
dda17a20a5 | ||
|
|
e582ff4ef7 | ||
|
|
42d4caa474 | ||
|
|
5f2dc52771 | ||
|
|
9c69d9fd17 | ||
|
|
b0ef455498 | ||
|
|
821f518bb3 | ||
|
|
eeba143b41 | ||
|
|
b068a6066e | ||
|
|
aae2c3d418 | ||
|
|
d1c9acbb8d | ||
|
|
12d3777e76 | ||
|
|
c205df4367 | ||
|
|
05a3d1e3b0 | ||
|
|
b84c27c698 | ||
|
|
744b213fa0 | ||
|
|
f362676c4e | ||
|
|
7257d32d6c | ||
|
|
1c6b8255c1 | ||
|
|
0b73d9f4ee | ||
|
|
b215e8030c | ||
|
|
6a5d2c4310 | ||
|
|
23c57da3cc | ||
|
|
1b1a3e233b | ||
|
|
bd3c0bdade | ||
|
|
50be0b6b57 | ||
|
|
5dfe614d15 | ||
|
|
ec408df2a1 | ||
|
|
f78382c019 | ||
|
|
9be0ec75ec | ||
|
|
be7b899837 | ||
|
|
d045dd0b07 | ||
|
|
138069590b | ||
|
|
8125b766c7 | ||
|
|
d53e2917aa | ||
|
|
d7847652db | ||
|
|
24ac54d606 | ||
|
|
62f392ad37 | ||
|
|
e42ae8585d | ||
|
|
06e0797722 | ||
|
|
914f478178 | ||
|
|
fb65d646f2 | ||
|
|
3493bf715e | ||
|
|
b98239ab3e | ||
|
|
40dc46c0c5 | ||
|
|
d23d84ea42 | ||
|
|
7d6cdcbe02 | ||
|
|
0e95f94025 | ||
|
|
8b1a09fbd4 | ||
|
|
fe0c108363 | ||
|
|
9e8d22ba85 | ||
|
|
7863658a19 | ||
|
|
7c97934bb9 | ||
|
|
3f51e24181 | ||
|
|
ab27ebfadf | ||
|
|
b93b41713a | ||
|
|
ceb8c1c9eb | ||
|
|
86ca3bdeb2 | ||
|
|
22f90b7e07 | ||
|
|
57a4883931 | ||
|
|
6ba41b854b | ||
|
|
580b335268 | ||
|
|
23549f1e45 | ||
|
|
2c45dd0065 | ||
|
|
92959d80ab | ||
|
|
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
-1
@@ -1 +1 @@
|
||||
0803262237
|
||||
0803262216
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.venv/
|
||||
**/.pytest_cache/
|
||||
stitch_magent_media_operations_redesign/
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
bootstrap-admin.json
|
||||
release.tar
|
||||
*.log
|
||||
data/*
|
||||
!data/branding/
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
name: Magent CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
- main
|
||||
- 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
|
||||
@@ -1,5 +1,4 @@
|
||||
.env
|
||||
bootstrap-admin.json
|
||||
.venv/
|
||||
data/
|
||||
!data/branding/
|
||||
@@ -11,10 +10,3 @@ backend/.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
*.log
|
||||
**/.pytest_cache/
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Production
|
||||
|
||||
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
|
||||
The root `Dockerfile` is the supported build entry point. Source releases come
|
||||
from `main`; use `prod-<short-commit>` tags to identify an exact release.
|
||||
|
||||
## Live deployment
|
||||
|
||||
- Host: GRZ-DKR01 (`10.30.1.81`).
|
||||
- Container and Compose service: `magent`; Compose project: `arrstack`.
|
||||
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
|
||||
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data` → `/app/data`.
|
||||
- Public URL: `https://magent.grizzlyflix.co.nz`.
|
||||
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
|
||||
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. Run the backend tests and frontend production build. Review only the intended
|
||||
changes, then commit and push `main`.
|
||||
2. Build from a clean source export using the root Dockerfile. Never include
|
||||
`.env`, databases or bootstrap credentials in the build context.
|
||||
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
|
||||
Confirm their digests match.
|
||||
4. Pull the new image before stopping production. Keep the old image under a
|
||||
rollback tag and back up the current Compose configuration.
|
||||
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
|
||||
and its WAL files are consistent. Protect backups: they contain private data.
|
||||
6. Recreate only this service with `docker compose -p arrstack -f
|
||||
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
|
||||
Confirm that Compose selects the intended image before running this command.
|
||||
7. Check container health, the API `/health` endpoint, public login, the changed
|
||||
feature, database integrity and account counts. Do not trigger bulk permission
|
||||
changes, email sends or user imports as a deployment smoke test.
|
||||
|
||||
For rollback, select the saved image and recreate only Magent. Restore data only
|
||||
if needed; doing so can discard activity since the backup. Never restore a whole
|
||||
shared Compose or Caddy file without checking for unrelated changes first.
|
||||
|
||||
## Build metadata
|
||||
|
||||
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
|
||||
display build number as the frontend package files. `.env` should have exactly
|
||||
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
|
||||
tags identify the deployed source commit independently of this display value.
|
||||
|
||||
`scripts/process1.ps1` is a local development workflow: it updates metadata,
|
||||
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
|
||||
It is **not** the production deployment command. Its build-number helper can be
|
||||
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
|
||||
|
||||
## Fresh instances and historical notes
|
||||
|
||||
`scripts/prepare_production_settings.py` exports only allowlisted connection and
|
||||
SMTP settings for a fresh instance. Do not use it to replace a live database.
|
||||
`docker-compose.production.yml` is the separate fresh-instance template, not the
|
||||
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
|
||||
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
|
||||
|
||||
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
|
||||
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
|
||||
@@ -23,7 +23,6 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
|
||||
- Local database for speed and audit history.
|
||||
- Users and access control (admin vs user, block access).
|
||||
- Local account password changes via "My profile".
|
||||
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
|
||||
- Docker-first deployment for easy hosting.
|
||||
|
||||
## Quick start (Docker - primary)
|
||||
@@ -65,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
||||
QBIT_USERNAME="..."
|
||||
QBIT_PASSWORD="..."
|
||||
SQLITE_PATH="data/magent.db"
|
||||
JWT_SECRET="replace-with-a-long-random-secret"
|
||||
JWT_SECRET="change-me"
|
||||
JWT_EXP_MINUTES="720"
|
||||
ADMIN_USERNAME="set-a-real-admin-username"
|
||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
ADMIN_USERNAME="admin"
|
||||
ADMIN_PASSWORD="adminadmin"
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
@@ -113,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
|
||||
$env:QBIT_USERNAME="..."
|
||||
$env:QBIT_PASSWORD="..."
|
||||
$env:SQLITE_PATH="data/magent.db"
|
||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
||||
$env:JWT_SECRET="change-me"
|
||||
$env:JWT_EXP_MINUTES="720"
|
||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
$env:ADMIN_USERNAME="admin"
|
||||
$env:ADMIN_PASSWORD="adminadmin"
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
@@ -142,26 +141,6 @@ The frontend proxies `/api/*` to the backend container. Set:
|
||||
|
||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||
|
||||
## Gitea CI/CD
|
||||
|
||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
||||
|
||||
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
|
||||
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
|
||||
|
||||
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
||||
|
||||
- `http://127.0.0.1:8000/health`
|
||||
- `http://127.0.0.1:3000/login`
|
||||
|
||||
Configure these Gitea Actions secrets before enabling the deploy job:
|
||||
|
||||
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
|
||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
||||
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
||||
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
||||
|
||||
## History endpoints
|
||||
|
||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY data/branding /app/data/branding
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+28
-93
@@ -1,15 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from .config import settings
|
||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||
from .network_security import request_trusts_forwarded_headers
|
||||
from .security import TokenError, safe_decode_token, verify_password
|
||||
from .security import safe_decode_token, TokenError, 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:
|
||||
@@ -26,79 +24,20 @@ def _is_expired(expires_at: str | None) -> bool:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed <= datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if direct_host:
|
||||
return direct_host
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _cookie_settings() -> dict[str, Any]:
|
||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
||||
if samesite not in {"lax", "strict", "none"}:
|
||||
samesite = "lax"
|
||||
return {
|
||||
"secure": bool(settings.auth_cookie_secure),
|
||||
"httponly": True,
|
||||
"samesite": samesite,
|
||||
"domain": settings.auth_cookie_domain or None,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
def _state_cookie_settings() -> dict[str, Any]:
|
||||
cookie = _cookie_settings()
|
||||
cookie["httponly"] = False
|
||||
return cookie
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, token: str) -> None:
|
||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
||||
response.set_cookie(
|
||||
settings.auth_cookie_name,
|
||||
token,
|
||||
max_age=max_age,
|
||||
**_cookie_settings(),
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
"1",
|
||||
max_age=max_age,
|
||||
**_state_cookie_settings(),
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
||||
response.delete_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
path="/",
|
||||
domain=settings.auth_cookie_domain or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header.split(" ", 1)[1].strip()
|
||||
if oauth_token:
|
||||
return oauth_token
|
||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
||||
return cookie_token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(user, dict):
|
||||
return "local"
|
||||
@@ -183,28 +122,24 @@ def _load_current_user_from_token(
|
||||
}
|
||||
|
||||
|
||||
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(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
|
||||
return _load_current_user_from_token(token, request)
|
||||
|
||||
|
||||
def get_current_user_event_stream(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
def get_current_user_event_stream(request: Request) -> 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:
|
||||
token = None
|
||||
stream_query_token = None
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
stream_query_token = request.query_params.get("stream_token")
|
||||
if not token and not stream_query_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
if token:
|
||||
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
|
||||
return _load_current_user_from_token(token, None)
|
||||
return _load_current_user_from_token(
|
||||
str(stream_query_token),
|
||||
None,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-321
@@ -4,252 +4,6 @@ import time
|
||||
import httpx
|
||||
|
||||
from ..logging_config import sanitize_headers, sanitize_value
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
from ..metrics import record_remote
|
||||
|
||||
|
||||
_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:
|
||||
@@ -275,24 +29,6 @@ class ApiClient:
|
||||
return f"{payload[:500]}..."
|
||||
return payload
|
||||
|
||||
async def _send_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]],
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> httpx.Response:
|
||||
return await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -300,17 +36,12 @@ class ApiClient:
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
started_at = time.perf_counter()
|
||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
||||
active_message, _ = _operation_messages(service_name, method, path)
|
||||
operation_event_id = start_remote_call(service_name, active_message)
|
||||
metric_status = 'error'
|
||||
self.logger.debug(
|
||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||
method,
|
||||
@@ -320,16 +51,14 @@ class ApiClient:
|
||||
sanitize_headers(self.headers()),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
response = await self._send_request(
|
||||
client,
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self.headers(),
|
||||
params=params,
|
||||
payload=payload,
|
||||
json=payload,
|
||||
)
|
||||
metric_status = str(response.status_code)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
self.logger.debug(
|
||||
@@ -339,21 +68,9 @@ class ApiClient:
|
||||
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
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
response = exc.response
|
||||
@@ -367,15 +84,6 @@ class ApiClient:
|
||||
duration_ms,
|
||||
self._response_summary(response),
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status if isinstance(status, int) else None,
|
||||
message=_operation_error_message(
|
||||
service_name,
|
||||
status if isinstance(status, int) else None,
|
||||
),
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
@@ -385,25 +93,10 @@ class ApiClient:
|
||||
url,
|
||||
duration_ms,
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
message=_operation_error_message(service_name, None),
|
||||
)
|
||||
raise
|
||||
|
||||
finally:
|
||||
record_remote(service_name, method, metric_status, time.perf_counter() - started_at)
|
||||
|
||||
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 get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("GET", path, params=params)
|
||||
|
||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("POST", path, payload=payload)
|
||||
@@ -411,9 +104,5 @@ class ApiClient:
|
||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("PUT", path, payload=payload)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Any]:
|
||||
return await self._request("DELETE", path, params=params)
|
||||
async def delete(self, path: str) -> Optional[Any]:
|
||||
return await self._request("DELETE", path)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class BazarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Any]:
|
||||
return await self._request("GET", "/api/system/status")
|
||||
|
||||
async def search_movie_subtitles(
|
||||
self,
|
||||
radarr_id: int,
|
||||
*,
|
||||
language: str,
|
||||
forced: bool = False,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
"/api/movies/subtitles",
|
||||
params={
|
||||
"radarrid": radarr_id,
|
||||
"language": language,
|
||||
"forced": str(forced).lower(),
|
||||
"hi": "false",
|
||||
},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search_episode_subtitles(
|
||||
self,
|
||||
series_id: int,
|
||||
episode_id: int,
|
||||
*,
|
||||
language: str,
|
||||
forced: bool = False,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
"/api/episodes/subtitles",
|
||||
params={
|
||||
"seriesid": series_id,
|
||||
"episodeid": episode_id,
|
||||
"language": language,
|
||||
"forced": str(forced).lower(),
|
||||
"hi": "false",
|
||||
},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
@@ -1,24 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import time
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
def _availability_message(result: Any) -> str:
|
||||
if not isinstance(result, dict):
|
||||
return "Jellyfin did not return any matching library items."
|
||||
total = result.get("TotalRecordCount")
|
||||
items = result.get("Items")
|
||||
available = (
|
||||
(isinstance(total, int) and total > 0)
|
||||
or (isinstance(items, list) and len(items) > 0)
|
||||
)
|
||||
return (
|
||||
"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."
|
||||
)
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
@@ -185,60 +167,18 @@ class JellyfinClient(ApiClient):
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"SearchTerm": term,
|
||||
"IncludeItemTypes": ",".join(item_types or []),
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,MediaSources",
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = self._emby_headers()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_availability_message(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("Jellyfin", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key or not str(series_id).strip():
|
||||
return []
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"ParentId": str(series_id).strip(),
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,ProviderIds,MediaSources",
|
||||
"Limit": 10000,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.get(url, headers=self._emby_headers(), params=params)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = payload.get("Items") or payload.get("items") or []
|
||||
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
||||
return response.json()
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
@@ -250,43 +190,12 @@ class JellyfinClient(ApiClient):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Sessions"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, list) else []
|
||||
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("Jellyfin", status_code),
|
||||
)
|
||||
raise
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyseerrClient(ApiClient):
|
||||
async def _send_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]],
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> httpx.Response:
|
||||
request_headers = dict(headers)
|
||||
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
|
||||
# Seerr's optional CSRF protection also applies to API-key writes.
|
||||
# Seed its secret/token cookie pair, then echo the readable token in
|
||||
# the header Seerr's own web client uses.
|
||||
csrf_response = await client.get(
|
||||
f"{self.base_url}/api/v1/auth/me",
|
||||
headers=self.headers(),
|
||||
)
|
||||
csrf_response.raise_for_status()
|
||||
csrf_token = client.cookies.get("XSRF-TOKEN")
|
||||
if csrf_token:
|
||||
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
|
||||
parsed_base = urlsplit(self.base_url)
|
||||
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
||||
return await super()._send_request(
|
||||
client,
|
||||
method,
|
||||
url,
|
||||
headers=request_headers,
|
||||
params=params,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/status")
|
||||
|
||||
@@ -61,15 +26,13 @@ class JellyseerrClient(ApiClient):
|
||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||
|
||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||
# Seerr rejects the `+` encoding that standard query builders use for
|
||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
||||
# percent-encoded values.
|
||||
encoded_query = quote(query, safe="")
|
||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
||||
|
||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
||||
service = "sonarr" if media_type == "tv" else "radarr"
|
||||
return await self.get(f"/api/v1/settings/{service}")
|
||||
return await self.get(
|
||||
"/api/v1/search",
|
||||
params={
|
||||
"query": query,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
@@ -78,9 +41,6 @@ class JellyseerrClient(ApiClient):
|
||||
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,
|
||||
@@ -90,12 +50,6 @@ class JellyseerrClient(ApiClient):
|
||||
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]]:
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Jellystat API adapter. Credentials and raw history never leave the backend."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellystatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HistoryLimitError(JellystatError):
|
||||
pass
|
||||
|
||||
|
||||
def same_user_id(left, right) -> bool:
|
||||
return bool(left and right) and str(left).replace("-", "").lower() == str(right).replace("-", "").lower()
|
||||
|
||||
|
||||
class JellystatClient(ApiClient):
|
||||
PAGE_SIZE = 200
|
||||
MAX_PAGES = 50
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
async def _read(self, client: httpx.AsyncClient, method: str, path: str, **kwargs):
|
||||
try:
|
||||
response = await client.request(method, f"{self.base_url}{path}",
|
||||
headers={"x-api-token": self.api_key}, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise JellystatError("Jellystat did not return a valid response") from exc
|
||||
|
||||
async def test_connection(self) -> dict:
|
||||
# This protected endpoint confirms API authentication without returning user data.
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
result = await self._read(client, "GET", "/api/getLibraries")
|
||||
if not isinstance(result, list):
|
||||
raise JellystatError("Jellystat returned an unexpected library response")
|
||||
return {"connected": True}
|
||||
|
||||
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
|
||||
raise JellystatError("Invalid linked Jellyfin identity")
|
||||
# Only fixed, user-scoped endpoints are used. Never pass browser search/filters through.
|
||||
filters = json.dumps([{"field": "ActivityDateInserted", "min": start.isoformat(), "max": end.isoformat()}])
|
||||
try:
|
||||
async with asyncio.timeout(30):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
libraries = await self._read(client, "GET", "/api/getLibraries")
|
||||
if not isinstance(libraries, list) or any(not isinstance(row, dict) for row in libraries):
|
||||
raise JellystatError("Jellystat returned an unexpected library response")
|
||||
history = []
|
||||
for page in range(1, self.MAX_PAGES + 1):
|
||||
payload = await self._read(client, "POST", "/api/getUserHistory",
|
||||
json={"userid": user_id}, params={"page": page, "size": self.PAGE_SIZE,
|
||||
"sort": "ActivityDateInserted", "desc": "true", "filters": filters})
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
||||
raise JellystatError("Jellystat returned an unexpected history response")
|
||||
rows = payload["results"]
|
||||
try:
|
||||
pages = int(payload["pages"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise JellystatError("Jellystat did not return history pagination") from exc
|
||||
if pages < 0 or (pages == 0 and rows) or len(rows) > self.PAGE_SIZE:
|
||||
raise JellystatError("Jellystat returned invalid history pagination")
|
||||
if pages > self.MAX_PAGES:
|
||||
raise HistoryLimitError("Select a shorter period to view this history")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not same_user_id(row.get("UserId"), user_id):
|
||||
raise JellystatError("Jellystat returned history for an unexpected account")
|
||||
history.extend(rows)
|
||||
if page >= pages:
|
||||
return history, libraries
|
||||
if not rows:
|
||||
raise JellystatError("Jellystat returned incomplete history")
|
||||
except TimeoutError as exc:
|
||||
raise JellystatError("Jellystat took too long to return history") from exc
|
||||
raise HistoryLimitError("Select a shorter period to view this history")
|
||||
@@ -1,65 +1,7 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
def _torrent_state_text(state: Any) -> str:
|
||||
normalized = str(state or "").strip().lower()
|
||||
if 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."
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
@@ -81,109 +23,34 @@ class QBittorrentClient(ApiClient):
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip().lower()
|
||||
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
||||
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
||||
if response.text.strip().lower() != "ok.":
|
||||
raise RuntimeError("qBittorrent login failed")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_torrent_result_message(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.text.strip()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.text.strip()
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_torrent_action_message(path),
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def is_webui_reachable(self) -> bool:
|
||||
if not self.base_url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(self.base_url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
async def get_torrents(self) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info")
|
||||
@@ -194,9 +61,6 @@ class QBittorrentClient(ApiClient):
|
||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
||||
|
||||
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"tag": tag})
|
||||
|
||||
async def get_app_version(self) -> Optional[Any]:
|
||||
return await self._get_text("/api/v2/app/version")
|
||||
|
||||
@@ -209,9 +73,7 @@ class QBittorrentClient(ApiClient):
|
||||
return
|
||||
raise
|
||||
|
||||
async def add_torrent_url(
|
||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
||||
) -> None:
|
||||
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
||||
url_host = None
|
||||
if isinstance(url, str) and "://" in url:
|
||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
||||
@@ -223,6 +85,4 @@ class QBittorrentClient(ApiClient):
|
||||
data: Dict[str, Any] = {"urls": url}
|
||||
if category:
|
||||
data["category"] = category
|
||||
if tags:
|
||||
data["tags"] = tags
|
||||
await self._post_form("/api/v2/torrents/add", data=data)
|
||||
|
||||
@@ -9,10 +9,6 @@ class RadarrClient(ApiClient):
|
||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||
|
||||
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
||||
|
||||
@@ -28,32 +24,12 @@ class RadarrClient(ApiClient):
|
||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
||||
|
||||
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
||||
return await self.get(
|
||||
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
|
||||
)
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
|
||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
||||
|
||||
async def monitor_movie(
|
||||
self, movie_id: int, monitored: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
movie = await self.get_movie(movie_id)
|
||||
if not isinstance(movie, dict):
|
||||
raise ValueError("Radarr did not return the movie before updating its monitored state")
|
||||
movie["monitored"] = monitored
|
||||
return await self.update_movie(movie)
|
||||
|
||||
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
|
||||
return await self.delete(
|
||||
f"/api/v3/moviefile/{movie_file_id}",
|
||||
params={"deleteFromClient": "true"},
|
||||
)
|
||||
|
||||
async def add_movie(
|
||||
self,
|
||||
tmdb_id: int,
|
||||
@@ -61,15 +37,9 @@ class RadarrClient(ApiClient):
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
search_for_movie: bool = True,
|
||||
title: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
|
||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("Radarr could not resolve a title for this TMDB ID")
|
||||
payload = {
|
||||
"tmdbId": tmdb_id,
|
||||
"title": resolved_title,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
|
||||
@@ -9,20 +9,6 @@ class SonarrClient(ApiClient):
|
||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||
|
||||
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
|
||||
if not isinstance(result, list):
|
||||
return None
|
||||
for item in result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
if int(item.get("tvdbId")) == tvdb_id:
|
||||
return item
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return next((item for item in result if isinstance(item, dict)), None)
|
||||
|
||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/series/{series_id}")
|
||||
|
||||
@@ -33,22 +19,7 @@ class SonarrClient(ApiClient):
|
||||
return await self.get("/api/v3/qualityprofile")
|
||||
|
||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
records = []
|
||||
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")
|
||||
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
@@ -56,36 +27,12 @@ class SonarrClient(ApiClient):
|
||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
||||
|
||||
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
|
||||
|
||||
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
|
||||
return await self.get(
|
||||
"/api/v3/release",
|
||||
params={"seriesId": series_id, "seasonNumber": season_number},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||
|
||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
||||
|
||||
async def monitor_episodes(
|
||||
self, episode_ids: list[int], monitored: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.put(
|
||||
"/api/v3/episode/monitor",
|
||||
payload={"episodeIds": episode_ids, "monitored": monitored},
|
||||
)
|
||||
|
||||
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
|
||||
return await self.delete(
|
||||
f"/api/v3/episodefile/{episode_file_id}",
|
||||
params={"deleteFromClient": "true"},
|
||||
)
|
||||
|
||||
async def add_series(
|
||||
self,
|
||||
tvdb_id: int,
|
||||
@@ -95,19 +42,16 @@ class SonarrClient(ApiClient):
|
||||
title: Optional[str] = None,
|
||||
search_missing: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
|
||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
|
||||
payload = {
|
||||
"tvdbId": tvdb_id,
|
||||
"title": resolved_title,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"seasonFolder": True,
|
||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||
}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
return await self.post("/api/v3/series", payload=payload)
|
||||
|
||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+3
-53
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
||||
sqlite_journal_mode: str = Field(
|
||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||
)
|
||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||
auth_rate_limit_window_seconds: int = Field(
|
||||
@@ -34,22 +34,7 @@ class Settings(BaseSettings):
|
||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
||||
)
|
||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
auth_cookie_name: str = Field(
|
||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||
)
|
||||
auth_cookie_secure: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||
)
|
||||
auth_cookie_samesite: str = Field(
|
||||
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
||||
)
|
||||
auth_cookie_domain: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||
)
|
||||
auth_state_cookie_name: str = Field(
|
||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||
)
|
||||
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||
log_file_max_bytes: int = Field(
|
||||
@@ -85,15 +70,6 @@ class Settings(BaseSettings):
|
||||
requests_data_source: str = Field(
|
||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
||||
)
|
||||
issue_confirmation_contact_attempts: int = Field(
|
||||
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
|
||||
)
|
||||
issue_confirmation_interval_value: int = Field(
|
||||
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
|
||||
)
|
||||
issue_confirmation_interval_unit: str = Field(
|
||||
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
|
||||
)
|
||||
artwork_cache_mode: str = Field(
|
||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||
)
|
||||
@@ -119,9 +95,6 @@ class Settings(BaseSettings):
|
||||
site_login_show_signup_link: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
||||
)
|
||||
site_nav_show_requests: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
||||
)
|
||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||
|
||||
magent_application_url: Optional[str] = Field(
|
||||
@@ -148,10 +121,6 @@ class Settings(BaseSettings):
|
||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
||||
)
|
||||
magent_proxy_trusted_proxies: str = Field(
|
||||
default="127.0.0.1,::1",
|
||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
||||
)
|
||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
||||
)
|
||||
@@ -247,10 +216,6 @@ class Settings(BaseSettings):
|
||||
magent_notify_webhook_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
||||
)
|
||||
magent_allow_private_notification_targets: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
||||
)
|
||||
|
||||
jellyseerr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
@@ -258,11 +223,6 @@ class Settings(BaseSettings):
|
||||
jellyseerr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
|
||||
)
|
||||
jellystat_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
|
||||
)
|
||||
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
|
||||
|
||||
jellyfin_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
||||
)
|
||||
@@ -310,16 +270,6 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
||||
)
|
||||
|
||||
bazarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
|
||||
)
|
||||
bazarr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
|
||||
)
|
||||
bazarr_default_language: str = Field(
|
||||
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
|
||||
)
|
||||
|
||||
prowlarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
||||
)
|
||||
@@ -338,7 +288,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
discord_webhook_url: Optional[str] = Field(
|
||||
default=None,
|
||||
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||
)
|
||||
|
||||
|
||||
+20
-323
@@ -21,8 +21,6 @@ SQLITE_BUSY_TIMEOUT_MS = 5_000
|
||||
SQLITE_CACHE_SIZE_KIB = 32_768
|
||||
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
||||
_DB_UNSET = object()
|
||||
_DEFAULT_JWT_SECRET = "change-me"
|
||||
_DEFAULT_ADMIN_PASSWORD = "adminadmin"
|
||||
|
||||
|
||||
def _db_path() -> str:
|
||||
@@ -180,29 +178,8 @@ def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
|
||||
return candidate
|
||||
|
||||
|
||||
def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||
password = str(settings.admin_password or "")
|
||||
return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jellyfin_user_links (
|
||||
source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL,
|
||||
PRIMARY KEY (source, local_user_id), UNIQUE (source, jellyfin_user_id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS request_repairs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
tracking_json TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
UNIQUE(request_id, started_at)
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
@@ -416,27 +393,6 @@ def init_db() -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portal_item_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
actor_role TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item
|
||||
ON portal_item_activity (item_id, created_at ASC, id ASC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
||||
@@ -455,16 +411,12 @@ def init_db() -> None:
|
||||
ON requests_cache (updated_at DESC, request_id DESC)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
||||
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# Older databases may not have requested_by_id until later migrations run.
|
||||
pass
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
||||
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
|
||||
@@ -722,77 +674,12 @@ def init_db() -> None:
|
||||
pass
|
||||
_backfill_auth_providers()
|
||||
ensure_admin_user()
|
||||
_backfill_request_repairs()
|
||||
|
||||
|
||||
def start_request_repair(tracking: Dict[str, Any]) -> None:
|
||||
"""Persist the new collection cycle before a managed file is removed."""
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO request_repairs (request_id, started_at, tracking_json) VALUES (?, ?, ?)",
|
||||
(str(tracking["requestId"]), tracking["startedAt"], json.dumps(tracking)),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_request_repairs() -> None:
|
||||
# Carry existing issue repairs forward once, without depending on the ticket's
|
||||
# lifetime. Deleting/closing an issue must not restore stale availability.
|
||||
with _connect() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT a.metadata_json FROM portal_item_activity a
|
||||
JOIN portal_items p ON p.id = a.item_id
|
||||
WHERE p.kind = 'issue' AND p.status IN ('in_progress', 'blocked')
|
||||
AND a.event_type IN ('replacement_started', 'missing_search_started')
|
||||
AND a.id = (SELECT MAX(b.id) FROM portal_item_activity b
|
||||
WHERE b.item_id = a.item_id
|
||||
AND b.event_type IN ('replacement_started', 'missing_search_started'))
|
||||
""").fetchall()
|
||||
for (raw,) in rows:
|
||||
try:
|
||||
tracking = json.loads(raw or "{}").get("repairTracking")
|
||||
if isinstance(tracking, dict) and tracking.get("requestId") and tracking.get("startedAt"):
|
||||
start_request_repair(tracking)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
|
||||
def get_request_repairs(request_id: str, *, active_only: bool = True) -> list[Dict[str, Any]]:
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, tracking_json, completed_at FROM request_repairs WHERE request_id = ?"
|
||||
+ (" AND completed_at IS NULL" if active_only else "") + " ORDER BY id",
|
||||
(str(request_id),),
|
||||
).fetchall()
|
||||
return [{"id": row[0], **json.loads(row[1]), "completedAt": row[2]} for row in rows]
|
||||
|
||||
|
||||
def complete_request_repair(repair_id: int) -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute("UPDATE request_repairs SET completed_at = ? WHERE id = ? AND completed_at IS NULL",
|
||||
(datetime.now(timezone.utc).isoformat(), repair_id))
|
||||
|
||||
|
||||
def active_repair_request_ids() -> set[str]:
|
||||
with _connect() as conn:
|
||||
return {row[0] for row in conn.execute("SELECT DISTINCT request_id FROM request_repairs WHERE completed_at IS NULL")}
|
||||
|
||||
|
||||
def save_snapshot(snapshot: Snapshot) -> None:
|
||||
payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
latest = conn.execute(
|
||||
"""
|
||||
SELECT state, state_reason
|
||||
FROM snapshots
|
||||
WHERE request_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(snapshot.request_id,),
|
||||
).fetchone()
|
||||
if latest and latest[0] == snapshot.state.value and latest[1] == snapshot.state_reason:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
|
||||
@@ -827,7 +714,6 @@ def save_action(
|
||||
|
||||
|
||||
def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
bounded_limit = max(1, min(int(limit or 10), 100))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
@@ -837,15 +723,10 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(request_id, min(bounded_limit * 20, 500)),
|
||||
(request_id, limit),
|
||||
).fetchall()
|
||||
results = []
|
||||
previous_signature: tuple[str, Optional[str]] | None = None
|
||||
for row in rows:
|
||||
signature = (row[1], row[2])
|
||||
if signature == previous_signature:
|
||||
continue
|
||||
previous_signature = signature
|
||||
results.append(
|
||||
{
|
||||
"request_id": row[0],
|
||||
@@ -855,8 +736,6 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
|
||||
"payload": json.loads(row[4]),
|
||||
}
|
||||
)
|
||||
if len(results) >= bounded_limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
@@ -887,63 +766,8 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
|
||||
return results
|
||||
|
||||
|
||||
def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str, Any]:
|
||||
"""Return the most recent proof that this request reached qBittorrent.
|
||||
|
||||
A current qBittorrent API error is not proof that a download exists. Historical
|
||||
snapshots are used so a torrent that has since been removed can still be
|
||||
described honestly in the UI.
|
||||
"""
|
||||
with _connect() as conn:
|
||||
cycle = conn.execute("SELECT MAX(started_at) FROM request_repairs WHERE request_id = ?",
|
||||
(str(request_id),)).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT created_at, payload_json
|
||||
FROM snapshots
|
||||
WHERE request_id = ? AND (? IS NULL OR created_at >= ?)
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(request_id, cycle, cycle, max(1, min(int(limit or 100), 500))),
|
||||
).fetchall()
|
||||
|
||||
for created_at, payload_json in rows:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# A poll begun before deletion may finish afterwards. Its wall-clock save
|
||||
# time alone is not evidence that it belongs to the replacement cycle.
|
||||
if cycle and (payload.get("raw", {}).get("repairCycle") or "") < cycle:
|
||||
continue
|
||||
timeline = payload.get("timeline") if isinstance(payload, dict) else None
|
||||
if not isinstance(timeline, list):
|
||||
continue
|
||||
for hop in timeline:
|
||||
if not isinstance(hop, dict) or hop.get("service") != "qBittorrent":
|
||||
continue
|
||||
details = hop.get("details") if isinstance(hop.get("details"), dict) else {}
|
||||
torrents = details.get("torrents")
|
||||
if isinstance(torrents, list) and torrents:
|
||||
return {
|
||||
"observed": True,
|
||||
"last_seen_at": created_at,
|
||||
"state": hop.get("status"),
|
||||
"summary": details.get("summary"),
|
||||
"torrents": torrents,
|
||||
}
|
||||
return {
|
||||
"observed": False,
|
||||
"last_seen_at": None,
|
||||
"state": None,
|
||||
"summary": None,
|
||||
"torrents": [],
|
||||
}
|
||||
|
||||
|
||||
def ensure_admin_user() -> None:
|
||||
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
||||
if not settings.admin_username or not settings.admin_password:
|
||||
return
|
||||
existing = get_user_by_username(settings.admin_username)
|
||||
if existing:
|
||||
@@ -951,14 +775,6 @@ def ensure_admin_user() -> None:
|
||||
create_user(settings.admin_username, settings.admin_password, role="admin")
|
||||
|
||||
|
||||
def has_admin_user() -> bool:
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
@@ -1229,8 +1045,9 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
"is_expired": _is_datetime_in_past(row[12]),
|
||||
}
|
||||
)
|
||||
# Imported Seerr accounts must remain manageable. Prefer a Jellyfin/local
|
||||
# account when a linked duplicate exists, without hiding Seerr-only users.
|
||||
# Admin user management uses Jellyfin as the source of truth for non-admin
|
||||
# user objects. Seerr rows are treated as enrichment-only and hidden
|
||||
# from admin/user-management views to avoid duplicate accounts in the UI.
|
||||
def _provider_rank(user: Dict[str, Any]) -> int:
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
||||
if provider == "jellyfin":
|
||||
@@ -1241,7 +1058,14 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
return 2
|
||||
return 2
|
||||
|
||||
visible_candidates = all_rows
|
||||
visible_candidates = [
|
||||
user
|
||||
for user in all_rows
|
||||
if not (
|
||||
str(user.get("auth_provider") or "local").strip().lower() == "jellyseerr"
|
||||
and str(user.get("role") or "user").strip().lower() != "admin"
|
||||
)
|
||||
]
|
||||
|
||||
visible_candidates.sort(
|
||||
key=lambda user: (
|
||||
@@ -1579,7 +1403,7 @@ def delete_user_profile(profile_id: int) -> bool:
|
||||
|
||||
|
||||
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
|
||||
max_uses = 1 if row[10] else row[6]
|
||||
max_uses = row[6]
|
||||
use_count = int(row[7] or 0)
|
||||
expires_at = row[9]
|
||||
is_expired = _is_datetime_in_past(expires_at)
|
||||
@@ -1663,8 +1487,6 @@ def create_signup_invite(
|
||||
recipient_email: Optional[str] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if recipient_email:
|
||||
max_uses = 1
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -1722,11 +1544,6 @@ def update_signup_invite(
|
||||
expires_at: Optional[str],
|
||||
recipient_email: Optional[str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
existing = get_signup_invite_by_id(invite_id)
|
||||
if recipient_email or (existing and existing.get('recipient_email')):
|
||||
max_uses = 1
|
||||
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
|
||||
raise ValueError('A used email invitation cannot be reassigned.')
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -1764,24 +1581,6 @@ def delete_signup_invite(invite_id: int) -> bool:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def reserve_signup_invite_use(invite_id: int) -> bool:
|
||||
"""Atomically reserve capacity before any remote account is provisioned."""
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute('''
|
||||
UPDATE signup_invites SET use_count = use_count + 1
|
||||
WHERE id = ? AND enabled = 1
|
||||
AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))
|
||||
AND ((recipient_email IS NOT NULL AND recipient_email != '' AND use_count < 1)
|
||||
OR ((recipient_email IS NULL OR recipient_email = '') AND (max_uses IS NULL OR use_count < max_uses)))
|
||||
''', (invite_id,))
|
||||
return cursor.rowcount == 1
|
||||
|
||||
|
||||
def release_signup_invite_use(invite_id: int) -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute('UPDATE signup_invites SET use_count = MAX(0, use_count - 1) WHERE id = ?', (invite_id,))
|
||||
|
||||
|
||||
def increment_signup_invite_use(invite_id: int) -> None:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
@@ -3611,23 +3410,6 @@ def update_portal_item(
|
||||
return updated
|
||||
|
||||
|
||||
def delete_portal_item(item_id: int) -> bool:
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE portal_items SET related_item_id = NULL WHERE related_item_id = ?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM portal_comments WHERE item_id = ?", (item_id,))
|
||||
conn.execute("DELETE FROM portal_item_activity WHERE item_id = ?", (item_id,))
|
||||
deleted = conn.execute(
|
||||
"DELETE FROM portal_items WHERE id = ?",
|
||||
(item_id,),
|
||||
).rowcount
|
||||
if deleted:
|
||||
logger.info("portal item deleted id=%s", item_id)
|
||||
return bool(deleted)
|
||||
|
||||
|
||||
def add_portal_comment(
|
||||
item_id: int,
|
||||
*,
|
||||
@@ -3710,91 +3492,6 @@ def list_portal_comments(item_id: int, *, include_internal: bool = True, limit:
|
||||
return [_portal_comment_from_row(row) for row in rows]
|
||||
|
||||
|
||||
def add_portal_item_activity(
|
||||
item_id: int,
|
||||
*,
|
||||
event_type: str,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
message: str,
|
||||
metadata_json: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO portal_item_activity (
|
||||
item_id,
|
||||
event_type,
|
||||
actor_username,
|
||||
actor_role,
|
||||
message,
|
||||
metadata_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
event_type,
|
||||
actor_username,
|
||||
actor_role,
|
||||
message,
|
||||
metadata_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
activity_id = cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
|
||||
FROM portal_item_activity
|
||||
WHERE id = ?
|
||||
""",
|
||||
(activity_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("Portal activity could not be loaded after insert.")
|
||||
return {
|
||||
"id": row[0],
|
||||
"item_id": row[1],
|
||||
"event_type": row[2],
|
||||
"actor_username": row[3],
|
||||
"actor_role": row[4],
|
||||
"message": row[5],
|
||||
"metadata_json": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
|
||||
|
||||
def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]:
|
||||
safe_limit = max(1, min(int(limit), 500))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
|
||||
FROM portal_item_activity
|
||||
WHERE item_id = ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(item_id, safe_limit),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"item_id": row[1],
|
||||
"event_type": row[2],
|
||||
"actor_username": row[3],
|
||||
"actor_role": row[4],
|
||||
"message": row[5],
|
||||
"metadata_json": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_portal_overview() -> Dict[str, Any]:
|
||||
with _connect() as conn:
|
||||
kind_rows = conn.execute(
|
||||
|
||||
+5
-57
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
@@ -9,7 +8,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .db import has_admin_user, init_db
|
||||
from .db import init_db
|
||||
from .routers.requests import (
|
||||
router as requests_router,
|
||||
startup_warmup_requests_cache,
|
||||
@@ -26,16 +25,7 @@ from .routers.feedback import router as feedback_router
|
||||
from .routers.site import router as site_router
|
||||
from .routers.events import router as events_router
|
||||
from .routers.portal import router as portal_router
|
||||
from .routers.operations import router as operations_router
|
||||
from .routers.insights import router as insights_router
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .services.issue_resolution import run_issue_confirmation_loop
|
||||
from .services.operation_progress import (
|
||||
begin_operation,
|
||||
finish_operation,
|
||||
normalize_operation_id,
|
||||
reset_operation,
|
||||
)
|
||||
from .logging_config import (
|
||||
bind_request_id,
|
||||
configure_logging,
|
||||
@@ -45,7 +35,6 @@ from .logging_config import (
|
||||
summarize_http_body,
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
from .metrics import record_api, start_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
@@ -70,14 +59,6 @@ app.add_middleware(
|
||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = bind_request_id(request_id)
|
||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
||||
operation_token = None
|
||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
operation_token = begin_operation(
|
||||
operation_id,
|
||||
label=request.headers.get("X-Magent-Operation-Label"),
|
||||
path=request.url.path,
|
||||
)
|
||||
request.state.request_id = request_id
|
||||
started_at = time.perf_counter()
|
||||
body = await request.body()
|
||||
@@ -114,21 +95,16 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
record_api(request, 500, time.perf_counter() - started_at)
|
||||
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)
|
||||
record_api(request, response.status_code, time.perf_counter() - started_at)
|
||||
response.headers.setdefault("X-Request-ID", request_id)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
@@ -154,13 +130,6 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
}
|
||||
),
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(
|
||||
operation_id,
|
||||
success=response.status_code < 400,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
return response
|
||||
|
||||
@@ -196,15 +165,13 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
|
||||
|
||||
|
||||
def _log_security_configuration_warnings() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
if str(settings.jwt_secret or "").strip() == "change-me":
|
||||
logger.warning(
|
||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
||||
"security configuration warning: JWT_SECRET is still set to the default value"
|
||||
)
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not admin_password or admin_password == "adminadmin":
|
||||
if str(settings.admin_password or "") == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
@@ -212,20 +179,8 @@ def _log_security_configuration_warnings() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _enforce_secure_startup_configuration() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||
raise RuntimeError(
|
||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
start_metrics()
|
||||
configure_logging(
|
||||
settings.log_level,
|
||||
settings.log_file,
|
||||
@@ -237,7 +192,6 @@ async def startup() -> None:
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
configure_logging(
|
||||
runtime.log_level,
|
||||
@@ -257,15 +211,11 @@ async def startup() -> None:
|
||||
runtime.log_background_sync_level,
|
||||
runtime.requests_data_source,
|
||||
)
|
||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||
logger.info("Background imports and automation paused for initial setup")
|
||||
return
|
||||
_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")
|
||||
|
||||
|
||||
@@ -280,5 +230,3 @@ app.include_router(feedback_router)
|
||||
app.include_router(site_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(portal_router)
|
||||
app.include_router(operations_router)
|
||||
app.include_router(insights_router)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Low-cardinality operational metrics; no URLs, query values or user data."""
|
||||
import os
|
||||
from prometheus_client import Counter, Histogram, start_http_server
|
||||
|
||||
BUCKETS = (.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60)
|
||||
API_CALLS = Counter('magent_api_requests_total', 'API responses by route template', ['method', 'route', 'status'])
|
||||
API_TIME = Histogram('magent_api_response_seconds', 'Time until response headers (not stream lifetime)', ['method', 'route'], buckets=BUCKETS)
|
||||
REMOTE_CALLS = Counter('magent_remote_requests_total', 'Logical service client calls', ['service', 'method', 'status'])
|
||||
REMOTE_TIME = Histogram('magent_remote_response_seconds', 'Logical service client call duration', ['service', 'method'], buckets=BUCKETS)
|
||||
_server = None
|
||||
|
||||
def start_metrics():
|
||||
global _server
|
||||
if _server is None and os.getenv('MAGENT_METRICS_ENABLED', '').lower() == 'true':
|
||||
_server = start_http_server(int(os.getenv('MAGENT_METRICS_PORT', '9108')), addr=os.getenv('MAGENT_METRICS_BIND', '127.0.0.1'))
|
||||
|
||||
def record_api(request, status, seconds):
|
||||
route = getattr(request.scope.get('route'), 'path', 'unmatched')
|
||||
method = request.method if request.method in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
|
||||
API_CALLS.labels(method, route, str(status)).inc()
|
||||
API_TIME.labels(method, route).observe(max(0, seconds))
|
||||
|
||||
def record_remote(service, method, status, seconds):
|
||||
service = service if service in {'Seerr', 'Jellyfin', 'Sonarr', 'Radarr', 'Bazarr', 'Prowlarr', 'qBittorrent'} else 'Other'
|
||||
method = method.upper() if method.upper() in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
|
||||
REMOTE_CALLS.labels(service, method, str(status)).inc()
|
||||
REMOTE_TIME.labels(service, method).observe(max(0, seconds))
|
||||
@@ -35,7 +35,6 @@ class ActionOption(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
risk: str
|
||||
description: Optional[str] = None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
@@ -49,7 +48,6 @@ class Snapshot(BaseModel):
|
||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||
actions: List[ActionOption] = Field(default_factory=list)
|
||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,6 @@ from ..auth import (
|
||||
resolve_user_auth_provider,
|
||||
)
|
||||
from ..config import settings as env_settings
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..db import (
|
||||
delete_setting,
|
||||
get_all_users,
|
||||
@@ -122,17 +121,7 @@ def _require_recipient_email(value: object) -> str:
|
||||
detail="recipient_email is required and must be a valid email address",
|
||||
)
|
||||
|
||||
|
||||
def _optional_recipient_email(value: object) -> Optional[str]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
normalized = normalize_delivery_email(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"jellystat_api_key",
|
||||
"magent_ssl_certificate_pem",
|
||||
"magent_ssl_private_key_pem",
|
||||
"magent_notify_email_smtp_password",
|
||||
@@ -145,13 +134,11 @@ SENSITIVE_KEYS = {
|
||||
"jellyfin_api_key",
|
||||
"sonarr_api_key",
|
||||
"radarr_api_key",
|
||||
"bazarr_api_key",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_password",
|
||||
}
|
||||
|
||||
URL_SETTING_KEYS = {
|
||||
"jellystat_base_url",
|
||||
"magent_application_url",
|
||||
"magent_api_url",
|
||||
"magent_proxy_base_url",
|
||||
@@ -162,20 +149,11 @@ URL_SETTING_KEYS = {
|
||||
"jellyfin_public_url",
|
||||
"sonarr_base_url",
|
||||
"radarr_base_url",
|
||||
"bazarr_base_url",
|
||||
"prowlarr_base_url",
|
||||
"qbittorrent_base_url",
|
||||
}
|
||||
|
||||
NOTIFICATION_URL_SETTING_KEYS = {
|
||||
"magent_notify_discord_webhook_url",
|
||||
"magent_notify_push_base_url",
|
||||
"magent_notify_webhook_url",
|
||||
}
|
||||
|
||||
SETTING_KEYS: List[str] = [
|
||||
"jellystat_base_url",
|
||||
"jellystat_api_key",
|
||||
"magent_application_url",
|
||||
"magent_application_port",
|
||||
"magent_api_url",
|
||||
@@ -231,9 +209,6 @@ SETTING_KEYS: List[str] = [
|
||||
"radarr_quality_profile_id",
|
||||
"radarr_root_folder",
|
||||
"radarr_qbittorrent_category",
|
||||
"bazarr_base_url",
|
||||
"bazarr_api_key",
|
||||
"bazarr_default_language",
|
||||
"prowlarr_base_url",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_base_url",
|
||||
@@ -252,9 +227,6 @@ SETTING_KEYS: List[str] = [
|
||||
"requests_cleanup_time",
|
||||
"requests_cleanup_days",
|
||||
"requests_data_source",
|
||||
"issue_confirmation_contact_attempts",
|
||||
"issue_confirmation_interval_value",
|
||||
"issue_confirmation_interval_unit",
|
||||
"site_banner_enabled",
|
||||
"site_banner_message",
|
||||
"site_banner_tone",
|
||||
@@ -262,7 +234,6 @@ SETTING_KEYS: List[str] = [
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
]
|
||||
|
||||
|
||||
@@ -682,38 +653,12 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
changed_keys.append(key)
|
||||
continue
|
||||
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
||||
if key == "issue_confirmation_contact_attempts":
|
||||
try:
|
||||
attempts = int(value_to_store)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
|
||||
if attempts < 0 or attempts > 10:
|
||||
raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
|
||||
value_to_store = str(attempts)
|
||||
if key == "issue_confirmation_interval_value":
|
||||
try:
|
||||
interval_value = int(value_to_store)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
|
||||
if interval_value < 1 or interval_value > 365:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
|
||||
value_to_store = str(interval_value)
|
||||
if key == "issue_confirmation_interval_unit":
|
||||
value_to_store = value_to_store.lower()
|
||||
if value_to_store not in {"days", "weeks", "months"}:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
|
||||
if key in URL_SETTING_KEYS and value_to_store:
|
||||
try:
|
||||
value_to_store = _normalize_service_url(value_to_store)
|
||||
except ValueError as exc:
|
||||
friendly_key = key.replace("_", " ")
|
||||
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
||||
if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
|
||||
try:
|
||||
value_to_store = validate_notification_target_url(value_to_store)
|
||||
except ValueError as exc:
|
||||
friendly_key = key.replace("_", " ")
|
||||
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
||||
set_setting(key, value_to_store)
|
||||
updates += 1
|
||||
changed_keys.append(key)
|
||||
@@ -1362,35 +1307,6 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
|
||||
return {"status": "ok", "username": username, "role": role}
|
||||
|
||||
|
||||
@router.post("/users/{username}/email")
|
||||
async def update_user_email(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
|
||||
email = _optional_recipient_email(payload.get("email"))
|
||||
if email:
|
||||
duplicate = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in get_all_users()
|
||||
if str(candidate.get("username") or "").casefold() != username.casefold()
|
||||
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(status_code=409, detail="That email address is already assigned to another user")
|
||||
|
||||
if not set_user_email(username, email):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
refreshed = get_user_by_username(username)
|
||||
logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email))
|
||||
return {"status": "ok", "user": refreshed, "email": email}
|
||||
|
||||
|
||||
@router.post("/users/{username}/auto-search")
|
||||
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||
@@ -1737,34 +1653,9 @@ async def get_invites() -> Dict[str, Any]:
|
||||
results = []
|
||||
for invite in invites:
|
||||
profile = profiles.get(invite.get("profile_id"))
|
||||
if not invite.get("enabled"):
|
||||
operational_state = "disabled"
|
||||
state_label = "Disabled"
|
||||
attention_reason = "This invite has been switched off."
|
||||
elif invite.get("is_expired"):
|
||||
operational_state = "expired"
|
||||
state_label = "Expired"
|
||||
attention_reason = "The invite has passed its expiry date."
|
||||
elif invite.get("remaining_uses") == 0:
|
||||
operational_state = "exhausted"
|
||||
state_label = "Fully used"
|
||||
attention_reason = "Every permitted sign-up has been used."
|
||||
elif invite.get("profile_id") is not None and (
|
||||
profile is None or profile.get("is_active") is False
|
||||
):
|
||||
operational_state = "profile_unavailable"
|
||||
state_label = "Profile unavailable"
|
||||
attention_reason = "The assigned profile is missing or disabled."
|
||||
else:
|
||||
operational_state = "ready"
|
||||
state_label = "Ready to use"
|
||||
attention_reason = None
|
||||
results.append(
|
||||
{
|
||||
**invite,
|
||||
"operational_state": operational_state,
|
||||
"state_label": state_label,
|
||||
"attention_reason": attention_reason,
|
||||
"profile": (
|
||||
{
|
||||
"id": profile.get("id"),
|
||||
@@ -1775,16 +1666,7 @@ async def get_invites() -> Dict[str, Any]:
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"invites": results,
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"ready": sum(1 for invite in results if invite["operational_state"] == "ready"),
|
||||
"attention": sum(1 for invite in results if invite["operational_state"] != "ready"),
|
||||
"used_signups": sum(int(invite.get("use_count") or 0) for invite in results),
|
||||
"with_recipient": sum(1 for invite in results if invite.get("recipient_email")),
|
||||
},
|
||||
}
|
||||
return {"invites": results}
|
||||
|
||||
|
||||
@router.get("/invites/policy")
|
||||
@@ -1923,20 +1805,6 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
message = _normalize_optional_text(payload.get("message"))
|
||||
reason = _normalize_optional_text(payload.get("reason"))
|
||||
|
||||
if template_key == 'invited':
|
||||
if not invite:
|
||||
raise HTTPException(status_code=400, detail='Choose an invitation before sending it.')
|
||||
if int(invite.get('use_count') or 0) > 0:
|
||||
raise HTTPException(status_code=400, detail='This invitation has already been used. Create a new invitation.')
|
||||
if invite.get('recipient_email') and normalize_delivery_email(invite['recipient_email']) != recipient_email:
|
||||
raise HTTPException(status_code=400, detail='This invitation belongs to a different recipient. Create a new invitation.')
|
||||
invite = update_signup_invite(
|
||||
int(invite['id']), code=invite['code'], label=invite.get('label'),
|
||||
description=invite.get('description'), profile_id=invite.get('profile_id'),
|
||||
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
|
||||
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await send_templated_email(
|
||||
template_key,
|
||||
@@ -1983,10 +1851,8 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||
recipient_email = _require_recipient_email(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
if send_email and not recipient_email:
|
||||
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
invite = create_signup_invite(
|
||||
@@ -2056,10 +1922,8 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
if send_email and not recipient_email:
|
||||
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
invite = update_signup_invite(
|
||||
|
||||
+135
-249
@@ -7,7 +7,7 @@ import time
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
@@ -17,7 +17,6 @@ from ..db import (
|
||||
set_last_login,
|
||||
get_user_by_username,
|
||||
get_users_by_username_ci,
|
||||
get_all_users,
|
||||
set_user_password,
|
||||
set_user_jellyseerr_id,
|
||||
set_user_email,
|
||||
@@ -28,8 +27,7 @@ from ..db import (
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
delete_signup_invite,
|
||||
reserve_signup_invite_use,
|
||||
release_signup_invite_use,
|
||||
increment_signup_invite_use,
|
||||
get_user_profile,
|
||||
get_user_activity,
|
||||
get_user_activity_summary,
|
||||
@@ -49,15 +47,8 @@ from ..security import (
|
||||
verify_password,
|
||||
)
|
||||
from ..security import create_stream_token
|
||||
from ..auth import (
|
||||
clear_auth_cookies,
|
||||
get_current_user,
|
||||
normalize_user_auth_provider,
|
||||
resolve_user_auth_provider,
|
||||
set_auth_cookies,
|
||||
)
|
||||
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
|
||||
from ..config import settings
|
||||
from ..network_security import request_trusts_forwarded_headers
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
@@ -104,33 +95,13 @@ def _require_recipient_email(value: object) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _optional_recipient_email(value: object) -> str | None:
|
||||
if value is None or not str(value).strip():
|
||||
return None
|
||||
return _require_recipient_email(value)
|
||||
|
||||
|
||||
def _optional_account_email(value: object) -> str | None:
|
||||
if value is None or not str(value).strip():
|
||||
return None
|
||||
normalized = normalize_delivery_email(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Enter a valid email address.",
|
||||
)
|
||||
|
||||
|
||||
def _auth_client_ip(request: Request) -> str:
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if isinstance(forwarded, str) and forwarded.strip():
|
||||
return forwarded.split(",", 1)[0].strip()
|
||||
real = request.headers.get("x-real-ip")
|
||||
if isinstance(real, str) and real.strip():
|
||||
return real.strip()
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if isinstance(forwarded, str) and forwarded.strip():
|
||||
return forwarded.split(",", 1)[0].strip()
|
||||
real = request.headers.get("x-real-ip")
|
||||
if isinstance(real, str) and real.strip():
|
||||
return real.strip()
|
||||
if request.client and request.client.host:
|
||||
return str(request.client.host)
|
||||
return "unknown"
|
||||
@@ -387,19 +358,9 @@ def _assert_user_can_login(user: dict | None) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
|
||||
|
||||
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
|
||||
set_auth_cookies(response, token)
|
||||
return {
|
||||
"authenticated": True,
|
||||
"token_type": "cookie",
|
||||
"user": user_payload,
|
||||
}
|
||||
|
||||
|
||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
"email_bound": bool(invite.get("recipient_email")),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"enabled": bool(invite.get("enabled")),
|
||||
@@ -619,11 +580,7 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=local username=%s client=%s",
|
||||
@@ -672,19 +629,15 @@ async def login(
|
||||
user["role"],
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": user["username"], "role": user["role"]},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user["username"], "role": user["role"]},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/jellyfin/login")
|
||||
async def jellyfin_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=jellyfin username=%s client=%s",
|
||||
@@ -715,13 +668,13 @@ async def jellyfin_login(
|
||||
canonical_username,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
try:
|
||||
auth_response = await client.authenticate_by_name(username, password)
|
||||
response = await client.authenticate_by_name(username, password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=jellyfin username=%s client=%s",
|
||||
@@ -729,7 +682,7 @@ async def jellyfin_login(
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
if not preferred_match:
|
||||
@@ -757,11 +710,6 @@ async def jellyfin_login(
|
||||
save_jellyfin_users_cache(users)
|
||||
except Exception:
|
||||
pass
|
||||
from ..services.jellyfin_identity import link_user
|
||||
|
||||
jellyfin_id = client._extract_user_id(auth_response)
|
||||
if jellyfin_id:
|
||||
link_user(canonical_username, jellyfin_id, runtime.jellyfin_base_url)
|
||||
sync_jellyfin_password_state(canonical_username, password)
|
||||
if user and user.get("jellyseerr_user_id") is None and candidate_map:
|
||||
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
||||
@@ -776,20 +724,16 @@ async def jellyfin_login(
|
||||
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seerr/login")
|
||||
@router.post("/jellyseerr/login")
|
||||
async def jellyseerr_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=seerr username=%s client=%s",
|
||||
@@ -801,7 +745,7 @@ async def jellyseerr_login(
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
|
||||
try:
|
||||
auth_response = await client.login_local(form_data.username, form_data.password)
|
||||
response = await client.login_local(form_data.username, form_data.password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=seerr username=%s client=%s",
|
||||
@@ -809,11 +753,11 @@ async def jellyseerr_login(
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(auth_response, dict):
|
||||
if not isinstance(response, dict):
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(response)
|
||||
ci_matches = get_users_by_username_ci(form_data.username)
|
||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||
@@ -847,11 +791,11 @@ async def jellyseerr_login(
|
||||
jellyseerr_user_id,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -859,12 +803,6 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response) -> dict:
|
||||
clear_auth_cookies(response)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/stream-token")
|
||||
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
token = create_stream_token(
|
||||
@@ -894,7 +832,7 @@ async def invite_details(code: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(payload: dict, response: Response) -> dict:
|
||||
async def signup(payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
invite_code = str(payload.get("invite_code") or "").strip()
|
||||
@@ -927,16 +865,6 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
if remaining_uses is not None and int(remaining_uses) <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
|
||||
|
||||
account_email = normalize_delivery_email(invite.get('recipient_email'))
|
||||
if account_email:
|
||||
supplied_email = str(payload.get('email') or '').strip()
|
||||
if supplied_email and normalize_delivery_email(supplied_email) != account_email:
|
||||
raise HTTPException(status_code=400, detail='This invitation is tied to the email address it was sent to.')
|
||||
else:
|
||||
account_email = normalize_delivery_email(payload.get('email'))
|
||||
if not account_email:
|
||||
raise HTTPException(status_code=400, detail='A valid email address is required to create your account.')
|
||||
|
||||
profile = None
|
||||
profile_id = invite.get("profile_id")
|
||||
if profile_id is not None:
|
||||
@@ -963,120 +891,113 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
if isinstance(account_expires_days, int) and account_expires_days > 0:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
||||
|
||||
if not reserve_signup_invite_use(int(invite['id'])):
|
||||
raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.')
|
||||
account_created = False
|
||||
try:
|
||||
runtime = get_runtime_settings()
|
||||
auth_provider = "local"
|
||||
local_password_value = password_value
|
||||
matched_jellyseerr_user_id: int | None = None
|
||||
runtime = get_runtime_settings()
|
||||
auth_provider = "local"
|
||||
local_password_value = password_value
|
||||
matched_jellyseerr_user_id: int | None = None
|
||||
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if jellyfin_client.configured():
|
||||
logger.info("signup provisioning jellyfin username=%s", username)
|
||||
auth_provider = "jellyfin"
|
||||
local_password_value = password_value
|
||||
try:
|
||||
await jellyfin_client.create_user_with_password(username, password_value)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code = exc.response.status_code if exc.response is not None else None
|
||||
duplicate_like = status_code in {400, 409}
|
||||
if duplicate_like:
|
||||
try:
|
||||
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
except Exception as auth_exc:
|
||||
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
||||
) from exc
|
||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Jellyfin account already exists for that username.",
|
||||
) from exc
|
||||
else:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if jellyfin_client.configured():
|
||||
logger.info("signup provisioning jellyfin username=%s", username)
|
||||
auth_provider = "jellyfin"
|
||||
local_password_value = password_value
|
||||
try:
|
||||
await jellyfin_client.create_user_with_password(username, password_value)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code = exc.response.status_code if exc.response is not None else None
|
||||
duplicate_like = status_code in {400, 409}
|
||||
if duplicate_like:
|
||||
try:
|
||||
response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
except Exception as auth_exc:
|
||||
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Jellyfin account already exists for that username.",
|
||||
) from exc
|
||||
else:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||
) from exc
|
||||
|
||||
await _refresh_jellyfin_user_cache(jellyfin_client)
|
||||
jellyseerr_users = get_cached_jellyseerr_users()
|
||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||
if candidate_map:
|
||||
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
|
||||
await _refresh_jellyfin_user_cache(jellyfin_client)
|
||||
jellyseerr_users = get_cached_jellyseerr_users()
|
||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||
if candidate_map:
|
||||
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
|
||||
|
||||
try:
|
||||
create_user(
|
||||
username,
|
||||
local_password_value,
|
||||
role=role,
|
||||
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
|
||||
auth_provider=auth_provider,
|
||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
profile_id=int(profile_id) if profile_id is not None else None,
|
||||
expires_at=expires_at,
|
||||
invited_by_code=invite.get("code"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
increment_signup_invite_use(int(invite["id"]))
|
||||
created_user = get_user_by_username(username)
|
||||
if auth_provider == "jellyfin":
|
||||
sync_jellyfin_password_state(username, password_value)
|
||||
if (
|
||||
created_user
|
||||
and created_user.get("jellyseerr_user_id") is None
|
||||
and matched_jellyseerr_user_id is not None
|
||||
):
|
||||
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
|
||||
created_user = get_user_by_username(username)
|
||||
if created_user:
|
||||
try:
|
||||
create_user(
|
||||
username,
|
||||
local_password_value,
|
||||
role=role,
|
||||
email=account_email,
|
||||
auth_provider=auth_provider,
|
||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
profile_id=int(profile_id) if profile_id is not None else None,
|
||||
expires_at=expires_at,
|
||||
invited_by_code=invite.get("code"),
|
||||
await send_templated_email(
|
||||
"welcome",
|
||||
invite=invite,
|
||||
user=created_user,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
account_created = True
|
||||
created_user = get_user_by_username(username)
|
||||
if auth_provider == "jellyfin":
|
||||
sync_jellyfin_password_state(username, password_value)
|
||||
if (
|
||||
created_user
|
||||
and created_user.get("jellyseerr_user_id") is None
|
||||
and matched_jellyseerr_user_id is not None
|
||||
):
|
||||
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
|
||||
created_user = get_user_by_username(username)
|
||||
if created_user:
|
||||
try:
|
||||
await send_templated_email(
|
||||
"welcome",
|
||||
invite=invite,
|
||||
user=created_user,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Welcome email delivery is best-effort and must not break signup.
|
||||
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
||||
_assert_user_can_login(created_user)
|
||||
token = create_access_token(username, role)
|
||||
set_last_login(username)
|
||||
logger.info(
|
||||
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
|
||||
username,
|
||||
role,
|
||||
created_user.get("auth_provider") if created_user else auth_provider,
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{
|
||||
"username": username,
|
||||
"role": role,
|
||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not account_created:
|
||||
release_signup_invite_use(int(invite['id']))
|
||||
# Welcome email delivery is best-effort and must not break signup.
|
||||
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
||||
_assert_user_can_login(created_user)
|
||||
token = create_access_token(username, role)
|
||||
set_last_login(username)
|
||||
logger.info(
|
||||
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
|
||||
username,
|
||||
role,
|
||||
created_user.get("auth_provider") if created_user else auth_provider,
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
@@ -1202,37 +1123,6 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.put("/profile/email")
|
||||
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
|
||||
email = _optional_account_email(payload.get("email"))
|
||||
if email:
|
||||
duplicate = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in get_all_users()
|
||||
if str(candidate.get("username") or "").casefold() != username.casefold()
|
||||
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="That email address is already assigned to another account.",
|
||||
)
|
||||
|
||||
if not set_user_email(username, email):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
logger.info("User updated profile contact email: username=%s email_set=%s", username, bool(email))
|
||||
return {"status": "ok", "email": email}
|
||||
|
||||
|
||||
@router.get("/profile/invites")
|
||||
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
@@ -1284,10 +1174,8 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
send_email = bool(payload.get("send_email"))
|
||||
recipient_email = _optional_recipient_email(recipient_email)
|
||||
if send_email and not recipient_email:
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
master_invite = _get_self_service_master_invite()
|
||||
@@ -1376,10 +1264,8 @@ async def update_profile_invite(
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
send_email = bool(payload.get("send_email"))
|
||||
recipient_email = _optional_recipient_email(recipient_email)
|
||||
if send_email and not recipient_email:
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
master_invite = _get_self_service_master_invite()
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..auth import get_current_user_event_stream
|
||||
from . import requests as requests_router
|
||||
from .status import services_status
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
@@ -84,7 +85,9 @@ async def events_stream(
|
||||
async def event_generator():
|
||||
yield "retry: 2000\n\n"
|
||||
last_recent_signature: Optional[str] = None
|
||||
last_services_signature: Optional[str] = None
|
||||
next_recent_at = 0.0
|
||||
next_services_at = 0.0
|
||||
heartbeat_counter = 0
|
||||
|
||||
while True:
|
||||
@@ -126,6 +129,27 @@ async def events_stream(
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if now >= next_services_at:
|
||||
next_services_at = now + 30.0
|
||||
try:
|
||||
status_payload = await services_status()
|
||||
payload = {
|
||||
"type": "home_services",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status_payload,
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "home_services",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
}
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_services_signature:
|
||||
last_services_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if sent_any:
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
|
||||
@@ -3,7 +3,6 @@ import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||
@@ -18,10 +17,6 @@ async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(
|
||||
)
|
||||
if not webhook_url:
|
||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||
try:
|
||||
webhook_url = validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||
if feedback_type not in {"bug", "feature"}:
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||
from ..services.insights import get_insights
|
||||
|
||||
router = APIRouter(prefix="/insights", tags=["insights"])
|
||||
|
||||
|
||||
class InsightsQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
days: int = 30
|
||||
|
||||
@field_validator("days")
|
||||
@classmethod
|
||||
def supported_period(cls, value: int) -> int:
|
||||
if value not in {7, 30, 90, 365}:
|
||||
raise ValueError("Choose 7, 30, 90 or 365 days")
|
||||
return value
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def dashboard(query: Annotated[InsightsQuery, Query()], response: Response,
|
||||
user: dict = Depends(get_current_user)) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return await get_insights(user, query.days)
|
||||
except HistoryLimitError as exc:
|
||||
raise HTTPException(status_code=422, detail="There is too much history for this period. Choose a shorter period.") from exc
|
||||
except JellystatError as exc:
|
||||
raise HTTPException(status_code=502, detail="Your viewing stats are temporarily unavailable. Please try again shortly.") from exc
|
||||
@@ -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
|
||||
@@ -1,37 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..db import (
|
||||
add_portal_item_activity,
|
||||
add_portal_comment,
|
||||
count_portal_items,
|
||||
create_portal_item,
|
||||
delete_portal_item,
|
||||
get_portal_item,
|
||||
get_portal_overview,
|
||||
list_portal_comments as _list_portal_comments,
|
||||
get_all_users,
|
||||
list_portal_item_activity,
|
||||
list_portal_comments,
|
||||
list_portal_items,
|
||||
update_portal_item,
|
||||
)
|
||||
from ..services.issue_resolution import (
|
||||
begin_issue_confirmation,
|
||||
issue_resolution_state,
|
||||
respond_to_issue_confirmation,
|
||||
)
|
||||
from ..services.notifications import send_portal_notification
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,7 +33,6 @@ PORTAL_STATUSES = {
|
||||
"done",
|
||||
"declined",
|
||||
"closed",
|
||||
"awaiting_confirmation",
|
||||
# Seerr-style request pipeline statuses
|
||||
"pending",
|
||||
"approved",
|
||||
@@ -70,11 +55,6 @@ PORTAL_MEDIA_STATUSES = {
|
||||
PORTAL_ISSUE_TYPES = {
|
||||
"general",
|
||||
"playback",
|
||||
"transcode",
|
||||
"service_unavailable",
|
||||
"broken_media",
|
||||
"wrong_content",
|
||||
"audio",
|
||||
"subtitle",
|
||||
"quality",
|
||||
"metadata",
|
||||
@@ -82,9 +62,6 @@ PORTAL_ISSUE_TYPES = {
|
||||
"other",
|
||||
}
|
||||
|
||||
_MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None}
|
||||
_MEDIA_STATUS_CACHE_SECONDS = 15.0
|
||||
|
||||
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
|
||||
"pending": {"pending", "approved", "declined"},
|
||||
"approved": {"approved", "declined"},
|
||||
@@ -262,97 +239,6 @@ def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
|
||||
return "Approved"
|
||||
|
||||
|
||||
ISSUE_WORKFLOW_STAGES = (
|
||||
("reported", "Reported"),
|
||||
("review", "Under review"),
|
||||
("planned", "Fix planned"),
|
||||
("repair", "Fix underway"),
|
||||
("confirmation", "Confirm fix"),
|
||||
("resolved", "Resolved"),
|
||||
)
|
||||
|
||||
ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = {
|
||||
"new": (
|
||||
0,
|
||||
"Issue received",
|
||||
"Your report has been logged and is waiting for the support team to review it.",
|
||||
"active",
|
||||
),
|
||||
"triaging": (
|
||||
1,
|
||||
"Being investigated",
|
||||
"The support team is checking the report and identifying the right fix.",
|
||||
"active",
|
||||
),
|
||||
"planned": (
|
||||
2,
|
||||
"Fix ready to begin",
|
||||
"The problem has been reviewed and the next action has been selected.",
|
||||
"active",
|
||||
),
|
||||
"in_progress": (
|
||||
3,
|
||||
"Fix in progress",
|
||||
"Work is underway on the affected content or service.",
|
||||
"active",
|
||||
),
|
||||
"blocked": (
|
||||
3,
|
||||
"Fix needs attention",
|
||||
"Work has paused because the support team needs another service, resource, or decision before continuing.",
|
||||
"attention",
|
||||
),
|
||||
"awaiting_confirmation": (
|
||||
4,
|
||||
"Waiting for confirmation",
|
||||
"A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.",
|
||||
"active",
|
||||
),
|
||||
"done": (
|
||||
5,
|
||||
"Issue resolved",
|
||||
"The reported problem has been fixed and the issue is complete.",
|
||||
"complete",
|
||||
),
|
||||
"closed": (
|
||||
5,
|
||||
"Issue resolved",
|
||||
"The reported problem has been fixed and the issue is closed.",
|
||||
"complete",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _issue_workflow_payload(status: Any) -> Dict[str, Any]:
|
||||
normalized_status = str(status or "new").strip().lower()
|
||||
stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get(
|
||||
normalized_status,
|
||||
ISSUE_STATUS_TO_STAGE["new"],
|
||||
)
|
||||
steps = []
|
||||
for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES):
|
||||
step_state = (
|
||||
"complete"
|
||||
if index < stage_index or (index == stage_index and state == "complete")
|
||||
else "active"
|
||||
if index == stage_index
|
||||
else "waiting"
|
||||
)
|
||||
if index == stage_index and state == "attention":
|
||||
step_state = "attention"
|
||||
steps.append({"key": key, "label": label, "state": step_state})
|
||||
return {
|
||||
"current_step": stage_index + 1,
|
||||
"total_steps": len(ISSUE_WORKFLOW_STAGES),
|
||||
"stage": ISSUE_WORKFLOW_STAGES[stage_index][0],
|
||||
"stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1],
|
||||
"headline": headline,
|
||||
"message": message,
|
||||
"state": state,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_request_pipeline(
|
||||
request_status: Optional[str],
|
||||
media_status: Optional[str],
|
||||
@@ -453,61 +339,6 @@ def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
|
||||
return str(user.get("username") or "") == str(item.get("created_by_username") or "")
|
||||
|
||||
|
||||
def _public_media_status_payload(
|
||||
*,
|
||||
status: str,
|
||||
headline: str,
|
||||
message: str,
|
||||
latency_ms: Optional[int] = None,
|
||||
version: Optional[str] = None,
|
||||
restart_pending: Optional[bool] = None,
|
||||
active_streams: Optional[int] = None,
|
||||
transcoding_streams: Optional[int] = None,
|
||||
session_check_available: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"headline": headline,
|
||||
"message": message,
|
||||
"latency_ms": latency_ms,
|
||||
"server": {
|
||||
"version": version,
|
||||
"restart_pending": restart_pending,
|
||||
},
|
||||
"activity": {
|
||||
"active_streams": active_streams,
|
||||
"transcoding_streams": transcoding_streams,
|
||||
"available": session_check_available,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _public_text(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
identities = {str(u.get(key) or '').strip() for u in get_all_users() for key in ('username', 'email')}
|
||||
identities.discard('')
|
||||
if identities:
|
||||
pattern = r'(?<![\w@])(?:' + '|'.join(re.escape(v) for v in sorted(identities, key=len, reverse=True)) + r')(?![\w@])'
|
||||
value = re.sub(pattern, '[private]', value, flags=re.IGNORECASE)
|
||||
return re.sub(r'[\w.+%-]+@[\w.-]+\.[A-Za-z]{2,}', '[private email]', value)
|
||||
|
||||
|
||||
def _public_comment(comment: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': comment.get('id'), 'item_id': comment.get('item_id'),
|
||||
'author_username': 'Support team' if comment.get('author_role') == 'admin' else 'Reporter',
|
||||
'author_role': comment.get('author_role'), 'created_at': comment.get('created_at'),
|
||||
'message': _public_text(comment.get('message')), 'is_internal': False,
|
||||
}
|
||||
|
||||
|
||||
def list_portal_comments(*args, **kwargs):
|
||||
comments = _list_portal_comments(*args, **kwargs)
|
||||
return comments if kwargs.get('include_internal') else [_public_comment(c) for c in comments if not c.get('is_internal')]
|
||||
|
||||
|
||||
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
is_admin = _is_admin(user)
|
||||
is_owner = _is_owner(user, item)
|
||||
@@ -516,13 +347,7 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
||||
"can_edit": is_admin or is_owner,
|
||||
"can_comment": True,
|
||||
"can_moderate": is_admin,
|
||||
"can_delete": is_admin and str(item.get("kind") or "").lower() == "issue",
|
||||
"can_raise_issue": str(item.get("kind") or "") == "request",
|
||||
"can_confirm_resolution": (
|
||||
str(item.get("kind") or "").lower() == "issue"
|
||||
and str(item.get("status") or "").lower() == "awaiting_confirmation"
|
||||
and (is_admin or is_owner)
|
||||
),
|
||||
}
|
||||
kind = str(item.get("kind") or "").strip().lower()
|
||||
if kind == "request":
|
||||
@@ -534,96 +359,15 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
||||
"is_terminal": media_status in {"available", "failed"} or request_status == "declined",
|
||||
}
|
||||
elif kind == "issue":
|
||||
resolution = issue_resolution_state(item)
|
||||
serialized["issue"] = {
|
||||
"issue_type": _clean_text(item.get("issue_type")) or "general",
|
||||
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
|
||||
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
|
||||
"resolved_at": _clean_text(item.get("issue_resolved_at")),
|
||||
"workflow": _issue_workflow_payload(item.get("status")),
|
||||
"confirmation": {
|
||||
"status": resolution.get("status"),
|
||||
"attempts_sent": int(resolution.get("attemptsSent") or 0),
|
||||
"maximum_attempts": int(resolution.get("maximumAttempts") or 0),
|
||||
"last_contact_at": resolution.get("lastContactAt"),
|
||||
"next_contact_at": resolution.get("nextContactAt"),
|
||||
"interval_value": resolution.get("intervalValue"),
|
||||
"interval_unit": resolution.get("intervalUnit"),
|
||||
"last_delivery_succeeded": resolution.get("lastDeliverySucceeded"),
|
||||
},
|
||||
}
|
||||
if not is_admin:
|
||||
serialized = {key: value for key, value in serialized.items() if key in {
|
||||
'id', 'kind', 'title', 'description', 'media_type', 'year', 'source_request_id',
|
||||
'related_item_id', 'status', 'workflow_request_status', 'workflow_media_status',
|
||||
'issue_type', 'issue_resolved_at', 'priority', 'created_at', 'updated_at',
|
||||
'last_activity_at', 'permissions', 'workflow', 'issue',
|
||||
}}
|
||||
serialized['created_by_username'] = user.get('username') if is_owner else 'Another member'
|
||||
serialized['title'] = _public_text(serialized.get('title'))
|
||||
serialized['description'] = _public_text(serialized.get('description'))
|
||||
return serialized
|
||||
|
||||
|
||||
def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -> list[Dict[str, Any]]:
|
||||
activity = [
|
||||
entry
|
||||
for entry in list_portal_item_activity(int(item["id"]), limit=300)
|
||||
if include_internal or entry.get("event_type") != "internal_note_added"
|
||||
]
|
||||
if not any(entry.get("event_type") == "item_created" for entry in activity):
|
||||
activity.insert(
|
||||
0,
|
||||
{
|
||||
"id": f"created-{item['id']}",
|
||||
"item_id": item["id"],
|
||||
"event_type": "item_created",
|
||||
"actor_username": item.get("created_by_username") or "unknown",
|
||||
"actor_role": "user",
|
||||
"message": (
|
||||
"Issue raised and added to the support queue."
|
||||
if str(item.get("kind") or "").lower() == "issue"
|
||||
else "Portal item created."
|
||||
),
|
||||
"metadata_json": None,
|
||||
"created_at": item.get("created_at"),
|
||||
},
|
||||
)
|
||||
if include_internal:
|
||||
return activity
|
||||
public_activity: list[Dict[str, Any]] = []
|
||||
for entry in activity:
|
||||
public_entry = {key: value for key, value in entry.items() if key != "metadata_json"}
|
||||
actor_role = str(entry.get("actor_role") or "user").lower()
|
||||
public_entry["actor_username"] = (
|
||||
"Magent"
|
||||
if actor_role == "system"
|
||||
else "Support team"
|
||||
if actor_role == "admin"
|
||||
else "Reporter"
|
||||
)
|
||||
public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user"
|
||||
public_entry['message'] = _public_text(public_entry.get('message'))
|
||||
public_activity.append(public_entry)
|
||||
return public_activity
|
||||
|
||||
|
||||
def _record_activity(
|
||||
item_id: int,
|
||||
*,
|
||||
event_type: str,
|
||||
message: str,
|
||||
user: Dict[str, Any],
|
||||
) -> None:
|
||||
add_portal_item_activity(
|
||||
item_id,
|
||||
event_type=event_type,
|
||||
actor_username=str(user.get("username") or "unknown"),
|
||||
actor_role=str(user.get("role") or "user"),
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
async def _notify(
|
||||
*,
|
||||
event_type: str,
|
||||
@@ -662,122 +406,6 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
|
||||
}
|
||||
|
||||
|
||||
@router.get("/issues/media-status")
|
||||
async def portal_media_status() -> Dict[str, Any]:
|
||||
"""Return a short, privacy-safe Jellyfin health check for guided issue reporting."""
|
||||
now = time.monotonic()
|
||||
cached_payload = _MEDIA_STATUS_CACHE.get("payload")
|
||||
if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0):
|
||||
return cached_payload
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not jellyfin.configured():
|
||||
payload = _public_media_status_payload(
|
||||
status="not_configured",
|
||||
headline="Media server status is unavailable",
|
||||
message="Magent cannot run a playback check right now. Your report can still be submitted.",
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
system_info = await jellyfin.get_system_info()
|
||||
except (httpx.HTTPError, RuntimeError, ValueError):
|
||||
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||
payload = _public_media_status_payload(
|
||||
status="down",
|
||||
headline="The media server is not responding",
|
||||
message=(
|
||||
"This looks broader than one title. The report will include the failed server check "
|
||||
"so an administrator can investigate the service first."
|
||||
),
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
except Exception:
|
||||
logger.exception("guided issue Jellyfin system check failed")
|
||||
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||
payload = _public_media_status_payload(
|
||||
status="down",
|
||||
headline="The media server check failed",
|
||||
message="Your report can still be submitted and will include this failed service check.",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||
info = system_info if isinstance(system_info, dict) else {}
|
||||
version_value = info.get("Version")
|
||||
version = str(version_value).strip() if version_value is not None else None
|
||||
restart_pending = bool(info.get("HasPendingRestart"))
|
||||
|
||||
session_check_available = False
|
||||
active_streams: Optional[int] = None
|
||||
transcoding_streams: Optional[int] = None
|
||||
try:
|
||||
sessions = await jellyfin.get_sessions()
|
||||
if isinstance(sessions, list):
|
||||
session_check_available = True
|
||||
active_streams = sum(
|
||||
1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem")
|
||||
)
|
||||
transcoding_streams = sum(
|
||||
1
|
||||
for session in sessions
|
||||
if isinstance(session, dict)
|
||||
and session.get("NowPlayingItem")
|
||||
and session.get("TranscodingInfo")
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("guided issue Jellyfin session check unavailable", exc_info=True)
|
||||
|
||||
if restart_pending:
|
||||
status = "degraded"
|
||||
headline = "Media server is online but needs attention"
|
||||
message = "Jellyfin is responding, but it reports that a restart is pending."
|
||||
elif session_check_available and active_streams:
|
||||
status = "up"
|
||||
headline = "Media server is online and actively streaming"
|
||||
message = (
|
||||
"Other playback is currently working, so this is more likely specific to the title, "
|
||||
"audio track, subtitle, client, or transcode path."
|
||||
)
|
||||
else:
|
||||
status = "up"
|
||||
headline = "Media server is online"
|
||||
message = "Jellyfin responded normally. Continue with the report if playback is still failing."
|
||||
|
||||
payload = _public_media_status_payload(
|
||||
status=status,
|
||||
headline=headline,
|
||||
message=message,
|
||||
latency_ms=latency_ms,
|
||||
version=version,
|
||||
restart_pending=restart_pending,
|
||||
active_streams=active_streams,
|
||||
transcoding_streams=transcoding_streams,
|
||||
session_check_available=session_check_available,
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/items")
|
||||
async def portal_list_items(
|
||||
kind: Optional[str] = None,
|
||||
@@ -993,16 +621,6 @@ async def portal_create_item(
|
||||
priority=priority or "normal",
|
||||
assignee_username=assignee_username,
|
||||
)
|
||||
_record_activity(
|
||||
int(created["id"]),
|
||||
event_type="item_created",
|
||||
message=(
|
||||
"Issue raised and added to the support queue."
|
||||
if created.get("kind") == "issue"
|
||||
else f"{str(created.get('kind') or 'Portal item').capitalize()} created."
|
||||
),
|
||||
user=current_user,
|
||||
)
|
||||
initial_comment = _clean_text(payload.get("comment"))
|
||||
if initial_comment:
|
||||
add_portal_comment(
|
||||
@@ -1022,7 +640,6 @@ async def portal_create_item(
|
||||
return {
|
||||
"item": _serialize_item(created, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
|
||||
}
|
||||
|
||||
|
||||
@@ -1075,12 +692,6 @@ async def portal_create_issue_for_request(
|
||||
priority=priority or "normal",
|
||||
assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
|
||||
)
|
||||
_record_activity(
|
||||
int(created["id"]),
|
||||
event_type="item_created",
|
||||
message=f"Issue raised and linked to collection request #{item_id}.",
|
||||
user=current_user,
|
||||
)
|
||||
initial_comment = _clean_text(payload.get("comment"))
|
||||
if initial_comment:
|
||||
add_portal_comment(
|
||||
@@ -1100,7 +711,6 @@ async def portal_create_issue_for_request(
|
||||
return {
|
||||
"item": _serialize_item(created, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
|
||||
"linked_request_id": item_id,
|
||||
}
|
||||
|
||||
@@ -1213,33 +823,9 @@ async def portal_get_item(
|
||||
return {
|
||||
"item": _serialize_item(item, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(item, include_internal=_is_admin(current_user)),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}")
|
||||
async def portal_delete_item(
|
||||
item_id: int,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
if not _is_admin(current_user):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
item = get_portal_item(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||
if str(item.get("kind") or "").lower() != "issue":
|
||||
raise HTTPException(status_code=400, detail="Only issues can be deleted here")
|
||||
if not delete_portal_item(item_id):
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
logger.info(
|
||||
"portal issue deleted id=%s title=%s actor=%s",
|
||||
item_id,
|
||||
item.get("title"),
|
||||
current_user.get("username"),
|
||||
)
|
||||
return {"status": "deleted", "item_id": item_id}
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}")
|
||||
async def portal_update_item(
|
||||
item_id: int,
|
||||
@@ -1251,7 +837,6 @@ async def portal_update_item(
|
||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||
is_admin = _is_admin(current_user)
|
||||
is_owner = _is_owner(current_user, item)
|
||||
item_kind = str(item.get("kind") or "").lower()
|
||||
if not (is_admin or is_owner):
|
||||
raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
|
||||
|
||||
@@ -1301,7 +886,7 @@ async def portal_update_item(
|
||||
if "external_ref" in payload:
|
||||
updates["external_ref"] = _clean_text(payload.get("external_ref"))
|
||||
if is_admin:
|
||||
kind = item_kind
|
||||
kind = str(item.get("kind") or "").lower()
|
||||
if "priority" in payload:
|
||||
updates["priority"] = _normalize_choice(
|
||||
payload.get("priority"),
|
||||
@@ -1391,9 +976,9 @@ async def portal_update_item(
|
||||
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
|
||||
if "status" in payload:
|
||||
next_status = str(updates.get("status") or item.get("status") or "").lower()
|
||||
if next_status == "closed":
|
||||
if next_status in {"done", "closed"}:
|
||||
updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
|
||||
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}:
|
||||
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
|
||||
updates.setdefault("issue_resolved_at", None)
|
||||
|
||||
if not updates:
|
||||
@@ -1401,47 +986,14 @@ async def portal_update_item(
|
||||
return {
|
||||
"item": _serialize_item(item, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(item, include_internal=is_admin),
|
||||
}
|
||||
|
||||
updated = update_portal_item(item_id, **updates)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||
|
||||
requested_issue_status = str(updates.get("status") or "").lower()
|
||||
should_start_confirmation = item_kind == "issue" and (
|
||||
(requested_issue_status == "done" and str(item.get("status") or "").lower() != "done")
|
||||
or (
|
||||
requested_issue_status == "awaiting_confirmation"
|
||||
and str(item.get("status") or "").lower() != "awaiting_confirmation"
|
||||
)
|
||||
)
|
||||
if should_start_confirmation:
|
||||
try:
|
||||
updated = await begin_issue_confirmation(
|
||||
item_id,
|
||||
actor_username=str(current_user.get("username") or "unknown"),
|
||||
actor_role=str(current_user.get("role") or "admin"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
|
||||
if changed_fields:
|
||||
if item_kind == "issue" and not should_start_confirmation:
|
||||
old_status = str(item.get("status") or "unknown").replace("_", " ")
|
||||
new_status = str(updated.get("status") or "unknown").replace("_", " ")
|
||||
activity_message = (
|
||||
f"Status changed from {old_status} to {new_status}."
|
||||
if item.get("status") != updated.get("status")
|
||||
else f"Issue details updated: {', '.join(sorted(changed_fields))}."
|
||||
)
|
||||
_record_activity(
|
||||
item_id,
|
||||
event_type="status_changed" if item.get("status") != updated.get("status") else "issue_updated",
|
||||
message=activity_message,
|
||||
user=current_user,
|
||||
)
|
||||
await _notify(
|
||||
event_type="portal_item_updated",
|
||||
item=updated,
|
||||
@@ -1452,42 +1004,6 @@ async def portal_update_item(
|
||||
return {
|
||||
"item": _serialize_item(updated, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(updated, include_internal=is_admin),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/issues/{item_id}/resolution-response")
|
||||
async def portal_issue_resolution_response(
|
||||
item_id: int,
|
||||
payload: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
if not (_is_admin(current_user) or _is_owner(current_user, item)):
|
||||
raise HTTPException(status_code=403, detail="Only the reporter or an admin can confirm this resolution")
|
||||
if not isinstance(payload.get("resolved"), bool):
|
||||
raise HTTPException(status_code=400, detail="resolved must be true or false")
|
||||
try:
|
||||
updated = respond_to_issue_confirmation(
|
||||
item_id,
|
||||
resolved=payload["resolved"],
|
||||
actor_username=str(current_user.get("username") or "unknown"),
|
||||
actor_role=str(current_user.get("role") or "user"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await _notify(
|
||||
event_type="portal_issue_resolution_confirmed" if payload["resolved"] else "portal_issue_resolution_rejected",
|
||||
item=updated,
|
||||
user=current_user,
|
||||
note="resolved=true" if payload["resolved"] else "resolved=false",
|
||||
)
|
||||
return {
|
||||
"item": _serialize_item(updated, current_user),
|
||||
"comments": list_portal_comments(item_id, include_internal=_is_admin(current_user)),
|
||||
"activity": _activity_payload(updated, include_internal=_is_admin(current_user)),
|
||||
}
|
||||
|
||||
|
||||
@@ -1529,17 +1045,6 @@ async def portal_create_comment(
|
||||
message=message,
|
||||
is_internal=is_internal,
|
||||
)
|
||||
if str(item.get("kind") or "").lower() == "issue":
|
||||
_record_activity(
|
||||
item_id,
|
||||
event_type="internal_note_added" if is_internal else "comment_added",
|
||||
message=(
|
||||
f"Internal troubleshooting note: {message[:240]}"
|
||||
if is_internal
|
||||
else f"Support update: {message[:240]}"
|
||||
),
|
||||
user=current_user,
|
||||
)
|
||||
updated_item = get_portal_item(item_id)
|
||||
if updated_item:
|
||||
await _notify(
|
||||
@@ -1548,4 +1053,4 @@ async def portal_create_comment(
|
||||
user=current_user,
|
||||
note=f"internal={is_internal}",
|
||||
)
|
||||
return {"comment": comment if is_admin else _public_comment(comment)}
|
||||
return {"comment": comment}
|
||||
|
||||
+168
-1570
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -31,19 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||
},
|
||||
"navigation": {
|
||||
"showRequests": bool(runtime.site_nav_show_requests),
|
||||
},
|
||||
}
|
||||
if include_changelog:
|
||||
info["changelog"] = (CHANGELOG or "").strip()
|
||||
playback_url = (runtime.jellyfin_public_url or "").strip()
|
||||
try:
|
||||
parsed = urlsplit(playback_url)
|
||||
valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
|
||||
except ValueError:
|
||||
valid = False
|
||||
info["mediaServerUrl"] = playback_url if valid else None
|
||||
return info
|
||||
|
||||
|
||||
|
||||
@@ -2,18 +2,16 @@ from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..auth import get_current_user
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.bazarr import BazarrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellystat import JellystatClient
|
||||
|
||||
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]:
|
||||
@@ -28,42 +26,12 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
||||
if not qbittorrent.base_url:
|
||||
return {"name": "qBittorrent", "status": "not_configured"}
|
||||
if not qbittorrent.username or not qbittorrent.password:
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded" if reachable else "not_configured",
|
||||
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
||||
}
|
||||
try:
|
||||
result = await qbittorrent.get_app_version()
|
||||
return {"name": "qBittorrent", "status": "up", "detail": result}
|
||||
except RuntimeError as exc:
|
||||
if "login failed" in str(exc).lower():
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
if reachable:
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded",
|
||||
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
||||
}
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def services_status() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
@@ -92,13 +60,6 @@ async def services_status() -> Dict[str, Any]:
|
||||
radarr.get_system_status,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.get_system_status,
|
||||
)
|
||||
)
|
||||
prowlarr_status = await _check(
|
||||
"Prowlarr",
|
||||
prowlarr.configured(),
|
||||
@@ -110,7 +71,13 @@ async def services_status() -> Dict[str, Any]:
|
||||
prowlarr_status["status"] = "degraded"
|
||||
prowlarr_status["message"] = "Health warnings"
|
||||
services.append(prowlarr_status)
|
||||
services.append(await _check_qbittorrent(qbittorrent))
|
||||
services.append(
|
||||
await _check(
|
||||
"qBittorrent",
|
||||
qbittorrent.configured(),
|
||||
qbittorrent.get_app_version,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyfin",
|
||||
@@ -119,11 +86,6 @@ async def services_status() -> Dict[str, Any]:
|
||||
)
|
||||
)
|
||||
|
||||
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
# Optional analytics must not degrade the media pipeline when not configured.
|
||||
if jellystat.configured():
|
||||
services.append(await _check("Jellystat", True, jellystat.test_connection))
|
||||
|
||||
overall = "up"
|
||||
if any(s.get("status") == "down" for s in services):
|
||||
overall = "down"
|
||||
@@ -139,7 +101,6 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
@@ -147,9 +108,6 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
service_key = service.strip().lower()
|
||||
if service_key == "jellystat":
|
||||
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
|
||||
checks = {
|
||||
"seerr": (
|
||||
"Seerr",
|
||||
@@ -163,18 +121,11 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
),
|
||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||
"bazarr": (
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.get_system_status,
|
||||
),
|
||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
|
||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||
}
|
||||
|
||||
if service_key == "qbittorrent":
|
||||
return await _check_qbittorrent(qbittorrent)
|
||||
|
||||
if service_key not in checks:
|
||||
raise HTTPException(status_code=404, detail="Unknown service")
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ _INT_FIELDS = {
|
||||
"requests_poll_interval_seconds",
|
||||
"requests_delta_sync_interval_minutes",
|
||||
"requests_cleanup_days",
|
||||
"issue_confirmation_contact_attempts",
|
||||
"issue_confirmation_interval_value",
|
||||
"magent_notify_email_smtp_port",
|
||||
}
|
||||
_BOOL_FIELDS = {
|
||||
@@ -41,7 +39,6 @@ _BOOL_FIELDS = {
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
}
|
||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||
|
||||
|
||||
@@ -44,8 +44,6 @@ def _create_token(
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
|
||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||
@@ -57,8 +55,6 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
|
||||
@@ -98,12 +97,7 @@ def _config_status(detail: str) -> str:
|
||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
||||
return False, "Discord notifications are disabled."
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
|
||||
return True, "ok"
|
||||
return False, "Discord webhook URL is required."
|
||||
|
||||
@@ -119,12 +113,7 @@ def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
||||
return False, "Generic webhook notifications are disabled."
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_webhook_url):
|
||||
return True, "ok"
|
||||
return False, "Generic webhook URL is required."
|
||||
|
||||
@@ -134,21 +123,11 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
return False, "Push notifications are disabled."
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
if provider == "ntfy":
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
|
||||
return True, "ok"
|
||||
return False, "Gotify requires a base URL and app token."
|
||||
if provider == "pushover":
|
||||
@@ -156,12 +135,7 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_push_base_url):
|
||||
return True, "ok"
|
||||
return False, "Webhook relay requires a target URL."
|
||||
if provider == "telegram":
|
||||
@@ -216,7 +190,6 @@ async def _run_http_post(
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -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
|
||||
@@ -1,181 +0,0 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import math
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .. import db
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellystat import JellystatClient, JellystatError
|
||||
from ..runtime import get_runtime_settings
|
||||
from .jellyfin_identity import link_user, linked_user_id
|
||||
|
||||
_cache: dict[tuple, tuple[float, dict]] = {}
|
||||
CACHE_SECONDS = 60
|
||||
|
||||
|
||||
def _date(value) -> datetime:
|
||||
try:
|
||||
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise JellystatError("Jellystat returned an invalid history date") from exc
|
||||
|
||||
|
||||
def _duration(value) -> float:
|
||||
try:
|
||||
result = float(value or 0)
|
||||
if not math.isfinite(result) or result < 0:
|
||||
raise ValueError()
|
||||
return result
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
raise JellystatError("Jellystat returned an invalid playback duration") from exc
|
||||
|
||||
|
||||
async def resolve_identity(user: dict, runtime) -> str | None:
|
||||
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
||||
if identity:
|
||||
return identity
|
||||
if user.get("auth_provider") != "jellyfin":
|
||||
return None
|
||||
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
|
||||
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
return None
|
||||
try:
|
||||
users = await client.get_users()
|
||||
except Exception as exc:
|
||||
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
|
||||
matches = [entry for entry in users if isinstance(entry, dict)
|
||||
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
|
||||
if len(matches) != 1 or not matches[0].get("Id"):
|
||||
return None
|
||||
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
|
||||
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
||||
|
||||
|
||||
def request_summary(user: dict, start: datetime, end: datetime) -> dict:
|
||||
clause = "julianday(created_at) >= julianday(?) AND julianday(created_at) <= julianday(?)"
|
||||
params = [start.isoformat(), end.isoformat()]
|
||||
if user.get("jellyseerr_user_id") is not None:
|
||||
clause += " AND requested_by_id = ?"
|
||||
params.append(user["jellyseerr_user_id"])
|
||||
else:
|
||||
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
|
||||
params.append(user["username"].strip().lower())
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
counts = conn.execute(f"""SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
|
||||
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
|
||||
COALESCE(SUM(status = 1), 0) AS pending,
|
||||
COALESCE(SUM(status = 2), 0) AS approved,
|
||||
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
|
||||
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
|
||||
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
|
||||
return {**dict(counts), "recent": [dict(row) for row in recent]}
|
||||
|
||||
|
||||
def summarize(history: list, libraries: list, start: datetime, end: datetime) -> dict:
|
||||
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
||||
daily_seconds = defaultdict(float)
|
||||
clients = defaultdict(float)
|
||||
methods = defaultdict(float)
|
||||
titles = {}
|
||||
movie_ids, episode_ids, seen = set(), set(), set()
|
||||
recent = []
|
||||
seconds = 0.0
|
||||
for row in history:
|
||||
row_id = str(row.get("Id") or "")
|
||||
if not row_id:
|
||||
raise JellystatError("Jellystat returned history without an activity ID")
|
||||
if row_id in seen:
|
||||
continue
|
||||
seen.add(row_id)
|
||||
date = _date(row.get("ActivityDateInserted"))
|
||||
# Defend against older upstream versions ignoring the range filter.
|
||||
if not start <= date <= end:
|
||||
continue
|
||||
duration = _duration(row.get("PlaybackDuration"))
|
||||
if duration <= 0:
|
||||
continue
|
||||
item_id = str(row.get("NowPlayingItemId") or row_id)
|
||||
episode_id = row.get("EpisodeId")
|
||||
library_type = library_types.get(str(row.get("ParentId")), "")
|
||||
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
|
||||
if media_type == "episode":
|
||||
episode_ids.add(str(episode_id))
|
||||
elif media_type == "movie":
|
||||
movie_ids.add(item_id)
|
||||
seconds += duration
|
||||
daily_seconds[date.date().isoformat()] += duration
|
||||
client = str(row.get("Client") or "Unknown player")[:200]
|
||||
clients[client] += duration
|
||||
method = str(row.get("PlayMethod") or "Unknown")
|
||||
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
|
||||
methods[method] += duration
|
||||
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
||||
series = str(row.get("SeriesName") or "")[:500]
|
||||
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0})
|
||||
title["minutes"] += duration / 60
|
||||
title["plays"] += 1
|
||||
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
|
||||
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
|
||||
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
|
||||
"method": method})
|
||||
count = (end.date() - start.date()).days + 1
|
||||
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
|
||||
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
|
||||
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
|
||||
longest = run = 0
|
||||
for day in daily:
|
||||
run = run + 1 if day["date"] in active_days else 0
|
||||
longest = max(longest, run)
|
||||
current = 0
|
||||
cursor = end.date() if end.date().isoformat() in active_days else end.date() - timedelta(days=1)
|
||||
while cursor.isoformat() in active_days:
|
||||
current += 1
|
||||
cursor -= timedelta(days=1)
|
||||
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
|
||||
for row in top:
|
||||
row["minutes"] = round(row["minutes"], 1)
|
||||
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
||||
"episodes": len(episode_ids), "active_days": len(active_days),
|
||||
"current_streak": current, "longest_streak": longest},
|
||||
"daily": daily, "top_titles": top,
|
||||
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
|
||||
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
|
||||
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
|
||||
|
||||
|
||||
async def get_insights(user: dict, days: int) -> dict:
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
requests = await asyncio.to_thread(request_summary, user, start, end)
|
||||
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
|
||||
"is_admin": user.get("role") == "admin", "summary": None}
|
||||
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
if not client.configured():
|
||||
return {**base, "state": "not_configured"}
|
||||
identity = await resolve_identity(user, runtime)
|
||||
if not identity:
|
||||
return {**base, "state": "unlinked"}
|
||||
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
|
||||
runtime.jellyfin_base_url, identity, days)
|
||||
cached = _cache.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
return {**base, **cached[1]}
|
||||
history, libraries = await client.get_user_history(identity, start, end)
|
||||
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
|
||||
"period_start": start.isoformat(), "period_end": end.isoformat()}
|
||||
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
|
||||
_cache.pop(expired, None)
|
||||
if len(_cache) >= 128:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
|
||||
return {**base, **data}
|
||||
@@ -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)
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Stable Jellyfin identities for private, user-scoped integrations."""
|
||||
|
||||
import hashlib
|
||||
from contextlib import closing
|
||||
|
||||
from .. import db
|
||||
|
||||
|
||||
def source_key(base_url: str | None) -> str:
|
||||
return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
|
||||
|
||||
|
||||
def linked_user_id(username: str, base_url: str | None) -> str | None:
|
||||
user = db.get_user_by_username(username)
|
||||
if not user or not base_url:
|
||||
return None
|
||||
with closing(db._connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
|
||||
(source_key(base_url), user["id"]),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
|
||||
"""Use only verified login or canonical Jellyfin user sync, never playback names."""
|
||||
user = db.get_user_by_username(username)
|
||||
if not user or not jellyfin_user_id or not base_url:
|
||||
return
|
||||
with closing(db._connect()) as conn, conn:
|
||||
# A renamed or re-created account must not silently take over an existing identity.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
|
||||
(source_key(base_url), user["id"], str(jellyfin_user_id)),
|
||||
)
|
||||
@@ -11,7 +11,6 @@ from ..db import (
|
||||
set_user_jellyseerr_id,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .jellyfin_identity import link_user
|
||||
from .user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
@@ -69,10 +68,6 @@ async def sync_jellyfin_users() -> int:
|
||||
set_user_jellyseerr_id(name, matched_id)
|
||||
if matched_email:
|
||||
set_user_email(name, matched_email)
|
||||
if user.get("Id"):
|
||||
local_user = get_user_by_username(name)
|
||||
if local_user and local_user.get("auth_provider") == "jellyfin":
|
||||
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
|
||||
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."}
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
@@ -50,7 +49,6 @@ def _portal_item_url(item_id: int) -> str:
|
||||
|
||||
|
||||
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()
|
||||
@@ -117,7 +115,6 @@ async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[
|
||||
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:
|
||||
@@ -127,7 +124,6 @@ async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,8 @@ fastapi==0.134.0
|
||||
uvicorn==0.41.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.14.2
|
||||
PyJWT==2.13.0
|
||||
pydantic-settings==2.13.1
|
||||
PyJWT==2.11.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.31
|
||||
Pillow==12.3.0
|
||||
prometheus-client==0.22.1
|
||||
python-multipart==0.0.22
|
||||
Pillow==12.1.1
|
||||
|
||||
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,201 +0,0 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
|
||||
from backend.app.routers import admin, insights as router
|
||||
from backend.app.services import insights
|
||||
from backend.app.services.jellyfin_identity import link_user, linked_user_id
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
|
||||
USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42}
|
||||
LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}]
|
||||
|
||||
|
||||
def play(id="play-1", **extra):
|
||||
return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1",
|
||||
"NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600,
|
||||
"ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE",
|
||||
"PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra}
|
||||
|
||||
|
||||
class JellystatClientTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def history(self, handler, **kwargs):
|
||||
original = httpx.AsyncClient
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)):
|
||||
return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history(
|
||||
kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW)
|
||||
|
||||
async def test_paginates_and_sends_only_backend_identity_and_header_credential(self):
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
self.assertEqual(request.headers["x-api-token"], "secret-api-key")
|
||||
self.assertNotIn("secret-api-key", str(request.url))
|
||||
if request.url.path == "/base/api/getLibraries":
|
||||
return httpx.Response(200, json=LIBRARIES)
|
||||
self.assertEqual(request.method, "POST")
|
||||
self.assertEqual(request.url.path, "/base/api/getUserHistory")
|
||||
self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"})
|
||||
self.assertNotIn("search", request.url.params)
|
||||
self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted")
|
||||
return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]})
|
||||
history, libraries = await self.history(handler)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(len(history), 2)
|
||||
self.assertEqual(libraries, LIBRARIES)
|
||||
|
||||
async def test_rejects_foreign_history_malformed_responses_and_overflow(self):
|
||||
for payload, exception in [
|
||||
({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError),
|
||||
({"pages": 1, "results": [play(UserId=None)]}, JellystatError),
|
||||
({"results": []}, JellystatError),
|
||||
({"pages": 51, "results": []}, HistoryLimitError),
|
||||
({"pages": 2, "results": []}, JellystatError),
|
||||
({"pages": 0, "results": [play()]}, JellystatError),
|
||||
]:
|
||||
with self.subTest(payload=payload):
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload)
|
||||
with self.assertRaises(exception):
|
||||
await self.history(handler)
|
||||
|
||||
async def test_empty_history_is_valid(self):
|
||||
result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []}))
|
||||
self.assertEqual(result, [])
|
||||
|
||||
async def test_upstream_failure_is_sanitized(self):
|
||||
with self.assertRaises(JellystatError) as error:
|
||||
await self.history(lambda _: httpx.Response(401, text="private upstream error"))
|
||||
self.assertNotIn("private", str(error.exception))
|
||||
self.assertNotIn("secret-api-key", str(error.exception))
|
||||
|
||||
|
||||
class SummaryTests(unittest.TestCase):
|
||||
def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self):
|
||||
rows = [play(), play(), play("rewatch"),
|
||||
play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200",
|
||||
ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()),
|
||||
play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200,
|
||||
ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()),
|
||||
play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180),
|
||||
play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()),
|
||||
play("zero", PlaybackDuration=0)]
|
||||
data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1,
|
||||
"active_days": 3, "current_streak": 3, "longest_streak": 3})
|
||||
self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163)
|
||||
self.assertEqual(data["top_titles"][0]["title"], "Arrival")
|
||||
self.assertEqual(len(data["recent"]), 5)
|
||||
self.assertNotIn("PRIVATE", json.dumps(data))
|
||||
|
||||
def test_invalid_durations_do_not_become_zero_or_nan(self):
|
||||
for value in [-1, "NaN", "Infinity", "nonsense"]:
|
||||
with self.subTest(value=value), self.assertRaises(JellystatError):
|
||||
insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
|
||||
def test_empty_history_has_zero_filled_days(self):
|
||||
result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(result["summary"]["minutes"], 0)
|
||||
self.assertEqual(len(result["daily"]), 8)
|
||||
self.assertEqual(result["summary"]["current_streak"], 0)
|
||||
|
||||
|
||||
class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
insights._cache.clear()
|
||||
db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin")
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key",
|
||||
jellystat_base_url="http://jellystat", jellystat_api_key="stats-key")
|
||||
|
||||
async def test_identity_does_not_change_with_username_reuse_or_server_changes(self):
|
||||
link_user("viewer", "jf-original", "http://jellyfin/")
|
||||
link_user("viewer", "jf-replacement", "http://jellyfin")
|
||||
self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original")
|
||||
self.assertIsNone(linked_user_id("viewer", "http://other-server"))
|
||||
|
||||
async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self):
|
||||
with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote:
|
||||
self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime))
|
||||
remote.assert_not_called()
|
||||
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
|
||||
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
|
||||
async def test_requests_use_seerr_id_even_when_name_matches_another_user(self):
|
||||
for request_id, seerr_id in [(1, 42), (2, 99)]:
|
||||
db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026,
|
||||
"viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}")
|
||||
report = insights.request_summary(USER, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(report["total"], 1)
|
||||
self.assertEqual(report["recent"][0]["request_id"], 1)
|
||||
|
||||
async def test_cache_isolated_by_identity_period_and_configuration(self):
|
||||
link_user("viewer", "jf-viewer", "http://jellyfin")
|
||||
db.create_user("second", "Test-Password123!", auth_provider="jellyfin")
|
||||
link_user("second", "jf-second", "http://jellyfin")
|
||||
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
|
||||
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
|
||||
await insights.get_insights(USER, 7)
|
||||
await insights.get_insights(USER, 7)
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
await insights.get_insights({**USER, "username": "second"}, 7)
|
||||
await insights.get_insights(USER, 30)
|
||||
self.runtime.jellystat_api_key = "rotated-key"
|
||||
await insights.get_insights(USER, 7)
|
||||
self.assertEqual(remote.await_count, 4)
|
||||
|
||||
async def test_disabled_integration_never_calls_upstream(self):
|
||||
self.runtime.jellystat_api_key = None
|
||||
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
|
||||
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote:
|
||||
result = await insights.get_insights(USER, 30)
|
||||
self.assertEqual(result["state"], "not_configured")
|
||||
self.assertIsNone(result["summary"])
|
||||
remote.assert_not_called()
|
||||
|
||||
async def test_settings_mask_jellystat_credential(self):
|
||||
db.set_setting("jellystat_api_key", "private-stats-key")
|
||||
result = await admin.list_settings()
|
||||
setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key")
|
||||
self.assertTrue(setting["sensitive"])
|
||||
self.assertTrue(setting["isSet"])
|
||||
self.assertNotIn("private-stats-key", json.dumps(result))
|
||||
|
||||
|
||||
class InsightsRouteTests(unittest.TestCase):
|
||||
def app(self, authenticated=True):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[router.get_current_user] = lambda: USER
|
||||
return TestClient(app)
|
||||
|
||||
def test_requires_authentication(self):
|
||||
self.assertEqual(self.app(False).get("/insights").status_code, 401)
|
||||
|
||||
def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self):
|
||||
with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report:
|
||||
client = self.app()
|
||||
for days in [7, 30, 90, 365]:
|
||||
response = client.get(f"/insights?days={days}")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
report.assert_awaited_with(USER, 365)
|
||||
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
|
||||
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
|
||||
|
||||
def test_errors_do_not_leak_upstream_details(self):
|
||||
with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")):
|
||||
response = self.app().get("/insights")
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertNotIn("PRIVATE", response.text)
|
||||
@@ -1,54 +0,0 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException, Response
|
||||
from backend.app import db
|
||||
from backend.app.routers import auth
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def signup(self, code, username, **extra):
|
||||
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
|
||||
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
|
||||
|
||||
async def test_email_invite_binds_account_and_cannot_be_reused(self):
|
||||
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
|
||||
self.assertEqual(invite['max_uses'], 1)
|
||||
public = auth._public_invite_payload(invite)
|
||||
self.assertTrue(public['email_bound'])
|
||||
self.assertNotIn('recipient@example.com', str(public))
|
||||
await self.signup('EMAILTEST', 'first-user')
|
||||
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('EMAILTEST', 'second-user')
|
||||
|
||||
async def test_email_invite_rejects_recipient_override(self):
|
||||
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
|
||||
|
||||
async def test_manual_invite_requires_and_saves_email(self):
|
||||
db.create_signup_invite(code='MANUALTEST', max_uses=3)
|
||||
for email in ['', 'invalid']:
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('MANUALTEST', 'manual-user', email=email)
|
||||
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
|
||||
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
|
||||
|
||||
async def test_failed_creation_releases_reservation(self):
|
||||
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
|
||||
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('FAILTEST', 'failed-user')
|
||||
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
|
||||
|
||||
async def test_single_use_reservation_is_atomic(self):
|
||||
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
|
||||
self.assertEqual(sum(results), 1)
|
||||
@@ -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,20 +0,0 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from prometheus_client import REGISTRY, generate_latest
|
||||
from backend.app.metrics import record_api, record_remote
|
||||
|
||||
|
||||
class MetricsTests(unittest.TestCase):
|
||||
def test_route_template_not_private_path(self):
|
||||
request = SimpleNamespace(method='GET', scope={'route': SimpleNamespace(path='/requests/{request_id}')})
|
||||
before = REGISTRY.get_sample_value('magent_api_requests_total', {'method': 'GET', 'route': '/requests/{request_id}', 'status': '200'}) or 0
|
||||
record_api(request, 200, .1)
|
||||
self.assertEqual(REGISTRY.get_sample_value('magent_api_requests_total', {'method': 'GET', 'route': '/requests/{request_id}', 'status': '200'}), before + 1)
|
||||
|
||||
def test_unknown_route_and_service_are_bounded(self):
|
||||
record_api(SimpleNamespace(method='SECRET-USER-METHOD', scope={}), 404, .01)
|
||||
record_remote('secret-service-name', 'GET', 'error', .1)
|
||||
data = generate_latest().decode()
|
||||
self.assertNotIn('secret-service-name', data)
|
||||
self.assertNotIn('SECRET-USER-METHOD', data)
|
||||
self.assertIn('route="unmatched"', data)
|
||||
@@ -1,33 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app.routers import portal
|
||||
|
||||
|
||||
class PortalPrivacyTests(unittest.TestCase):
|
||||
def test_detail_and_comments_are_private_for_regular_users(self):
|
||||
item = {'id': 1, 'kind': 'issue', 'title': 'Broken movie', 'status': 'new',
|
||||
'created_by_username': 'private-reporter', 'created_by_id': 42,
|
||||
'assignee_username': 'private-admin', 'metadata_json': '{"email":"secret@example.com"}',
|
||||
'description': 'Contact private-reporter or secret@example.com', 'created_at': '2026-09-07'}
|
||||
comment = {'id': 1, 'item_id': 1, 'author_username': 'private-admin', 'author_role': 'admin',
|
||||
'message': 'Sent to secret@example.com for private-reporter', 'is_internal': False}
|
||||
app = FastAPI()
|
||||
app.include_router(portal.router)
|
||||
app.dependency_overrides[portal.get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
|
||||
with patch.object(portal, 'get_portal_item', return_value=item), \
|
||||
patch.object(portal, '_list_portal_comments', return_value=[comment]), \
|
||||
patch.object(portal, 'list_portal_item_activity', return_value=[]), \
|
||||
patch.object(portal, 'issue_resolution_state', return_value={}), \
|
||||
patch.object(portal, 'get_all_users', return_value=[{'username': 'private-reporter', 'email': 'secret@example.com'}, {'username': 'private-admin'}]):
|
||||
client = TestClient(app)
|
||||
for path in ['/portal/items/1', '/portal/items/1/comments']:
|
||||
response = client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for secret in ['private-reporter', 'private-admin', 'secret@example.com', 'metadata_json', 'assignee_username', 'created_by_id']:
|
||||
self.assertNotIn(secret, response.text)
|
||||
admin_result = portal._serialize_item(item, {'username': 'admin', 'role': 'admin'})
|
||||
self.assertEqual(admin_result['created_by_username'], 'private-reporter')
|
||||
own_result = portal._serialize_item(item, {'username': 'private-reporter', 'role': 'user'})
|
||||
self.assertTrue(own_result['permissions']['can_edit'])
|
||||
@@ -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,17 +0,0 @@
|
||||
import unittest
|
||||
from backend.app import db
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class SeerrUserVisibilityTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_seerr_only_users_are_visible(self):
|
||||
db.create_user('local-admin', 'test-password', role='admin')
|
||||
db.create_user('imported-member', 'jellyseerr-user', auth_provider='jellyseerr', jellyseerr_user_id=42)
|
||||
self.assertEqual({u['username'] for u in db.get_all_users()}, {'local-admin', 'imported-member'})
|
||||
|
||||
def test_linked_duplicate_prefers_jellyfin(self):
|
||||
db.create_user('member@example.com', 'jellyseerr-user', auth_provider='jellyseerr', jellyseerr_user_id=42)
|
||||
db.create_user('member', 'jellyfin-user', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
users = db.get_all_users()
|
||||
self.assertEqual(len(users), 1)
|
||||
self.assertEqual(users[0]['username'], 'member')
|
||||
@@ -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,19 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from backend.app.config import Settings
|
||||
from backend.app.routers.site import _build_site_info
|
||||
|
||||
|
||||
class WelcomeSiteTests(unittest.TestCase):
|
||||
def test_public_response_does_not_expose_playback_url(self):
|
||||
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com'})):
|
||||
self.assertNotIn('mediaServerUrl', _build_site_info(False))
|
||||
|
||||
def test_authenticated_response_uses_public_playback_url(self):
|
||||
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com/web/'})):
|
||||
self.assertEqual(_build_site_info(True)['mediaServerUrl'], 'https://watch.example.com/web/')
|
||||
|
||||
def test_missing_unsafe_or_credential_urls_have_no_watch_link(self):
|
||||
for url in ['', 'javascript:alert(1)', '//internal', 'https://user:secret@example.com', 'https://[broken']:
|
||||
with self.subTest(url=url), patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': url})):
|
||||
self.assertIsNone(_build_site_info(True)['mediaServerUrl'])
|
||||
@@ -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
|
||||
@@ -1,13 +0,0 @@
|
||||
name: magent-production
|
||||
|
||||
services:
|
||||
magent:
|
||||
build: .
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- "10.30.1.32:3200:3000"
|
||||
- "127.0.0.1:8200:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -1,5 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101012"><title>Coming soon | Magent — Grizzlyflix</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#101012;color:#f4f0ff;font-family:Arial,Helvetica,sans-serif}main{min-height:100svh;padding:60px 24px 28px;text-align:center;display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at 50% 20%,#282139,transparent 60%)}.brand{color:#c7bdff;letter-spacing:.3em;font-size:14px;font-weight:700;margin-bottom:36px}.badge{color:#8be7f1;border:1px solid #6eddec66;border-radius:30px;padding:10px 22px;font-size:12px;letter-spacing:.15em}h1{font-size:clamp(40px,7vw,88px);line-height:1.08;letter-spacing:-.045em;margin:28px 0 22px}h1 span{color:#c7bdff}p{max-width:560px;color:#bcb8c9;line-height:1.65;font-size:18px;margin:0}.steps{display:grid;grid-template-columns:repeat(3,1fr);width:min(580px,100%);margin:40px 0 24px;border:1px solid #ffffff20;border-radius:16px;background:#ffffff04}.steps div{padding:22px 12px;display:grid;gap:8px}.steps div+div{border-left:1px solid #ffffff15}.steps small{color:#8be7f1}.note{font-size:14px;color:#a9a4b5}footer{margin-top:60px;color:#a9a4b5;font-size:12px;display:flex;flex-wrap:wrap;justify-content:center;gap:14px}a{color:#c7bdff;text-underline-offset:3px}a:focus-visible{outline:2px solid #8be7f1;outline-offset:5px}
|
||||
</style></head><body><main><div class="brand">GRIZZLYFLIX</div><div class="badge">COMING SOON</div><h1>Your next watch.<br><span>Made simpler.</span></h1><p>The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p><div class="steps" aria-label="Request journey"><div><small>01</small><strong>Request</strong></div><div><small>02</small><strong>Track</strong></div><div><small>03</small><strong>Watch</strong></div></div><p class="note">We’re getting everything ready. Check back soon.</p><footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer></main></body></html>
|
||||
@@ -1,68 +0,0 @@
|
||||
# Historical production cutover notes — superseded
|
||||
|
||||
These notes describe the temporary AMS-DEV01 setup, not the current production
|
||||
deployment. Do not run these cutover or rollback instructions against the live
|
||||
service. See [current production instructions](../../PRODUCTION.md).
|
||||
|
||||
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
|
||||
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
|
||||
overwritten. Main runs CI verification; production activation is deliberately
|
||||
manual during the initial cutover.
|
||||
|
||||
Only API connection URLs/credentials and SMTP configuration are exported by
|
||||
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
|
||||
uses an explicit allowlist, refuses existing output directories, and creates
|
||||
private files. It never copies a database, users, invite codes, issues, history,
|
||||
tokens, sessions, branding or notification templates. A new bootstrap admin and
|
||||
JWT secret are generated. Retrieve the bootstrap credentials from the protected
|
||||
`bootstrap-admin.json` on the server; never commit them.
|
||||
|
||||
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
|
||||
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
|
||||
imports and repair emails. The cover is not an authentication/security boundary;
|
||||
normal API authentication remains in force. Administrators can use `/login`.
|
||||
|
||||
Run `docker compose -f docker-compose.production.yml up -d --build` from the
|
||||
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
|
||||
Next forwards `/api` internally. The backend health port is localhost-only at
|
||||
8200. Do not alter beta's route or other Caddy sites.
|
||||
|
||||
Before public activation, validate Caddy config, save its existing configuration,
|
||||
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
|
||||
Do not send SMTP tests without approval. Keep the old upstream for rollback.
|
||||
|
||||
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
|
||||
then recreate the container. External service records can then be imported through
|
||||
normal synchronization; no beta client data is migrated. Review quality profiles,
|
||||
root folders, invite policy and notification rules in admin settings before use.
|
||||
|
||||
## Initial cutover — 7 September 2026
|
||||
|
||||
- Public HTTPS cover and `/api/health` verified after cutover.
|
||||
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
|
||||
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
|
||||
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
|
||||
`10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged.
|
||||
- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`.
|
||||
Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then
|
||||
`sudo systemctl reload caddy`. Review subsequent edits before restoring the
|
||||
whole file; the old application was not stopped or deleted.
|
||||
- Initial database: one newly generated bootstrap admin; zero invites, issues,
|
||||
cached requests, actions or snapshots. Login smoke-testing subsequently creates
|
||||
normal admin login activity only.
|
||||
- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on
|
||||
AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active.
|
||||
- No SMTP message was sent as part of validation. Background jobs remain paused.
|
||||
|
||||
## Cover resilience update
|
||||
|
||||
The application host subsequently became unreachable over TCP from Caddy (both
|
||||
3100 and 3200 timed out, despite responding to ping). The cover is now served
|
||||
directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from
|
||||
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
|
||||
It needs no application server, JavaScript, API or external assets.
|
||||
Other paths retain the production reverse proxy. Full launch now also requires
|
||||
removing the `@landing`/static `handle` block from the production Caddy site once
|
||||
upstream connectivity is stable; the environment switch alone is insufficient.
|
||||
Pre-static configuration backup:
|
||||
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Jellystat in Magent Beta
|
||||
|
||||
Magent's **My Stats** page (`/insights`) reads personal viewing history from an existing Jellystat instance. Jellystat owns playback collection, history and retention. Magent does not install Jellystat, collect sessions or keep a second playback database.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run Jellystat and connect it to the same Jellyfin server Magent uses. Let its initial sync finish.
|
||||
2. Create an API key in Jellystat's settings.
|
||||
3. In Magent, open **Configuration → Jellystat**, enter its internal URL and API key, save, and test the connection. Include any reverse-proxy base path in the URL.
|
||||
4. Sign in using Jellyfin. Existing Jellyfin accounts can also be linked by **Configuration → Jellyfin → Import Jellyfin users**. First use of My Stats resolves an existing Jellyfin account against Jellyfin's user directory using its exact username.
|
||||
|
||||
Alternatively, set these backend environment variables:
|
||||
|
||||
```dotenv
|
||||
JELLYSTAT_URL=http://jellystat:3000
|
||||
JELLYSTAT_API_KEY=your-jellystat-api-key
|
||||
```
|
||||
|
||||
`JELLYSTAT_BASE_URL` is also accepted. Docker deployments already load the backend environment through `.env`. These are server settings; no `NEXT_PUBLIC_` variables or browser credentials are needed. Saved Configuration values override environment values.
|
||||
|
||||
## What users see
|
||||
|
||||
- Past 7, 30, 90 or 365 days of watch time, distinct movies and episodes played, and total plays.
|
||||
- Watch-time chart, current/longest streak within the chosen period, active days, favourite titles, players and streaming methods.
|
||||
- Latest 20 plays in the chosen period and personal request totals from Magent's Seerr cache.
|
||||
- Clear setup, account-link, no-history and temporary-unavailability states.
|
||||
|
||||
The page is personal for admins as well as ordinary users. There is no arbitrary user-ID parameter or server-wide history endpoint in this version. Reports and newsletters can build on this integration in a later beta increment; they are not included here.
|
||||
|
||||
## Data semantics and boundaries
|
||||
|
||||
History comes from Jellystat's `POST /api/getUserHistory`, with the backend's linked Jellyfin ID in `userid`, and a fixed date filter. `GET /api/getLibraries` supplies movie-library classification and the connection test. Authentication uses the `x-api-token` header. The adapter follows the [upstream API routes](https://github.com/CyferShepard/Jellystat/blob/main/backend/routes/api.js) and [playback model](https://github.com/CyferShepard/Jellystat/blob/main/backend/models/jf_playback_activity.js); the installed instance exposes its API at `/swagger`.
|
||||
|
||||
- Playback duration is in seconds and displayed as minutes. Positive-duration history entries count as plays, including unfinished watches. Repeat plays add time without inflating distinct movie/episode counts.
|
||||
- Episodes are identified by `EpisodeId`. Movies are identified by their movie library. Mixed libraries or deleted library metadata may leave an item classified as other media; that time still contributes to totals.
|
||||
- Ranges cover a rolling number of days. Charts and streaks use UTC and Jellystat's `ActivityDateInserted`, so the first/last chart days can be partial. A streak day requires at least one minute. Streaks are bounded by the selected period. Long charts group days for readability.
|
||||
- Requests use their creation date and the authenticated account's canonical Seerr ID. Exact usernames are only used for legacy requests without an owner ID; conflicting IDs never fall back to a name.
|
||||
- Pages are fetched at 200 rows per request, up to 50 pages, with a 30-second total timeout. Excess history asks the user to choose a shorter period; it is never presented as a complete partial total.
|
||||
- A normalized, per-identity response is cached in memory for up to 60 seconds, with a 128-entry bound. The cache is separated by Jellystat URL/key, Jellyfin URL, user ID and period. HTTP responses are marked `no-store`.
|
||||
- Browser output excludes raw Jellystat responses, usernames from playback data, user/device IDs, IP addresses, tokens and media stream details. Unexpected account IDs in upstream history are rejected.
|
||||
- The only new database table is the stable Magent-to-Jellyfin identity mapping. It is scoped to the configured Jellyfin URL and does not automatically transfer ownership after account replacement. A changed Jellyfin URL needs identity resolution again.
|
||||
|
||||
## Validation
|
||||
|
||||
Backend coverage is in `backend/tests/test_insights.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification and empty/error states.
|
||||
|
||||
After building the frontend, `scripts/review_insights_ui.cjs` checks the page and configuration using fixture-only requests. Set `REVIEW_BASE`, `REVIEW_PLAYWRIGHT`, and optionally `REVIEW_DIR` to save screenshots outside the repository. Live Jellystat verification requires configuring the actual instance.
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY app ./app
|
||||
COPY public ./public
|
||||
COPY next-env.d.ts ./next-env.d.ts
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -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,411 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from './ui/PageHeading'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
||||
|
||||
const normalizeRecentResults = (items: any[]) =>
|
||||
items
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => {
|
||||
const id = item.id
|
||||
const rawTitle = item.title
|
||||
const placeholder =
|
||||
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
||||
return {
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: item.year,
|
||||
type: item.type,
|
||||
statusLabel: item.statusLabel,
|
||||
artwork: item.artwork,
|
||||
createdAt: item.createdAt ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const REQUEST_STAGE_OPTIONS = [
|
||||
{ value: 'all', label: 'All stages' },
|
||||
{ value: 'pending', label: 'Waiting' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'in_progress', label: 'In progress' },
|
||||
{ value: 'working', label: 'Working' },
|
||||
{ value: 'partial', label: 'Partial' },
|
||||
{ value: 'ready', label: 'Ready' },
|
||||
{ value: 'declined', label: 'Declined' },
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [query, setQuery] = useState('')
|
||||
const [recent, setRecent] = useState<
|
||||
{
|
||||
id: number
|
||||
title: string
|
||||
year?: number
|
||||
type?: string
|
||||
statusLabel?: string
|
||||
artwork?: { poster_url?: string; backdrop_url?: string }
|
||||
createdAt?: string | null
|
||||
}[]
|
||||
>([])
|
||||
const [recentError, setRecentError] = useState<string | null>(null)
|
||||
const [recentLoading, setRecentLoading] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
{
|
||||
title: string
|
||||
year?: number
|
||||
type?: string
|
||||
requestId?: number
|
||||
statusLabel?: string
|
||||
requestedBy?: string | null
|
||||
accessible?: boolean
|
||||
}[]
|
||||
>([])
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [recentDays, setRecentDays] = useState(90)
|
||||
const [recentStage, setRecentStage] = useState('all')
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
router.push(`/requests/${encodeURIComponent(trimmed)}`)
|
||||
return
|
||||
}
|
||||
void runSearch(trimmed)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
setRecentLoading(true)
|
||||
setRecentError(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const meResponse = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!meResponse.ok) {
|
||||
if (meResponse.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Auth failed: ${meResponse.status}`)
|
||||
}
|
||||
const me = await meResponse.json()
|
||||
const userRole = me?.role ?? null
|
||||
setRole(userRole)
|
||||
setAuthReady(true)
|
||||
const take = userRole === 'admin' ? 50 : 6
|
||||
const params = new URLSearchParams({
|
||||
take: String(take),
|
||||
days: String(recentDays),
|
||||
})
|
||||
if (recentStage !== 'all') {
|
||||
params.set('stage', recentStage)
|
||||
}
|
||||
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Recent requests failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data?.results)) {
|
||||
setRecent(normalizeRecentResults(data.results))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setRecentError('Recent requests are not available right now.')
|
||||
} finally {
|
||||
setRecentLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [recentDays, recentStage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) {
|
||||
return
|
||||
}
|
||||
if (!getToken()) {
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
let closed = false
|
||||
let source: EventSource | null = null
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const streamToken = await getEventStreamToken()
|
||||
if (closed) return
|
||||
const params = new URLSearchParams({
|
||||
stream_token: streamToken,
|
||||
recent_days: String(recentDays),
|
||||
})
|
||||
if (recentStage !== 'all') {
|
||||
params.set('recent_stage', recentStage)
|
||||
}
|
||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
||||
source = new EventSource(streamUrl)
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
if (payload.type === 'home_recent') {
|
||||
if (Array.isArray(payload.results)) {
|
||||
setRecent(normalizeRecentResults(payload.results))
|
||||
setRecentError(null)
|
||||
setRecentLoading(false)
|
||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||
setRecentError('Recent requests are not available right now.')
|
||||
setRecentLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (closed) return
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
source?.close()
|
||||
}
|
||||
}, [authReady, recentDays, recentStage])
|
||||
|
||||
const runSearch = async (term: string) => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Search failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data?.results)) {
|
||||
setSearchResults(
|
||||
data.results.map((item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: item.type,
|
||||
requestId: item.requestId,
|
||||
statusLabel: item.statusLabel,
|
||||
requestedBy: item.requestedBy ?? null,
|
||||
accessible: Boolean(item.accessible),
|
||||
}))
|
||||
)
|
||||
setSearchError(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setSearchError('Search failed. Try a request ID instead.')
|
||||
setSearchResults([])
|
||||
}
|
||||
}
|
||||
|
||||
const resolveArtworkUrl = (url?: string | null) => {
|
||||
if (!url) return null
|
||||
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('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
|
||||
if (!/not |unavailable|waiting/.test(label) && (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('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
|
||||
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card home-page">
|
||||
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
|
||||
<form onSubmit={submit} className="home-search">
|
||||
<label htmlFor="request-search">Title, year, or request number</label>
|
||||
<div className="home-search-row">
|
||||
<input
|
||||
id="request-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Dune 2021 or 1289"
|
||||
/>
|
||||
<button type="submit">Find request</button>
|
||||
</div>
|
||||
</form>
|
||||
} />
|
||||
|
||||
{(searchError || searchResults.length > 0) && (
|
||||
<section className="home-search-results" aria-live="polite">
|
||||
<div className="home-section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Search results</span>
|
||||
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={() => {
|
||||
setSearchResults([])
|
||||
setSearchError(null)
|
||||
}}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{searchError ? (
|
||||
<div className="error-banner">{searchError}</div>
|
||||
) : (
|
||||
<div className="home-result-grid">
|
||||
{searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.title || 'Untitled'}-${index}`}
|
||||
type="button"
|
||||
className="home-result-card"
|
||||
disabled={!item.requestId}
|
||||
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
|
||||
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
|
||||
</span>
|
||||
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="home-metric-strip" aria-label="Request summary">
|
||||
<div><span>In view</span><strong>{recent.length}</strong></div>
|
||||
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
|
||||
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
|
||||
</section>
|
||||
|
||||
<section className="recent home-recent">
|
||||
<div className="recent-header home-section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Request activity</span>
|
||||
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
|
||||
</div>
|
||||
{authReady && (
|
||||
<div className="recent-filter-group">
|
||||
<label className="recent-filter">
|
||||
<span>Period</span>
|
||||
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
|
||||
<option value={0}>All time</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={60}>60 days</option>
|
||||
<option value={90}>90 days</option>
|
||||
<option value={180}>180 days</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{authReady && (
|
||||
<div className="request-filter-chips" aria-label="Filter requests by stage">
|
||||
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className={recentStage === option.value ? 'is-active' : undefined}
|
||||
onClick={() => setRecentStage(option.value)}
|
||||
>
|
||||
{option.value === 'working' ? <i aria-hidden="true" /> : null}
|
||||
{option.value === 'all' ? 'All' : option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="recent-grid home-recent-grid">
|
||||
{recentLoading ? (
|
||||
<div className="loading-center">
|
||||
<div className="spinner" aria-hidden="true" />
|
||||
<span className="loading-text">Loading recent requests...</span>
|
||||
</div>
|
||||
) : recentError ? (
|
||||
<div className="error-banner">{recentError}</div>
|
||||
) : recent.length === 0 ? (
|
||||
<div className="home-empty-state">
|
||||
<strong>No requests match these filters</strong>
|
||||
<span>Try a wider period or a different stage.</span>
|
||||
</div>
|
||||
) : (
|
||||
recent.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/requests/${item.id}`)}
|
||||
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
|
||||
>
|
||||
{item.artwork?.poster_url ? (
|
||||
<img
|
||||
className="recent-poster"
|
||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
|
||||
)}
|
||||
<span className="recent-info">
|
||||
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
|
||||
<span className="recent-status-badge">
|
||||
<span aria-hidden="true">{({ ready: '✓', processing: '↻', attention: '!', waiting: '◷' })[requestCardState(item.statusLabel).key]}</span>
|
||||
{item.statusLabel || 'Status not available yet'}
|
||||
</span>
|
||||
<span className="recent-meta">
|
||||
Request {item.id}
|
||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="recent-open-cue" aria-hidden="true">Open</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+849
-488
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>
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,12 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'seerr',
|
||||
'jellyseerr',
|
||||
'jellyfin',
|
||||
'jellystat',
|
||||
'artwork',
|
||||
'sonarr',
|
||||
'radarr',
|
||||
'bazarr',
|
||||
'prowlarr',
|
||||
'qbittorrent',
|
||||
'requests',
|
||||
'issue-workflow',
|
||||
'cache',
|
||||
'logs',
|
||||
'maintenance',
|
||||
|
||||
@@ -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,35 +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/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' },
|
||||
{ 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')
|
||||
@@ -7,7 +7,19 @@ export default function AdminDiagnosticsPage() {
|
||||
return (
|
||||
<AdminShell
|
||||
title="Diagnostics"
|
||||
subtitle="Check connections and investigate service problems."
|
||||
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
|
||||
rail={
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Diagnostics</span>
|
||||
<h2>Shared console</h2>
|
||||
<p>
|
||||
This page and Maintenance now use the same diagnostics panel, so every test target and
|
||||
notification ping stays in one source of truth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminDiagnosticsPanel />
|
||||
</AdminShell>
|
||||
|
||||
+258
-528
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'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
|
||||
|
||||
type ServiceState = { name: string; status: string }
|
||||
|
||||
export default function AdminLandingPage() {
|
||||
const router = useRouter()
|
||||
const [services, setServices] = useState<ServiceState[]>([])
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const load = async () => {
|
||||
if (!getToken()) { router.replace('/login'); return }
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/me`)
|
||||
if (!response.ok) { router.replace('/login'); return }
|
||||
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
|
||||
if (!active) return
|
||||
setReady(true)
|
||||
const status = await authFetch(`${getApiBase()}/status/services`)
|
||||
if (!status.ok) throw new Error('Status unavailable')
|
||||
const data = await status.json()
|
||||
if (active) setServices(Array.isArray(data.services) ? data.services : [])
|
||||
} catch {
|
||||
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => { active = false }
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
|
||||
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings…</p> : (
|
||||
<div className="config-directory">
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
|
||||
<section className="config-directory-region" key={group.title}>
|
||||
<header><h2>{group.title}</h2><p>{group.description}</p></header>
|
||||
<div className="config-directory-links">
|
||||
{group.items.map((item) => {
|
||||
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
|
||||
return (
|
||||
<a href={item.href} key={item.href} className="config-directory-link">
|
||||
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
|
||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
|
||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<details className="config-advanced-directory">
|
||||
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary>
|
||||
<div className="config-directory-links">
|
||||
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => (
|
||||
<a href={item.href} key={item.href} className="config-directory-link">
|
||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<AdminShell
|
||||
title="Settings"
|
||||
subtitle="Choose what you want to manage."
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/')}>
|
||||
Back to requests
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
<div className="status-banner">
|
||||
Pick a section from the left. Each page explains what it does and how it helps.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,6 +109,11 @@ export default function AdminRequestsAllPage() {
|
||||
<AdminShell
|
||||
title="All requests"
|
||||
subtitle="Paginated view of every cached request."
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
<div className="admin-toolbar">
|
||||
|
||||
@@ -116,9 +116,14 @@ export default function AdminSystemGuidePage() {
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="System guide"
|
||||
subtitle="Service connections, controls, and recovery paths."
|
||||
title="How it works"
|
||||
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section system-guide">
|
||||
<div className="admin-panel">
|
||||
@@ -281,7 +286,7 @@ export default function AdminSystemGuidePage() {
|
||||
<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>
|
||||
<p>Recent requests and service summaries refresh live for signed-in users.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Request pages</h3>
|
||||
@@ -289,7 +294,7 @@ export default function AdminSystemGuidePage() {
|
||||
</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>
|
||||
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
@@ -108,9 +106,14 @@ export default function ChangelogPage() {
|
||||
}, [groups, loading])
|
||||
|
||||
return (
|
||||
<main className="card changelog-page">
|
||||
<PageHeading title="Changelog" description="What’s new and improved in Magent." />
|
||||
{content}
|
||||
</main>
|
||||
<div className="page">
|
||||
<section className="card changelog-card">
|
||||
<div className="changelog-header">
|
||||
<h1>Changelog</h1>
|
||||
<p className="lede">Latest updates and release notes.</p>
|
||||
</div>
|
||||
{content}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import './style.css'
|
||||
|
||||
export const metadata = { title: 'Coming soon | Magent — Grizzlyflix' }
|
||||
|
||||
export default function ComingSoonPage() {
|
||||
return <main className="launch-cover">
|
||||
<div className="launch-brand">GRIZZLYFLIX</div>
|
||||
<span className="launch-badge">COMING SOON</span>
|
||||
<h1>Your next watch.<br /><em>Made simpler.</em></h1>
|
||||
<p className="launch-intro">The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p>
|
||||
<div className="launch-path" aria-label="Request journey">
|
||||
{['Request', 'Track', 'Watch'].map((label, index) => <div key={label}><span>0{index + 1}</span><strong>{label}</strong></div>)}
|
||||
</div>
|
||||
<p className="launch-note">We’re getting everything ready. Check back soon.</p>
|
||||
<footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.page:has(.launch-cover) { max-width: none; margin: 0; padding: 0; }
|
||||
.launch-cover { box-sizing: border-box; min-height: 100svh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px 24px; text-align: center; background: radial-gradient(ellipse at 50% 25%, #252039 0%, transparent 55%), #101012; color: #f4f0ff; }
|
||||
.launch-brand { font-size: 14px; letter-spacing: .3em; color: #c7bdff; font-weight: 700; margin-bottom: 36px; }
|
||||
.launch-badge { border: 1px solid #6eddec66; color: #8be7f1; border-radius: 30px; padding: 8px 18px; font-size: 12px; letter-spacing: .15em; }
|
||||
.launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; }
|
||||
.launch-cover h1 em { color: #c7bdff; font-style: normal; }
|
||||
.launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; }
|
||||
.launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
|
||||
.launch-path > div { padding: 22px 12px; display: grid; gap: 8px; }
|
||||
.launch-path > div + div { border-left: 1px solid #ffffff15; }
|
||||
.launch-path span { color: #8be7f1; font-size: 12px; }
|
||||
.launch-path strong { font-size: 18px; }
|
||||
.launch-note { color: #a9a4b5; font-size: 14px; }
|
||||
.launch-cover footer { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin-top: 64px; font-size: 12px; color: #a9a4b5; }
|
||||
.launch-cover footer a { color: #c7bdff; text-underline-offset: 3px; }
|
||||
.launch-cover a:focus-visible { outline: 2px solid #8be7f1; outline-offset: 5px; }
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type Profile = {
|
||||
username?: string
|
||||
@@ -26,17 +24,15 @@ export default function FeedbackPage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not load profile.')
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setProfile({ username: data?.username })
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -53,7 +49,7 @@ export default function FeedbackPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
||||
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -62,16 +58,17 @@ export default function FeedbackPage() {
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Request failed: ${response.status}`)
|
||||
}
|
||||
setMessage('')
|
||||
setStatus('Thanks! Your message has been sent.')
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
setStatus('That did not send. Please try again.')
|
||||
} finally {
|
||||
@@ -80,10 +77,16 @@ export default function FeedbackPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card feedback-page">
|
||||
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
|
||||
<main className="card">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">Send feedback</p>
|
||||
<h1>Help us improve Magent</h1>
|
||||
<p className="lede">
|
||||
Found a problem or have an idea? Send it here and we will see it right away.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="account-panel account-form feedback-form" onSubmit={submit}>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label htmlFor="feedback-user">Your username</label>
|
||||
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
import { getApiBase } from '../lib/auth'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
@@ -46,8 +46,14 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
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}>
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Forgot password</h1>
|
||||
<p className="lede">
|
||||
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
|
||||
will be emailed to you.
|
||||
</p>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Username or email
|
||||
<input
|
||||
@@ -57,10 +63,10 @@ export default function ForgotPasswordPage() {
|
||||
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>}
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit" className="account-primary" disabled={loading}>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Sending reset link…' : 'Send reset link'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -68,6 +74,6 @@ export default function ForgotPasswordPage() {
|
||||
Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-721
@@ -181,107 +181,6 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signed-in-context {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-view-toggle {
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
color: #bfeaff;
|
||||
box-shadow: none;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-view-toggle.is-active {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.user-view-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin: 14px 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.38);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, rgba(245, 158, 11, 0.13), rgba(14, 165, 233, 0.06));
|
||||
color: #f8e7b2;
|
||||
}
|
||||
|
||||
.user-view-banner > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-view-banner strong {
|
||||
color: #fde68a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.user-view-banner span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.user-view-banner button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-color: rgba(251, 191, 36, 0.32);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #fde68a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.signed-in-header span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #fde68a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.signed-in-context {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.user-view-toggle {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.user-view-banner {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-view-banner > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-view-banner button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -512,9 +411,10 @@ button {
|
||||
}
|
||||
|
||||
button span {
|
||||
font-size: inherit;
|
||||
text-transform: inherit;
|
||||
text-align: inherit;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -3665,14 +3565,12 @@ button:disabled {
|
||||
.user-grid-pill.is-blocked {
|
||||
background: rgba(244, 114, 114, 0.14);
|
||||
border-color: rgba(244, 114, 114, 0.24);
|
||||
color: #ffd5d5;
|
||||
}
|
||||
|
||||
.system-pill-degraded,
|
||||
.user-grid-pill.is-disabled {
|
||||
background: rgba(208, 166, 92, 0.14);
|
||||
border-color: rgba(208, 166, 92, 0.22);
|
||||
color: #ffe3a6;
|
||||
}
|
||||
|
||||
.system-dot {
|
||||
@@ -4657,51 +4555,6 @@ button:hover:not(:disabled) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-operations-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-operations-strip > button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.055), rgba(255, 255, 255, 0.018));
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-operations-strip > button:hover {
|
||||
border-color: rgba(126, 215, 255, 0.42);
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(255, 255, 255, 0.026));
|
||||
}
|
||||
|
||||
.invite-operations-strip span,
|
||||
.invite-operations-strip small {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-operations-strip span {
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.invite-operations-strip strong {
|
||||
color: #eef7ff;
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.invite-operations-strip small {
|
||||
align-self: end;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.invite-admin-summary-tile {
|
||||
min-height: 96px;
|
||||
}
|
||||
@@ -4830,128 +4683,6 @@ button:hover:not(:disabled) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-automation-card {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-automation-card > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.invite-readiness-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-readiness-list > button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-readiness-list span {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-readiness-list strong {
|
||||
color: #dceafa;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invite-list-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-list-heading h2,
|
||||
.invite-list-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invite-view-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.invite-view-filter legend {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.invite-view-filter button {
|
||||
padding: 7px 10px;
|
||||
border-color: rgba(255, 255, 255, 0.07);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: #aeb8c7;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-view-filter button.is-active {
|
||||
border-color: rgba(126, 215, 255, 0.38);
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #eaf8ff;
|
||||
}
|
||||
|
||||
.invite-list-item {
|
||||
border-left: 3px solid rgba(126, 215, 255, 0.4);
|
||||
}
|
||||
|
||||
.invite-list-item.is-ready {
|
||||
border-left-color: #48e0b2;
|
||||
}
|
||||
|
||||
.invite-list-item.is-expired,
|
||||
.invite-list-item.is-exhausted,
|
||||
.invite-list-item.is-profile_unavailable {
|
||||
border-left-color: #ffc56d;
|
||||
}
|
||||
|
||||
.invite-list-item.is-disabled {
|
||||
border-left-color: #7e8999;
|
||||
}
|
||||
|
||||
.invite-state-pill.is-ready {
|
||||
border-color: rgba(72, 224, 178, 0.32);
|
||||
color: #69edc5;
|
||||
}
|
||||
|
||||
.invite-state-pill.is-expired,
|
||||
.invite-state-pill.is-exhausted,
|
||||
.invite-state-pill.is-profile_unavailable {
|
||||
border-color: rgba(255, 197, 109, 0.34);
|
||||
color: #ffd38a;
|
||||
}
|
||||
|
||||
.invite-attention-reason {
|
||||
margin: 5px 0 0;
|
||||
color: #ffc56d;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.invite-admin-bulk-panel .user-bulk-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -5039,328 +4770,6 @@ button:hover:not(:disabled) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-expiry-presets {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.invite-expiry-presets > span {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.invite-expiry-presets > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-expiry-presets button {
|
||||
padding: 7px 9px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-expiry-presets small {
|
||||
color: #aeb8c7;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-flow-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invite-flow-heading h2,
|
||||
.invite-created-card h3,
|
||||
.invite-flow-step h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invite-flow-heading .lede {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.invite-flow-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-flow-route {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.invite-flow-route li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(138, 155, 185, 0.18);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
color: #77849a;
|
||||
}
|
||||
|
||||
.invite-flow-route li span {
|
||||
color: #718095;
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.invite-flow-route li strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.76rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.invite-flow-route li.is-active {
|
||||
border-color: rgba(126, 215, 255, 0.5);
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.14), rgba(35, 74, 119, 0.11));
|
||||
color: #edf8ff;
|
||||
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.75);
|
||||
}
|
||||
|
||||
.invite-flow-route li.is-active span,
|
||||
.invite-flow-route li.is-complete span {
|
||||
color: #7ed7ff;
|
||||
}
|
||||
|
||||
.invite-flow-route li.is-complete {
|
||||
border-color: rgba(72, 224, 178, 0.28);
|
||||
color: #c9d7e5;
|
||||
}
|
||||
|
||||
.invite-flow-step {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(138, 155, 185, 0.18);
|
||||
background: linear-gradient(145deg, rgba(31, 43, 65, 0.82), rgba(20, 29, 45, 0.82));
|
||||
}
|
||||
|
||||
.invite-flow-step.is-active {
|
||||
border-color: rgba(126, 215, 255, 0.38);
|
||||
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.7);
|
||||
}
|
||||
|
||||
.invite-flow-step.is-complete {
|
||||
border-color: rgba(72, 224, 178, 0.24);
|
||||
}
|
||||
|
||||
.invite-flow-step > header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(138, 155, 185, 0.14);
|
||||
}
|
||||
|
||||
.invite-flow-step > header > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.invite-flow-step > header p {
|
||||
margin: 0;
|
||||
color: #aeb8c7;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.invite-flow-number {
|
||||
display: grid;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(126, 215, 255, 0.4);
|
||||
border-radius: 50%;
|
||||
color: #7ed7ff;
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.invite-flow-fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.invite-flow-fields > label,
|
||||
.invite-flow-field-grid > label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-flow-fields label > span:first-child,
|
||||
.invite-flow-field-grid label > span:first-child {
|
||||
color: #cbd5e3;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.invite-flow-fields input:not([type='checkbox']),
|
||||
.invite-flow-fields select,
|
||||
.invite-flow-fields textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-flow-fields label > small {
|
||||
color: #8f9caf;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-flow-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line,
|
||||
.invite-status-control {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px !important;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(138, 155, 185, 0.2);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line > input,
|
||||
.invite-status-control > input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line > span,
|
||||
.invite-status-control > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line strong,
|
||||
.invite-status-control strong {
|
||||
color: #e8edf5;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line small,
|
||||
.invite-status-control small {
|
||||
color: #9ba8ba;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-flow-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.invite-policy-note,
|
||||
.invite-delivery-summary {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(72, 224, 178, 0.2);
|
||||
background: rgba(72, 224, 178, 0.055);
|
||||
}
|
||||
|
||||
.invite-policy-note strong,
|
||||
.invite-delivery-summary strong {
|
||||
color: #dffaf1;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.invite-policy-note span,
|
||||
.invite-delivery-summary span {
|
||||
color: #9fb4b5;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.invite-delivery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border-color: rgba(138, 155, 185, 0.2);
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
color: #cbd5e3;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button strong {
|
||||
color: #f2f6fb;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button small {
|
||||
color: #98a5b7;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button.is-selected {
|
||||
border-color: rgba(126, 215, 255, 0.58);
|
||||
background: linear-gradient(145deg, rgba(14, 165, 233, 0.16), rgba(35, 74, 119, 0.13));
|
||||
box-shadow: inset 0 2px 0 #7ed7ff;
|
||||
}
|
||||
|
||||
.invite-status-control {
|
||||
border-color: rgba(255, 197, 109, 0.27);
|
||||
background: rgba(255, 197, 109, 0.045);
|
||||
}
|
||||
|
||||
.invite-created-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(72, 224, 178, 0.38);
|
||||
background: linear-gradient(145deg, rgba(72, 224, 178, 0.1), rgba(20, 29, 45, 0.72));
|
||||
box-shadow: inset 0 2px 0 rgba(72, 224, 178, 0.72);
|
||||
}
|
||||
|
||||
.invite-created-card p {
|
||||
margin: 0;
|
||||
color: #b7c5d4;
|
||||
}
|
||||
|
||||
.invite-created-card > .ghost-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.invite-created-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-created-link input {
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono), monospace;
|
||||
}
|
||||
|
||||
.invite-email-template-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -5420,10 +4829,6 @@ button:hover:not(:disabled) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-operations-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-admin-bulk-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -5435,10 +4840,6 @@ button:hover:not(:disabled) {
|
||||
.invite-form-row-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-flow-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -5477,19 +4878,6 @@ button:hover:not(:disabled) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-operations-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-list-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.invite-view-filter {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
@@ -5509,29 +4897,6 @@ button:hover:not(:disabled) {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.invite-flow-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.invite-flow-route {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-delivery-grid,
|
||||
.invite-created-link {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-flow-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.invite-flow-actions button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enterprise UI tightening pass */
|
||||
.admin-panel,
|
||||
.user-detail-panel,
|
||||
@@ -5642,21 +5007,6 @@ textarea {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
min-inline-size: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.invite-trace-view-toggle legend {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.invite-trace-view-toggle button {
|
||||
@@ -5684,18 +5034,6 @@ textarea {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-trace-account-link {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: currentColor;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.invite-trace-account-link:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.invite-trace-graph {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
@@ -5954,34 +5292,6 @@ textarea {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-contact-card {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-contact-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: min(100%, 440px);
|
||||
}
|
||||
|
||||
.profile-contact-form label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-contact-form label > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-contact-form .status-banner,
|
||||
.profile-contact-form .error-banner {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-invites-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -6446,8 +5756,7 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the account menu above header controls. Header positioning is owned
|
||||
by the responsive application shell in ops-redesign.css. */
|
||||
/* Final header account menu stacking override (must be last) */
|
||||
.page,
|
||||
.header,
|
||||
.header-left,
|
||||
@@ -6459,7 +5768,9 @@ textarea {
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative !important;
|
||||
isolation: isolate;
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.header-nav,
|
||||
@@ -6646,14 +5957,11 @@ textarea {
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostic-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
@@ -7257,27 +6565,6 @@ textarea {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.portal-workspace-switch {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-workspace-switch button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-workspace-switch button.is-active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
|
||||
background: rgba(107, 146, 255, 0.12);
|
||||
}
|
||||
|
||||
.portal-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -7402,7 +6689,7 @@ textarea {
|
||||
|
||||
.portal-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 160px 180px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,186 @@
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import '../welcome.css'
|
||||
'use client'
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
return <main className="friendly-guide">
|
||||
<PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." />
|
||||
<nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav>
|
||||
<details open><summary>Request a movie or TV show</summary>
|
||||
<ol>
|
||||
<li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">02 New Requests</a> and pick what you’re looking for.</p></li>
|
||||
<li><strong>Search and choose the right title.</strong><p>For TV, choose the seasons you want. If it’s already requested, open that request to see its progress.</p></li>
|
||||
<li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the library’s settings.</p></li>
|
||||
<li><strong>Follow it in My Requests.</strong><p>We’ll show what’s happening and any next step you can take. Some titles need approval or may not have a suitable download yet.</p></li>
|
||||
</ol>
|
||||
</details>
|
||||
<details><summary>Understand the six progress steps</summary>
|
||||
<ol>
|
||||
<li><strong>Requested:</strong> Your request has been received.</li>
|
||||
<li><strong>Approved:</strong> It has permission to go ahead.</li>
|
||||
<li><strong>Library collection:</strong> The library is tracking what’s collected and what’s missing.</li>
|
||||
<li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isn’t a good match yet.</li>
|
||||
<li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li>
|
||||
<li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li>
|
||||
</ol>
|
||||
<p>A finished download still needs to be added to the media library. Wait for “Available to watch” before heading over.</p>
|
||||
</details>
|
||||
<details><summary>Something looks stuck</summary>
|
||||
<ol>
|
||||
<li><strong>Open the request.</strong><p>Read its current status and next step.</p></li>
|
||||
<li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li>
|
||||
<li><strong>Follow the action offered.</strong><p>You may be able to restart a search or review suitable releases. Choose “Best pick” when offered if you’re unsure.</p></li>
|
||||
</ol>
|
||||
<p>Remote activity explains the latest check. Open it to see the full list. A successful search doesn’t always mean a download was found.</p>
|
||||
</details>
|
||||
<details><summary>Report a problem and follow the fix</summary>
|
||||
<ol>
|
||||
<li><strong>Open <a href="/portal/issues">03 Issues</a>.</strong><p>Choose what’s wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p></li>
|
||||
<li><strong>Choose the affected content.</strong><p>Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.</p></li>
|
||||
<li><strong>Read “What will happen”, then submit.</strong><p>It tells you whether the selected files will be replaced, missing content searched for, subtitles checked, or playback investigated.</p></li>
|
||||
<li><strong>Follow the issue’s progress.</strong><p>Open your reported issue to see the work recorded and where the fix is up to.</p></li>
|
||||
<li><strong>Tell us if it worked.</strong><p>When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose “Yes” if it’s fixed or “No” if you still need help.</p></li>
|
||||
</ol>
|
||||
<p>Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure timings depend on the site’s settings.</p>
|
||||
</details>
|
||||
<details><summary>Invite someone</summary>
|
||||
<ol>
|
||||
<li><strong>Open <a href="/profile/invites">04 Invites</a>.</strong><p>If invites are enabled for your account, give your invite a name you’ll recognise.</p></li>
|
||||
<li><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></li>
|
||||
<li><strong>Choose how to share it.</strong><p>Copy the link yourself, or enter an email address to send it directly.</p></li>
|
||||
<li><strong>Manage it later.</strong><p>You can return to your invites to check them or disable a link. Your account’s invite limits apply automatically.</p></li>
|
||||
</ol>
|
||||
</details>
|
||||
<details><summary>Update your account</summary><p>Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your activity, or use the password options available for your account.</p><p>Looking for your downloads instead? <a href="/">01 My Requests</a> is your starting point.</p></details>
|
||||
<footer>Ready? <a href="/welcome">Choose where to go next →</a></footer>
|
||||
</main>
|
||||
return (
|
||||
<main className="card how-page">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">How it works</p>
|
||||
<h1>How Magent works for users</h1>
|
||||
<p className="lede">
|
||||
Use Magent to find a request, watch it move through the pipeline, and know when it is
|
||||
ready without constantly refreshing the page.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What Magent is for</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Track requests</h3>
|
||||
<p>
|
||||
Search by title, year, or request number to open the request page and see where an
|
||||
item is up to.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>See live progress</h3>
|
||||
<p>
|
||||
Request status, timeline events, and download progress update live while you are
|
||||
viewing the page.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Know when it is ready</h3>
|
||||
<p>
|
||||
When the request is fully imported and available, Magent shows it as ready and links
|
||||
you through to Jellyfin.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>The request pipeline</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>You request a movie or show</strong> through Seerr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Magent picks up the request</strong> and shows its current state.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The automation stack searches and downloads it</strong> if it can find a valid
|
||||
release.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The file is imported into the library</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Jellyfin serves it</strong> once it is ready to watch.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What the statuses usually mean</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Pending</h3>
|
||||
<p>The request exists, but it is still waiting for approval or the next step.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Approved / Processing</h3>
|
||||
<p>The request has been accepted and the automation tools are working on it.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Downloading</h3>
|
||||
<p>Magent can show live progress while the content is still being downloaded.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Ready</h3>
|
||||
<p>The item has been imported and should now be available in Jellyfin.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Partial / Waiting</h3>
|
||||
<p>
|
||||
Part of the workflow completed, but the request is still waiting on another service or
|
||||
on content becoming available.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Declined</h3>
|
||||
<p>The request was rejected or cannot proceed in its current form.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Live updates you can expect</h2>
|
||||
<div className="how-step-grid">
|
||||
<article className="how-step-card step-seerr">
|
||||
<div className="step-badge">1</div>
|
||||
<h3>Recent requests refresh automatically</h3>
|
||||
<p className="step-note">
|
||||
Your request list and landing-page activity update automatically while you are signed
|
||||
in.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-step-card step-qbit">
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Request pages update in real time</h3>
|
||||
<p className="step-note">
|
||||
State changes, timeline steps, and downloader progress are pushed to the page live.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-step-card step-jellyfin">
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Ready state appears as soon as the import completes</h3>
|
||||
<p className="step-note">
|
||||
Once the content is actually available, Magent updates the request page without a hard
|
||||
refresh.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>User actions you may see</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Open request</h3>
|
||||
<p>Jump into the full request page to inspect the current state and activity.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Open in Jellyfin</h3>
|
||||
<p>Appears when the request is ready and Magent can link you through for playback.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Search + auto-download</h3>
|
||||
<p>
|
||||
Only appears for accounts that have been granted self-service download access by the
|
||||
admin team.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>My invites</h3>
|
||||
<p>
|
||||
If your account is allowed to invite others, you can create and manage invite links
|
||||
from your profile.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Invites and signup</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>You receive an invite link</strong> by email or directly from the person who
|
||||
invited you.
|
||||
</li>
|
||||
<li>
|
||||
<strong>You sign up through Magent</strong> and your account is linked into the media
|
||||
stack.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Your account defaults apply</strong> based on the invite or your assigned
|
||||
profile.
|
||||
</li>
|
||||
<li>
|
||||
<strong>You sign in and track requests</strong> from the landing page and your request
|
||||
pages.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-callout">
|
||||
<h2>If a request looks stuck</h2>
|
||||
<p>
|
||||
A waiting request usually means no usable release has been found yet, the download is
|
||||
still in progress, or the import has not completed. Magent will keep updating as the
|
||||
underlying services move forward.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../lib/auth'
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import './stats.css'
|
||||
|
||||
type Breakdown = { name: string; minutes: number }
|
||||
type Day = { date: string; minutes: number }
|
||||
type Stats = {
|
||||
state: 'ready' | 'not_configured' | 'unlinked'
|
||||
is_admin: boolean
|
||||
days: number
|
||||
updated_at?: string
|
||||
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
|
||||
daily?: Day[]
|
||||
top_titles?: { title: string; type: string; minutes: number; plays: number }[]
|
||||
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string }[]
|
||||
clients?: Breakdown[]
|
||||
methods?: Breakdown[]
|
||||
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
|
||||
}
|
||||
|
||||
const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
function ViewingChart({ daily }: { daily: Day[] }) {
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1
|
||||
const bars: { start: string; end: string; minutes: number }[] = []
|
||||
for (let i = 0; i < daily.length; i += bucket) {
|
||||
const group = daily.slice(i, i + bucket)
|
||||
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) })
|
||||
}
|
||||
const peak = Math.max(1, ...bars.map((bar) => bar.minutes))
|
||||
const active = selected === null ? null : bars[selected]
|
||||
return (
|
||||
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
|
||||
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div>
|
||||
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` – ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div>
|
||||
<div className="stats-chart">
|
||||
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div>
|
||||
<div className="stats-chart-bars">
|
||||
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
|
||||
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
|
||||
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
|
||||
}
|
||||
|
||||
export default function InsightsPage() {
|
||||
const router = useRouter()
|
||||
const [days, setDays] = useState(30)
|
||||
const [data, setData] = useState<Stats | null>(null)
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
const load = useCallback(async (signal: AbortSignal) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setData(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal })
|
||||
if (response.status === 401) { router.replace('/login?next=%2Finsights'); return }
|
||||
if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.')
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}))
|
||||
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.')
|
||||
}
|
||||
const result = await response.json() as Stats
|
||||
if (!signal.aborted) setData(result)
|
||||
} catch (err) {
|
||||
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.')
|
||||
} finally {
|
||||
if (!signal.aborted) setBusy(false)
|
||||
}
|
||||
}, [days, router])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void load(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [load, revision])
|
||||
|
||||
const summary = data?.summary
|
||||
return (
|
||||
<main className="stats-page">
|
||||
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} />
|
||||
<div className="stats-toolbar">
|
||||
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset>
|
||||
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p>
|
||||
</div>
|
||||
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true">◷</span><h2>Gathering your stats</h2><p>Fetching your viewing history from Jellystat.</p></div>}
|
||||
{error && <div className="stats-state" role="alert"><h2>Stats couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
|
||||
{data?.state === 'not_configured' && <section className="stats-state"><span className="stats-state-symbol" aria-hidden="true">▥</span><h2>Your viewing story starts here</h2><p>{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
|
||||
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.</p></section>}
|
||||
{summary && <>
|
||||
<section className="stats-metrics" aria-label="Viewing totals">
|
||||
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{number(summary.minutes / 60)} hours across {number(summary.plays)} plays</small></article>
|
||||
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small></article>
|
||||
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small></article>
|
||||
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests</small></article>
|
||||
</section>
|
||||
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history in this period yet. Try a longer period, or come back after your next watch.</div>}
|
||||
<div className="stats-main-grid">
|
||||
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
|
||||
<section className="stats-panel stats-highlights"><div className="stats-panel-heading"><h2>A little watch history</h2></div>
|
||||
<div className="stats-highlight"><span className="stats-highlight-number">{summary.current_streak}<small> days</small></span><div><strong>Current streak</strong><p>Consecutive viewing days through today or yesterday.</p></div></div>
|
||||
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Your best streak in this period.</p></div></div>
|
||||
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Time for a story</strong><p>Days with at least a minute watched.</p></div></div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="stats-three-grid">
|
||||
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your favourites will find their place here.</p>}</section>
|
||||
<BreakdownCard title="Your players" rows={data.clients ?? []} />
|
||||
<BreakdownCard title="How you streamed" rows={data.methods ?? []} />
|
||||
</div>
|
||||
</>}
|
||||
{data && <div className="stats-main-grid">
|
||||
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><div className={`stats-media-icon stats-media-icon-${play.type}`} aria-hidden="true">{play.type === 'episode' ? 'TV' : play.type === 'movie' ? 'MV' : '▶'}</div><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.client}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
|
||||
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true">↗</span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
|
||||
</div>}
|
||||
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystat’s movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
.stats-page { padding-bottom: 32px !important; }
|
||||
.stats-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.stats-period { display: flex; padding: 4px; margin: 0; min-width: 0; gap: 4px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); }
|
||||
.stats-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.stats-period button { min-height: 38px; padding: 8px 16px; border: 0; border-radius: 6px; background: transparent !important; color: var(--ops-muted) !important; font-size: 13px; text-transform: none; }
|
||||
.stats-period button[aria-pressed=true] { background: #c7bdff !important; color: #211a36 !important; font-weight: 700; }
|
||||
.stats-source { margin: 0; font-size: 12px; color: var(--ops-muted); }
|
||||
.stats-source-dot { display: inline-block; height: 6px; width: 6px; margin-right: 8px; border-radius: 50%; background: var(--ops-faint); }
|
||||
.stats-source-dot.is-ready { background: #95d5b2; }
|
||||
.stats-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.stats-metric { display: grid; align-content: start; gap: 12px; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
|
||||
.stats-metric > span { font-size: 13px; color: var(--ops-muted); }
|
||||
.stats-metric > strong { font: 600 clamp(28px, 3vw, 42px)/1.15 "DM Sans", sans-serif; color: var(--ops-text); letter-spacing: -.03em; }
|
||||
.stats-metric > small { color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
|
||||
.stats-metric-accent { border-color: #655987; background: linear-gradient(135deg, #2f2940, var(--ops-panel)); }
|
||||
.stats-metric-accent > strong { color: #d5cbff; }
|
||||
.stats-main-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 24px; }
|
||||
.stats-three-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
|
||||
.stats-panel { min-width: 0; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
|
||||
.stats-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 24px; }
|
||||
.stats-panel h2 { margin: 0; color: var(--ops-text); font-size: 17px; font-weight: 600; }
|
||||
.stats-panel-heading p { margin: 8px 0 0; color: var(--ops-faint); font-size: 12px; }
|
||||
.stats-panel-heading a { font-size: 12px; white-space: nowrap; color: #c7bdff; }
|
||||
.stats-unit { color: var(--ops-faint); font-size: 11px; white-space: nowrap; }
|
||||
.stats-chart-detail { min-height: 28px; color: var(--ops-muted); font-size: 12px; }
|
||||
.stats-chart { height: 180px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
|
||||
.stats-chart-scale { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
|
||||
.stats-chart-bars { display: flex; align-items: stretch; gap: clamp(2px, .5vw, 8px); background: repeating-linear-gradient(to top, var(--ops-line-soft) 0px, var(--ops-line-soft) 1px, transparent 1px, transparent 50%); }
|
||||
.stats-chart-bars button { display: flex; align-items: flex-end; justify-content: center; padding: 0; min-width: 0; flex: 1; border: 0; background: transparent !important; border-radius: 3px; }
|
||||
.stats-chart-bars button > span { display: block; width: 100%; max-width: 44px; background: #9085b8; border-radius: 3px 3px 0 0; }
|
||||
.stats-chart-bars button:is(:hover, :focus-visible, .is-selected) > span { background: #d1c6ff; }
|
||||
.stats-chart-axis { display: flex; justify-content: space-between; padding-left: 50px; margin-top: 12px; color: var(--ops-faint); font-size: 11px; }
|
||||
.stats-highlight { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: 16px; align-items: center; padding: 19px 0; border-top: 1px solid var(--ops-line-soft); }
|
||||
.stats-highlight:first-of-type { border-top: 0; }
|
||||
.stats-highlight-number { font-size: 28px; color: #d1c6ff; font-weight: 600; }
|
||||
.stats-highlight-number small { font-size: 11px; color: var(--ops-faint); font-weight: 400; }
|
||||
.stats-highlight strong { font-size: 13px; color: var(--ops-text); }
|
||||
.stats-highlight p { margin: 6px 0 0; color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
|
||||
.stats-top-titles { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; }
|
||||
.stats-top-titles li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 12px; align-items: center; }
|
||||
.stats-rank { font: 11px "JetBrains Mono", monospace; color: var(--ops-faint); }
|
||||
.stats-top-titles strong { display: block; font-size: 13px; font-weight: 500; color: var(--ops-text); overflow-wrap: anywhere; }
|
||||
.stats-top-titles small { display: block; margin-top: 5px; font-size: 11px; color: var(--ops-faint); }
|
||||
.stats-top-titles li > span:last-child { text-align: right; font-size: 13px; color: var(--ops-muted); }
|
||||
.stats-breakdown { display: grid; gap: 24px; }
|
||||
.stats-breakdown-label { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 12px; }
|
||||
.stats-breakdown-label span { color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||
.stats-breakdown-label strong { white-space: nowrap; font-size: 11px; color: var(--ops-faint); font-weight: 400; }
|
||||
.stats-meter { height: 5px; background: var(--ops-line-soft); border-radius: 5px; overflow: hidden; }
|
||||
.stats-meter > span { display: block; height: 100%; background: #a497c9; border-radius: 5px; }
|
||||
.stats-history-list { display: grid; }
|
||||
.stats-history-list article { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-top: 1px solid var(--ops-line-soft); }
|
||||
.stats-history-list article:first-child { padding-top: 0; border-top: 0; }
|
||||
.stats-media-icon { display: grid; place-items: center; flex: 0 0 40px; height: 48px; border-radius: 6px; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
|
||||
.stats-media-icon-movie { background: #3c322c; color: #e5bfa8; }
|
||||
.stats-history-title { flex: 1; min-width: 0; }
|
||||
.stats-history-title strong { display: block; color: var(--ops-text); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.stats-history-title small, .stats-history-title > span { display: block; color: var(--ops-faint); font-size: 11px; line-height: 1.6; margin-top: 3px; overflow-wrap: anywhere; }
|
||||
.stats-history-title > span { font-size: 10px; }
|
||||
.stats-history-time { display: grid; gap: 8px; text-align: right; flex-shrink: 0; }
|
||||
.stats-history-time strong { font-size: 12px; color: var(--ops-muted); font-weight: 500; }
|
||||
.stats-history-time time { font-size: 11px; color: var(--ops-faint); }
|
||||
.stats-requests { align-self: start; }
|
||||
.stats-request-total { display: flex; align-items: center; gap: 16px; }
|
||||
.stats-request-total > strong { font-size: 36px; color: var(--ops-text); }
|
||||
.stats-request-total > span { max-width: 15ch; color: var(--ops-muted); font-size: 12px; line-height: 1.6; }
|
||||
.stats-request-counts { display: flex; justify-content: space-between; gap: 8px; padding: 20px 0; margin-top: 16px; border-block: 1px solid var(--ops-line-soft); }
|
||||
.stats-request-counts > span { font-size: 11px; color: var(--ops-faint); }
|
||||
.stats-request-counts strong { display: block; margin-bottom: 8px; color: var(--ops-text); font-size: 18px; font-weight: 500; }
|
||||
.stats-request-list { list-style: none; margin: 10px 0 0; padding: 0; }
|
||||
.stats-request-list a { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; color: var(--ops-muted); font-size: 12px; text-decoration: none; overflow-wrap: anywhere; }
|
||||
.stats-request-list a:hover { color: #d1c6ff; }
|
||||
.stats-request-list a > span { color: var(--ops-faint); }
|
||||
.stats-state { display: grid; justify-items: center; gap: 14px; padding: 56px 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); text-align: center; }
|
||||
.stats-state h2 { margin: 0; font-size: 22px; color: var(--ops-text); }
|
||||
.stats-state p { margin: 0; max-width: 60ch; font-size: 14px; color: var(--ops-muted); line-height: 1.8; }
|
||||
.stats-state-symbol { margin-bottom: 8px; color: #c7bdff; font-size: 36px; }
|
||||
.stats-action { display: inline-block; margin-top: 8px; padding: 12px 20px; background: #c7bdff; color: #211a36; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none; }
|
||||
.stats-notice { padding: 16px 20px; border: 1px solid var(--ops-line); border-radius: 8px; color: var(--ops-muted); font-size: 13px; line-height: 1.6; }
|
||||
.stats-muted, .stats-footnote { color: var(--ops-faint); font-size: 12px; line-height: 1.8; }
|
||||
.stats-footnote { margin: 0; }
|
||||
@media (max-width: 1100px) {
|
||||
.stats-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stats-main-grid { grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr); }
|
||||
.stats-three-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stats-three-grid > :first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.stats-main-grid, .stats-three-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||
.stats-metric { padding: 18px; gap: 10px; }
|
||||
.stats-panel { padding: 20px; }
|
||||
.stats-period { width: 100%; }
|
||||
.stats-period button { flex: 1; padding-inline: 8px; }
|
||||
.stats-metrics { gap: 12px; }
|
||||
}
|
||||
@@ -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 './ops-redesign.css'
|
||||
import './admin/config.css'
|
||||
import './account.css'
|
||||
import './workspace.css'
|
||||
import './portal/issue-flow.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import HeaderActions from './ui/HeaderActions'
|
||||
import HeaderIdentity from './ui/HeaderIdentity'
|
||||
import ThemeToggle from './ui/ThemeToggle'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import ApplicationChrome from './ui/ApplicationChrome'
|
||||
import BrandingLogo from './ui/BrandingLogo'
|
||||
import SiteStatus from './ui/SiteStatus'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Magent',
|
||||
@@ -19,7 +18,25 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<body>
|
||||
<BrandingFavicon />
|
||||
<div className="page">
|
||||
<ApplicationChrome />
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<a className="brand-link" href="/">
|
||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||
<div className="brand-stack">
|
||||
<div className="brand">Magent</div>
|
||||
<div className="tagline">Find and fix media requests fast.</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<ThemeToggle />
|
||||
<HeaderIdentity />
|
||||
</div>
|
||||
<div className="header-nav">
|
||||
<HeaderActions />
|
||||
</div>
|
||||
</header>
|
||||
<SiteStatus />
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+11
-71
@@ -1,53 +1,27 @@
|
||||
const AUTH_STATE_COOKIE = 'magent_logged_in'
|
||||
|
||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
||||
|
||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
const clearCookie = (name: string) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
export const getToken = () => {
|
||||
if (typeof document === 'undefined') return null
|
||||
const cookies = document.cookie.split(';').map((entry) => entry.trim())
|
||||
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
|
||||
if (!marker) return null
|
||||
const [, value] = marker.split('=', 2)
|
||||
return value || null
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem('magent_token')
|
||||
}
|
||||
|
||||
export const setToken = (_token: string) => {
|
||||
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
||||
export const setToken = (token: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem('magent_token', token)
|
||||
}
|
||||
|
||||
export const clearToken = () => {
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
if (typeof window === 'undefined') return
|
||||
const baseUrl = getApiBase()
|
||||
void fetch(`${baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export const logout = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
await fetch(`${baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
window.localStorage.removeItem('magent_token')
|
||||
}
|
||||
|
||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const token = getToken()
|
||||
const headers = new Headers(init?.headers || {})
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return fetch(input, { ...init, headers })
|
||||
}
|
||||
|
||||
export const getEventStreamToken = async () => {
|
||||
@@ -64,37 +38,3 @@ export const getEventStreamToken = async () => {
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Unauthorized')
|
||||
this.name = 'UnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor() {
|
||||
super('Forbidden')
|
||||
this.name = 'ForbiddenError'
|
||||
}
|
||||
}
|
||||
|
||||
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await authFetch(input, init)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export const readResponseText = async (response: Response) => {
|
||||
try {
|
||||
return (await response.text()).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+153
-84
@@ -1,107 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { getApiBase, setToken } from '../lib/auth'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
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 }
|
||||
const DEFAULT_LOGIN_OPTIONS = {
|
||||
showJellyfinLogin: true,
|
||||
showLocalLogin: true,
|
||||
showForgotPassword: true,
|
||||
showSignupLink: true,
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [mode, setMode] = useState<LoginMode>('jellyfin')
|
||||
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
|
||||
const [optionsReady, setOptionsReady] = useState(false)
|
||||
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
|
||||
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin'
|
||||
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
|
||||
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
|
||||
? 'jellyfin'
|
||||
: loginOptions.showLocalLogin
|
||||
? 'local'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal })
|
||||
if (!response.ok) throw new Error('Options unavailable')
|
||||
const data = await response.json()
|
||||
if (controller.signal.aborted) return
|
||||
setOptions({
|
||||
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
|
||||
showLocalLogin: data?.login?.showLocalLogin !== false,
|
||||
showForgotPassword: data?.login?.showForgotPassword !== false,
|
||||
showSignupLink: data?.login?.showSignupLink !== false,
|
||||
})
|
||||
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
|
||||
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
|
||||
}
|
||||
} catch {
|
||||
// Keep the normal sign-in methods available during a settings outage.
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setOptionsReady(true)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
|
||||
event.preventDefault()
|
||||
if (loading || !canSignIn || !optionsReady) return
|
||||
setError('')
|
||||
if (!primaryMode) {
|
||||
setError('Login is currently disabled. Contact an administrator.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, {
|
||||
clearToken()
|
||||
const baseUrl = getApiBase()
|
||||
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
|
||||
const body = new URLSearchParams({ username, password })
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ username: username.trim(), password }),
|
||||
credentials: 'include',
|
||||
body,
|
||||
})
|
||||
if (!response.ok) {
|
||||
setError(response.status === 429 ? 'Too many attempts. Please wait a moment and try again.'
|
||||
: response.status >= 500 ? 'Sign-in is temporarily unavailable. Please try again shortly.'
|
||||
: response.status === 403 ? 'This account cannot sign in. Please contact an administrator.'
|
||||
: 'Check your username and password, then try again.')
|
||||
return
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
||||
setToken('cookie')
|
||||
const next = new URLSearchParams(window.location.search).get('next') || ''
|
||||
window.location.assign(next === '/insights' || /^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
|
||||
} catch {
|
||||
setError('Could not reach Magent. Check your connection and try again.')
|
||||
} finally { setLoading(false) }
|
||||
if (data?.access_token) {
|
||||
setToken(data.access_token)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/'
|
||||
return
|
||||
}
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
throw new Error('Login failed')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Invalid username or password.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const loadLoginOptions = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await fetch(`${baseUrl}/site/public`)
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
const login = data?.login ?? {}
|
||||
if (!active) return
|
||||
setLoginOptions({
|
||||
showJellyfinLogin: login.showJellyfinLogin !== false,
|
||||
showLocalLogin: login.showLocalLogin !== false,
|
||||
showForgotPassword: login.showForgotPassword !== false,
|
||||
showSignupLink: login.showSignupLink !== false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
void loadLoginOptions()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loginHelpText = (() => {
|
||||
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
|
||||
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
|
||||
}
|
||||
if (loginOptions.showJellyfinLogin) {
|
||||
return 'Use your Jellyfin account to sign in.'
|
||||
}
|
||||
if (loginOptions.showLocalLogin) {
|
||||
return 'Use your local Magent admin account to sign in.'
|
||||
}
|
||||
return 'No sign-in methods are currently available. Contact an administrator.'
|
||||
})()
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
|
||||
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></>
|
||||
}>
|
||||
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
||||
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
|
||||
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
|
||||
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
|
||||
</div>}
|
||||
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in…</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
|
||||
<form className="account-form login-form" onSubmit={submit}>
|
||||
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
|
||||
<label htmlFor="login-username">Username</label>
|
||||
<input id="login-username" name="username" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" autoCapitalize="none" spellCheck={false} required disabled={loading} />
|
||||
<div className="login-password-label"><label htmlFor="login-password">Password</label>{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}</div>
|
||||
<div className="login-password-field">
|
||||
<input id="login-password" name="password" type={showPassword ? 'text' : 'password'} value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required disabled={loading} />
|
||||
<button type="button" className="password-visibility" aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} onClick={() => setShowPassword(!showPassword)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />{showPassword && <path d="m3 3 18 18" />}</svg>
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
)}
|
||||
</AuthLayout>
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Sign in</h1>
|
||||
<p className="lede">{loginHelpText}</p>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
if (!primaryMode) {
|
||||
event.preventDefault()
|
||||
setError('Login is currently disabled. Contact an administrator.')
|
||||
return
|
||||
}
|
||||
void submit(event, primaryMode)
|
||||
}}
|
||||
className="auth-form"
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<div className="auth-actions">
|
||||
{loginOptions.showJellyfinLogin ? (
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{loginOptions.showLocalLogin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={loading}
|
||||
onClick={(event) => submit(event, 'local')}
|
||||
>
|
||||
Sign in with Magent account
|
||||
</button>
|
||||
) : null}
|
||||
{loginOptions.showForgotPassword ? (
|
||||
<a className="ghost-button" href="/forgot-password">
|
||||
Forgot password?
|
||||
</a>
|
||||
) : null}
|
||||
{loginOptions.showSignupLink ? (
|
||||
<a className="ghost-button" href="/signup">
|
||||
Have an invite? Create your account (Jellyfin + Magent)
|
||||
</a>
|
||||
) : null}
|
||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||
) : null}
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
+587
-5
@@ -1,9 +1,591 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import MyRequests from './MyRequests'
|
||||
'use client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
||||
|
||||
const normalizeRecentResults = (items: any[]) =>
|
||||
items
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => {
|
||||
const id = item.id
|
||||
const rawTitle = item.title
|
||||
const placeholder =
|
||||
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
||||
return {
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: item.year,
|
||||
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() {
|
||||
if (process.env.MAGENT_COMING_SOON === 'true') redirect('/coming-soon')
|
||||
return <MyRequests />
|
||||
const router = useRouter()
|
||||
const [query, setQuery] = useState('')
|
||||
const [recent, setRecent] = useState<
|
||||
{
|
||||
id: number
|
||||
title: string
|
||||
year?: number
|
||||
statusLabel?: string
|
||||
artwork?: { poster_url?: string }
|
||||
createdAt?: string | null
|
||||
}[]
|
||||
>([])
|
||||
const [recentError, setRecentError] = useState<string | null>(null)
|
||||
const [recentLoading, setRecentLoading] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
{
|
||||
title: string
|
||||
year?: number
|
||||
type?: string
|
||||
requestId?: number
|
||||
statusLabel?: string
|
||||
requestedBy?: string | null
|
||||
accessible?: boolean
|
||||
}[]
|
||||
>([])
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [recentDays, setRecentDays] = useState(90)
|
||||
const [recentStage, setRecentStage] = useState('all')
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
const [servicesStatus, setServicesStatus] = useState<
|
||||
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
|
||||
>(null)
|
||||
const [servicesLoading, setServicesLoading] = useState(false)
|
||||
const [servicesError, setServicesError] = useState<string | null>(null)
|
||||
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
router.push(`/requests/${encodeURIComponent(trimmed)}`)
|
||||
return
|
||||
}
|
||||
void runSearch(trimmed)
|
||||
}
|
||||
|
||||
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
|
||||
const updateServiceStatus = (name: string, status: string, message?: string) => {
|
||||
setServicesStatus((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
services: prev.services.map((service) =>
|
||||
service.name === name ? { ...service, status, message } : service
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const testService = async (name: string) => {
|
||||
const slug = toServiceSlug(name)
|
||||
setServiceTesting((prev) => ({ ...prev, [name]: true }))
|
||||
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Service test failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const status = data?.status ?? 'unknown'
|
||||
const message =
|
||||
data?.message ||
|
||||
(status === 'up'
|
||||
? 'API OK'
|
||||
: status === 'down'
|
||||
? 'API unreachable'
|
||||
: status === 'degraded'
|
||||
? 'Health warnings'
|
||||
: status === 'not_configured'
|
||||
? 'Not configured'
|
||||
: 'Unknown')
|
||||
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
|
||||
updateServiceStatus(name, status, data?.message)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
|
||||
} finally {
|
||||
setServiceTesting((prev) => ({ ...prev, [name]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
setRecentLoading(true)
|
||||
setRecentError(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const meResponse = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!meResponse.ok) {
|
||||
if (meResponse.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Auth failed: ${meResponse.status}`)
|
||||
}
|
||||
const me = await meResponse.json()
|
||||
const userRole = me?.role ?? null
|
||||
setRole(userRole)
|
||||
setAuthReady(true)
|
||||
const take = userRole === 'admin' ? 50 : 6
|
||||
const params = new URLSearchParams({
|
||||
take: String(take),
|
||||
days: String(recentDays),
|
||||
})
|
||||
if (recentStage !== 'all') {
|
||||
params.set('stage', recentStage)
|
||||
}
|
||||
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Recent requests failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data?.results)) {
|
||||
setRecent(normalizeRecentResults(data.results))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setRecentError('Recent requests are not available right now.')
|
||||
} finally {
|
||||
setRecentLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [recentDays, recentStage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) {
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
setServicesLoading(true)
|
||||
setServicesError(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/status/services`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Service status failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setServicesStatus(data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setServicesError('Service status is not available right now.')
|
||||
} finally {
|
||||
setServicesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
if (liveStreamConnected) {
|
||||
return
|
||||
}
|
||||
const timer = setInterval(load, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [authReady, liveStreamConnected, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
if (!getToken()) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
let closed = false
|
||||
let source: EventSource | null = null
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const streamToken = await getEventStreamToken()
|
||||
if (closed) return
|
||||
const params = new URLSearchParams({
|
||||
stream_token: streamToken,
|
||||
recent_days: String(recentDays),
|
||||
})
|
||||
if (recentStage !== 'all') {
|
||||
params.set('recent_stage', recentStage)
|
||||
}
|
||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
||||
source = new EventSource(streamUrl)
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
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
|
||||
}
|
||||
if (payload.type === 'home_services') {
|
||||
if (payload.status && typeof payload.status === 'object') {
|
||||
setServicesStatus(payload.status)
|
||||
setServicesError(null)
|
||||
setServicesLoading(false)
|
||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||
setServicesError('Service status is not available right now.')
|
||||
setServicesLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed) return
|
||||
console.error(error)
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source?.close()
|
||||
}
|
||||
}, [authReady, recentDays, recentStage])
|
||||
|
||||
const runSearch = async (term: string) => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Search failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data?.results)) {
|
||||
setSearchResults(
|
||||
data.results.map((item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: item.type,
|
||||
requestId: item.requestId,
|
||||
statusLabel: item.statusLabel,
|
||||
requestedBy: item.requestedBy ?? null,
|
||||
accessible: Boolean(item.accessible),
|
||||
}))
|
||||
)
|
||||
setSearchError(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setSearchError('Search failed. Try a request ID instead.')
|
||||
setSearchResults([])
|
||||
}
|
||||
}
|
||||
|
||||
const resolveArtworkUrl = (url?: string | null) => {
|
||||
if (!url) return null
|
||||
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()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<div className="layout-grid">
|
||||
<section className="recent centerpiece">
|
||||
<div className="system-status">
|
||||
<div className="system-header">
|
||||
<h2>System status</h2>
|
||||
<span
|
||||
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
||||
>
|
||||
{servicesLoading
|
||||
? 'Checking services...'
|
||||
: servicesError
|
||||
? 'Status not available yet'
|
||||
: servicesStatus?.overall === 'up'
|
||||
? 'Services are up and running'
|
||||
: servicesStatus?.overall === 'down'
|
||||
? 'Something is down'
|
||||
: 'Some services need attention'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-list">
|
||||
{(() => {
|
||||
const order = [
|
||||
'Seerr',
|
||||
'Sonarr',
|
||||
'Radarr',
|
||||
'Prowlarr',
|
||||
'qBittorrent',
|
||||
'Jellyfin',
|
||||
]
|
||||
const items = servicesStatus?.services ?? []
|
||||
return order.map((name) => {
|
||||
const item = items.find((entry) => entry.name === name)
|
||||
const status = item?.status ?? 'unknown'
|
||||
const testing = serviceTesting[name] ?? false
|
||||
return (
|
||||
<div key={name} className={`system-item system-${status}`}>
|
||||
<span className="system-dot" />
|
||||
<div className="system-meta">
|
||||
<span className="system-name">{name}</span>
|
||||
{serviceTestResults[name] && (
|
||||
<span className="system-test-message">{serviceTestResults[name]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-actions">
|
||||
<span className="system-state">
|
||||
{status === 'up'
|
||||
? 'Up'
|
||||
: status === 'down'
|
||||
? 'Down'
|
||||
: status === 'degraded'
|
||||
? 'Needs attention'
|
||||
: status === 'not_configured'
|
||||
? 'Not configured'
|
||||
: 'Unknown'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="system-test"
|
||||
onClick={() => void testService(name)}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-header">
|
||||
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||
{authReady && (
|
||||
<div className="recent-filter-group">
|
||||
<label className="recent-filter">
|
||||
<span>Show</span>
|
||||
<select
|
||||
value={recentDays}
|
||||
onChange={(event) => setRecentDays(Number(event.target.value))}
|
||||
>
|
||||
<option value={0}>All</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={60}>60 days</option>
|
||||
<option value={90}>90 days</option>
|
||||
<option value={180}>180 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="recent-filter">
|
||||
<span>Stage</span>
|
||||
<select
|
||||
value={recentStage}
|
||||
onChange={(event) => setRecentStage(event.target.value)}
|
||||
>
|
||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="recent-grid">
|
||||
{recentLoading ? (
|
||||
<div className="loading-center">
|
||||
<div className="spinner" aria-hidden="true" />
|
||||
<span className="loading-text">Loading recent requests…</span>
|
||||
</div>
|
||||
) : recentError ? (
|
||||
<button type="button" disabled>
|
||||
{recentError}
|
||||
</button>
|
||||
) : recent.length === 0 ? (
|
||||
<button type="button" disabled>
|
||||
No recent requests found
|
||||
</button>
|
||||
) : (
|
||||
recent.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/requests/${item.id}`)}
|
||||
className="recent-card"
|
||||
>
|
||||
{item.artwork?.poster_url && (
|
||||
<img
|
||||
className="recent-poster"
|
||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<span className="recent-info">
|
||||
<span className="recent-title">
|
||||
{item.title || 'Untitled'}
|
||||
{item.year ? ` (${item.year})` : ''}
|
||||
</span>
|
||||
<span className="recent-meta">
|
||||
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
|
||||
{item.id}
|
||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<aside className="side-panel">
|
||||
<section className="main-panel find-panel">
|
||||
<div className="find-header">
|
||||
<h1>Search all requests</h1>
|
||||
<p className="lede">
|
||||
Search any request by title + year or request number and see whether it already
|
||||
exists in the system.
|
||||
</p>
|
||||
</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}`
|
||||
: '- already requested'}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user