Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
271261524
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
.venv/
|
|
||||||
**/.pytest_cache/
|
|
||||||
stitch_magent_media_operations_redesign/
|
|
||||||
*.tar
|
|
||||||
*.tar.gz
|
|
||||||
*.zip
|
|
||||||
bootstrap-admin.json
|
|
||||||
release.tar
|
|
||||||
*.log
|
*.log
|
||||||
data/*
|
data/*
|
||||||
!data/branding/
|
!data/branding/
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
*.ps1 text eol=crlf
|
|
||||||
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.ico binary
|
|
||||||
*.pdf binary
|
|
||||||
*.zip binary
|
|
||||||
*.gz binary
|
|
||||||
*.tgz binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
@@ -1,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
|
.env
|
||||||
bootstrap-admin.json
|
|
||||||
.venv/
|
.venv/
|
||||||
data/
|
data/
|
||||||
!data/branding/
|
!data/branding/
|
||||||
@@ -11,10 +10,3 @@ backend/.pytest_cache/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
*.log
|
*.log
|
||||||
**/.pytest_cache/
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.*.example
|
|
||||||
*.tar
|
|
||||||
*.tar.gz
|
|
||||||
*.zip
|
|
||||||
|
|||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
FROM node:24-slim AS frontend-builder
|
|
||||||
|
|
||||||
WORKDIR /frontend
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
|
|
||||||
NEXT_PUBLIC_API_BASE=/api
|
|
||||||
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
|
||||||
RUN npm ci --include=dev
|
|
||||||
|
|
||||||
COPY frontend/app ./app
|
|
||||||
COPY frontend/public ./public
|
|
||||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
|
||||||
COPY frontend/next.config.js ./next.config.js
|
|
||||||
COPY frontend/tsconfig.json ./tsconfig.json
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY backend/app ./app
|
|
||||||
COPY data/branding /app/data/branding
|
|
||||||
|
|
||||||
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
|
|
||||||
COPY --from=frontend-builder /frontend/public /app/frontend/public
|
|
||||||
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
|
||||||
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
|
||||||
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
|
||||||
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
|
|
||||||
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
|
||||||
|
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
|
||||||
|
|
||||||
EXPOSE 3000 8000
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
|
||||||
@@ -1,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).
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Magent
|
# Magent
|
||||||
|
|
||||||
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1) Requests are pulled from Seerr and stored locally.
|
1) Requests are pulled from Jellyseerr and stored locally.
|
||||||
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
|
||||||
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
|
||||||
4) The UI renders a timeline and a central status box for each request.
|
4) The UI renders a timeline and a central status box for each request.
|
||||||
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
|
|||||||
|
|
||||||
- Request search by title/year or request ID.
|
- Request search by title/year or request ID.
|
||||||
- Recent requests list with posters and status.
|
- Recent requests list with posters and status.
|
||||||
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
|
||||||
- Central status box with clear reason + next steps.
|
- Central status box with clear reason + next steps.
|
||||||
- Safe action buttons (search, resume, re-add, etc.).
|
- Safe action buttons (search, resume, re-add, etc.).
|
||||||
- Admin settings for service URLs, API keys, profiles, and root folders.
|
- Admin settings for service URLs, API keys, profiles, and root folders.
|
||||||
@@ -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.
|
- Local database for speed and audit history.
|
||||||
- Users and access control (admin vs user, block access).
|
- Users and access control (admin vs user, block access).
|
||||||
- Local account password changes via "My profile".
|
- 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.
|
- Docker-first deployment for easy hosting.
|
||||||
|
|
||||||
## Quick start (Docker - primary)
|
## Quick start (Docker - primary)
|
||||||
@@ -65,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
|||||||
QBIT_USERNAME="..."
|
QBIT_USERNAME="..."
|
||||||
QBIT_PASSWORD="..."
|
QBIT_PASSWORD="..."
|
||||||
SQLITE_PATH="data/magent.db"
|
SQLITE_PATH="data/magent.db"
|
||||||
JWT_SECRET="replace-with-a-long-random-secret"
|
JWT_SECRET="change-me"
|
||||||
JWT_EXP_MINUTES="720"
|
JWT_EXP_MINUTES="720"
|
||||||
ADMIN_USERNAME="set-a-real-admin-username"
|
ADMIN_USERNAME="admin"
|
||||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
ADMIN_PASSWORD="adminadmin"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -113,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
|
|||||||
$env:QBIT_USERNAME="..."
|
$env:QBIT_USERNAME="..."
|
||||||
$env:QBIT_PASSWORD="..."
|
$env:QBIT_PASSWORD="..."
|
||||||
$env:SQLITE_PATH="data/magent.db"
|
$env:SQLITE_PATH="data/magent.db"
|
||||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
$env:JWT_SECRET="change-me"
|
||||||
$env:JWT_EXP_MINUTES="720"
|
$env:JWT_EXP_MINUTES="720"
|
||||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
$env:ADMIN_USERNAME="admin"
|
||||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
$env:ADMIN_PASSWORD="adminadmin"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (Next.js)
|
### Frontend (Next.js)
|
||||||
@@ -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.
|
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||||
|
|
||||||
## Gitea CI/CD
|
|
||||||
|
|
||||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
|
||||||
|
|
||||||
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
|
|
||||||
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
|
|
||||||
|
|
||||||
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
|
||||||
|
|
||||||
- `http://127.0.0.1:8000/health`
|
|
||||||
- `http://127.0.0.1:3000/login`
|
|
||||||
|
|
||||||
Configure these Gitea Actions secrets before enabling the deploy job:
|
|
||||||
|
|
||||||
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
|
|
||||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
|
||||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
|
||||||
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
|
||||||
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
|
||||||
|
|
||||||
## History endpoints
|
## History endpoints
|
||||||
|
|
||||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||||
@@ -181,7 +160,7 @@ Configure these Gitea Actions secrets before enabling the deploy job:
|
|||||||
|
|
||||||
### No recent requests
|
### No recent requests
|
||||||
|
|
||||||
- Confirm Seerr credentials in Settings.
|
- Confirm Jellyseerr credentials in Settings.
|
||||||
- Run a full sync from Settings -> Requests.
|
- Run a full sync from Settings -> Requests.
|
||||||
|
|
||||||
### Docker images not updating
|
### Docker images not updating
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG BUILD_NUMBER=dev
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
SITE_BUILD_NUMBER=${BUILD_NUMBER}
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
|||||||
|
|
||||||
if snapshot.state == NormalizedState.requested:
|
if snapshot.state == NormalizedState.requested:
|
||||||
root_cause = "approval"
|
root_cause = "approval"
|
||||||
summary = "The request is waiting for approval in Seerr."
|
summary = "The request is waiting for approval in Jellyseerr."
|
||||||
recommendations.append(
|
recommendations.append(
|
||||||
TriageRecommendation(
|
TriageRecommendation(
|
||||||
action_id="wait_for_approval",
|
action_id="wait_for_approval",
|
||||||
title="Ask an admin to approve the request",
|
title="Ask an admin to approve the request",
|
||||||
reason="Seerr has not marked this request as approved.",
|
reason="Jellyseerr has not marked this request as approved.",
|
||||||
risk="low",
|
risk="low",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+16
-186
@@ -1,152 +1,32 @@
|
|||||||
from datetime import datetime, timezone
|
from typing import Dict, Any
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, Response, status
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
from .config import settings
|
from .db import get_user_by_username, upsert_user_activity
|
||||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
from .security import safe_decode_token, TokenError
|
||||||
from .network_security import request_trusts_forwarded_headers
|
|
||||||
from .security import TokenError, safe_decode_token, verify_password
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(expires_at: str | None) -> bool:
|
|
||||||
if not isinstance(expires_at, str) or not expires_at.strip():
|
|
||||||
return False
|
|
||||||
candidate = expires_at.strip()
|
|
||||||
if candidate.endswith("Z"):
|
|
||||||
candidate = candidate[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed <= datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
def _extract_client_ip(request: Request) -> str:
|
def _extract_client_ip(request: Request) -> str:
|
||||||
direct_host = request.client.host if request.client else None
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
if request_trusts_forwarded_headers(direct_host):
|
if forwarded:
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||||
if forwarded:
|
if parts:
|
||||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
return parts[0]
|
||||||
if parts:
|
real_ip = request.headers.get("x-real-ip")
|
||||||
return parts[0]
|
if real_ip:
|
||||||
real_ip = request.headers.get("x-real-ip")
|
return real_ip.strip()
|
||||||
if real_ip:
|
if request.client and request.client.host:
|
||||||
return real_ip.strip()
|
return request.client.host
|
||||||
if direct_host:
|
|
||||||
return direct_host
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _cookie_settings() -> dict[str, Any]:
|
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
|
||||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
|
||||||
if samesite not in {"lax", "strict", "none"}:
|
|
||||||
samesite = "lax"
|
|
||||||
return {
|
|
||||||
"secure": bool(settings.auth_cookie_secure),
|
|
||||||
"httponly": True,
|
|
||||||
"samesite": samesite,
|
|
||||||
"domain": settings.auth_cookie_domain or None,
|
|
||||||
"path": "/",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _state_cookie_settings() -> dict[str, Any]:
|
|
||||||
cookie = _cookie_settings()
|
|
||||||
cookie["httponly"] = False
|
|
||||||
return cookie
|
|
||||||
|
|
||||||
|
|
||||||
def set_auth_cookies(response: Response, token: str) -> None:
|
|
||||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
|
||||||
response.set_cookie(
|
|
||||||
settings.auth_cookie_name,
|
|
||||||
token,
|
|
||||||
max_age=max_age,
|
|
||||||
**_cookie_settings(),
|
|
||||||
)
|
|
||||||
response.set_cookie(
|
|
||||||
settings.auth_state_cookie_name,
|
|
||||||
"1",
|
|
||||||
max_age=max_age,
|
|
||||||
**_state_cookie_settings(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_auth_cookies(response: Response) -> None:
|
|
||||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
|
||||||
response.delete_cookie(
|
|
||||||
settings.auth_state_cookie_name,
|
|
||||||
path="/",
|
|
||||||
domain=settings.auth_cookie_domain or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
if auth_header.lower().startswith("bearer "):
|
|
||||||
return auth_header.split(" ", 1)[1].strip()
|
|
||||||
if oauth_token:
|
|
||||||
return oauth_token
|
|
||||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
|
||||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
|
||||||
return cookie_token.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return "local"
|
|
||||||
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
|
||||||
if provider != "local":
|
|
||||||
return provider
|
|
||||||
password_hash = user.get("password_hash")
|
|
||||||
if isinstance(password_hash, str) and password_hash:
|
|
||||||
if verify_password("jellyfin-user", password_hash):
|
|
||||||
return "jellyfin"
|
|
||||||
if verify_password("jellyseerr-user", password_hash):
|
|
||||||
return "jellyseerr"
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return {}
|
|
||||||
resolved_provider = resolve_user_auth_provider(user)
|
|
||||||
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
|
||||||
if resolved_provider != stored_provider:
|
|
||||||
username = str(user.get("username") or "").strip()
|
|
||||||
if username:
|
|
||||||
set_user_auth_provider(username, resolved_provider)
|
|
||||||
refreshed_user = get_user_by_username(username)
|
|
||||||
if refreshed_user:
|
|
||||||
user = refreshed_user
|
|
||||||
normalized = dict(user)
|
|
||||||
normalized["auth_provider"] = resolved_provider
|
|
||||||
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
|
|
||||||
normalized["password_provider"] = (
|
|
||||||
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
|
|
||||||
)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _load_current_user_from_token(
|
|
||||||
token: str,
|
|
||||||
request: Optional[Request] = None,
|
|
||||||
allowed_token_types: Optional[set[str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
payload = safe_decode_token(token)
|
payload = safe_decode_token(token)
|
||||||
except TokenError as exc:
|
except TokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||||
token_type = str(payload.get("typ") or "access").strip().lower()
|
|
||||||
if allowed_token_types and token_type not in allowed_token_types:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
@@ -157,10 +37,6 @@ def _load_current_user_from_token(
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
if user.get("is_blocked"):
|
if user.get("is_blocked"):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||||
if _is_expired(user.get("expires_at")):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
|
||||||
|
|
||||||
user = normalize_user_auth_provider(user)
|
|
||||||
|
|
||||||
if request is not None:
|
if request is not None:
|
||||||
ip = _extract_client_ip(request)
|
ip = _extract_client_ip(request)
|
||||||
@@ -169,58 +45,12 @@ def _load_current_user_from_token(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"email": user.get("email"),
|
|
||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"auth_provider": user.get("auth_provider", "local"),
|
"auth_provider": user.get("auth_provider", "local"),
|
||||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
|
||||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
|
||||||
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
|
||||||
"profile_id": user.get("profile_id"),
|
|
||||||
"expires_at": user.get("expires_at"),
|
|
||||||
"is_expired": bool(user.get("is_expired", False)),
|
|
||||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
|
||||||
"password_provider": user.get("password_provider"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
request: Request,
|
|
||||||
token: Optional[str] = Depends(oauth2_scheme),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
resolved_token = _extract_access_token(request, token)
|
|
||||||
if not resolved_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
|
||||||
return _load_current_user_from_token(resolved_token, request)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_event_stream(
|
|
||||||
request: Request,
|
|
||||||
token: Optional[str] = Depends(oauth2_scheme),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
|
||||||
resolved_token = _extract_access_token(request, token)
|
|
||||||
stream_query_token = request.query_params.get("stream_token")
|
|
||||||
if resolved_token:
|
|
||||||
# Allow standard bearer tokens for non-browser EventSource clients.
|
|
||||||
return _load_current_user_from_token(resolved_token, None)
|
|
||||||
if not stream_query_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
|
||||||
return _load_current_user_from_token(
|
|
||||||
str(stream_query_token),
|
|
||||||
None,
|
|
||||||
allowed_token_types={"sse"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||||
if user.get("role") != "admin":
|
if user.get("role") != "admin":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_admin_event_stream(
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if user.get("role") != "admin":
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
||||||
return user
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+12
-399
@@ -1,262 +1,11 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..logging_config import sanitize_headers, sanitize_value
|
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
|
||||||
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:
|
class ApiClient:
|
||||||
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
||||||
self.base_url = base_url.rstrip("/") if base_url else None
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
|
||||||
|
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url)
|
return bool(self.base_url)
|
||||||
@@ -264,156 +13,20 @@ class ApiClient:
|
|||||||
def headers(self) -> Dict[str, str]:
|
def headers(self) -> Dict[str, str]:
|
||||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||||
|
|
||||||
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
|
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if response is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = sanitize_value(response.json())
|
|
||||||
except ValueError:
|
|
||||||
payload = sanitize_value(response.text)
|
|
||||||
if isinstance(payload, str) and len(payload) > 500:
|
|
||||||
return f"{payload[:500]}..."
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def _send_request(
|
|
||||||
self,
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Dict[str, str],
|
|
||||||
params: Optional[Dict[str, Any]],
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
) -> httpx.Response:
|
|
||||||
return await client.request(
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
timeout_seconds: float = 10.0,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
response = await client.get(url, headers=self.headers(), params=params)
|
||||||
active_message, _ = _operation_messages(service_name, method, path)
|
response.raise_for_status()
|
||||||
operation_event_id = start_remote_call(service_name, active_message)
|
return response.json()
|
||||||
metric_status = 'error'
|
|
||||||
self.logger.debug(
|
|
||||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
sanitize_value(params),
|
|
||||||
sanitize_value(payload),
|
|
||||||
sanitize_headers(self.headers()),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
|
||||||
response = await self._send_request(
|
|
||||||
client,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=self.headers(),
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
metric_status = str(response.status_code)
|
|
||||||
response.raise_for_status()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
self.logger.debug(
|
|
||||||
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
response.status_code,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
result = response.json() if response.content else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_operation_result_message(
|
|
||||||
service_name,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
result,
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
response = exc.response
|
|
||||||
status = response.status_code if response is not None else "unknown"
|
|
||||||
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
|
|
||||||
log_fn(
|
|
||||||
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
status,
|
|
||||||
duration_ms,
|
|
||||||
self._response_summary(response),
|
|
||||||
)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status if isinstance(status, int) else None,
|
|
||||||
message=_operation_error_message(
|
|
||||||
service_name,
|
|
||||||
status if isinstance(status, int) else None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
self.logger.exception(
|
|
||||||
"outbound request failed method=%s url=%s duration_ms=%s",
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
message=_operation_error_message(service_name, None),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
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 post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
return await self._request("POST", path, payload=payload)
|
if not self.base_url:
|
||||||
|
return None
|
||||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
url = f"{self.base_url}{path}"
|
||||||
return await self._request("PUT", path, payload=payload)
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(url, headers=self.headers(), json=payload)
|
||||||
async def delete(
|
response.raise_for_status()
|
||||||
self,
|
return response.json()
|
||||||
path: str,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request("DELETE", path, params=params)
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from .base import ApiClient
|
|
||||||
|
|
||||||
|
|
||||||
class BazarrClient(ApiClient):
|
|
||||||
async def get_system_status(self) -> Optional[Any]:
|
|
||||||
return await self._request("GET", "/api/system/status")
|
|
||||||
|
|
||||||
async def search_movie_subtitles(
|
|
||||||
self,
|
|
||||||
radarr_id: int,
|
|
||||||
*,
|
|
||||||
language: str,
|
|
||||||
forced: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"PATCH",
|
|
||||||
"/api/movies/subtitles",
|
|
||||||
params={
|
|
||||||
"radarrid": radarr_id,
|
|
||||||
"language": language,
|
|
||||||
"forced": str(forced).lower(),
|
|
||||||
"hi": "false",
|
|
||||||
},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def search_episode_subtitles(
|
|
||||||
self,
|
|
||||||
series_id: int,
|
|
||||||
episode_id: int,
|
|
||||||
*,
|
|
||||||
language: str,
|
|
||||||
forced: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
return await self._request(
|
|
||||||
"PATCH",
|
|
||||||
"/api/episodes/subtitles",
|
|
||||||
params={
|
|
||||||
"seriesid": series_id,
|
|
||||||
"episodeid": episode_id,
|
|
||||||
"language": language,
|
|
||||||
"forced": str(forced).lower(),
|
|
||||||
"hi": "false",
|
|
||||||
},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
+12
-234
@@ -1,24 +1,6 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import time
|
from .base import ApiClient
|
||||||
from .base import ApiClient, _operation_error_message
|
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
|
||||||
|
|
||||||
|
|
||||||
def _availability_message(result: Any) -> str:
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
return "Jellyfin did not return any matching library items."
|
|
||||||
total = result.get("TotalRecordCount")
|
|
||||||
items = result.get("Items")
|
|
||||||
available = (
|
|
||||||
(isinstance(total, int) and total > 0)
|
|
||||||
or (isinstance(items, list) and len(items) > 0)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
|
|
||||||
if available
|
|
||||||
else "Grizzlyflix did not find this title in its library search."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JellyfinClient(ApiClient):
|
class JellyfinClient(ApiClient):
|
||||||
@@ -28,265 +10,61 @@ class JellyfinClient(ApiClient):
|
|||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url and self.api_key)
|
return bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
def _emby_headers(self) -> Dict[str, str]:
|
|
||||||
return {"X-Emby-Token": self.api_key} if self.api_key else {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_user_id(payload: Any) -> Optional[str]:
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
|
||||||
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
|
|
||||||
if not isinstance(candidate, dict):
|
|
||||||
return None
|
|
||||||
for key in ("Id", "id", "UserId", "userId"):
|
|
||||||
value = candidate.get(key)
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (str, int)):
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/Users"
|
url = f"{self.base_url}/Users"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/{user_id}"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.get(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
|
|
||||||
users = await self.get_users()
|
|
||||||
if not isinstance(users, list):
|
|
||||||
return None
|
|
||||||
target = username.strip().lower()
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
name = str(user.get("Name") or "").strip().lower()
|
|
||||||
if name and name == target:
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||||
payload = {"Username": username, "Pw": password}
|
payload = {"Username": username, "Pw": password}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/New"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
payload = {"Name": username}
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.content:
|
|
||||||
return None
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def set_user_password(self, user_id: str, password: str) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
headers = self._emby_headers()
|
|
||||||
payloads = [
|
|
||||||
{"CurrentPw": "", "NewPw": password},
|
|
||||||
{"CurrentPwd": "", "NewPw": password},
|
|
||||||
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
|
|
||||||
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
|
|
||||||
{"NewPw": password, "ResetPassword": False},
|
|
||||||
]
|
|
||||||
paths = [
|
|
||||||
f"/Users/{user_id}/Password",
|
|
||||||
f"/Users/{user_id}/EasyPassword",
|
|
||||||
]
|
|
||||||
last_error: Exception | None = None
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
for path in paths:
|
|
||||||
url = f"{self.base_url}{path}"
|
|
||||||
for payload in payloads:
|
|
||||||
try:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
return
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
if last_error:
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
user = await self.get_user(user_id)
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
raise RuntimeError("Jellyfin user details not available")
|
|
||||||
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
|
|
||||||
payload = {**policy, "IsDisabled": bool(disabled)}
|
|
||||||
url = f"{self.base_url}/Users/{user_id}/Policy"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: str) -> None:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Users/{user_id}"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.delete(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
||||||
created = await self.create_user(username)
|
|
||||||
user_id = self._extract_user_id(created)
|
|
||||||
if not user_id:
|
|
||||||
users = await self.get_users()
|
|
||||||
if isinstance(users, list):
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
name = str(user.get("Name") or "").strip()
|
|
||||||
if name.lower() == username.strip().lower():
|
|
||||||
created = user
|
|
||||||
user_id = self._extract_user_id(user)
|
|
||||||
break
|
|
||||||
if not user_id:
|
|
||||||
raise RuntimeError("Jellyfin user created but user ID was not returned")
|
|
||||||
await self.set_user_password(user_id, password)
|
|
||||||
return created
|
|
||||||
|
|
||||||
async def search_items(
|
async def search_items(
|
||||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
|
||||||
url = f"{self.base_url}/Items"
|
url = f"{self.base_url}/Items"
|
||||||
params = {
|
params = {
|
||||||
"SearchTerm": term,
|
"SearchTerm": term,
|
||||||
"IncludeItemTypes": ",".join(item_types or []),
|
"IncludeItemTypes": ",".join(item_types or []),
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"Fields": "Path,MediaSources",
|
|
||||||
"Limit": limit,
|
"Limit": limit,
|
||||||
}
|
}
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
try:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response = await client.get(url, headers=headers, params=params)
|
||||||
response = await client.get(url, headers=headers, params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_availability_message(result),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("Jellyfin", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
|
|
||||||
if not self.base_url or not self.api_key or not str(series_id).strip():
|
|
||||||
return []
|
|
||||||
url = f"{self.base_url}/Items"
|
|
||||||
params = {
|
|
||||||
"ParentId": str(series_id).strip(),
|
|
||||||
"IncludeItemTypes": "Episode",
|
|
||||||
"Recursive": "true",
|
|
||||||
"Fields": "Path,ProviderIds,MediaSources",
|
|
||||||
"Limit": 10000,
|
|
||||||
}
|
|
||||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
||||||
response = await client.get(url, headers=self._emby_headers(), params=params)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
return response.json()
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return []
|
|
||||||
items = payload.get("Items") or payload.get("items") or []
|
|
||||||
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
|
||||||
|
|
||||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
url = f"{self.base_url}/System/Info"
|
url = f"{self.base_url}/System/Info"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
|
|
||||||
if not self.base_url or not self.api_key:
|
|
||||||
return None
|
|
||||||
url = f"{self.base_url}/Sessions"
|
|
||||||
headers = self._emby_headers()
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.get(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
return payload if isinstance(payload, list) else []
|
|
||||||
|
|
||||||
async def refresh_library(self, recursive: bool = True) -> None:
|
async def refresh_library(self, recursive: bool = True) -> None:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
|
||||||
url = f"{self.base_url}/Library/Refresh"
|
url = f"{self.base_url}/Library/Refresh"
|
||||||
headers = self._emby_headers()
|
headers = {"X-Emby-Token": self.api_key}
|
||||||
params = {"Recursive": "true" if recursive else "false"}
|
params = {"Recursive": "true" if recursive else "false"}
|
||||||
try:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response = await client.post(url, headers=headers, params=params)
|
||||||
response = await client.post(url, headers=headers, params=params)
|
response.raise_for_status()
|
||||||
response.raise_for_status()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("Jellyfin", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,44 +1,8 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from urllib.parse import quote, unquote, urlsplit
|
|
||||||
import httpx
|
|
||||||
from .base import ApiClient
|
from .base import ApiClient
|
||||||
|
|
||||||
|
|
||||||
class JellyseerrClient(ApiClient):
|
class JellyseerrClient(ApiClient):
|
||||||
async def _send_request(
|
|
||||||
self,
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Dict[str, str],
|
|
||||||
params: Optional[Dict[str, Any]],
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
) -> httpx.Response:
|
|
||||||
request_headers = dict(headers)
|
|
||||||
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
|
|
||||||
# Seerr's optional CSRF protection also applies to API-key writes.
|
|
||||||
# Seed its secret/token cookie pair, then echo the readable token in
|
|
||||||
# the header Seerr's own web client uses.
|
|
||||||
csrf_response = await client.get(
|
|
||||||
f"{self.base_url}/api/v1/auth/me",
|
|
||||||
headers=self.headers(),
|
|
||||||
)
|
|
||||||
csrf_response.raise_for_status()
|
|
||||||
csrf_token = client.cookies.get("XSRF-TOKEN")
|
|
||||||
if csrf_token:
|
|
||||||
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
|
|
||||||
parsed_base = urlsplit(self.base_url)
|
|
||||||
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
|
||||||
return await super()._send_request(
|
|
||||||
client,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers=request_headers,
|
|
||||||
params=params,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v1/status")
|
return await self.get("/api/v1/status")
|
||||||
|
|
||||||
@@ -54,6 +18,9 @@ class JellyseerrClient(ApiClient):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
return await self.get(f"/api/v1/media/{media_id}")
|
||||||
|
|
||||||
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
||||||
|
|
||||||
@@ -61,65 +28,10 @@ class JellyseerrClient(ApiClient):
|
|||||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||||
|
|
||||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||||
# Seerr rejects the `+` encoding that standard query builders use for
|
|
||||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
|
||||||
# percent-encoded values.
|
|
||||||
encoded_query = quote(query, safe="")
|
|
||||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
|
||||||
|
|
||||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
|
||||||
service = "sonarr" if media_type == "tv" else "radarr"
|
|
||||||
return await self.get(f"/api/v1/settings/{service}")
|
|
||||||
|
|
||||||
async def create_request(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
media_type: str,
|
|
||||||
media_id: int,
|
|
||||||
seasons: Optional[list[int]] = None,
|
|
||||||
is_4k: Optional[bool] = None,
|
|
||||||
server_id: Optional[int] = None,
|
|
||||||
profile_id: Optional[int] = None,
|
|
||||||
root_folder: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"mediaType": media_type,
|
|
||||||
"mediaId": media_id,
|
|
||||||
}
|
|
||||||
if isinstance(seasons, list) and seasons:
|
|
||||||
payload["seasons"] = seasons
|
|
||||||
if isinstance(is_4k, bool):
|
|
||||||
payload["is4k"] = is_4k
|
|
||||||
if isinstance(server_id, int):
|
|
||||||
payload["serverId"] = server_id
|
|
||||||
if isinstance(profile_id, int):
|
|
||||||
payload["profileId"] = profile_id
|
|
||||||
if isinstance(root_folder, str) and root_folder.strip():
|
|
||||||
payload["rootFolder"] = root_folder.strip()
|
|
||||||
return await self.post("/api/v1/request", payload=payload)
|
|
||||||
|
|
||||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/api/v1/user",
|
"/api/v1/search",
|
||||||
params={
|
params={
|
||||||
"take": take,
|
"query": query,
|
||||||
"skip": skip,
|
"page": page,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v1/user/{user_id}")
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.delete(f"/api/v1/user/{user_id}")
|
|
||||||
|
|
||||||
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
|
||||||
payload = {"email": email, "password": password}
|
|
||||||
try:
|
|
||||||
return await self.post("/api/v1/auth/local", payload=payload)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
# Backward compatibility for older Seerr/Overseerr deployments
|
|
||||||
# that still expose /auth/login instead of /auth/local.
|
|
||||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
|
||||||
return await self.post("/api/v1/auth/login", payload=payload)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,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
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
import time
|
from .base import ApiClient
|
||||||
from .base import ApiClient, _operation_error_message
|
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
|
||||||
|
|
||||||
|
|
||||||
def _torrent_state_text(state: Any) -> str:
|
|
||||||
normalized = str(state or "").strip().lower()
|
|
||||||
if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
|
|
||||||
return "finished"
|
|
||||||
if "pause" in normalized or normalized == "stoppeddl":
|
|
||||||
return "paused"
|
|
||||||
if "stall" in normalized:
|
|
||||||
return "waiting for data"
|
|
||||||
if normalized.startswith("queued"):
|
|
||||||
return "waiting in the queue"
|
|
||||||
if normalized == "metadl":
|
|
||||||
return "getting the download details"
|
|
||||||
if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
|
|
||||||
return "checking the downloaded files"
|
|
||||||
if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
|
|
||||||
return "downloading"
|
|
||||||
if "upload" in normalized:
|
|
||||||
return "downloaded and sharing with others"
|
|
||||||
if normalized in {"completed", "missingfiles"}:
|
|
||||||
return "finished" if normalized == "completed" else "missing files"
|
|
||||||
if "error" in normalized:
|
|
||||||
return "unable to continue"
|
|
||||||
return "present"
|
|
||||||
|
|
||||||
|
|
||||||
def _torrent_result_message(result: Any) -> str:
|
|
||||||
torrents = result if isinstance(result, list) else []
|
|
||||||
if not torrents:
|
|
||||||
return "qBittorrent found no matching downloads."
|
|
||||||
first = next((item for item in torrents if isinstance(item, dict)), {})
|
|
||||||
if len(torrents) == 1:
|
|
||||||
progress = first.get("progress")
|
|
||||||
progress_text = (
|
|
||||||
f" — {max(0, min(100, round(progress * 100)))}% complete"
|
|
||||||
if isinstance(progress, (int, float))
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
state_text = _torrent_state_text(first.get("state"))
|
|
||||||
return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.'
|
|
||||||
active = sum(
|
|
||||||
1
|
|
||||||
for item in torrents
|
|
||||||
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
|
|
||||||
)
|
|
||||||
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
|
|
||||||
|
|
||||||
|
|
||||||
def _torrent_action_message(path: str) -> str:
|
|
||||||
normalized_path = path.lower()
|
|
||||||
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
|
|
||||||
return "qBittorrent accepted the request to resume the download."
|
|
||||||
if normalized_path.endswith("/add"):
|
|
||||||
return "qBittorrent accepted the release and added it to the download queue."
|
|
||||||
return "qBittorrent accepted the requested download action."
|
|
||||||
|
|
||||||
|
|
||||||
class QBittorrentClient(ApiClient):
|
class QBittorrentClient(ApiClient):
|
||||||
@@ -81,109 +23,34 @@ class QBittorrentClient(ApiClient):
|
|||||||
headers={"Referer": self.base_url},
|
headers={"Referer": self.base_url},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
text = response.text.strip().lower()
|
if response.text.strip().lower() != "ok.":
|
||||||
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
|
||||||
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
|
||||||
raise RuntimeError("qBittorrent login failed")
|
raise RuntimeError("qBittorrent login failed")
|
||||||
|
|
||||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
await self._login(client)
|
||||||
try:
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response.raise_for_status()
|
||||||
await self._login(client)
|
return response.json()
|
||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_torrent_result_message(result),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("qBittorrent", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
await self._login(client)
|
||||||
try:
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response.raise_for_status()
|
||||||
await self._login(client)
|
return response.text.strip()
|
||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.text.strip()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("qBittorrent", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
await self._login(client)
|
||||||
try:
|
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
response.raise_for_status()
|
||||||
await self._login(client)
|
|
||||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
|
||||||
response.raise_for_status()
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=True,
|
|
||||||
status_code=response.status_code,
|
|
||||||
message=_torrent_action_message(path),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
||||||
finish_remote_call(
|
|
||||||
operation_event_id,
|
|
||||||
success=False,
|
|
||||||
status_code=status_code,
|
|
||||||
message=_operation_error_message("qBittorrent", status_code),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def is_webui_reachable(self) -> bool:
|
|
||||||
if not self.base_url:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(self.base_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
return True
|
|
||||||
except httpx.HTTPError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_torrents(self) -> Optional[Any]:
|
async def get_torrents(self) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info")
|
return await self._get("/api/v2/torrents/info")
|
||||||
@@ -194,9 +61,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
return await self._get("/api/v2/torrents/info", params={"category": category})
|
||||||
|
|
||||||
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
|
|
||||||
return await self._get("/api/v2/torrents/info", params={"tag": tag})
|
|
||||||
|
|
||||||
async def get_app_version(self) -> Optional[Any]:
|
async def get_app_version(self) -> Optional[Any]:
|
||||||
return await self._get_text("/api/v2/app/version")
|
return await self._get_text("/api/v2/app/version")
|
||||||
|
|
||||||
@@ -209,9 +73,7 @@ class QBittorrentClient(ApiClient):
|
|||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def add_torrent_url(
|
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
||||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
url_host = None
|
url_host = None
|
||||||
if isinstance(url, str) and "://" in url:
|
if isinstance(url, str) and "://" in url:
|
||||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
||||||
@@ -223,6 +85,4 @@ class QBittorrentClient(ApiClient):
|
|||||||
data: Dict[str, Any] = {"urls": url}
|
data: Dict[str, Any] = {"urls": url}
|
||||||
if category:
|
if category:
|
||||||
data["category"] = category
|
data["category"] = category
|
||||||
if tags:
|
|
||||||
data["tags"] = tags
|
|
||||||
await self._post_form("/api/v2/torrents/add", data=data)
|
await self._post_form("/api/v2/torrents/add", data=data)
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||||
|
|
||||||
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
|
|
||||||
return result if isinstance(result, dict) else None
|
|
||||||
|
|
||||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
|
||||||
|
|
||||||
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/movie")
|
return await self.get("/api/v3/movie")
|
||||||
|
|
||||||
@@ -28,32 +21,12 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
||||||
|
|
||||||
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
|
||||||
return await self.get(
|
|
||||||
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/indexer")
|
return await self.get("/api/v3/indexer")
|
||||||
|
|
||||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
||||||
|
|
||||||
async def monitor_movie(
|
|
||||||
self, movie_id: int, monitored: bool = True
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
movie = await self.get_movie(movie_id)
|
|
||||||
if not isinstance(movie, dict):
|
|
||||||
raise ValueError("Radarr did not return the movie before updating its monitored state")
|
|
||||||
movie["monitored"] = monitored
|
|
||||||
return await self.update_movie(movie)
|
|
||||||
|
|
||||||
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
|
|
||||||
return await self.delete(
|
|
||||||
f"/api/v3/moviefile/{movie_file_id}",
|
|
||||||
params={"deleteFromClient": "true"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def add_movie(
|
async def add_movie(
|
||||||
self,
|
self,
|
||||||
tmdb_id: int,
|
tmdb_id: int,
|
||||||
@@ -61,15 +34,9 @@ class RadarrClient(ApiClient):
|
|||||||
root_folder: str,
|
root_folder: str,
|
||||||
monitored: bool = True,
|
monitored: bool = True,
|
||||||
search_for_movie: bool = True,
|
search_for_movie: bool = True,
|
||||||
title: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
|
|
||||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
|
||||||
if not resolved_title:
|
|
||||||
raise ValueError("Radarr could not resolve a title for this TMDB ID")
|
|
||||||
payload = {
|
payload = {
|
||||||
"tmdbId": tmdb_id,
|
"tmdbId": tmdb_id,
|
||||||
"title": resolved_title,
|
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
@@ -77,9 +44,6 @@ class RadarrClient(ApiClient):
|
|||||||
}
|
}
|
||||||
return await self.post("/api/v3/movie", payload=payload)
|
return await self.post("/api/v3/movie", payload=payload)
|
||||||
|
|
||||||
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put("/api/v3/movie", payload=payload)
|
|
||||||
|
|
||||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||||
|
|
||||||
|
|||||||
@@ -9,23 +9,6 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||||
|
|
||||||
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
|
|
||||||
if not isinstance(result, list):
|
|
||||||
return None
|
|
||||||
for item in result:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if int(item.get("tvdbId")) == tvdb_id:
|
|
||||||
return item
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return next((item for item in result if isinstance(item, dict)), None)
|
|
||||||
|
|
||||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get(f"/api/v3/series/{series_id}")
|
|
||||||
|
|
||||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/rootfolder")
|
return await self.get("/api/v3/rootfolder")
|
||||||
|
|
||||||
@@ -33,22 +16,7 @@ class SonarrClient(ApiClient):
|
|||||||
return await self.get("/api/v3/qualityprofile")
|
return await self.get("/api/v3/qualityprofile")
|
||||||
|
|
||||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
records = []
|
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
result = await self.get("/api/v3/queue", params={
|
|
||||||
"seriesIds": series_id, "includeEpisode": "true",
|
|
||||||
"page": page, "pageSize": 100,
|
|
||||||
})
|
|
||||||
if not isinstance(result, dict) or not isinstance(result.get("records"), list):
|
|
||||||
raise ValueError("Sonarr returned an invalid queue")
|
|
||||||
batch = result["records"]
|
|
||||||
records.extend(batch)
|
|
||||||
if not batch or len(records) >= int(result.get("totalRecords", len(records))):
|
|
||||||
return {**result, "records": records, "totalRecords": len(records)}
|
|
||||||
page += 1
|
|
||||||
if page > 100:
|
|
||||||
raise ValueError("Sonarr queue exceeded the safe paging limit")
|
|
||||||
|
|
||||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/indexer")
|
return await self.get("/api/v3/indexer")
|
||||||
@@ -56,36 +24,12 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
||||||
|
|
||||||
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
|
|
||||||
|
|
||||||
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
|
|
||||||
return await self.get(
|
|
||||||
"/api/v3/release",
|
|
||||||
params={"seriesId": series_id, "seasonNumber": season_number},
|
|
||||||
timeout_seconds=90.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||||
|
|
||||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
||||||
|
|
||||||
async def monitor_episodes(
|
|
||||||
self, episode_ids: list[int], monitored: bool = True
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put(
|
|
||||||
"/api/v3/episode/monitor",
|
|
||||||
payload={"episodeIds": episode_ids, "monitored": monitored},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
|
|
||||||
return await self.delete(
|
|
||||||
f"/api/v3/episodefile/{episode_file_id}",
|
|
||||||
params={"deleteFromClient": "true"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def add_series(
|
async def add_series(
|
||||||
self,
|
self,
|
||||||
tvdb_id: int,
|
tvdb_id: int,
|
||||||
@@ -95,24 +39,18 @@ class SonarrClient(ApiClient):
|
|||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
search_missing: bool = True,
|
search_missing: bool = True,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
|
|
||||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
|
||||||
if not resolved_title:
|
|
||||||
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
|
|
||||||
payload = {
|
payload = {
|
||||||
"tvdbId": tvdb_id,
|
"tvdbId": tvdb_id,
|
||||||
"title": resolved_title,
|
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
"seasonFolder": True,
|
"seasonFolder": True,
|
||||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||||
}
|
}
|
||||||
|
if title:
|
||||||
|
payload["title"] = title
|
||||||
return await self.post("/api/v3/series", payload=payload)
|
return await self.post("/api/v3/series", payload=payload)
|
||||||
|
|
||||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
return await self.put("/api/v3/series", payload=payload)
|
|
||||||
|
|
||||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||||
|
|
||||||
|
|||||||
+8
-221
@@ -2,68 +2,18 @@ from typing import Optional
|
|||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from .build_info import BUILD_NUMBER, CHANGELOG
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_prefix="")
|
model_config = SettingsConfigDict(env_prefix="")
|
||||||
app_name: str = "Magent"
|
app_name: str = "Magent"
|
||||||
cors_allow_origin: str = "http://localhost:3000"
|
cors_allow_origin: str = "http://localhost:3000"
|
||||||
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
||||||
sqlite_journal_mode: str = Field(
|
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
|
||||||
)
|
|
||||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
|
||||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
|
||||||
auth_rate_limit_window_seconds: int = Field(
|
|
||||||
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
|
||||||
)
|
|
||||||
auth_rate_limit_max_attempts_ip: int = Field(
|
|
||||||
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
|
||||||
)
|
|
||||||
auth_rate_limit_max_attempts_user: int = Field(
|
|
||||||
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_window_seconds: int = Field(
|
|
||||||
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_max_attempts_ip: int = Field(
|
|
||||||
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
|
||||||
)
|
|
||||||
password_reset_rate_limit_max_attempts_identifier: int = Field(
|
|
||||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
|
||||||
)
|
|
||||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||||
auth_cookie_name: str = Field(
|
|
||||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
|
||||||
)
|
|
||||||
auth_cookie_secure: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
|
||||||
)
|
|
||||||
auth_cookie_samesite: str = Field(
|
|
||||||
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
|
||||||
)
|
|
||||||
auth_cookie_domain: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
|
||||||
)
|
|
||||||
auth_state_cookie_name: str = Field(
|
|
||||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
|
||||||
)
|
|
||||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||||
log_file_max_bytes: int = Field(
|
|
||||||
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
|
||||||
)
|
|
||||||
log_file_backup_count: int = Field(
|
|
||||||
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
|
|
||||||
)
|
|
||||||
log_http_client_level: str = Field(
|
|
||||||
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
|
|
||||||
)
|
|
||||||
log_background_sync_level: str = Field(
|
|
||||||
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
|
|
||||||
)
|
|
||||||
requests_sync_ttl_minutes: int = Field(
|
requests_sync_ttl_minutes: int = Field(
|
||||||
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
||||||
)
|
)
|
||||||
@@ -85,19 +35,12 @@ class Settings(BaseSettings):
|
|||||||
requests_data_source: str = Field(
|
requests_data_source: str = Field(
|
||||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
||||||
)
|
)
|
||||||
issue_confirmation_contact_attempts: int = Field(
|
|
||||||
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
|
|
||||||
)
|
|
||||||
issue_confirmation_interval_value: int = Field(
|
|
||||||
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
|
|
||||||
)
|
|
||||||
issue_confirmation_interval_unit: str = Field(
|
|
||||||
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
|
|
||||||
)
|
|
||||||
artwork_cache_mode: str = Field(
|
artwork_cache_mode: str = Field(
|
||||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||||
)
|
)
|
||||||
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
|
site_build_number: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
|
||||||
|
)
|
||||||
site_banner_enabled: bool = Field(
|
site_banner_enabled: bool = Field(
|
||||||
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
||||||
)
|
)
|
||||||
@@ -107,149 +50,8 @@ class Settings(BaseSettings):
|
|||||||
site_banner_tone: str = Field(
|
site_banner_tone: str = Field(
|
||||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
||||||
)
|
)
|
||||||
site_login_show_jellyfin_login: bool = Field(
|
site_changelog: Optional[str] = Field(
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
|
||||||
)
|
|
||||||
site_login_show_local_login: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
|
|
||||||
)
|
|
||||||
site_login_show_forgot_password: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
|
|
||||||
)
|
|
||||||
site_login_show_signup_link: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
|
||||||
)
|
|
||||||
site_nav_show_requests: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
|
||||||
)
|
|
||||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
|
||||||
|
|
||||||
magent_application_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
|
|
||||||
)
|
|
||||||
magent_application_port: int = Field(
|
|
||||||
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
|
|
||||||
)
|
|
||||||
magent_api_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
|
|
||||||
)
|
|
||||||
magent_api_port: int = Field(
|
|
||||||
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
|
|
||||||
)
|
|
||||||
magent_bind_host: str = Field(
|
|
||||||
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
|
|
||||||
)
|
|
||||||
magent_proxy_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
|
|
||||||
)
|
|
||||||
magent_proxy_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
|
|
||||||
)
|
|
||||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
|
||||||
)
|
|
||||||
magent_proxy_trusted_proxies: str = Field(
|
|
||||||
default="127.0.0.1,::1",
|
|
||||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
|
||||||
)
|
|
||||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
|
||||||
)
|
|
||||||
magent_ssl_bind_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
|
|
||||||
)
|
|
||||||
magent_ssl_certificate_path: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
|
|
||||||
)
|
|
||||||
magent_ssl_private_key_path: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
|
|
||||||
)
|
|
||||||
magent_ssl_certificate_pem: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
|
|
||||||
)
|
|
||||||
magent_ssl_private_key_pem: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_email_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_host: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_port: int = Field(
|
|
||||||
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_username: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
|
|
||||||
)
|
|
||||||
magent_notify_email_smtp_password: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
|
|
||||||
)
|
|
||||||
magent_notify_email_from_address: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
|
|
||||||
)
|
|
||||||
magent_notify_email_from_name: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
|
|
||||||
)
|
|
||||||
magent_notify_email_use_tls: bool = Field(
|
|
||||||
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
|
|
||||||
)
|
|
||||||
magent_notify_email_use_ssl: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_discord_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_discord_webhook_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_telegram_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_telegram_bot_token: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
|
|
||||||
)
|
|
||||||
magent_notify_telegram_chat_id: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_push_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_push_provider: Optional[str] = Field(
|
|
||||||
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
|
|
||||||
)
|
|
||||||
magent_notify_push_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
|
|
||||||
)
|
|
||||||
magent_notify_push_topic: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
|
|
||||||
)
|
|
||||||
magent_notify_push_token: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
|
|
||||||
)
|
|
||||||
magent_notify_push_user_key: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
|
|
||||||
)
|
|
||||||
magent_notify_push_device: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
|
|
||||||
)
|
|
||||||
|
|
||||||
magent_notify_webhook_enabled: bool = Field(
|
|
||||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
|
|
||||||
)
|
|
||||||
magent_notify_webhook_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
|
||||||
)
|
|
||||||
magent_allow_private_notification_targets: bool = Field(
|
|
||||||
default=False,
|
|
||||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
jellyseerr_base_url: Optional[str] = Field(
|
jellyseerr_base_url: Optional[str] = Field(
|
||||||
@@ -258,11 +60,6 @@ class Settings(BaseSettings):
|
|||||||
jellyseerr_api_key: Optional[str] = Field(
|
jellyseerr_api_key: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
|
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(
|
jellyfin_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
||||||
)
|
)
|
||||||
@@ -310,16 +107,6 @@ class Settings(BaseSettings):
|
|||||||
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
||||||
)
|
)
|
||||||
|
|
||||||
bazarr_base_url: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
|
|
||||||
)
|
|
||||||
bazarr_api_key: Optional[str] = Field(
|
|
||||||
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
|
|
||||||
)
|
|
||||||
bazarr_default_language: str = Field(
|
|
||||||
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
|
|
||||||
)
|
|
||||||
|
|
||||||
prowlarr_base_url: Optional[str] = Field(
|
prowlarr_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
||||||
)
|
)
|
||||||
@@ -338,7 +125,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
discord_webhook_url: Optional[str] = Field(
|
discord_webhook_url: Optional[str] = Field(
|
||||||
default=None,
|
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
||||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+168
-2800
File diff suppressed because it is too large
Load Diff
@@ -1,148 +1,10 @@
|
|||||||
import contextvars
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Any, Mapping, Optional
|
from typing import Optional
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
||||||
"magent_request_id", default="-"
|
|
||||||
)
|
|
||||||
|
|
||||||
_SENSITIVE_KEYWORDS = (
|
|
||||||
"api_key",
|
|
||||||
"authorization",
|
|
||||||
"cert",
|
|
||||||
"cookie",
|
|
||||||
"jwt",
|
|
||||||
"key",
|
|
||||||
"pass",
|
|
||||||
"password",
|
|
||||||
"pem",
|
|
||||||
"private",
|
|
||||||
"secret",
|
|
||||||
"session",
|
|
||||||
"signature",
|
|
||||||
"token",
|
|
||||||
)
|
|
||||||
_MAX_BODY_BYTES = 4096
|
|
||||||
|
|
||||||
|
|
||||||
class RequestContextFilter(logging.Filter):
|
def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
record.request_id = REQUEST_ID_CONTEXT.get("-")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
|
||||||
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
|
||||||
|
|
||||||
|
|
||||||
def reset_request_id(token: contextvars.Token[str]) -> None:
|
|
||||||
REQUEST_ID_CONTEXT.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def current_request_id() -> str:
|
|
||||||
return REQUEST_ID_CONTEXT.get("-")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_key(key: str) -> bool:
|
|
||||||
lowered = key.strip().lower()
|
|
||||||
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_scalar(value: Any) -> Any:
|
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
|
||||||
return value
|
|
||||||
text = str(value)
|
|
||||||
if len(text) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{text[:2]}***{text[-2:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
|
||||||
if key_hint and _is_sensitive_key(key_hint):
|
|
||||||
return _redact_scalar(value)
|
|
||||||
if value is None or isinstance(value, (bool, int, float)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return f"<bytes:{len(value)}>"
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value if len(value) <= 512 else f"{value[:509]}..."
|
|
||||||
if depth >= 3:
|
|
||||||
return f"<{type(value).__name__}>"
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {
|
|
||||||
str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
|
|
||||||
for key, item in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
|
|
||||||
if hasattr(value, "model_dump"):
|
|
||||||
try:
|
|
||||||
return sanitize_value(value.model_dump(), depth=depth + 1)
|
|
||||||
except Exception:
|
|
||||||
return f"<{type(value).__name__}>"
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
str(key).lower(): sanitize_value(value, key_hint=str(key))
|
|
||||||
for key, value in headers.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
normalized = (content_type or "").split(";")[0].strip().lower()
|
|
||||||
if normalized == "application/json":
|
|
||||||
preview = body[:_MAX_BODY_BYTES]
|
|
||||||
try:
|
|
||||||
payload = json.loads(preview.decode("utf-8"))
|
|
||||||
summary = sanitize_value(payload)
|
|
||||||
if len(body) > _MAX_BODY_BYTES:
|
|
||||||
return {"truncated": True, "bytes": len(body), "payload": summary}
|
|
||||||
return summary
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if normalized == "application/x-www-form-urlencoded":
|
|
||||||
try:
|
|
||||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
|
||||||
compact = {
|
|
||||||
key: value[0] if len(value) == 1 else value
|
|
||||||
for key, value in parsed.items()
|
|
||||||
}
|
|
||||||
return sanitize_value(compact)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if normalized.startswith("multipart/"):
|
|
||||||
return {"content_type": normalized, "bytes": len(body)}
|
|
||||||
preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
|
|
||||||
return {
|
|
||||||
"content_type": normalized or "unknown",
|
|
||||||
"bytes": len(body),
|
|
||||||
"preview": preview if len(body) <= 256 else f"{preview}...",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_level(level_name: Optional[str], fallback: int) -> int:
|
|
||||||
if not level_name:
|
|
||||||
return fallback
|
|
||||||
return getattr(logging, str(level_name).upper(), fallback)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(
|
|
||||||
log_level: Optional[str],
|
|
||||||
log_file: Optional[str],
|
|
||||||
*,
|
|
||||||
log_file_max_bytes: int = 20_000_000,
|
|
||||||
log_file_backup_count: int = 10,
|
|
||||||
log_http_client_level: Optional[str] = "INFO",
|
|
||||||
log_background_sync_level: Optional[str] = "INFO",
|
|
||||||
) -> None:
|
|
||||||
level_name = (log_level or "INFO").upper()
|
level_name = (log_level or "INFO").upper()
|
||||||
level = getattr(logging, level_name, logging.INFO)
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
@@ -156,20 +18,15 @@ def configure_logging(
|
|||||||
log_path = os.path.join(os.getcwd(), log_path)
|
log_path = os.path.join(os.getcwd(), log_path)
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
file_handler = RotatingFileHandler(
|
file_handler = RotatingFileHandler(
|
||||||
log_path,
|
log_path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
||||||
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
|
|
||||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
handlers.append(file_handler)
|
handlers.append(file_handler)
|
||||||
|
|
||||||
context_filter = RequestContextFilter()
|
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
handler.addFilter(context_filter)
|
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
@@ -181,10 +38,4 @@ def configure_logging(
|
|||||||
|
|
||||||
logging.getLogger("uvicorn").setLevel(level)
|
logging.getLogger("uvicorn").setLevel(level)
|
||||||
logging.getLogger("uvicorn.error").setLevel(level)
|
logging.getLogger("uvicorn.error").setLevel(level)
|
||||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
logging.getLogger("uvicorn.access").setLevel(level)
|
||||||
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
|
|
||||||
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
|
|
||||||
logging.getLogger("app.clients.base").setLevel(http_client_level)
|
|
||||||
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
|
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
||||||
|
|||||||
+13
-235
@@ -1,15 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import has_admin_user, init_db
|
from .db import init_db, set_setting
|
||||||
from .routers.requests import (
|
from .routers.requests import (
|
||||||
router as requests_router,
|
router as requests_router,
|
||||||
startup_warmup_requests_cache,
|
startup_warmup_requests_cache,
|
||||||
@@ -18,44 +13,17 @@ from .routers.requests import (
|
|||||||
run_daily_db_cleanup,
|
run_daily_db_cleanup,
|
||||||
)
|
)
|
||||||
from .routers.auth import router as auth_router
|
from .routers.auth import router as auth_router
|
||||||
from .routers.admin import router as admin_router, events_router as admin_events_router
|
from .routers.admin import router as admin_router
|
||||||
from .routers.images import router as images_router
|
from .routers.images import router as images_router
|
||||||
from .routers.branding import router as branding_router
|
from .routers.branding import router as branding_router
|
||||||
from .routers.status import router as status_router
|
from .routers.status import router as status_router
|
||||||
from .routers.feedback import router as feedback_router
|
from .routers.feedback import router as feedback_router
|
||||||
from .routers.site import router as site_router
|
from .routers.site import router as site_router
|
||||||
from .routers.events import router as events_router
|
|
||||||
from .routers.portal import router as portal_router
|
|
||||||
from .routers.operations import router as operations_router
|
|
||||||
from .routers.insights import router as insights_router
|
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .services.issue_resolution import run_issue_confirmation_loop
|
from .logging_config import configure_logging
|
||||||
from .services.operation_progress import (
|
|
||||||
begin_operation,
|
|
||||||
finish_operation,
|
|
||||||
normalize_operation_id,
|
|
||||||
reset_operation,
|
|
||||||
)
|
|
||||||
from .logging_config import (
|
|
||||||
bind_request_id,
|
|
||||||
configure_logging,
|
|
||||||
reset_request_id,
|
|
||||||
sanitize_headers,
|
|
||||||
sanitize_value,
|
|
||||||
summarize_http_body,
|
|
||||||
)
|
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
from .metrics import record_api, start_metrics
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
app = FastAPI(title=settings.app_name)
|
||||||
_background_tasks: list[asyncio.Task[None]] = []
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title=settings.app_name,
|
|
||||||
docs_url="/docs" if settings.api_docs_enabled else None,
|
|
||||||
redoc_url=None,
|
|
||||||
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -66,219 +34,29 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
|
||||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
|
||||||
token = bind_request_id(request_id)
|
|
||||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
|
||||||
operation_token = None
|
|
||||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
|
||||||
operation_token = begin_operation(
|
|
||||||
operation_id,
|
|
||||||
label=request.headers.get("X-Magent-Operation-Label"),
|
|
||||||
path=request.url.path,
|
|
||||||
)
|
|
||||||
request.state.request_id = request_id
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
body = await request.body()
|
|
||||||
body_summary = summarize_http_body(body, request.headers.get("content-type"))
|
|
||||||
|
|
||||||
async def receive() -> dict:
|
|
||||||
return {"type": "http.request", "body": body, "more_body": False}
|
|
||||||
|
|
||||||
request._receive = receive
|
|
||||||
logger.info(
|
|
||||||
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
sanitize_value(dict(request.query_params)),
|
|
||||||
request.client.host if request.client else "-",
|
|
||||||
sanitize_headers(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in request.headers.items()
|
|
||||||
if key.lower()
|
|
||||||
in {
|
|
||||||
"content-type",
|
|
||||||
"content-length",
|
|
||||||
"user-agent",
|
|
||||||
"x-forwarded-for",
|
|
||||||
"x-forwarded-proto",
|
|
||||||
"x-request-id",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
body_summary,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
except Exception:
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
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")
|
|
||||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
||||||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
||||||
# Keep API responses non-executable and non-embeddable by default.
|
|
||||||
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
|
||||||
response.headers.setdefault(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
response.status_code,
|
|
||||||
duration_ms,
|
|
||||||
sanitize_headers(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in response.headers.items()
|
|
||||||
if key.lower() in {"content-type", "content-length", "x-request-id"}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if operation_id and operation_token is not None:
|
|
||||||
finish_operation(
|
|
||||||
operation_id,
|
|
||||||
success=response.status_code < 400,
|
|
||||||
status_code=response.status_code,
|
|
||||||
)
|
|
||||||
reset_operation(operation_token)
|
|
||||||
reset_request_id(token)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
async def _run_background_task(
|
|
||||||
name: str, coroutine_factory: Callable[[], Awaitable[None]]
|
|
||||||
) -> None:
|
|
||||||
token = bind_request_id(f"task-{name}")
|
|
||||||
logger.info("background task started task=%s", name)
|
|
||||||
try:
|
|
||||||
await coroutine_factory()
|
|
||||||
logger.warning("background task exited task=%s", name)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("background task cancelled task=%s", name)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("background task crashed task=%s", name)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
reset_request_id(token)
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
|
|
||||||
task = asyncio.create_task(
|
|
||||||
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
|
|
||||||
)
|
|
||||||
_background_tasks.append(task)
|
|
||||||
|
|
||||||
|
|
||||||
def _log_security_configuration_warnings() -> None:
|
|
||||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
|
||||||
if not jwt_secret or jwt_secret == "change-me":
|
|
||||||
logger.warning(
|
|
||||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
|
||||||
)
|
|
||||||
admin_password = str(settings.admin_password or "")
|
|
||||||
if not admin_password or admin_password == "adminadmin":
|
|
||||||
logger.warning(
|
|
||||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
|
||||||
)
|
|
||||||
if bool(settings.api_docs_enabled):
|
|
||||||
logger.warning(
|
|
||||||
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _enforce_secure_startup_configuration() -> None:
|
|
||||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
|
||||||
if not jwt_secret or jwt_secret == "change-me":
|
|
||||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
|
||||||
admin_password = str(settings.admin_password or "")
|
|
||||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
|
||||||
raise RuntimeError(
|
|
||||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
start_metrics()
|
|
||||||
configure_logging(
|
|
||||||
settings.log_level,
|
|
||||||
settings.log_file,
|
|
||||||
log_file_max_bytes=settings.log_file_max_bytes,
|
|
||||||
log_file_backup_count=settings.log_file_backup_count,
|
|
||||||
log_http_client_level=settings.log_http_client_level,
|
|
||||||
log_background_sync_level=settings.log_background_sync_level,
|
|
||||||
)
|
|
||||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
|
||||||
_log_security_configuration_warnings()
|
|
||||||
init_db()
|
init_db()
|
||||||
_enforce_secure_startup_configuration()
|
if settings.site_build_number and settings.site_build_number.strip():
|
||||||
|
set_setting("site_build_number", settings.site_build_number.strip())
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
configure_logging(
|
configure_logging(runtime.log_level, runtime.log_file)
|
||||||
runtime.log_level,
|
asyncio.create_task(run_daily_jellyfin_sync())
|
||||||
runtime.log_file,
|
asyncio.create_task(startup_warmup_requests_cache())
|
||||||
log_file_max_bytes=runtime.log_file_max_bytes,
|
asyncio.create_task(run_requests_delta_loop())
|
||||||
log_file_backup_count=runtime.log_file_backup_count,
|
asyncio.create_task(run_daily_requests_full_sync())
|
||||||
log_http_client_level=runtime.log_http_client_level,
|
asyncio.create_task(run_daily_db_cleanup())
|
||||||
log_background_sync_level=runtime.log_background_sync_level,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
|
||||||
runtime.log_level,
|
|
||||||
runtime.log_file,
|
|
||||||
runtime.log_file_max_bytes,
|
|
||||||
runtime.log_file_backup_count,
|
|
||||||
runtime.log_http_client_level,
|
|
||||||
runtime.log_background_sync_level,
|
|
||||||
runtime.requests_data_source,
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(requests_router)
|
app.include_router(requests_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(admin_events_router)
|
|
||||||
app.include_router(images_router)
|
app.include_router(images_router)
|
||||||
app.include_router(branding_router)
|
app.include_router(branding_router)
|
||||||
app.include_router(status_router)
|
app.include_router(status_router)
|
||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
app.include_router(events_router)
|
|
||||||
app.include_router(portal_router)
|
|
||||||
app.include_router(operations_router)
|
|
||||||
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
|
id: str
|
||||||
label: str
|
label: str
|
||||||
risk: str
|
risk: str
|
||||||
description: Optional[str] = None
|
|
||||||
requires_confirmation: bool = True
|
requires_confirmation: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ class Snapshot(BaseModel):
|
|||||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||||
actions: List[ActionOption] = Field(default_factory=list)
|
actions: List[ActionOption] = Field(default_factory=list)
|
||||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Iterable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
|
|
||||||
_METADATA_HOSTS = {
|
|
||||||
"169.254.169.254",
|
|
||||||
"metadata.google.internal",
|
|
||||||
"metadata.azure.internal",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text(value: object) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _split_csv(value: object) -> list[str]:
|
|
||||||
raw = _normalize_text(value)
|
|
||||||
if not raw:
|
|
||||||
return []
|
|
||||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
|
||||||
return bool(
|
|
||||||
ip_obj.is_loopback
|
|
||||||
or ip_obj.is_link_local
|
|
||||||
or ip_obj.is_multicast
|
|
||||||
or ip_obj.is_unspecified
|
|
||||||
or ip_obj.is_reserved
|
|
||||||
or ip_obj.is_private
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=256)
|
|
||||||
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
|
||||||
resolved: list[ipaddress._BaseAddress] = []
|
|
||||||
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
|
||||||
if family == socket.AF_INET:
|
|
||||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
|
||||||
elif family == socket.AF_INET6:
|
|
||||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
|
||||||
return tuple(resolved)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
|
||||||
candidate = _normalize_text(host)
|
|
||||||
if not candidate:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
host_ip = ipaddress.ip_address(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
|
||||||
|
|
||||||
for entry in trusted_proxies:
|
|
||||||
raw = _normalize_text(entry)
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if "/" in raw:
|
|
||||||
if host_ip in ipaddress.ip_network(raw, strict=False):
|
|
||||||
return True
|
|
||||||
elif host_ip == ipaddress.ip_address(raw):
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
|
||||||
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
|
||||||
return False
|
|
||||||
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
|
||||||
if not trusted:
|
|
||||||
return False
|
|
||||||
return _is_trusted_proxy_host(client_host or "", trusted)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_notification_target_url(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
allow_private: bool | None = None,
|
|
||||||
) -> str:
|
|
||||||
raw = _normalize_text(url)
|
|
||||||
if not raw:
|
|
||||||
raise ValueError("URL cannot be empty.")
|
|
||||||
|
|
||||||
parsed = urlparse(raw)
|
|
||||||
if parsed.scheme not in {"http", "https"}:
|
|
||||||
raise ValueError("URL must use http:// or https://.")
|
|
||||||
if parsed.username or parsed.password:
|
|
||||||
raise ValueError("URL must not embed credentials.")
|
|
||||||
hostname = _normalize_text(parsed.hostname).lower()
|
|
||||||
if not hostname:
|
|
||||||
raise ValueError("URL must include a valid host.")
|
|
||||||
|
|
||||||
allow_private_targets = (
|
|
||||||
settings.magent_allow_private_notification_targets
|
|
||||||
if allow_private is None
|
|
||||||
else bool(allow_private)
|
|
||||||
)
|
|
||||||
if hostname in _METADATA_HOSTS:
|
|
||||||
raise ValueError("Metadata service targets are not allowed.")
|
|
||||||
if hostname == "localhost" and not allow_private_targets:
|
|
||||||
raise ValueError("Local notification targets are not allowed.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
host_ip = ipaddress.ip_address(hostname)
|
|
||||||
except ValueError:
|
|
||||||
host_ip = None
|
|
||||||
|
|
||||||
if host_ip is not None:
|
|
||||||
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
|
||||||
raise ValueError("Private or local notification targets are not allowed.")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved_ips = _resolve_host_ips(hostname)
|
|
||||||
except socket.gaierror as exc:
|
|
||||||
raise ValueError("Host could not be resolved.") from exc
|
|
||||||
if not resolved_ips:
|
|
||||||
raise ValueError("Host could not be resolved.")
|
|
||||||
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
|
||||||
raise ValueError("Private or local notification targets are not allowed.")
|
|
||||||
return raw
|
|
||||||
+46
-1732
File diff suppressed because it is too large
Load Diff
+64
-1404
File diff suppressed because it is too large
Load Diff
@@ -1,229 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from ..auth import get_current_user_event_stream
|
|
||||||
from . import requests as requests_router
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
|
||||||
|
|
||||||
|
|
||||||
def _sse_json(payload: Dict[str, Any]) -> str:
|
|
||||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _jsonable(value: Any) -> Any:
|
|
||||||
if hasattr(value, "model_dump"):
|
|
||||||
try:
|
|
||||||
return value.model_dump(mode="json")
|
|
||||||
except TypeError:
|
|
||||||
return value.model_dump()
|
|
||||||
if hasattr(value, "dict"):
|
|
||||||
try:
|
|
||||||
return value.dict()
|
|
||||||
except TypeError:
|
|
||||||
return value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
return []
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"request_id": entry.get("request_id"),
|
|
||||||
"state": entry.get("state"),
|
|
||||||
"state_reason": entry.get("state_reason"),
|
|
||||||
"created_at": entry.get("created_at"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
return []
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"request_id": entry.get("request_id"),
|
|
||||||
"action_id": entry.get("action_id"),
|
|
||||||
"label": entry.get("label"),
|
|
||||||
"status": entry.get("status"),
|
|
||||||
"message": entry.get("message"),
|
|
||||||
"created_at": entry.get("created_at"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def events_stream(
|
|
||||||
request: Request,
|
|
||||||
recent_days: int = 90,
|
|
||||||
recent_stage: str = "all",
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
recent_days = max(0, min(int(recent_days or 90), 3650))
|
|
||||||
recent_take = 50 if user.get("role") == "admin" else 6
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
yield "retry: 2000\n\n"
|
|
||||||
last_recent_signature: Optional[str] = None
|
|
||||||
next_recent_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
sent_any = False
|
|
||||||
|
|
||||||
if now >= next_recent_at:
|
|
||||||
next_recent_at = now + 15.0
|
|
||||||
try:
|
|
||||||
recent_payload = await requests_router.recent_requests(
|
|
||||||
take=recent_take,
|
|
||||||
skip=0,
|
|
||||||
days=recent_days,
|
|
||||||
stage=recent_stage,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
|
|
||||||
payload = {
|
|
||||||
"type": "home_recent",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"days": recent_days,
|
|
||||||
"stage": recent_stage,
|
|
||||||
"results": results if isinstance(results, list) else [],
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "home_recent",
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"days": recent_days,
|
|
||||||
"stage": recent_stage,
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_recent_signature:
|
|
||||||
last_recent_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
|
||||||
heartbeat_counter = 0
|
|
||||||
else:
|
|
||||||
heartbeat_counter += 1
|
|
||||||
if heartbeat_counter >= 15:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
}
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}/stream")
|
|
||||||
async def request_events_stream(
|
|
||||||
request_id: str,
|
|
||||||
request: Request,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
request_id = str(request_id).strip()
|
|
||||||
if not request_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing request id")
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
yield "retry: 2000\n\n"
|
|
||||||
last_signature: Optional[str] = None
|
|
||||||
next_refresh_at = 0.0
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
sent_any = False
|
|
||||||
|
|
||||||
if now >= next_refresh_at:
|
|
||||||
next_refresh_at = now + 2.0
|
|
||||||
try:
|
|
||||||
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
|
||||||
history_payload = await requests_router.request_history(
|
|
||||||
request_id=request_id, limit=5, user=user
|
|
||||||
)
|
|
||||||
actions_payload = await requests_router.request_actions(
|
|
||||||
request_id=request_id, limit=5, user=user
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"snapshot": _jsonable(snapshot),
|
|
||||||
"history": _request_history_brief(
|
|
||||||
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
|
||||||
),
|
|
||||||
"actions": _request_actions_brief(
|
|
||||||
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except HTTPException as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc.detail),
|
|
||||||
"status_code": int(exc.status_code),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
payload = {
|
|
||||||
"type": "request_live",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
|
|
||||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
||||||
if signature != last_signature:
|
|
||||||
last_signature = signature
|
|
||||||
yield _sse_json(payload)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
if sent_any:
|
|
||||||
heartbeat_counter = 0
|
|
||||||
else:
|
|
||||||
heartbeat_counter += 1
|
|
||||||
if heartbeat_counter >= 15:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
heartbeat_counter = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
}
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
|
||||||
@@ -3,7 +3,6 @@ import httpx
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||||
@@ -12,16 +11,9 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
webhook_url = (
|
webhook_url = runtime.discord_webhook_url
|
||||||
getattr(runtime, "magent_notify_discord_webhook_url", None)
|
|
||||||
or runtime.discord_webhook_url
|
|
||||||
)
|
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||||
try:
|
|
||||||
webhook_url = validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
feedback_type = str(payload.get("type") or "").strip().lower()
|
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||||
if feedback_type not in {"bug", "feature"}:
|
if feedback_type not in {"bug", "feature"}:
|
||||||
|
|||||||
@@ -1,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
|
|
||||||
File diff suppressed because it is too large
Load Diff
+416
-2183
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..build_info import BUILD_NUMBER, CHANGELOG
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/site", tags=["site"])
|
router = APIRouter(prefix="/site", tags=["site"])
|
||||||
@@ -19,31 +17,15 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|||||||
if tone not in _BANNER_TONES:
|
if tone not in _BANNER_TONES:
|
||||||
tone = "info"
|
tone = "info"
|
||||||
info = {
|
info = {
|
||||||
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
|
"buildNumber": (runtime.site_build_number or "").strip(),
|
||||||
"banner": {
|
"banner": {
|
||||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||||
"message": banner_message,
|
"message": banner_message,
|
||||||
"tone": tone,
|
"tone": tone,
|
||||||
},
|
},
|
||||||
"login": {
|
|
||||||
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
|
||||||
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
|
||||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
|
||||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"showRequests": bool(runtime.site_nav_show_requests),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if include_changelog:
|
if include_changelog:
|
||||||
info["changelog"] = (CHANGELOG or "").strip()
|
info["changelog"] = (runtime.site_changelog or "").strip()
|
||||||
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
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ from typing import Any, Dict
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..auth import require_admin
|
from ..auth import get_current_user
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..clients.sonarr import SonarrClient
|
from ..clients.sonarr import SonarrClient
|
||||||
from ..clients.radarr import RadarrClient
|
from ..clients.radarr import RadarrClient
|
||||||
from ..clients.bazarr import BazarrClient
|
|
||||||
from ..clients.prowlarr import ProwlarrClient
|
from ..clients.prowlarr import ProwlarrClient
|
||||||
from ..clients.qbittorrent import QBittorrentClient
|
from ..clients.qbittorrent import QBittorrentClient
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
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]:
|
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)}
|
return {"name": name, "status": "down", "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
|
||||||
if not qbittorrent.base_url:
|
|
||||||
return {"name": "qBittorrent", "status": "not_configured"}
|
|
||||||
if not qbittorrent.username or not qbittorrent.password:
|
|
||||||
reachable = await qbittorrent.is_webui_reachable()
|
|
||||||
return {
|
|
||||||
"name": "qBittorrent",
|
|
||||||
"status": "degraded" if reachable else "not_configured",
|
|
||||||
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result = await qbittorrent.get_app_version()
|
|
||||||
return {"name": "qBittorrent", "status": "up", "detail": result}
|
|
||||||
except RuntimeError as exc:
|
|
||||||
if "login failed" in str(exc).lower():
|
|
||||||
reachable = await qbittorrent.is_webui_reachable()
|
|
||||||
if reachable:
|
|
||||||
return {
|
|
||||||
"name": "qBittorrent",
|
|
||||||
"status": "degraded",
|
|
||||||
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
|
||||||
}
|
|
||||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
|
||||||
except Exception as exc:
|
|
||||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services")
|
@router.get("/services")
|
||||||
async def services_status() -> Dict[str, Any]:
|
async def services_status() -> Dict[str, Any]:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
|
||||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||||
qbittorrent = QBittorrentClient(
|
qbittorrent = QBittorrentClient(
|
||||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||||
@@ -73,7 +41,7 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
services = []
|
services = []
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Seerr",
|
"Jellyseerr",
|
||||||
jellyseerr.configured(),
|
jellyseerr.configured(),
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||||
)
|
)
|
||||||
@@ -92,13 +60,6 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
radarr.get_system_status,
|
radarr.get_system_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
services.append(
|
|
||||||
await _check(
|
|
||||||
"Bazarr",
|
|
||||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
|
||||||
bazarr.get_system_status,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
prowlarr_status = await _check(
|
prowlarr_status = await _check(
|
||||||
"Prowlarr",
|
"Prowlarr",
|
||||||
prowlarr.configured(),
|
prowlarr.configured(),
|
||||||
@@ -110,7 +71,13 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
prowlarr_status["status"] = "degraded"
|
prowlarr_status["status"] = "degraded"
|
||||||
prowlarr_status["message"] = "Health warnings"
|
prowlarr_status["message"] = "Health warnings"
|
||||||
services.append(prowlarr_status)
|
services.append(prowlarr_status)
|
||||||
services.append(await _check_qbittorrent(qbittorrent))
|
services.append(
|
||||||
|
await _check(
|
||||||
|
"qBittorrent",
|
||||||
|
qbittorrent.configured(),
|
||||||
|
qbittorrent.get_app_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Jellyfin",
|
"Jellyfin",
|
||||||
@@ -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"
|
overall = "up"
|
||||||
if any(s.get("status") == "down" for s in services):
|
if any(s.get("status") == "down" for s in services):
|
||||||
overall = "down"
|
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)
|
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
|
||||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||||
qbittorrent = QBittorrentClient(
|
qbittorrent = QBittorrentClient(
|
||||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||||
@@ -147,34 +108,19 @@ async def test_service(service: str) -> Dict[str, Any]:
|
|||||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
|
|
||||||
service_key = service.strip().lower()
|
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 = {
|
checks = {
|
||||||
"seerr": (
|
|
||||||
"Seerr",
|
|
||||||
jellyseerr.configured(),
|
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
|
||||||
),
|
|
||||||
"jellyseerr": (
|
"jellyseerr": (
|
||||||
"Seerr",
|
"Jellyseerr",
|
||||||
jellyseerr.configured(),
|
jellyseerr.configured(),
|
||||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||||
),
|
),
|
||||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||||
"bazarr": (
|
|
||||||
"Bazarr",
|
|
||||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
|
||||||
bazarr.get_system_status,
|
|
||||||
),
|
|
||||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||||
|
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
|
||||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||||
}
|
}
|
||||||
|
|
||||||
if service_key == "qbittorrent":
|
|
||||||
return await _check_qbittorrent(qbittorrent)
|
|
||||||
|
|
||||||
if service_key not in checks:
|
if service_key not in checks:
|
||||||
raise HTTPException(status_code=404, detail="Unknown service")
|
raise HTTPException(status_code=404, detail="Unknown service")
|
||||||
|
|
||||||
|
|||||||
@@ -2,48 +2,18 @@ from .config import settings
|
|||||||
from .db import get_settings_overrides
|
from .db import get_settings_overrides
|
||||||
|
|
||||||
_INT_FIELDS = {
|
_INT_FIELDS = {
|
||||||
"magent_application_port",
|
|
||||||
"magent_api_port",
|
|
||||||
"auth_rate_limit_window_seconds",
|
|
||||||
"auth_rate_limit_max_attempts_ip",
|
|
||||||
"auth_rate_limit_max_attempts_user",
|
|
||||||
"password_reset_rate_limit_window_seconds",
|
|
||||||
"password_reset_rate_limit_max_attempts_ip",
|
|
||||||
"password_reset_rate_limit_max_attempts_identifier",
|
|
||||||
"sonarr_quality_profile_id",
|
"sonarr_quality_profile_id",
|
||||||
"radarr_quality_profile_id",
|
"radarr_quality_profile_id",
|
||||||
"jwt_exp_minutes",
|
"jwt_exp_minutes",
|
||||||
"log_file_max_bytes",
|
|
||||||
"log_file_backup_count",
|
|
||||||
"requests_sync_ttl_minutes",
|
"requests_sync_ttl_minutes",
|
||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"issue_confirmation_contact_attempts",
|
|
||||||
"issue_confirmation_interval_value",
|
|
||||||
"magent_notify_email_smtp_port",
|
|
||||||
}
|
}
|
||||||
_BOOL_FIELDS = {
|
_BOOL_FIELDS = {
|
||||||
"magent_proxy_enabled",
|
|
||||||
"magent_proxy_trust_forwarded_headers",
|
|
||||||
"magent_ssl_bind_enabled",
|
|
||||||
"magent_notify_enabled",
|
|
||||||
"magent_notify_email_enabled",
|
|
||||||
"magent_notify_email_use_tls",
|
|
||||||
"magent_notify_email_use_ssl",
|
|
||||||
"magent_notify_discord_enabled",
|
|
||||||
"magent_notify_telegram_enabled",
|
|
||||||
"magent_notify_push_enabled",
|
|
||||||
"magent_notify_webhook_enabled",
|
|
||||||
"jellyfin_sync_to_arr",
|
"jellyfin_sync_to_arr",
|
||||||
"site_banner_enabled",
|
"site_banner_enabled",
|
||||||
"site_login_show_jellyfin_login",
|
|
||||||
"site_login_show_local_login",
|
|
||||||
"site_login_show_forgot_password",
|
|
||||||
"site_login_show_signup_link",
|
|
||||||
"site_nav_show_requests",
|
|
||||||
}
|
}
|
||||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_settings():
|
def get_runtime_settings():
|
||||||
@@ -52,8 +22,6 @@ def get_runtime_settings():
|
|||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
if key in _SKIP_OVERRIDE_FIELDS:
|
|
||||||
continue
|
|
||||||
if key in _INT_FIELDS:
|
if key in _INT_FIELDS:
|
||||||
try:
|
try:
|
||||||
update[key] = int(value)
|
update[key] = int(value)
|
||||||
|
|||||||
+4
-37
@@ -1,16 +1,13 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import jwt
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
_ALGORITHM = "HS256"
|
_ALGORITHM = "HS256"
|
||||||
MIN_PASSWORD_LENGTH = 8
|
|
||||||
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
@@ -21,44 +18,14 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||||||
return _pwd_context.verify(plain_password, hashed_password)
|
return _pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def validate_password_policy(password: str) -> str:
|
|
||||||
candidate = password.strip()
|
|
||||||
if len(candidate) < MIN_PASSWORD_LENGTH:
|
|
||||||
raise ValueError(PASSWORD_POLICY_MESSAGE)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _create_token(
|
|
||||||
subject: str,
|
|
||||||
role: str,
|
|
||||||
*,
|
|
||||||
expires_at: datetime,
|
|
||||||
token_type: str = "access",
|
|
||||||
) -> str:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"sub": subject,
|
|
||||||
"role": role,
|
|
||||||
"typ": token_type,
|
|
||||||
"exp": expires_at,
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||||
if not settings.jwt_secret:
|
|
||||||
raise ValueError("JWT_SECRET is not configured")
|
|
||||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||||
|
|
||||||
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
|
|
||||||
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="sse")
|
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Dict[str, Any]:
|
def decode_token(token: str) -> Dict[str, Any]:
|
||||||
if not settings.jwt_secret:
|
|
||||||
raise ValueError("JWT_SECRET is not configured")
|
|
||||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||||
|
|
||||||
|
|
||||||
@@ -69,5 +36,5 @@ class TokenError(Exception):
|
|||||||
def safe_decode_token(token: str) -> Dict[str, Any]:
|
def safe_decode_token(token: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return decode_token(token)
|
return decode_token(token)
|
||||||
except InvalidTokenError as exc:
|
except JWTError as exc:
|
||||||
raise TokenError("Invalid token") from exc
|
raise TokenError("Invalid token") from exc
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"""Read title-specific search activity without starting a search or changing monitoring."""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ..clients.base import ApiClient
|
|
||||||
from ..models import RequestType
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(values: Any) -> set[int]:
|
|
||||||
if not isinstance(values, list):
|
|
||||||
return set()
|
|
||||||
return {value for value in values if type(value) is int and value > 0}
|
|
||||||
|
|
||||||
|
|
||||||
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
|
|
||||||
"""Only a matching queued/started search is evidence of current activity.
|
|
||||||
|
|
||||||
Completed commands, RSS syncs and library-wide jobs do not establish that this
|
|
||||||
title is being searched. Episode searches are matched using Sonarr episode IDs.
|
|
||||||
"""
|
|
||||||
if not isinstance(commands, list):
|
|
||||||
return "unavailable"
|
|
||||||
episode_ids = _ids([
|
|
||||||
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
|
|
||||||
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
|
|
||||||
])
|
|
||||||
queued = False
|
|
||||||
for command in commands:
|
|
||||||
if not isinstance(command, dict):
|
|
||||||
continue
|
|
||||||
body = command.get("body")
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
continue
|
|
||||||
name = str(command.get("name") or body.get("name") or "").lower()
|
|
||||||
if request_type == RequestType.movie:
|
|
||||||
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
|
|
||||||
else:
|
|
||||||
matches = (
|
|
||||||
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
|
|
||||||
) or (
|
|
||||||
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
|
|
||||||
)
|
|
||||||
if not matches or command.get("ended"):
|
|
||||||
continue
|
|
||||||
status = str(command.get("status", "")).lower()
|
|
||||||
if status in {"started", "1"}:
|
|
||||||
return "searching"
|
|
||||||
if status in {"queued", "0"}:
|
|
||||||
queued = True
|
|
||||||
return "queued" if queued else "idle"
|
|
||||||
|
|
||||||
|
|
||||||
async def read_search_status(
|
|
||||||
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
|
|
||||||
except Exception:
|
|
||||||
# Search telemetry must not turn a healthy library record into an error.
|
|
||||||
return "unavailable"
|
|
||||||
return search_status(commands, request_type, item_id, episodes)
|
|
||||||
@@ -1,735 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from time import perf_counter
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
|
||||||
from ..clients.prowlarr import ProwlarrClient
|
|
||||||
from ..clients.qbittorrent import QBittorrentClient
|
|
||||||
from ..clients.radarr import RadarrClient
|
|
||||||
from ..clients.sonarr import SonarrClient
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import get_database_diagnostics
|
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
|
||||||
|
|
||||||
|
|
||||||
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DiagnosticCheck:
|
|
||||||
key: str
|
|
||||||
label: str
|
|
||||||
category: str
|
|
||||||
description: str
|
|
||||||
live_safe: bool
|
|
||||||
configured: bool
|
|
||||||
config_detail: str
|
|
||||||
target: Optional[str]
|
|
||||||
runner: DiagnosticRunner
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
|
||||||
if value is None:
|
|
||||||
return fallback
|
|
||||||
if isinstance(value, str):
|
|
||||||
trimmed = value.strip()
|
|
||||||
return trimmed if trimmed else fallback
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _url_target(url: Optional[str]) -> Optional[str]:
|
|
||||||
raw = _clean_text(url)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = urlparse(raw)
|
|
||||||
except Exception:
|
|
||||||
return raw
|
|
||||||
host = parsed.hostname or parsed.netloc or raw
|
|
||||||
if parsed.port:
|
|
||||||
host = f"{host}:{parsed.port}"
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
|
|
||||||
resolved_host = _clean_text(host)
|
|
||||||
if not resolved_host:
|
|
||||||
return None
|
|
||||||
if port is None:
|
|
||||||
return resolved_host
|
|
||||||
return f"{resolved_host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error_detail(exc: Exception) -> str:
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
|
||||||
response = exc.response
|
|
||||||
body = ""
|
|
||||||
try:
|
|
||||||
body = response.text.strip()
|
|
||||||
except Exception:
|
|
||||||
body = ""
|
|
||||||
if body:
|
|
||||||
return f"HTTP {response.status_code}: {body}"
|
|
||||||
return f"HTTP {response.status_code}"
|
|
||||||
return str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _config_status(detail: str) -> str:
|
|
||||||
lowered = detail.lower()
|
|
||||||
if "disabled" in lowered:
|
|
||||||
return "disabled"
|
|
||||||
return "not_configured"
|
|
||||||
|
|
||||||
|
|
||||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
|
||||||
return False, "Discord notifications are disabled."
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Discord webhook URL is required."
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
|
|
||||||
return False, "Telegram notifications are disabled."
|
|
||||||
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Telegram bot token and chat ID are required."
|
|
||||||
|
|
||||||
|
|
||||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
|
||||||
return False, "Generic webhook notifications are disabled."
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(webhook_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Generic webhook URL is required."
|
|
||||||
|
|
||||||
|
|
||||||
def _push_config_ready(runtime) -> tuple[bool, str]:
|
|
||||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
|
|
||||||
return False, "Push notifications are disabled."
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
if provider == "ntfy":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "ntfy requires a base URL and topic."
|
|
||||||
if provider == "gotify":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url and _clean_text(runtime.magent_notify_push_token):
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Gotify requires a base URL and app token."
|
|
||||||
if provider == "pushover":
|
|
||||||
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Pushover requires an application token and user key."
|
|
||||||
if provider == "webhook":
|
|
||||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
if push_url:
|
|
||||||
try:
|
|
||||||
validate_notification_target_url(push_url)
|
|
||||||
except ValueError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
return True, "ok"
|
|
||||||
return False, "Webhook relay requires a target URL."
|
|
||||||
if provider == "telegram":
|
|
||||||
return _telegram_config_ready(runtime)
|
|
||||||
if provider == "discord":
|
|
||||||
return _discord_config_ready(runtime)
|
|
||||||
return False, f"Unsupported push provider: {provider or 'unknown'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
|
|
||||||
summary = {
|
|
||||||
"total": len(results),
|
|
||||||
"up": 0,
|
|
||||||
"down": 0,
|
|
||||||
"degraded": 0,
|
|
||||||
"not_configured": 0,
|
|
||||||
"disabled": 0,
|
|
||||||
}
|
|
||||||
for result in results:
|
|
||||||
status = str(result.get("status") or "").strip().lower()
|
|
||||||
if status in summary:
|
|
||||||
summary[status] += 1
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_json_get(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(url, headers=headers, params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
return {"response": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_text_get(url: str) -> Dict[str, Any]:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
||||||
response = await client.get(url)
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.text
|
|
||||||
return {"response": body, "message": f"HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_http_post(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
json_payload: Optional[Dict[str, Any]] = None,
|
|
||||||
data_payload: Any = None,
|
|
||||||
params: Optional[Dict[str, Any]] = None,
|
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
validate_notification_target_url(url)
|
|
||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
|
||||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.content:
|
|
||||||
return {"message": f"HTTP {response.status_code}"}
|
|
||||||
content_type = response.headers.get("content-type", "")
|
|
||||||
if "application/json" in content_type.lower():
|
|
||||||
try:
|
|
||||||
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_database_check() -> Dict[str, Any]:
|
|
||||||
detail = await asyncio.to_thread(get_database_diagnostics)
|
|
||||||
integrity = _clean_text(detail.get("integrity_check"), "unknown")
|
|
||||||
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
|
|
||||||
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
|
|
||||||
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
|
|
||||||
status = "up" if integrity == "ok" else "degraded"
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
|
|
||||||
"detail": detail,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
|
|
||||||
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
|
|
||||||
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
|
|
||||||
payload = result.get("response")
|
|
||||||
build_number = payload.get("build") if isinstance(payload, dict) else None
|
|
||||||
message = "Health endpoint responded"
|
|
||||||
if build_number:
|
|
||||||
message = f"Health endpoint responded (build {build_number})"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
|
|
||||||
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
|
|
||||||
result = await _run_http_text_get(base_url.rstrip("/"))
|
|
||||||
body = result.get("response")
|
|
||||||
if isinstance(body, str) and "<html" in body.lower():
|
|
||||||
return {"message": "Application page responded", "detail": "html"}
|
|
||||||
return {"status": "degraded", "message": "Application responded with unexpected content"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_seerr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
payload = await client.get_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Seerr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Seerr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
payload = await client.get_system_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Sonarr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Sonarr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_radarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
||||||
payload = await client.get_system_status()
|
|
||||||
version = payload.get("version") if isinstance(payload, dict) else None
|
|
||||||
message = "Radarr responded"
|
|
||||||
if version:
|
|
||||||
message = f"Radarr version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
|
||||||
payload = await client.get_health()
|
|
||||||
if isinstance(payload, list) and payload:
|
|
||||||
return {
|
|
||||||
"status": "degraded",
|
|
||||||
"message": f"Prowlarr health warnings: {len(payload)}",
|
|
||||||
"detail": payload,
|
|
||||||
}
|
|
||||||
return {"message": "Prowlarr reported healthy", "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = QBittorrentClient(
|
|
||||||
runtime.qbittorrent_base_url,
|
|
||||||
runtime.qbittorrent_username,
|
|
||||||
runtime.qbittorrent_password,
|
|
||||||
)
|
|
||||||
version = await client.get_app_version()
|
|
||||||
message = "qBittorrent responded"
|
|
||||||
if isinstance(version, str) and version:
|
|
||||||
message = f"qBittorrent version {version}"
|
|
||||||
return {"message": message, "detail": version}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
|
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
payload = await client.get_system_info()
|
|
||||||
version = payload.get("Version") if isinstance(payload, dict) else None
|
|
||||||
message = "Jellyfin responded"
|
|
||||||
if version:
|
|
||||||
message = f"Jellyfin version {version}"
|
|
||||||
return {"message": message, "detail": payload}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
result = await send_test_email(recipient_email=recipient_email)
|
|
||||||
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
|
|
||||||
warning = _clean_text(result.get("warning"))
|
|
||||||
if warning:
|
|
||||||
return {
|
|
||||||
"status": "degraded",
|
|
||||||
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
|
|
||||||
"detail": result,
|
|
||||||
}
|
|
||||||
return {"message": f"Test email sent to {recipient}", "detail": result}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_discord_check(runtime) -> Dict[str, Any]:
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
|
||||||
payload = {
|
|
||||||
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
|
||||||
}
|
|
||||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
|
||||||
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_telegram_check(runtime) -> Dict[str, Any]:
|
|
||||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
|
||||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
||||||
payload = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
|
||||||
}
|
|
||||||
result = await _run_http_post(url, json_payload=payload)
|
|
||||||
return {"message": "Telegram ping accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_webhook_check(runtime) -> Dict[str, Any]:
|
|
||||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
payload = {
|
|
||||||
"type": "diagnostics.ping",
|
|
||||||
"application": env_settings.app_name,
|
|
||||||
"build": env_settings.site_build_number,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
|
||||||
return {"message": "Webhook accepted ping", "detail": result.get("response")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_push_check(runtime) -> Dict[str, Any]:
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
message = f"{env_settings.app_name} diagnostics ping"
|
|
||||||
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
|
|
||||||
|
|
||||||
if provider == "ntfy":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
|
||||||
result = await _run_http_post(
|
|
||||||
f"{base_url.rstrip('/')}/{topic}",
|
|
||||||
data_payload=f"{message}\n{build_suffix}",
|
|
||||||
headers={"Content-Type": "text/plain; charset=utf-8"},
|
|
||||||
)
|
|
||||||
return {"message": "ntfy push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "gotify":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
result = await _run_http_post(
|
|
||||||
f"{base_url.rstrip('/')}/message",
|
|
||||||
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
|
|
||||||
params={"token": token},
|
|
||||||
)
|
|
||||||
return {"message": "Gotify push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "pushover":
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
|
||||||
device = _clean_text(runtime.magent_notify_push_device)
|
|
||||||
payload = {
|
|
||||||
"token": token,
|
|
||||||
"user": user_key,
|
|
||||||
"message": f"{message}\n{build_suffix}",
|
|
||||||
"title": env_settings.app_name,
|
|
||||||
}
|
|
||||||
if device:
|
|
||||||
payload["device"] = device
|
|
||||||
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
|
|
||||||
return {"message": "Pushover push accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "webhook":
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
payload = {
|
|
||||||
"type": "diagnostics.push",
|
|
||||||
"application": env_settings.app_name,
|
|
||||||
"build": env_settings.site_build_number,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
result = await _run_http_post(base_url, json_payload=payload)
|
|
||||||
return {"message": "Push webhook accepted", "detail": result.get("response")}
|
|
||||||
|
|
||||||
if provider == "telegram":
|
|
||||||
return await _run_telegram_check(runtime)
|
|
||||||
|
|
||||||
if provider == "discord":
|
|
||||||
return await _run_discord_check(runtime)
|
|
||||||
|
|
||||||
raise RuntimeError(f"Unsupported push provider: {provider}")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
seerr_target = _url_target(runtime.jellyseerr_base_url)
|
|
||||||
jellyfin_target = _url_target(runtime.jellyfin_base_url)
|
|
||||||
sonarr_target = _url_target(runtime.sonarr_base_url)
|
|
||||||
radarr_target = _url_target(runtime.radarr_base_url)
|
|
||||||
prowlarr_target = _url_target(runtime.prowlarr_base_url)
|
|
||||||
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
|
|
||||||
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
|
|
||||||
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
|
|
||||||
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
|
|
||||||
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
|
|
||||||
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
|
|
||||||
webhook_target = _url_target(runtime.magent_notify_webhook_url)
|
|
||||||
|
|
||||||
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
push_target = None
|
|
||||||
if push_provider == "pushover":
|
|
||||||
push_target = "api.pushover.net"
|
|
||||||
elif push_provider == "telegram":
|
|
||||||
push_target = telegram_target or "api.telegram.org"
|
|
||||||
elif push_provider == "discord":
|
|
||||||
push_target = discord_target or "discord.com"
|
|
||||||
else:
|
|
||||||
push_target = _url_target(runtime.magent_notify_push_base_url)
|
|
||||||
|
|
||||||
email_ready, email_detail = smtp_email_config_ready()
|
|
||||||
email_warning = smtp_email_delivery_warning()
|
|
||||||
discord_ready, discord_detail = _discord_config_ready(runtime)
|
|
||||||
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
|
||||||
push_ready, push_detail = _push_config_ready(runtime)
|
|
||||||
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
|
|
||||||
|
|
||||||
checks = [
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="magent-web",
|
|
||||||
label="Magent application",
|
|
||||||
category="Application",
|
|
||||||
description="Checks that the frontend application URL is responding.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target=application_target,
|
|
||||||
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="magent-api",
|
|
||||||
label="Magent API",
|
|
||||||
category="Application",
|
|
||||||
description="Checks the Magent API health endpoint.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target=api_target,
|
|
||||||
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="database",
|
|
||||||
label="SQLite database",
|
|
||||||
category="Application",
|
|
||||||
description="Runs SQLite integrity_check against the current Magent database.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=True,
|
|
||||||
config_detail="ok",
|
|
||||||
target="sqlite",
|
|
||||||
runner=_run_database_check,
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="seerr",
|
|
||||||
label="Seerr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Seerr API reachability and version.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
|
|
||||||
config_detail="Seerr URL and API key are required.",
|
|
||||||
target=seerr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_seerr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="jellyfin",
|
|
||||||
label="Jellyfin",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Jellyfin system info with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
|
|
||||||
config_detail="Jellyfin URL and API key are required.",
|
|
||||||
target=jellyfin_target,
|
|
||||||
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="sonarr",
|
|
||||||
label="Sonarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Sonarr system status with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
|
|
||||||
config_detail="Sonarr URL and API key are required.",
|
|
||||||
target=sonarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="radarr",
|
|
||||||
label="Radarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Radarr system status with the configured API key.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
|
|
||||||
config_detail="Radarr URL and API key are required.",
|
|
||||||
target=radarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_radarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="prowlarr",
|
|
||||||
label="Prowlarr",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks Prowlarr health and flags warnings as degraded.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
|
|
||||||
config_detail="Prowlarr URL and API key are required.",
|
|
||||||
target=prowlarr_target,
|
|
||||||
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="qbittorrent",
|
|
||||||
label="qBittorrent",
|
|
||||||
category="Media services",
|
|
||||||
description="Checks qBittorrent login and app version.",
|
|
||||||
live_safe=True,
|
|
||||||
configured=bool(
|
|
||||||
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
|
|
||||||
),
|
|
||||||
config_detail="qBittorrent URL, username, and password are required.",
|
|
||||||
target=qbittorrent_target,
|
|
||||||
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="email",
|
|
||||||
label="SMTP email",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test email using the configured SMTP provider.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=email_ready,
|
|
||||||
config_detail=email_warning or email_detail,
|
|
||||||
target=smtp_target,
|
|
||||||
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="discord",
|
|
||||||
label="Discord webhook",
|
|
||||||
category="Notifications",
|
|
||||||
description="Posts a live test message to the configured Discord webhook.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=discord_ready,
|
|
||||||
config_detail=discord_detail,
|
|
||||||
target=discord_target,
|
|
||||||
runner=lambda runtime=runtime: _run_discord_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="telegram",
|
|
||||||
label="Telegram",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test message to the configured Telegram chat.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=telegram_ready,
|
|
||||||
config_detail=telegram_detail,
|
|
||||||
target=telegram_target,
|
|
||||||
runner=lambda runtime=runtime: _run_telegram_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="push",
|
|
||||||
label="Push/mobile provider",
|
|
||||||
category="Notifications",
|
|
||||||
description="Sends a live test message through the configured push provider.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=push_ready,
|
|
||||||
config_detail=push_detail,
|
|
||||||
target=push_target,
|
|
||||||
runner=lambda runtime=runtime: _run_push_check(runtime),
|
|
||||||
),
|
|
||||||
DiagnosticCheck(
|
|
||||||
key="webhook",
|
|
||||||
label="Generic webhook",
|
|
||||||
category="Notifications",
|
|
||||||
description="Posts a live test payload to the configured generic webhook.",
|
|
||||||
live_safe=False,
|
|
||||||
configured=webhook_ready,
|
|
||||||
config_detail=webhook_detail,
|
|
||||||
target=webhook_target,
|
|
||||||
runner=lambda runtime=runtime: _run_webhook_check(runtime),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
return checks
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
|
|
||||||
if not check.configured:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": False,
|
|
||||||
"status": _config_status(check.config_detail),
|
|
||||||
"message": check.config_detail,
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
"duration_ms": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
started = perf_counter()
|
|
||||||
checked_at = _now_iso()
|
|
||||||
try:
|
|
||||||
payload = await check.runner()
|
|
||||||
status = _clean_text(payload.get("status"), "up")
|
|
||||||
message = _clean_text(payload.get("message"), "Check passed")
|
|
||||||
detail = payload.get("detail")
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"detail": detail,
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": "down",
|
|
||||||
"message": _http_error_detail(exc),
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"target": check.target,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"configured": True,
|
|
||||||
"status": "down",
|
|
||||||
"message": str(exc),
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_diagnostics_catalog() -> Dict[str, Any]:
|
|
||||||
checks = _build_diagnostic_checks()
|
|
||||||
items = []
|
|
||||||
for check in checks:
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"key": check.key,
|
|
||||||
"label": check.label,
|
|
||||||
"category": check.category,
|
|
||||||
"description": check.description,
|
|
||||||
"live_safe": check.live_safe,
|
|
||||||
"target": check.target,
|
|
||||||
"configured": check.configured,
|
|
||||||
"config_status": "configured" if check.configured else _config_status(check.config_detail),
|
|
||||||
"config_detail": "Ready to test." if check.configured else check.config_detail,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
categories = sorted({item["category"] for item in items})
|
|
||||||
return {
|
|
||||||
"checks": items,
|
|
||||||
"categories": categories,
|
|
||||||
"generated_at": _now_iso(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
checks = _build_diagnostic_checks(recipient_email=recipient_email)
|
|
||||||
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
|
|
||||||
if selected:
|
|
||||||
checks = [check for check in checks if check.key.lower() in selected]
|
|
||||||
results = await asyncio.gather(*(_execute_check(check) for check in checks))
|
|
||||||
return {
|
|
||||||
"results": results,
|
|
||||||
"summary": _summary_from_results(results),
|
|
||||||
"checked_at": _now_iso(),
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]:
|
|
||||||
"""Join by collector download ID, never by fuzzy title matching.
|
|
||||||
|
|
||||||
A pack shares one transfer percentage; do not pretend its episodes have
|
|
||||||
individually measured progress.
|
|
||||||
"""
|
|
||||||
records = queue.get("records", []) if isinstance(queue, dict) else queue
|
|
||||||
labels: dict[str, set[str]] = {}
|
|
||||||
for row in records if isinstance(records, list) else []:
|
|
||||||
episode = row.get("episode") or {}
|
|
||||||
season, number = episode.get("seasonNumber"), episode.get("episodeNumber")
|
|
||||||
if isinstance(season, int) and isinstance(number, int):
|
|
||||||
key = str(row.get("downloadId") or "").lower()
|
|
||||||
labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}")
|
|
||||||
for torrent in torrents:
|
|
||||||
episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set()))
|
|
||||||
torrent["episodeLabels"] = episodes
|
|
||||||
torrent["episodeLabel"] = (
|
|
||||||
" · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "")
|
|
||||||
if episodes else None
|
|
||||||
)
|
|
||||||
return torrents
|
|
||||||
@@ -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}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,477 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from html import escape
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import (
|
|
||||||
add_portal_item_activity,
|
|
||||||
get_portal_item,
|
|
||||||
get_user_by_username,
|
|
||||||
list_portal_item_activity,
|
|
||||||
list_portal_items,
|
|
||||||
update_portal_item,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import resolve_user_delivery_email, send_generic_email
|
|
||||||
from .snapshot import build_snapshot
|
|
||||||
from .media_repair import evaluate_media_repair
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
_SYSTEM_USER = "Magent"
|
|
||||||
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime(value: Any) -> Optional[datetime]:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
raw = item.get("metadata_json")
|
|
||||||
if not isinstance(raw, str) or not raw.strip():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {}
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
state = _metadata(item).get("resolutionConfirmation")
|
|
||||||
return dict(state) if isinstance(state, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
|
|
||||||
metadata = _metadata(item)
|
|
||||||
metadata["resolutionConfirmation"] = state
|
|
||||||
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _interval_delta(value: int, unit: str) -> timedelta:
|
|
||||||
safe_value = max(1, min(int(value), 365))
|
|
||||||
normalized_unit = str(unit or "days").strip().lower()
|
|
||||||
if normalized_unit == "weeks":
|
|
||||||
return timedelta(weeks=safe_value)
|
|
||||||
if normalized_unit == "months":
|
|
||||||
return timedelta(days=30 * safe_value)
|
|
||||||
return timedelta(days=safe_value)
|
|
||||||
|
|
||||||
|
|
||||||
def _workflow_settings() -> tuple[int, int, str]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
|
|
||||||
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
|
|
||||||
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
|
|
||||||
if interval_unit not in {"days", "weeks", "months"}:
|
|
||||||
interval_unit = "days"
|
|
||||||
return attempts, interval_value, interval_unit
|
|
||||||
|
|
||||||
|
|
||||||
def _app_url() -> str:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
|
|
||||||
candidate = str(value or "").strip()
|
|
||||||
if candidate:
|
|
||||||
return candidate.rstrip("/")
|
|
||||||
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _issue_url(item_id: int) -> str:
|
|
||||||
return f"{_app_url()}/portal/issues?item={item_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _activity(
|
|
||||||
item_id: int,
|
|
||||||
event_type: str,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
actor_username: str = _SYSTEM_USER,
|
|
||||||
actor_role: str = "system",
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> None:
|
|
||||||
add_portal_item_activity(
|
|
||||||
item_id,
|
|
||||||
event_type=event_type,
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
message=message,
|
|
||||||
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
raw = entry.get("metadata_json")
|
|
||||||
if not isinstance(raw, str) or not raw.strip():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {}
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
|
|
||||||
activity = list_portal_item_activity(item_id, limit=500)
|
|
||||||
for entry in reversed(activity):
|
|
||||||
# A rejected repair must not be proposed again simply because the same
|
|
||||||
# replacement file is still present. Wait for a NEW repair attempt.
|
|
||||||
if str(entry.get("event_type") or "") == "resolution_rejected":
|
|
||||||
return {}, activity
|
|
||||||
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
|
|
||||||
continue
|
|
||||||
tracking = _activity_metadata(entry).get("repairTracking")
|
|
||||||
if isinstance(tracking, dict):
|
|
||||||
return dict(tracking), activity
|
|
||||||
return {}, activity
|
|
||||||
|
|
||||||
|
|
||||||
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
|
|
||||||
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
|
|
||||||
jellyfin = dict(raw.get("jellyfin") or {})
|
|
||||||
jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
|
|
||||||
return await evaluate_media_repair(
|
|
||||||
tracking, (raw.get("arr") or {}).get("item"), jellyfin,
|
|
||||||
episodes=(raw.get("arr") or {}).get("episodes"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _close_issue(
|
|
||||||
item: Dict[str, Any],
|
|
||||||
*,
|
|
||||||
reason: str,
|
|
||||||
confirmed: bool,
|
|
||||||
actor_username: str = _SYSTEM_USER,
|
|
||||||
actor_role: str = "system",
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
now = _now().isoformat()
|
|
||||||
state = issue_resolution_state(item)
|
|
||||||
state.update(
|
|
||||||
{
|
|
||||||
"status": "confirmed" if confirmed else "auto_closed",
|
|
||||||
"confirmedAt": now if confirmed else state.get("confirmedAt"),
|
|
||||||
"closedAt": now,
|
|
||||||
"nextContactAt": None,
|
|
||||||
"closedReason": reason,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
updated = update_portal_item(
|
|
||||||
int(item["id"]),
|
|
||||||
status="closed",
|
|
||||||
issue_resolved_at=now,
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue could not be closed")
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"resolution_confirmed" if confirmed else "issue_auto_closed",
|
|
||||||
reason,
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
maximum, interval_value, interval_unit = _workflow_settings()
|
|
||||||
state = issue_resolution_state(item)
|
|
||||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
|
||||||
if maximum <= 0:
|
|
||||||
return _close_issue(
|
|
||||||
item,
|
|
||||||
reason="Issue closed automatically because reporter confirmation emails are disabled.",
|
|
||||||
confirmed=False,
|
|
||||||
)
|
|
||||||
if attempts >= maximum:
|
|
||||||
return _close_issue(
|
|
||||||
item,
|
|
||||||
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
|
|
||||||
confirmed=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
attempt_number = attempts + 1
|
|
||||||
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
|
|
||||||
recipient = resolve_user_delivery_email(reporter)
|
|
||||||
issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
|
|
||||||
sent = False
|
|
||||||
delivery_error: Optional[str] = None
|
|
||||||
if recipient:
|
|
||||||
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
|
|
||||||
body_text = (
|
|
||||||
"Your repair looks ready to test.\n\n"
|
|
||||||
f"{item.get('title') or 'Your reported issue'}\n\n"
|
|
||||||
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
|
|
||||||
f"YES — it works: {issue_url}#yes\n"
|
|
||||||
f"NO — still broken: {issue_url}#no\n\n"
|
|
||||||
"Confirm your answer in Magent. You may need to sign in first.\n"
|
|
||||||
"Yes closes the report. No keeps it open for another look.\n\n"
|
|
||||||
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
|
|
||||||
)
|
|
||||||
body_html = (
|
|
||||||
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
|
|
||||||
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
|
|
||||||
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
|
|
||||||
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
|
|
||||||
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
|
|
||||||
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
|
|
||||||
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
|
|
||||||
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
|
|
||||||
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
|
|
||||||
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
|
|
||||||
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
|
|
||||||
'</td></tr></table></div>'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await send_generic_email(
|
|
||||||
recipient_email=recipient,
|
|
||||||
subject=subject,
|
|
||||||
body_text=body_text,
|
|
||||||
body_html=body_html,
|
|
||||||
)
|
|
||||||
sent = True
|
|
||||||
except Exception as exc:
|
|
||||||
delivery_error = str(exc)
|
|
||||||
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
|
|
||||||
else:
|
|
||||||
delivery_error = "No email address is stored for the reporter."
|
|
||||||
|
|
||||||
now = _now()
|
|
||||||
state.update(
|
|
||||||
{
|
|
||||||
"status": "awaiting_confirmation",
|
|
||||||
"attemptsSent": attempt_number,
|
|
||||||
"maximumAttempts": maximum,
|
|
||||||
"lastContactAt": now.isoformat(),
|
|
||||||
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
|
|
||||||
"intervalValue": interval_value,
|
|
||||||
"intervalUnit": interval_unit,
|
|
||||||
"lastDeliverySucceeded": sent,
|
|
||||||
"lastDeliveryError": delivery_error,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
updated = update_portal_item(
|
|
||||||
int(item["id"]),
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue confirmation schedule could not be saved")
|
|
||||||
if sent:
|
|
||||||
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
|
|
||||||
else:
|
|
||||||
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"confirmation_email_sent" if sent else "confirmation_email_failed",
|
|
||||||
message,
|
|
||||||
metadata={
|
|
||||||
"attempt": attempt_number,
|
|
||||||
"maximum": maximum,
|
|
||||||
"nextContactAt": state["nextContactAt"],
|
|
||||||
"deliveryError": delivery_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def begin_issue_confirmation(
|
|
||||||
item_id: int,
|
|
||||||
*,
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
item = get_portal_item(item_id)
|
|
||||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
|
||||||
raise ValueError("Issue not found")
|
|
||||||
now = _now().isoformat()
|
|
||||||
maximum, interval_value, interval_unit = _workflow_settings()
|
|
||||||
state = {
|
|
||||||
"status": "awaiting_confirmation",
|
|
||||||
"startedAt": now,
|
|
||||||
"attemptsSent": 0,
|
|
||||||
"maximumAttempts": maximum,
|
|
||||||
"lastContactAt": None,
|
|
||||||
"nextContactAt": now,
|
|
||||||
"intervalValue": interval_value,
|
|
||||||
"intervalUnit": interval_unit,
|
|
||||||
"confirmedAt": None,
|
|
||||||
"closedAt": None,
|
|
||||||
}
|
|
||||||
updated = update_portal_item(
|
|
||||||
item_id,
|
|
||||||
status="awaiting_confirmation",
|
|
||||||
issue_resolved_at=None,
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue confirmation workflow could not be started")
|
|
||||||
_activity(
|
|
||||||
item_id,
|
|
||||||
"resolution_proposed",
|
|
||||||
"The issue was marked fixed and sent to the reporter for confirmation.",
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
|
|
||||||
)
|
|
||||||
return await _contact_reporter(updated)
|
|
||||||
|
|
||||||
|
|
||||||
def respond_to_issue_confirmation(
|
|
||||||
item_id: int,
|
|
||||||
*,
|
|
||||||
resolved: bool,
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
item = get_portal_item(item_id)
|
|
||||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
|
||||||
raise ValueError("Issue not found")
|
|
||||||
if str(item.get("status") or "").lower() != "awaiting_confirmation":
|
|
||||||
raise ValueError("This issue is not waiting for resolution confirmation")
|
|
||||||
if resolved:
|
|
||||||
return _close_issue(
|
|
||||||
item,
|
|
||||||
reason="The reporter confirmed that the issue is fixed.",
|
|
||||||
confirmed=True,
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
)
|
|
||||||
|
|
||||||
now = _now().isoformat()
|
|
||||||
state = issue_resolution_state(item)
|
|
||||||
state.update(
|
|
||||||
{
|
|
||||||
"status": "reported_still_broken",
|
|
||||||
"reporterResponseAt": now,
|
|
||||||
"nextContactAt": None,
|
|
||||||
"closedAt": None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
updated = update_portal_item(
|
|
||||||
item_id,
|
|
||||||
status="in_progress",
|
|
||||||
issue_resolved_at=None,
|
|
||||||
metadata_json=_metadata_with_resolution(item, state),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise RuntimeError("Issue could not be reopened")
|
|
||||||
_activity(
|
|
||||||
item_id,
|
|
||||||
"resolution_rejected",
|
|
||||||
"The reporter said the issue is still happening. The issue was returned to In progress.",
|
|
||||||
actor_username=actor_username,
|
|
||||||
actor_role=actor_role,
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def process_active_media_repairs() -> Dict[str, int]:
|
|
||||||
items = list_portal_items(kind="issue", status="in_progress", limit=500)
|
|
||||||
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
|
|
||||||
for item in items:
|
|
||||||
tracking, activity = _repair_tracking(int(item["id"]))
|
|
||||||
if not tracking:
|
|
||||||
continue
|
|
||||||
result["checked"] += 1
|
|
||||||
try:
|
|
||||||
evidence = await _media_repair_evidence(tracking)
|
|
||||||
if evidence.get("complete"):
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"repair_verified",
|
|
||||||
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
|
|
||||||
metadata={
|
|
||||||
"requestId": tracking.get("requestId"),
|
|
||||||
"actionId": tracking.get("actionId"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await begin_issue_confirmation(
|
|
||||||
int(item["id"]),
|
|
||||||
actor_username=_SYSTEM_USER,
|
|
||||||
actor_role="system",
|
|
||||||
)
|
|
||||||
result["completed"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result["waiting"] += 1
|
|
||||||
if evidence.get("phase") == "indexing" and not any(
|
|
||||||
str(entry.get("event_type") or "") == "repair_imported"
|
|
||||||
for entry in activity
|
|
||||||
):
|
|
||||||
_activity(
|
|
||||||
int(item["id"]),
|
|
||||||
"repair_imported",
|
|
||||||
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
|
|
||||||
metadata={
|
|
||||||
"requestId": tracking.get("requestId"),
|
|
||||||
"actionId": tracking.get("actionId"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
result["failed"] += 1
|
|
||||||
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
|
|
||||||
current = (now or _now()).astimezone(timezone.utc)
|
|
||||||
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
|
|
||||||
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
|
|
||||||
maximum, _, _ = _workflow_settings()
|
|
||||||
for item in items:
|
|
||||||
state = issue_resolution_state(item)
|
|
||||||
due_at = _parse_datetime(state.get("nextContactAt"))
|
|
||||||
if due_at and due_at > current:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
|
||||||
if maximum <= 0 or attempts >= maximum:
|
|
||||||
_close_issue(
|
|
||||||
item,
|
|
||||||
reason=(
|
|
||||||
"Issue closed automatically because reporter confirmation emails are disabled."
|
|
||||||
if maximum <= 0
|
|
||||||
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
|
|
||||||
),
|
|
||||||
confirmed=False,
|
|
||||||
)
|
|
||||||
result["closed"] += 1
|
|
||||||
else:
|
|
||||||
await _contact_reporter(item)
|
|
||||||
result["contacted"] += 1
|
|
||||||
except Exception:
|
|
||||||
result["failed"] += 1
|
|
||||||
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def run_issue_confirmation_loop() -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
repair_result = await process_active_media_repairs()
|
|
||||||
if repair_result["completed"] or repair_result["failed"]:
|
|
||||||
logger.info("automatic media repair sweep complete result=%s", repair_result)
|
|
||||||
result = await process_due_issue_confirmations()
|
|
||||||
if result["contacted"] or result["closed"] or result["failed"]:
|
|
||||||
logger.info("issue confirmation sweep complete result=%s", result)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("issue confirmation sweep failed")
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
@@ -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)),
|
|
||||||
)
|
|
||||||
@@ -3,23 +3,8 @@ import logging
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import (
|
from ..db import create_user_if_missing
|
||||||
create_user_if_missing,
|
|
||||||
get_user_by_username,
|
|
||||||
set_user_email,
|
|
||||||
set_user_auth_provider,
|
|
||||||
set_user_jellyseerr_id,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .jellyfin_identity import link_user
|
|
||||||
from .user_cache import (
|
|
||||||
build_jellyseerr_candidate_map,
|
|
||||||
extract_jellyseerr_user_email,
|
|
||||||
find_matching_jellyseerr_user,
|
|
||||||
get_cached_jellyseerr_users,
|
|
||||||
match_jellyseerr_user_id,
|
|
||||||
save_jellyfin_users_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -32,11 +17,6 @@ async def sync_jellyfin_users() -> int:
|
|||||||
users = await client.get_users()
|
users = await client.get_users()
|
||||||
if not isinstance(users, list):
|
if not isinstance(users, list):
|
||||||
return 0
|
return 0
|
||||||
save_jellyfin_users_cache(users)
|
|
||||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
|
||||||
# matched as enrichment when possible.
|
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
|
||||||
imported = 0
|
imported = 0
|
||||||
for user in users:
|
for user in users:
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
@@ -44,35 +24,8 @@ async def sync_jellyfin_users() -> int:
|
|||||||
name = user.get("Name")
|
name = user.get("Name")
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
|
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
|
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
|
||||||
created = create_user_if_missing(
|
|
||||||
name,
|
|
||||||
"jellyfin-user",
|
|
||||||
role="user",
|
|
||||||
email=matched_email,
|
|
||||||
auth_provider="jellyfin",
|
|
||||||
jellyseerr_user_id=matched_id,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
imported += 1
|
imported += 1
|
||||||
else:
|
|
||||||
existing = get_user_by_username(name)
|
|
||||||
if (
|
|
||||||
existing
|
|
||||||
and str(existing.get("role") or "user").strip().lower() != "admin"
|
|
||||||
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
|
||||||
):
|
|
||||||
set_user_auth_provider(name, "jellyfin")
|
|
||||||
if matched_id is not None:
|
|
||||||
set_user_jellyseerr_id(name, matched_id)
|
|
||||||
if matched_email:
|
|
||||||
set_user_email(name, matched_email)
|
|
||||||
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
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.sonarr import SonarrClient
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
|
|
||||||
|
|
||||||
def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
|
|
||||||
"""Old seeding jobs are not proof of a replacement download.
|
|
||||||
|
|
||||||
A same-hash retry is valid when it is downloading again or was added anew.
|
|
||||||
Without a completion/add timestamp, a completed legacy job cannot prove that.
|
|
||||||
"""
|
|
||||||
rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
|
|
||||||
if not cycle:
|
|
||||||
return rows
|
|
||||||
cutoff = datetime.fromisoformat(cycle).timestamp()
|
|
||||||
def belongs(item: Dict[str, Any]) -> bool:
|
|
||||||
try:
|
|
||||||
progress = float(item.get("progress", 0))
|
|
||||||
completed = float(item.get("completion_on") or 0)
|
|
||||||
added = float(item.get("added_on") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return progress < 1 or max(completed, added) >= cutoff
|
|
||||||
return [item for item in rows if belongs(item)]
|
|
||||||
|
|
||||||
|
|
||||||
def _positive_ints(value: Any) -> list[int]:
|
|
||||||
if not isinstance(value, list):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
int(item)
|
|
||||||
for item in value
|
|
||||||
if isinstance(item, int) and not isinstance(item, bool) and item > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _media_signature(item: Any) -> Dict[str, str]:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
return {}
|
|
||||||
result: Dict[str, str] = {}
|
|
||||||
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
|
|
||||||
value = item.get(key)
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
||||||
elif value is not None and str(value).strip():
|
|
||||||
result[key] = str(value).strip()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
|
|
||||||
previous = _media_signature(baseline)
|
|
||||||
if not previous:
|
|
||||||
return True
|
|
||||||
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
|
|
||||||
|
|
||||||
|
|
||||||
async def evaluate_media_repair(
|
|
||||||
tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
|
|
||||||
*, episodes: Any = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
request_id = str(tracking.get("requestId") or "").strip()
|
|
||||||
action_id = str(tracking.get("actionId") or "").strip()
|
|
||||||
media_type = str(tracking.get("mediaType") or "").strip().lower()
|
|
||||||
collector_id = tracking.get("collectorId")
|
|
||||||
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
|
|
||||||
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
|
|
||||||
|
|
||||||
jellyfin_item = jellyfin.get("item")
|
|
||||||
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
|
|
||||||
baselines = tracking.get("jellyfinBaseline")
|
|
||||||
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
|
|
||||||
found_at_start = tracking.get("jellyfinFoundAtStart") is True
|
|
||||||
|
|
||||||
if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
|
|
||||||
return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
|
|
||||||
|
|
||||||
if media_type == "movie":
|
|
||||||
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
|
|
||||||
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
|
|
||||||
imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
|
|
||||||
if not imported:
|
|
||||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
|
|
||||||
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
|
|
||||||
current_signature = _media_signature(jellyfin_item)
|
|
||||||
if action_id == "replace_media" and found_at_start:
|
|
||||||
if not baselines or not _signature_changed(current_signature, baselines[0]):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
|
|
||||||
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
|
|
||||||
|
|
||||||
target_rows = tracking.get("episodes")
|
|
||||||
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
|
|
||||||
target_ids = {
|
|
||||||
int(item["id"])
|
|
||||||
for item in targets
|
|
||||||
if isinstance(item.get("id"), int) and int(item["id"]) > 0
|
|
||||||
}
|
|
||||||
target_pairs = {
|
|
||||||
(int(item["seasonNumber"]), int(item["episodeNumber"]))
|
|
||||||
for item in targets
|
|
||||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
|
||||||
}
|
|
||||||
if not target_ids or not target_pairs:
|
|
||||||
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
|
|
||||||
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
||||||
if episodes is None:
|
|
||||||
episodes = await sonarr.get_episodes(collector_id)
|
|
||||||
episode_map = {
|
|
||||||
int(item["id"]): item
|
|
||||||
for item in episodes
|
|
||||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
|
||||||
} if isinstance(episodes, list) else {}
|
|
||||||
imported = all(
|
|
||||||
episode_id in episode_map
|
|
||||||
and episode_map[episode_id].get("hasFile") is not False
|
|
||||||
and (
|
|
||||||
episode_map[episode_id].get("hasFile") is True
|
|
||||||
or (
|
|
||||||
isinstance(episode_map[episode_id].get("episodeFileId"), int)
|
|
||||||
and episode_map[episode_id]["episodeFileId"] > 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
|
|
||||||
for episode_id in target_ids
|
|
||||||
)
|
|
||||||
if not imported:
|
|
||||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
|
|
||||||
|
|
||||||
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
|
|
||||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
|
|
||||||
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
|
|
||||||
current_by_pair = {
|
|
||||||
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
|
|
||||||
for item in jellyfin_episodes
|
|
||||||
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
|
|
||||||
}
|
|
||||||
if not all(pair in current_by_pair for pair in target_pairs):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
|
|
||||||
if action_id == "replace_media" and found_at_start:
|
|
||||||
baseline_by_pair = {
|
|
||||||
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
|
|
||||||
for item in baselines
|
|
||||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
|
||||||
}
|
|
||||||
if any(pair not in baseline_by_pair for pair in target_pairs):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
|
|
||||||
if not all(
|
|
||||||
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
|
|
||||||
for pair in target_pairs
|
|
||||||
):
|
|
||||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
|
|
||||||
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ..config import settings as env_settings
|
|
||||||
from ..db import get_setting
|
|
||||||
from ..network_security import validate_notification_target_url
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_generic_email
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
|
||||||
if value is None:
|
|
||||||
return fallback
|
|
||||||
if isinstance(value, str):
|
|
||||||
trimmed = value.strip()
|
|
||||||
return trimmed if trimmed else fallback
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_emails(value: str) -> list[str]:
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
|
||||||
return [entry for entry in parts if entry and "@" in entry]
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_app_url() -> str:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
for candidate in (
|
|
||||||
runtime.magent_application_url,
|
|
||||||
runtime.magent_proxy_base_url,
|
|
||||||
env_settings.cors_allow_origin,
|
|
||||||
):
|
|
||||||
normalized = _clean_text(candidate)
|
|
||||||
if normalized:
|
|
||||||
return normalized.rstrip("/")
|
|
||||||
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
|
||||||
return f"http://localhost:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _portal_item_url(item_id: int) -> str:
|
|
||||||
return f"{_resolve_app_url()}/portal?item={item_id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
validate_notification_target_url(url)
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except ValueError:
|
|
||||||
body = response.text
|
|
||||||
return {"status_code": response.status_code, "body": body}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
|
||||||
runtime.discord_webhook_url
|
|
||||||
)
|
|
||||||
if not webhook:
|
|
||||||
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
|
||||||
data = {
|
|
||||||
"content": f"**{title}**\n{message}",
|
|
||||||
"embeds": [
|
|
||||||
{
|
|
||||||
"title": title,
|
|
||||||
"description": message,
|
|
||||||
"fields": [
|
|
||||||
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
|
||||||
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
|
||||||
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
|
||||||
],
|
|
||||||
"url": _clean_text(payload.get("item_url")),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
result = await _http_post_json(webhook, data)
|
|
||||||
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
|
||||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
|
||||||
if not bot_token or not chat_id:
|
|
||||||
return {"status": "skipped", "detail": "Telegram is not configured."}
|
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
||||||
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
|
||||||
result = await _http_post_json(url, payload)
|
|
||||||
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
|
||||||
if not webhook:
|
|
||||||
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
|
||||||
result = await _http_post_json(webhook, payload)
|
|
||||||
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
|
||||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
|
||||||
token = _clean_text(runtime.magent_notify_push_token)
|
|
||||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
|
||||||
if provider == "ntfy":
|
|
||||||
if not base_url or not topic:
|
|
||||||
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
|
||||||
validate_notification_target_url(base_url)
|
|
||||||
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
|
||||||
headers = {"Title": title, "Tags": "magent,portal"}
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
|
||||||
if provider == "gotify":
|
|
||||||
if not base_url or not token:
|
|
||||||
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
|
||||||
validate_notification_target_url(base_url)
|
|
||||||
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
|
||||||
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
|
||||||
result = await _http_post_json(url, body)
|
|
||||||
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
|
||||||
if provider == "pushover":
|
|
||||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
|
||||||
if not token or not user_key:
|
|
||||||
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
|
||||||
form = {"token": token, "user": user_key, "title": title, "message": message}
|
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
|
||||||
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
|
||||||
response.raise_for_status()
|
|
||||||
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
|
||||||
if provider == "discord":
|
|
||||||
return await _send_discord(title, message, payload)
|
|
||||||
if provider == "telegram":
|
|
||||||
return await _send_telegram(title, message)
|
|
||||||
if provider == "webhook":
|
|
||||||
return await _send_webhook(payload)
|
|
||||||
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
|
||||||
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
|
||||||
if fallback and fallback not in recipients:
|
|
||||||
recipients.append(fallback)
|
|
||||||
if not recipients:
|
|
||||||
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
|
||||||
|
|
||||||
body_text = (
|
|
||||||
f"{title}\n\n"
|
|
||||||
f"{message}\n\n"
|
|
||||||
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
|
||||||
f"Status: {_clean_text(payload.get('status'))}\n"
|
|
||||||
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
|
||||||
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
|
||||||
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
|
||||||
)
|
|
||||||
body_html = (
|
|
||||||
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
|
||||||
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
|
||||||
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
|
||||||
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
|
||||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
|
||||||
"</table>"
|
|
||||||
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
|
||||||
"</div>"
|
|
||||||
)
|
|
||||||
deliveries: list[Dict[str, Any]] = []
|
|
||||||
for recipient in recipients:
|
|
||||||
try:
|
|
||||||
result = await send_generic_email(
|
|
||||||
recipient_email=recipient,
|
|
||||||
subject=title,
|
|
||||||
body_text=body_text,
|
|
||||||
body_html=body_html,
|
|
||||||
)
|
|
||||||
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
|
||||||
except Exception as exc:
|
|
||||||
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
|
||||||
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
|
||||||
if successful:
|
|
||||||
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
|
||||||
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
|
||||||
|
|
||||||
|
|
||||||
async def send_portal_notification(
|
|
||||||
*,
|
|
||||||
event_type: str,
|
|
||||||
item: Dict[str, Any],
|
|
||||||
actor_username: str,
|
|
||||||
actor_role: str,
|
|
||||||
note: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
if not runtime.magent_notify_enabled:
|
|
||||||
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
|
||||||
|
|
||||||
item_id = int(item.get("id") or 0)
|
|
||||||
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
|
||||||
message_lines = [
|
|
||||||
f"Event: {event_type}",
|
|
||||||
f"Actor: {actor_username} ({actor_role})",
|
|
||||||
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
|
||||||
]
|
|
||||||
if note:
|
|
||||||
message_lines.append(f"Note: {note}")
|
|
||||||
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
|
||||||
message = "\n".join(message_lines)
|
|
||||||
payload = {
|
|
||||||
"type": "portal.notification",
|
|
||||||
"event": event_type,
|
|
||||||
"item_id": item_id,
|
|
||||||
"item_url": _portal_item_url(item_id),
|
|
||||||
"kind": _clean_text(item.get("kind")),
|
|
||||||
"status": _clean_text(item.get("status")),
|
|
||||||
"priority": _clean_text(item.get("priority")),
|
|
||||||
"requested_by": _clean_text(item.get("created_by_username")),
|
|
||||||
"actor_username": actor_username,
|
|
||||||
"actor_role": actor_role,
|
|
||||||
"note": note or "",
|
|
||||||
}
|
|
||||||
|
|
||||||
channels: Dict[str, Dict[str, Any]] = {}
|
|
||||||
if runtime.magent_notify_discord_enabled:
|
|
||||||
try:
|
|
||||||
channels["discord"] = await _send_discord(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["discord"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_telegram_enabled:
|
|
||||||
try:
|
|
||||||
channels["telegram"] = await _send_telegram(title, message)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_webhook_enabled:
|
|
||||||
try:
|
|
||||||
channels["webhook"] = await _send_webhook(payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_push_enabled:
|
|
||||||
try:
|
|
||||||
channels["push"] = await _send_push(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["push"] = {"status": "error", "detail": str(exc)}
|
|
||||||
if runtime.magent_notify_email_enabled:
|
|
||||||
try:
|
|
||||||
channels["email"] = await _send_email(title, message, payload)
|
|
||||||
except Exception as exc:
|
|
||||||
channels["email"] = {"status": "error", "detail": str(exc)}
|
|
||||||
|
|
||||||
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
|
||||||
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
|
||||||
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
|
||||||
logger.info(
|
|
||||||
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
|
||||||
event_type,
|
|
||||||
item_id,
|
|
||||||
successful,
|
|
||||||
failed,
|
|
||||||
skipped,
|
|
||||||
)
|
|
||||||
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
|
||||||
if not channels:
|
|
||||||
overall = "skipped"
|
|
||||||
return {"status": overall, "channels": channels}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextvars import ContextVar, Token
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
|
||||||
_OPERATION_TTL_SECONDS = 15 * 60
|
|
||||||
_MAX_OPERATIONS = 500
|
|
||||||
_MAX_EVENTS = 60
|
|
||||||
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
|
||||||
"magent_operation_id", default=None
|
|
||||||
)
|
|
||||||
_operations: Dict[str, Dict[str, Any]] = {}
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_locked(now_monotonic: float) -> None:
|
|
||||||
expired = [
|
|
||||||
operation_id
|
|
||||||
for operation_id, operation in _operations.items()
|
|
||||||
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
|
||||||
]
|
|
||||||
for operation_id in expired:
|
|
||||||
_operations.pop(operation_id, None)
|
|
||||||
if len(_operations) <= _MAX_OPERATIONS:
|
|
||||||
return
|
|
||||||
oldest = sorted(
|
|
||||||
_operations,
|
|
||||||
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
|
||||||
)
|
|
||||||
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
|
||||||
_operations.pop(operation_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
now_iso = _now_iso()
|
|
||||||
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
|
||||||
with _lock:
|
|
||||||
_prune_locked(now_monotonic)
|
|
||||||
_operations[operation_id] = {
|
|
||||||
"id": operation_id,
|
|
||||||
"label": normalized_label,
|
|
||||||
"path": path,
|
|
||||||
"status": "running",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"updated_at": now_iso,
|
|
||||||
"updated_monotonic": now_monotonic,
|
|
||||||
"duration_ms": None,
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"id": uuid.uuid4().hex,
|
|
||||||
"service": "Magent",
|
|
||||||
"state": "complete",
|
|
||||||
"message": "Your action has been received. Magent is starting the checks.",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": now_iso,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"status_code": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return _current_operation_id.set(operation_id)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_operation(token: Token) -> None:
|
|
||||||
_current_operation_id.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
|
|
||||||
operation_id = _current_operation_id.get()
|
|
||||||
if not operation_id:
|
|
||||||
return None
|
|
||||||
event_id = uuid.uuid4().hex
|
|
||||||
now_iso = _now_iso()
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return None
|
|
||||||
operation["events"].append(
|
|
||||||
{
|
|
||||||
"id": event_id,
|
|
||||||
"service": service,
|
|
||||||
"state": "active",
|
|
||||||
"message": message or f"Contacting {service}…",
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": None,
|
|
||||||
"duration_ms": None,
|
|
||||||
"status_code": None,
|
|
||||||
"started_monotonic": now_monotonic,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
|
||||||
operation["updated_at"] = now_iso
|
|
||||||
operation["updated_monotonic"] = now_monotonic
|
|
||||||
return event_id
|
|
||||||
|
|
||||||
|
|
||||||
def finish_remote_call(
|
|
||||||
event_id: Optional[str],
|
|
||||||
*,
|
|
||||||
success: bool,
|
|
||||||
status_code: Optional[int] = None,
|
|
||||||
message: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
operation_id = _current_operation_id.get()
|
|
||||||
if not operation_id or not event_id:
|
|
||||||
return
|
|
||||||
now_iso = _now_iso()
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return
|
|
||||||
event = next(
|
|
||||||
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not event:
|
|
||||||
return
|
|
||||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
|
||||||
event["state"] = "complete" if success else "error"
|
|
||||||
event["finished_at"] = now_iso
|
|
||||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
|
||||||
event["status_code"] = status_code
|
|
||||||
event["message"] = message or (
|
|
||||||
f"{event['service']} responded successfully."
|
|
||||||
if success
|
|
||||||
else f"{event['service']} returned an error."
|
|
||||||
)
|
|
||||||
operation["updated_at"] = now_iso
|
|
||||||
operation["updated_monotonic"] = now_monotonic
|
|
||||||
|
|
||||||
|
|
||||||
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
|
|
||||||
now_iso = _now_iso()
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return
|
|
||||||
for event in operation["events"]:
|
|
||||||
if event.get("state") == "active":
|
|
||||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
|
||||||
event["state"] = "error"
|
|
||||||
event["finished_at"] = now_iso
|
|
||||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
|
||||||
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
|
|
||||||
started = datetime.fromisoformat(str(operation["started_at"]))
|
|
||||||
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
|
|
||||||
operation["status"] = "complete" if success else "error"
|
|
||||||
operation["status_code"] = status_code
|
|
||||||
operation["duration_ms"] = round(duration_ms, 1)
|
|
||||||
operation["updated_at"] = now_iso
|
|
||||||
operation["updated_monotonic"] = now_monotonic
|
|
||||||
operation["events"].append(
|
|
||||||
{
|
|
||||||
"id": uuid.uuid4().hex,
|
|
||||||
"service": "Magent",
|
|
||||||
"state": "complete" if success else "error",
|
|
||||||
"message": (
|
|
||||||
"This action has finished. Check the request status for what happens next."
|
|
||||||
if success
|
|
||||||
else "This action could not be completed. Open the activity details to see which step needs attention."
|
|
||||||
),
|
|
||||||
"started_at": now_iso,
|
|
||||||
"finished_at": now_iso,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
|
||||||
|
|
||||||
|
|
||||||
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
normalized = normalize_operation_id(operation_id)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
with _lock:
|
|
||||||
operation = _operations.get(normalized)
|
|
||||||
if not operation:
|
|
||||||
return None
|
|
||||||
result = deepcopy(operation)
|
|
||||||
result.pop("updated_monotonic", None)
|
|
||||||
for event in result.get("events", []):
|
|
||||||
event.pop("started_monotonic", None)
|
|
||||||
return result
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
|
||||||
from ..db import (
|
|
||||||
create_password_reset_token,
|
|
||||||
delete_expired_password_reset_tokens,
|
|
||||||
get_password_reset_token,
|
|
||||||
get_user_by_jellyseerr_id,
|
|
||||||
get_user_by_username,
|
|
||||||
get_users_by_username_ci,
|
|
||||||
mark_password_reset_token_used,
|
|
||||||
set_user_auth_provider,
|
|
||||||
set_user_password,
|
|
||||||
sync_jellyfin_password_state,
|
|
||||||
)
|
|
||||||
from ..runtime import get_runtime_settings
|
|
||||||
from .invite_email import send_password_reset_email
|
|
||||||
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetUnavailableError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_handles(value: object) -> list[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return []
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
handles = [normalized]
|
|
||||||
if "@" in normalized:
|
|
||||||
handles.append(normalized.split("@", 1)[0])
|
|
||||||
return list(dict.fromkeys(handles))
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
|
|
||||||
if not users:
|
|
||||||
return None
|
|
||||||
requested = str(requested_identifier or "").strip().lower()
|
|
||||||
|
|
||||||
def _rank(user: dict) -> tuple[int, int, int, int]:
|
|
||||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
|
||||||
role = str(user.get("role") or "user").strip().lower()
|
|
||||||
username = str(user.get("username") or "").strip().lower()
|
|
||||||
return (
|
|
||||||
0 if role == "admin" else 1,
|
|
||||||
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
|
||||||
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
|
|
||||||
0 if username == requested else 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
return sorted(users, key=_rank)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
|
|
||||||
target_handles = set(_normalize_handles(identifier))
|
|
||||||
if not target_handles:
|
|
||||||
return None
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email"):
|
|
||||||
value = user.get(key)
|
|
||||||
if target_handles.intersection(_normalize_handles(value)):
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_all_seerr_users() -> list[dict]:
|
|
||||||
cached = get_cached_jellyseerr_users()
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
||||||
if not client.configured():
|
|
||||||
return []
|
|
||||||
users: list[dict] = []
|
|
||||||
take = 100
|
|
||||||
skip = 0
|
|
||||||
while True:
|
|
||||||
payload = await client.get_users(take=take, skip=skip)
|
|
||||||
if not payload:
|
|
||||||
break
|
|
||||||
if isinstance(payload, list):
|
|
||||||
batch = payload
|
|
||||||
elif isinstance(payload, dict):
|
|
||||||
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
|
|
||||||
else:
|
|
||||||
batch = None
|
|
||||||
if not isinstance(batch, list) or not batch:
|
|
||||||
break
|
|
||||||
users.extend([user for user in batch if isinstance(user, dict)])
|
|
||||||
if len(batch) < take:
|
|
||||||
break
|
|
||||||
skip += take
|
|
||||||
if users:
|
|
||||||
return save_jellyseerr_users_cache(users)
|
|
||||||
return users
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
|
|
||||||
if isinstance(local_user, dict):
|
|
||||||
stored_email = str(local_user.get("email") or "").strip()
|
|
||||||
if "@" in stored_email:
|
|
||||||
return stored_email
|
|
||||||
username = str(local_user.get("username") or "").strip()
|
|
||||||
if "@" in username:
|
|
||||||
return username
|
|
||||||
if isinstance(seerr_user, dict):
|
|
||||||
email = str(seerr_user.get("email") or "").strip()
|
|
||||||
if "@" in email:
|
|
||||||
return email
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
|
|
||||||
normalized_identifier = str(identifier or "").strip()
|
|
||||||
if not normalized_identifier:
|
|
||||||
return None
|
|
||||||
|
|
||||||
local_user = normalize_user_auth_provider(
|
|
||||||
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
|
|
||||||
)
|
|
||||||
seerr_users: list[dict] | None = None
|
|
||||||
seerr_user: dict | None = None
|
|
||||||
|
|
||||||
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
|
|
||||||
seerr_users = await _fetch_all_seerr_users()
|
|
||||||
seerr_user = next(
|
|
||||||
(
|
|
||||||
user
|
|
||||||
for user in seerr_users
|
|
||||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not local_user:
|
|
||||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
|
||||||
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
|
|
||||||
if seerr_user:
|
|
||||||
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
|
|
||||||
try:
|
|
||||||
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
seerr_user_id = None
|
|
||||||
if seerr_user_id is not None:
|
|
||||||
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
|
|
||||||
if not local_user:
|
|
||||||
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
|
|
||||||
if not isinstance(candidate, str) or not candidate.strip():
|
|
||||||
continue
|
|
||||||
local_user = normalize_user_auth_provider(
|
|
||||||
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
|
|
||||||
)
|
|
||||||
if local_user:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not local_user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
auth_provider = resolve_user_auth_provider(local_user)
|
|
||||||
username = str(local_user.get("username") or "").strip()
|
|
||||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
|
||||||
if not recipient_email:
|
|
||||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
|
||||||
if isinstance(local_user.get("jellyseerr_user_id"), int):
|
|
||||||
seerr_user = next(
|
|
||||||
(
|
|
||||||
user
|
|
||||||
for user in seerr_users
|
|
||||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not seerr_user:
|
|
||||||
seerr_user = _find_matching_seerr_user(username, seerr_users)
|
|
||||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
|
||||||
if not recipient_email:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if auth_provider == "jellyseerr":
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if jellyfin_client.configured():
|
|
||||||
try:
|
|
||||||
jellyfin_user = await jellyfin_client.find_user_by_name(username)
|
|
||||||
except Exception:
|
|
||||||
jellyfin_user = None
|
|
||||||
if isinstance(jellyfin_user, dict):
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
|
|
||||||
if auth_provider not in {"local", "jellyfin"}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"username": username,
|
|
||||||
"recipient_email": recipient_email,
|
|
||||||
"auth_provider": auth_provider,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _token_record_is_usable(record: Optional[dict]) -> bool:
|
|
||||||
if not isinstance(record, dict):
|
|
||||||
return False
|
|
||||||
if record.get("is_used"):
|
|
||||||
return False
|
|
||||||
if record.get("is_expired"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_email(email: str) -> str:
|
|
||||||
candidate = str(email or "").strip()
|
|
||||||
if "@" not in candidate:
|
|
||||||
return "valid reset link"
|
|
||||||
local_part, domain = candidate.split("@", 1)
|
|
||||||
if not local_part:
|
|
||||||
return f"***@{domain}"
|
|
||||||
if len(local_part) == 1:
|
|
||||||
return f"{local_part}***@{domain}"
|
|
||||||
return f"{local_part[0]}***{local_part[-1]}@{domain}"
|
|
||||||
|
|
||||||
|
|
||||||
async def request_password_reset(
|
|
||||||
identifier: str,
|
|
||||||
*,
|
|
||||||
requested_by_ip: Optional[str] = None,
|
|
||||||
requested_user_agent: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
target = await _resolve_reset_target(identifier)
|
|
||||||
if not target:
|
|
||||||
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
|
|
||||||
return {"status": "ok", "issued": False}
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
|
|
||||||
create_password_reset_token(
|
|
||||||
token,
|
|
||||||
target["username"],
|
|
||||||
target["recipient_email"],
|
|
||||||
target["auth_provider"],
|
|
||||||
expires_at,
|
|
||||||
requested_by_ip=requested_by_ip,
|
|
||||||
requested_user_agent=requested_user_agent,
|
|
||||||
)
|
|
||||||
await send_password_reset_email(
|
|
||||||
recipient_email=target["recipient_email"],
|
|
||||||
username=target["username"],
|
|
||||||
token=token,
|
|
||||||
expires_at=expires_at,
|
|
||||||
auth_provider=target["auth_provider"],
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"issued": True,
|
|
||||||
"username": target["username"],
|
|
||||||
"recipient_email": target["recipient_email"],
|
|
||||||
"auth_provider": target["auth_provider"],
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password_reset_token(token: str) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
record = get_password_reset_token(token)
|
|
||||||
if not _token_record_is_usable(record):
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
|
|
||||||
"auth_provider": record.get("auth_provider"),
|
|
||||||
"expires_at": record.get("expires_at"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
|
|
||||||
delete_expired_password_reset_tokens()
|
|
||||||
record = get_password_reset_token(token)
|
|
||||||
if not _token_record_is_usable(record):
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
username = str(record.get("username") or "").strip()
|
|
||||||
if not username:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
stored_user = normalize_user_auth_provider(get_user_by_username(username))
|
|
||||||
if not stored_user:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
|
|
||||||
auth_provider = resolve_user_auth_provider(stored_user)
|
|
||||||
if auth_provider == "jellyseerr":
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
|
|
||||||
if auth_provider == "local":
|
|
||||||
set_user_password(username, new_password)
|
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
|
|
||||||
set_user_auth_provider(username, "local")
|
|
||||||
mark_password_reset_token_used(token)
|
|
||||||
logger.info("password reset applied username=%s provider=local", username)
|
|
||||||
return {"status": "ok", "provider": "local", "username": username}
|
|
||||||
|
|
||||||
if auth_provider == "jellyfin":
|
|
||||||
runtime = get_runtime_settings()
|
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
||||||
if not client.configured():
|
|
||||||
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
|
|
||||||
jellyfin_user = await client.find_user_by_name(username)
|
|
||||||
user_id = client._extract_user_id(jellyfin_user)
|
|
||||||
if not user_id:
|
|
||||||
raise ValueError("Password reset link is invalid or has expired.")
|
|
||||||
await client.set_user_password(user_id, new_password)
|
|
||||||
sync_jellyfin_password_state(username, new_password)
|
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
|
|
||||||
set_user_auth_provider(username, "jellyfin")
|
|
||||||
mark_password_reset_token_used(token)
|
|
||||||
logger.info("password reset applied username=%s provider=jellyfin", username)
|
|
||||||
return {"status": "ok", "provider": "jellyfin", "username": username}
|
|
||||||
|
|
||||||
raise ValueError("Password reset is not available for this sign-in provider.")
|
|
||||||
+89
-1099
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from ..db import get_setting, set_setting, delete_setting
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
|
|
||||||
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
|
|
||||||
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
|
|
||||||
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
|
|
||||||
parsed = _parse_iso(cached_at)
|
|
||||||
if not parsed:
|
|
||||||
return False
|
|
||||||
age = datetime.now(timezone.utc) - parsed
|
|
||||||
return age <= timedelta(minutes=max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cached_users(
|
|
||||||
cache_key: str, cache_at_key: str, max_age_minutes: int
|
|
||||||
) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
cached_at = get_setting(cache_at_key)
|
|
||||||
if not _cache_is_fresh(cached_at, max_age_minutes):
|
|
||||||
return None
|
|
||||||
raw = get_setting(cache_key)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [item for item in data if isinstance(item, dict)]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
|
|
||||||
payload = json.dumps(users, ensure_ascii=True)
|
|
||||||
set_setting(cache_key, payload)
|
|
||||||
set_setting(cache_at_key, _now_iso())
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_handles(value: Any) -> List[str]:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return []
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
handles = [normalized]
|
|
||||||
if "@" in normalized:
|
|
||||||
handles.append(normalized.split("@", 1)[0])
|
|
||||||
return list(dict.fromkeys(handles))
|
|
||||||
|
|
||||||
|
|
||||||
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
|
|
||||||
candidate_to_id: Dict[str, int] = {}
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
|
||||||
try:
|
|
||||||
user_id = int(user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email", "displayName", "name"):
|
|
||||||
for handle in _normalized_handles(user.get(key)):
|
|
||||||
candidate_to_id.setdefault(handle, user_id)
|
|
||||||
return candidate_to_id
|
|
||||||
|
|
||||||
|
|
||||||
def find_matching_jellyseerr_user(
|
|
||||||
identifier: str, users: List[Dict[str, Any]]
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
target_handles = set(_normalized_handles(identifier))
|
|
||||||
if not target_handles:
|
|
||||||
return None
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
for key in ("username", "email", "displayName", "name"):
|
|
||||||
if target_handles.intersection(_normalized_handles(user.get(key))):
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
return None
|
|
||||||
value = user.get("email")
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
candidate = value.strip()
|
|
||||||
if not candidate or "@" not in candidate:
|
|
||||||
return None
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def match_jellyseerr_user_id(
|
|
||||||
username: str, candidate_map: Dict[str, int]
|
|
||||||
) -> Optional[int]:
|
|
||||||
for handle in _normalized_handles(username):
|
|
||||||
matched = candidate_map.get(handle)
|
|
||||||
if matched is not None:
|
|
||||||
return matched
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
normalized: List[Dict[str, Any]] = []
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": user.get("id") or user.get("userId") or user.get("Id"),
|
|
||||||
"email": user.get("email"),
|
|
||||||
"username": user.get("username"),
|
|
||||||
"displayName": user.get("displayName"),
|
|
||||||
"name": user.get("name"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
|
|
||||||
logger.debug("Cached Seerr users: %s", len(normalized))
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
normalized: List[Dict[str, Any]] = []
|
|
||||||
for user in users:
|
|
||||||
if not isinstance(user, dict):
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": user.get("Id"),
|
|
||||||
"name": user.get("Name"),
|
|
||||||
"hasPassword": user.get("HasPassword"),
|
|
||||||
"lastLoginDate": user.get("LastLoginDate"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
|
|
||||||
logger.debug("Cached Jellyfin users: %s", len(normalized))
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_user_import_caches() -> Dict[str, int]:
|
|
||||||
cleared = 0
|
|
||||||
for key in (
|
|
||||||
JELLYSEERR_CACHE_KEY,
|
|
||||||
JELLYSEERR_CACHE_AT_KEY,
|
|
||||||
JELLYFIN_CACHE_KEY,
|
|
||||||
JELLYFIN_CACHE_AT_KEY,
|
|
||||||
):
|
|
||||||
delete_setting(key)
|
|
||||||
cleared += 1
|
|
||||||
logger.debug("Cleared user import cache keys: %s", cleared)
|
|
||||||
return {"settingsKeysCleared": cleared}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
fastapi==0.134.0
|
fastapi==0.115.0
|
||||||
uvicorn==0.41.0
|
uvicorn==0.30.6
|
||||||
httpx==0.28.1
|
httpx==0.27.2
|
||||||
pydantic==2.12.5
|
pydantic==2.9.2
|
||||||
pydantic-settings==2.14.2
|
pydantic-settings==2.5.2
|
||||||
PyJWT==2.13.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
python-multipart==0.0.31
|
python-multipart==0.0.9
|
||||||
Pillow==12.3.0
|
Pillow==10.4.0
|
||||||
prometheus-client==0.22.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
|
|
||||||
+12
-3
@@ -1,10 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
magent:
|
backend:
|
||||||
image: rephl3xnz/magent:latest
|
image: rephl3xnz/magent-backend:latest
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: rephl3xnz/magent-frontend:latest
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_BASE=/api
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|||||||
@@ -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
|
|
||||||
+16
-3
@@ -1,12 +1,25 @@
|
|||||||
services:
|
services:
|
||||||
magent:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_NUMBER: ${BUILD_NUMBER}
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_BASE=/api
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|||||||
@@ -1,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,28 +0,0 @@
|
|||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
logfile=/dev/null
|
|
||||||
logfile_maxbytes=0
|
|
||||||
pidfile=/tmp/supervisord.pid
|
|
||||||
|
|
||||||
[program:backend]
|
|
||||||
directory=/app
|
|
||||||
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
priority=10
|
|
||||||
|
|
||||||
[program:frontend]
|
|
||||||
directory=/app/frontend
|
|
||||||
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
|
|
||||||
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
priority=20
|
|
||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+646
-1285
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react'
|
|
||||||
|
|
||||||
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) {
|
|
||||||
const [open, setOpen] = useState(!collapsed)
|
|
||||||
return (
|
|
||||||
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}>
|
|
||||||
{collapsed && <button type="button" className="config-region-toggle" aria-expanded={open} aria-controls={`${id}-content`} onClick={() => setOpen(!open)}><strong>{title}</strong><span>{open ? 'Hide' : 'Configure'} <b aria-hidden="true">{open ? '−' : '+'}</b></span></button>}
|
|
||||||
<div id={`${id}-content`} hidden={!open}>{children}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,35 +2,27 @@ import { notFound } from 'next/navigation'
|
|||||||
import SettingsPage from '../SettingsPage'
|
import SettingsPage from '../SettingsPage'
|
||||||
|
|
||||||
const ALLOWED_SECTIONS = new Set([
|
const ALLOWED_SECTIONS = new Set([
|
||||||
'seerr',
|
|
||||||
'jellyseerr',
|
'jellyseerr',
|
||||||
'jellyfin',
|
'jellyfin',
|
||||||
'jellystat',
|
|
||||||
'artwork',
|
'artwork',
|
||||||
'sonarr',
|
'sonarr',
|
||||||
'radarr',
|
'radarr',
|
||||||
'bazarr',
|
|
||||||
'prowlarr',
|
'prowlarr',
|
||||||
'qbittorrent',
|
'qbittorrent',
|
||||||
'requests',
|
'requests',
|
||||||
'issue-workflow',
|
|
||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
'magent',
|
|
||||||
'general',
|
|
||||||
'notifications',
|
|
||||||
'site',
|
'site',
|
||||||
])
|
])
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ section: string }>
|
params: { section: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminSectionPage({ params }: PageProps) {
|
export default function AdminSectionPage({ params }: PageProps) {
|
||||||
const { section } = await params
|
if (!ALLOWED_SECTIONS.has(params.section)) {
|
||||||
if (!ALLOWED_SECTIONS.has(section)) {
|
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
return <SettingsPage section={section} />
|
return <SettingsPage section={params.section} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
/* Settings workspace. Shared Stitch tokens, compact controls and clear regions. */
|
|
||||||
.config-directory { display: grid; gap: 32px; max-width: 1120px; }
|
|
||||||
.config-directory-region { display: grid; gap: 16px; }
|
|
||||||
.config-directory-region header h2 { margin: 0 0 4px; font-size: 18px; }
|
|
||||||
.config-directory-region header p { margin: 0; color: var(--ops-muted); font-size: 13px; }
|
|
||||||
.config-directory-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
||||||
.config-directory-link { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); color: var(--ops-text); text-decoration: none; min-width: 0; transition: border-color .15s, background .15s; }
|
|
||||||
.config-directory-link:hover { border-color: var(--ops-primary-2); background: var(--ops-panel-2); }
|
|
||||||
.config-link-icon { flex: 0 0 34px; display: grid; place-items: center; height: 34px; border-radius: 8px; background: var(--ops-primary); color: var(--ops-primary-2); font: 11px "JetBrains Mono", monospace; }
|
|
||||||
.config-link-copy { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
|
||||||
.config-link-copy strong { font-size: 14px; }
|
|
||||||
.config-link-copy small { font-size: 12px; font-weight: 400; color: var(--ops-muted); line-height: 1.5; }
|
|
||||||
.config-link-arrow { color: var(--ops-faint); }
|
|
||||||
.config-connection-badge { flex-shrink: 0; font: 11px "JetBrains Mono", monospace; color: var(--ops-muted); }
|
|
||||||
.config-connection-badge::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
|
|
||||||
.config-connection-badge.is-up { color: var(--ops-green); }
|
|
||||||
.config-connection-badge.is-down { color: var(--ops-red); }
|
|
||||||
.config-connection-badge.is-degraded { color: var(--ops-warn); }
|
|
||||||
.config-advanced-directory { border: 1px solid var(--ops-line); border-radius: 10px; padding: 18px; }
|
|
||||||
.config-advanced-directory > summary { cursor: pointer; color: var(--ops-text); }
|
|
||||||
.config-advanced-directory > summary > span { margin-left: 12px; color: var(--ops-muted); font-size: 12px; }
|
|
||||||
.config-advanced-directory[open] > summary { margin-bottom: 18px; }
|
|
||||||
.config-sidebar-home { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px 20px; font: 600 20px "DM Sans", sans-serif; text-decoration: none; color: var(--ops-primary-2); }
|
|
||||||
.config-sidebar-back { display: block; margin-top: 20px; padding: 12px 10px; color: var(--ops-muted); font-size: 12px; }
|
|
||||||
.config-desktop-navigation { display: grid; gap: 18px; }
|
|
||||||
.admin-sidebar .admin-nav-links a { font: 13px Inter, sans-serif; padding: 8px 10px; min-height: 34px; }
|
|
||||||
.admin-sidebar .admin-nav-title { font-size: 10px; }
|
|
||||||
.config-nav-advanced > summary { cursor: pointer; padding: 8px 10px; font-size: 12px; color: var(--ops-muted); }
|
|
||||||
.config-mobile-picker { display: none; }
|
|
||||||
.admin-card { min-width: 0; }
|
|
||||||
.admin-shell { grid-template-areas: "nav main"; }
|
|
||||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main rail"; }
|
|
||||||
.admin-shell--no-rail > .admin-card { width: 100%; max-width: 1280px; }
|
|
||||||
.admin-card .admin-header { margin-bottom: 24px; }
|
|
||||||
.admin-card .admin-header .lede { max-width: 720px; margin: 8px 0 0; font-size: 14px; }
|
|
||||||
.admin-card .admin-header .section-kicker { font-size: 10px; }
|
|
||||||
.config-service-status { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 20px; font-size: 12px; color: var(--ops-muted); }
|
|
||||||
.config-service-status button { margin-left: auto; }
|
|
||||||
.admin-form.admin-zone-stack { gap: 16px; }
|
|
||||||
.admin-form .config-subsection { padding: 22px !important; }
|
|
||||||
.config-subsection form { display: grid; gap: 18px; min-width: 0; }
|
|
||||||
.config-subsection .section-header { margin: 0; padding: 0; border: 0; align-items: center; }
|
|
||||||
.config-subsection .section-header h2 { font-size: 18px; padding: 0; }
|
|
||||||
.config-subsection .section-header h2::after { display: none; }
|
|
||||||
.config-subsection .section-subtitle { margin: -10px 0 0; font-size: 12px; line-height: 1.6; }
|
|
||||||
.config-subsection .admin-grid { gap: 20px 24px; }
|
|
||||||
.config-subsection .setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
||||||
.config-subsection .setting-field > label, .config-subsection .setting-switch label { display: flex; align-items: center; gap: 10px; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0; background: none; color: var(--ops-text); font: 500 13px Inter, sans-serif; text-transform: none; letter-spacing: 0; }
|
|
||||||
.config-subsection .setting-field label small { color: var(--ops-green); font-size: 11px; font-weight: 400; }
|
|
||||||
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
|
|
||||||
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
|
|
||||||
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
|
|
||||||
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
|
|
||||||
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
|
|
||||||
.setting-switch > div { display: grid; gap: 6px; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: 0 0 38px; width: 38px; height: 22px; min-height: 22px; padding: 2px; margin: 0; background: var(--ops-panel-3) !important; border: 1px solid var(--ops-line); border-radius: 20px !important; cursor: pointer; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox]::before { content: ''; display: block; width: 16px; height: 16px; background: var(--ops-muted); border-radius: 50%; transition: transform .15s; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox]:checked { background: var(--ops-primary-2) !important; border-color: var(--ops-primary-2) !important; }
|
|
||||||
.config-subsection .setting-switch input[type=checkbox]:checked::before { background: var(--ops-primary); transform: translateX(16px); }
|
|
||||||
.config-subsection .settings-section-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding-top: 16px; border-top: 1px solid var(--ops-line); }
|
|
||||||
.config-subsection .settings-inline-field { padding: 0; border: 0; background: none; min-height: 0; flex: 1 1 230px; max-width: 330px; }
|
|
||||||
.config-subsection .settings-inline-field span { font: 500 12px Inter, sans-serif; text-transform: none; }
|
|
||||||
.config-subsection button { font: 600 12px "DM Sans", "Segoe UI", sans-serif; min-height: 38px; }
|
|
||||||
.config-subsection .config-unsaved { margin-right: auto; font-size: 12px; color: var(--ops-warn); }
|
|
||||||
.config-subsection .config-region-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; background: transparent !important; padding: 0; color: var(--ops-text); text-align: left; box-shadow: none; }
|
|
||||||
.config-region-toggle strong { font-size: 15px; }
|
|
||||||
.config-region-toggle > span { font-size: 12px; color: var(--ops-muted); }
|
|
||||||
.config-region-toggle + div:not([hidden]) { margin-top: 18px; }
|
|
||||||
.config-subsection [hidden] { display: none !important; }
|
|
||||||
.config-region-toggle + div .config-subsection-heading { display: none; }
|
|
||||||
.config-inline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
|
||||||
.config-inline-controls label { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.config-inline-controls select { width: auto; }
|
|
||||||
.admin-card .maintenance-layout { grid-template-columns: 1fr; }
|
|
||||||
.admin-card .cache-table, .admin-card .log-viewer { overflow-x: auto; max-width: 100%; }
|
|
||||||
.admin-card .cache-row { min-width: 650px; }
|
|
||||||
.config-tool-link { padding: 16px 0; color: var(--ops-primary-2); font-size: 13px; }
|
|
||||||
|
|
||||||
@media (max-width: 1250px) {
|
|
||||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main"; }
|
|
||||||
}
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.config-directory-links { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.admin-shell-nav .admin-sidebar { display: block; padding: 12px 18px; }
|
|
||||||
.config-desktop-navigation { display: none; }
|
|
||||||
.config-mobile-picker { display: flex; align-items: center; gap: 16px; margin: 0; }
|
|
||||||
.config-mobile-picker > span { font: 500 12px Inter, sans-serif; color: var(--ops-muted); }
|
|
||||||
.config-mobile-picker select { flex: 1; width: 100%; min-width: 0; padding: 10px; font: 13px Inter, sans-serif; }
|
|
||||||
}
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.admin-form .admin-grid { grid-template-columns: 1fr; }
|
|
||||||
.admin-form .config-subsection { padding: 16px !important; }
|
|
||||||
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
|
|
||||||
.config-link-copy { flex-basis: calc(100% - 62px); }
|
|
||||||
.config-directory-link .config-connection-badge { margin-left: 46px; }
|
|
||||||
.config-link-arrow { display: none; }
|
|
||||||
.config-advanced-directory > summary > span { display: block; margin: 8px 0 0; }
|
|
||||||
.admin-card .admin-header { align-items: flex-start; gap: 14px; flex-direction: column; }
|
|
||||||
.config-subsection .section-header { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
|
||||||
.config-subsection .section-subtitle { margin-top: 0; }
|
|
||||||
.config-subsection .settings-section-actions > button { flex-grow: 1; }
|
|
||||||
.config-subsection .settings-section-actions .config-unsaved { flex-basis: 100%; }
|
|
||||||
.config-service-status { gap: 10px; }
|
|
||||||
}
|
|
||||||
@@ -1,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')
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
|
|
||||||
|
|
||||||
export default function AdminDiagnosticsPage() {
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="Diagnostics"
|
|
||||||
subtitle="Check connections and investigate service problems."
|
|
||||||
>
|
|
||||||
<AdminDiagnosticsPanel />
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
import PortalClient from '../../portal/PortalClient'
|
|
||||||
|
|
||||||
export default function AdminIssuesPage() {
|
|
||||||
return <PortalClient workspace="issue" />
|
|
||||||
}
|
|
||||||
+13
-64
@@ -1,77 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
|
|
||||||
|
|
||||||
type ServiceState = { name: string; status: string }
|
|
||||||
|
|
||||||
export default function AdminLandingPage() {
|
export default function AdminLandingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [services, setServices] = useState<ServiceState[]>([])
|
|
||||||
const [ready, setReady] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) { router.replace('/login'); return }
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/me`)
|
|
||||||
if (!response.ok) { router.replace('/login'); return }
|
|
||||||
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
|
|
||||||
if (!active) return
|
|
||||||
setReady(true)
|
|
||||||
const status = await authFetch(`${getApiBase()}/status/services`)
|
|
||||||
if (!status.ok) throw new Error('Status unavailable')
|
|
||||||
const data = await status.json()
|
|
||||||
if (active) setServices(Array.isArray(data.services) ? data.services : [])
|
|
||||||
} catch {
|
|
||||||
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => { active = false }
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
|
<AdminShell
|
||||||
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings…</p> : (
|
title="Settings"
|
||||||
<div className="config-directory">
|
subtitle="Choose what you want to manage."
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
actions={
|
||||||
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
|
<button type="button" onClick={() => router.push('/')}>
|
||||||
<section className="config-directory-region" key={group.title}>
|
Back to requests
|
||||||
<header><h2>{group.title}</h2><p>{group.description}</p></header>
|
</button>
|
||||||
<div className="config-directory-links">
|
}
|
||||||
{group.items.map((item) => {
|
>
|
||||||
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
|
<section className="admin-section">
|
||||||
return (
|
<div className="status-banner">
|
||||||
<a href={item.href} key={item.href} className="config-directory-link">
|
Pick a section from the left. Each page explains what it does and how it helps.
|
||||||
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
|
|
||||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
|
||||||
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
|
|
||||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
<details className="config-advanced-directory">
|
|
||||||
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary>
|
|
||||||
<div className="config-directory-links">
|
|
||||||
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => (
|
|
||||||
<a href={item.href} key={item.href} className="config-directory-link">
|
|
||||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
|
||||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function AdminProfilesRedirectPage() {
|
|
||||||
redirect('/admin/invites')
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
|
|
||||||
type RequestRow = {
|
|
||||||
id: number
|
|
||||||
title?: string | null
|
|
||||||
year?: number | null
|
|
||||||
type?: string | null
|
|
||||||
statusLabel?: string | null
|
|
||||||
requestedBy?: string | null
|
|
||||||
createdAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_STAGE_OPTIONS = [
|
|
||||||
{ value: 'all', label: 'All stages' },
|
|
||||||
{ value: 'pending', label: 'Waiting for approval' },
|
|
||||||
{ value: 'approved', label: 'Approved' },
|
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
|
||||||
{ value: 'working', label: 'Working on it' },
|
|
||||||
{ value: 'partial', label: 'Partially ready' },
|
|
||||||
{ value: 'ready', label: 'Ready to watch' },
|
|
||||||
{ value: 'declined', label: 'Declined' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return 'Unknown'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminRequestsAllPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [rows, setRows] = useState<RequestRow[]>([])
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [pageSize, setPageSize] = useState(50)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [stage, setStage] = useState('all')
|
|
||||||
|
|
||||||
const pageCount = useMemo(() => {
|
|
||||||
if (!total || pageSize <= 0) return 1
|
|
||||||
return Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
}, [total, pageSize])
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const skip = (page - 1) * pageSize
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
take: String(pageSize),
|
|
||||||
skip: String(skip),
|
|
||||||
})
|
|
||||||
if (stage !== 'all') {
|
|
||||||
params.set('stage', stage)
|
|
||||||
}
|
|
||||||
const response = await authFetch(
|
|
||||||
`${baseUrl}/admin/requests/all?${params.toString()}`
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Load failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setRows(Array.isArray(data?.results) ? data.results : [])
|
|
||||||
setTotal(Number(data?.total ?? 0))
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Unable to load requests.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [page, pageSize, stage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > pageCount) {
|
|
||||||
setPage(pageCount)
|
|
||||||
}
|
|
||||||
}, [pageCount, page])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
}, [stage])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="All requests"
|
|
||||||
subtitle="Paginated view of every cached request."
|
|
||||||
>
|
|
||||||
<section className="admin-section">
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<div className="admin-toolbar-info">
|
|
||||||
<span>{total.toLocaleString()} total</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-toolbar-actions">
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Stage</span>
|
|
||||||
<select value={stage} onChange={(e) => setStage(e.target.value)}>
|
|
||||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="admin-select">
|
|
||||||
<span>Per page</span>
|
|
||||||
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={200}>200</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="status-banner">Loading requests…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="error-banner">{error}</div>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<div className="status-banner">No requests found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table">
|
|
||||||
<div className="admin-table-head">
|
|
||||||
<span>Request</span>
|
|
||||||
<span>Status</span>
|
|
||||||
<span>Requested by</span>
|
|
||||||
<span>Created</span>
|
|
||||||
</div>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<button
|
|
||||||
key={row.id}
|
|
||||||
type="button"
|
|
||||||
className="admin-table-row"
|
|
||||||
onClick={() => router.push(`/requests/${row.id}`)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{row.title || `Request #${row.id}`}
|
|
||||||
{row.year ? ` (${row.year})` : ''}
|
|
||||||
</span>
|
|
||||||
<span>{row.statusLabel || 'Unknown'}</span>
|
|
||||||
<span>{row.requestedBy || 'Unknown'}</span>
|
|
||||||
<span>{formatDateTime(row.createdAt)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="admin-pagination">
|
|
||||||
<button type="button" onClick={() => setPage(1)} disabled={page <= 1}>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setPage(page - 1)} disabled={page <= 1}>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
Page {page} of {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(pageCount)}
|
|
||||||
disabled={page >= pageCount}
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
||||||
|
|
||||||
type FlowStage = {
|
|
||||||
title: string
|
|
||||||
input: string
|
|
||||||
action: string
|
|
||||||
output: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_FLOW: FlowStage[] = [
|
|
||||||
{
|
|
||||||
title: 'Identity + access',
|
|
||||||
input: 'Jellyfin/local login',
|
|
||||||
action: 'Magent validates credentials and role',
|
|
||||||
output: 'JWT token + user scope',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Request intake',
|
|
||||||
input: 'Seerr request ID',
|
|
||||||
action: 'Magent snapshots request + media metadata',
|
|
||||||
output: 'Unified request state',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Queue orchestration',
|
|
||||||
input: 'Approved request',
|
|
||||||
action: 'Sonarr/Radarr add/search operations',
|
|
||||||
output: 'Grab decision',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Download execution',
|
|
||||||
input: 'Selected release',
|
|
||||||
action: 'qBittorrent downloads + reports progress',
|
|
||||||
output: 'Import-ready payload',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Library import',
|
|
||||||
input: 'Completed download',
|
|
||||||
action: 'Sonarr/Radarr import and finalize',
|
|
||||||
output: 'Available media object',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Playback availability',
|
|
||||||
input: 'Imported media',
|
|
||||||
action: 'Jellyfin refresh + link resolution',
|
|
||||||
output: 'Ready-to-watch state',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function AdminSystemGuidePage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [authorized, setAuthorized] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const load = async () => {
|
|
||||||
if (!getToken()) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const me = await response.json()
|
|
||||||
if (!active) return
|
|
||||||
if (me?.role !== 'admin') {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAuthorized(true)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
router.push('/')
|
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <main className="card">Loading system guide...</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authorized) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const rail = (
|
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card">
|
|
||||||
<span className="admin-rail-eyebrow">How it works</span>
|
|
||||||
<h2>Admin flow map</h2>
|
|
||||||
<p>Identity → Request intake → Queue orchestration → Download → Import → Playback.</p>
|
|
||||||
<span className="small-pill">Admin only</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell
|
|
||||||
title="System guide"
|
|
||||||
subtitle="Service connections, controls, and recovery paths."
|
|
||||||
rail={rail}
|
|
||||||
>
|
|
||||||
<section className="admin-section system-guide">
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>End-to-end system flow</h2>
|
|
||||||
<p className="lede">
|
|
||||||
This is the runtime path the platform follows from authentication through to playback
|
|
||||||
availability.
|
|
||||||
</p>
|
|
||||||
<div className="system-flow-track">
|
|
||||||
{REQUEST_FLOW.map((stage, index) => (
|
|
||||||
<div key={stage.title} className="system-flow-segment">
|
|
||||||
<article className="system-flow-card">
|
|
||||||
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Input</span>
|
|
||||||
<strong>{stage.input}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Action</span>
|
|
||||||
<strong>{stage.action}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="system-flow-card-row">
|
|
||||||
<span>Output</span>
|
|
||||||
<strong>{stage.output}</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true">→</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>What each service is responsible for</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Magent</h3>
|
|
||||||
<p>
|
|
||||||
Handles authentication, request pages, live event updates, invite workflows,
|
|
||||||
diagnostics, notifications, and admin operations.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Seerr</h3>
|
|
||||||
<p>
|
|
||||||
Stores the request itself and remains the request-state source for approval and
|
|
||||||
media request metadata.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Jellyfin</h3>
|
|
||||||
<p>
|
|
||||||
Provides user sign-in identity and the final playback destination once content is
|
|
||||||
available.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Sonarr / Radarr</h3>
|
|
||||||
<p>
|
|
||||||
Control queue placement, quality-profile decisions, import handling, and release
|
|
||||||
monitoring.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Prowlarr</h3>
|
|
||||||
<p>Provides search/indexer coverage for Arr-side release searches.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>qBittorrent</h3>
|
|
||||||
<p>
|
|
||||||
Executes the download and exposes live progress, paused states, and queue
|
|
||||||
visibility.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Operational controls by area</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>General</h3>
|
|
||||||
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Users</h3>
|
|
||||||
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Invite management</h3>
|
|
||||||
<p>
|
|
||||||
Master template, profile assignment, invite access policy, invite emails, and trace
|
|
||||||
map lineage.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Requests + cache</h3>
|
|
||||||
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Maintenance + diagnostics</h3>
|
|
||||||
<p>
|
|
||||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
|
||||||
nuclear flush/resync operations.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>User and invite model</h2>
|
|
||||||
<ol className="system-decision-list">
|
|
||||||
<li>
|
|
||||||
Jellyfin is used for sign-in identity and user presence across the platform.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Seerr provides request ownership and request-state data for Magent request pages.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Invite links, invite profiles, blanket rules, and invite-access controls are managed
|
|
||||||
inside Magent.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
If invite tracing is enabled, the lineage view shows who invited whom and how the
|
|
||||||
chain branches.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Cross-system removal and ban flows are initiated from Magent admin controls.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Stall recovery path (decision flow)</h2>
|
|
||||||
<ol className="system-decision-list">
|
|
||||||
<li>
|
|
||||||
Request approved but not in Arr queue <span>→</span> run <strong>Re-add to Arr</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
In queue but no release found <span>→</span> run <strong>Search releases</strong> and inspect options.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Release exists and user should not pick manually <span>→</span> run <strong>Search + auto-download</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Download paused/stalled in qBittorrent <span>→</span> run <strong>Resume download</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Imported but not visible to user <span>→</span> validate Jellyfin visibility/link from request page.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<h2>Live update surfaces</h2>
|
|
||||||
<div className="system-guide-grid">
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Landing page</h3>
|
|
||||||
<p>Recent request activity refreshes live for signed-in users.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Request pages</h3>
|
|
||||||
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
|
|
||||||
</article>
|
|
||||||
<article className="system-guide-card">
|
|
||||||
<h3>Admin views</h3>
|
|
||||||
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</AdminShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
@@ -10,42 +8,15 @@ type SiteInfo = {
|
|||||||
changelog?: string
|
changelog?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangelogGroup = {
|
const parseChangelog = (raw: string) =>
|
||||||
date: string
|
raw
|
||||||
entries: string[]
|
.split('\n')
|
||||||
}
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
||||||
|
|
||||||
const parseChangelog = (raw: string): ChangelogGroup[] => {
|
|
||||||
const groups: ChangelogGroup[] = []
|
|
||||||
for (const rawLine of raw.split('\n')) {
|
|
||||||
const line = rawLine.trim()
|
|
||||||
if (!line) continue
|
|
||||||
const [candidateDate, ...messageParts] = line.split('|')
|
|
||||||
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
|
|
||||||
const message = messageParts.join('|').trim()
|
|
||||||
if (!message) continue
|
|
||||||
const currentGroup = groups[groups.length - 1]
|
|
||||||
if (currentGroup?.date === candidateDate) {
|
|
||||||
currentGroup.entries.push(message)
|
|
||||||
} else {
|
|
||||||
groups.push({ date: candidateDate, entries: [message] })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (groups.length === 0) {
|
|
||||||
groups.push({ date: 'Updates', entries: [line] })
|
|
||||||
} else {
|
|
||||||
groups[groups.length - 1].entries.push(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChangelogPage() {
|
export default function ChangelogPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [groups, setGroups] = useState<ChangelogGroup[]>([])
|
const [entries, setEntries] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,11 +40,11 @@ export default function ChangelogPage() {
|
|||||||
}
|
}
|
||||||
const data: SiteInfo = await response.json()
|
const data: SiteInfo = await response.json()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setGroups(parseChangelog(data?.changelog ?? ''))
|
setEntries(parseChangelog(data?.changelog ?? ''))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setGroups([])
|
setEntries([])
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoading(false)
|
if (active) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -88,29 +59,27 @@ export default function ChangelogPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading-text">Loading changelog...</div>
|
return <div className="loading-text">Loading changelog...</div>
|
||||||
}
|
}
|
||||||
if (groups.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <div className="meta">No updates posted yet.</div>
|
return <div className="meta">No updates posted yet.</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="changelog-groups">
|
<ul className="changelog-list">
|
||||||
{groups.map((group) => (
|
{entries.map((entry, index) => (
|
||||||
<section key={group.date} className="changelog-group">
|
<li key={`${entry}-${index}`}>{entry}</li>
|
||||||
<h2>{group.date}</h2>
|
|
||||||
<ul className="changelog-list">
|
|
||||||
{group.entries.map((entry, index) => (
|
|
||||||
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)
|
)
|
||||||
}, [groups, loading])
|
}, [entries, loading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card changelog-page">
|
<div className="page">
|
||||||
<PageHeading title="Changelog" description="What’s new and improved in Magent." />
|
<section className="card changelog-card">
|
||||||
{content}
|
<div className="changelog-header">
|
||||||
</main>
|
<h1>Changelog</h1>
|
||||||
|
<p className="lede">Latest updates and release notes.</p>
|
||||||
|
</div>
|
||||||
|
{content}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
type Profile = {
|
type Profile = {
|
||||||
username?: string
|
username?: string
|
||||||
@@ -26,17 +24,15 @@ export default function FeedbackPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Could not load profile.')
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setProfile({ username: data?.username })
|
setProfile({ username: data?.username })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +49,7 @@ export default function FeedbackPage() {
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -62,16 +58,17 @@ export default function FeedbackPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
throw new Error(text || `Request failed: ${response.status}`)
|
throw new Error(text || `Request failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setStatus('Thanks! Your message has been sent.')
|
setStatus('Thanks! Your message has been sent.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setStatus('That did not send. Please try again.')
|
setStatus('That did not send. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,10 +77,16 @@ export default function FeedbackPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card feedback-page">
|
<main className="card">
|
||||||
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
|
<header className="how-hero">
|
||||||
|
<p className="eyebrow">Send feedback</p>
|
||||||
|
<h1>Help us improve Magent</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Found a problem or have an idea? Send it here and we will see it right away.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<form className="account-panel account-form feedback-form" onSubmit={submit}>
|
<form className="auth-form" onSubmit={submit}>
|
||||||
<label htmlFor="feedback-user">Your username</label>
|
<label htmlFor="feedback-user">Your username</label>
|
||||||
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import AuthLayout from '../ui/AuthLayout'
|
|
||||||
import { getApiBase } from '../lib/auth'
|
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [identifier, setIdentifier] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!identifier.trim()) {
|
|
||||||
setError('Enter your username or email.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/auth/password/forgot`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ identifier: identifier.trim() }),
|
|
||||||
})
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.')
|
|
||||||
}
|
|
||||||
setStatus(
|
|
||||||
typeof data?.message === 'string'
|
|
||||||
? data.message
|
|
||||||
: 'If an account exists for that username or email, a password reset link has been sent.',
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to send reset link.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
|
|
||||||
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
|
|
||||||
<label>
|
|
||||||
Username or email
|
|
||||||
<input
|
|
||||||
value={identifier}
|
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
|
||||||
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit" className="account-primary" disabled={loading}>
|
|
||||||
{loading ? 'Sending reset link…' : 'Send reset link'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</AuthLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+6
-5688
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,143 @@
|
|||||||
import PageHeading from '../ui/PageHeading'
|
'use client'
|
||||||
import '../welcome.css'
|
|
||||||
|
|
||||||
export default function HowItWorksPage() {
|
export default function HowItWorksPage() {
|
||||||
return <main className="friendly-guide">
|
return (
|
||||||
<PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." />
|
<main className="card how-page">
|
||||||
<nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav>
|
<header className="how-hero">
|
||||||
<details open><summary>Request a movie or TV show</summary>
|
<p className="eyebrow">How this works</p>
|
||||||
<ol>
|
<h1>Your request, step by step</h1>
|
||||||
<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>
|
<p className="lede">
|
||||||
<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>
|
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
|
||||||
<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>
|
your request is and what you can safely do next.
|
||||||
<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>
|
</p>
|
||||||
</ol>
|
</header>
|
||||||
</details>
|
|
||||||
<details><summary>Understand the six progress steps</summary>
|
<section className="how-grid">
|
||||||
<ol>
|
<article className="how-card">
|
||||||
<li><strong>Requested:</strong> Your request has been received.</li>
|
<h2>Jellyseerr</h2>
|
||||||
<li><strong>Approved:</strong> It has permission to go ahead.</li>
|
<p className="how-title">The request box</p>
|
||||||
<li><strong>Library collection:</strong> The library is tracking what’s collected and what’s missing.</li>
|
<p>
|
||||||
<li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isn’t a good match yet.</li>
|
This is where you ask for a movie or show. It keeps the request and whether it is
|
||||||
<li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li>
|
approved.
|
||||||
<li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li>
|
</p>
|
||||||
</ol>
|
</article>
|
||||||
<p>A finished download still needs to be added to the media library. Wait for “Available to watch” before heading over.</p>
|
<article className="how-card">
|
||||||
</details>
|
<h2>Sonarr / Radarr</h2>
|
||||||
<details><summary>Something looks stuck</summary>
|
<p className="how-title">The library manager</p>
|
||||||
<ol>
|
<p>
|
||||||
<li><strong>Open the request.</strong><p>Read its current status and next step.</p></li>
|
These add the request to the library list and decide what quality to look for.
|
||||||
<li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li>
|
</p>
|
||||||
<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>
|
</article>
|
||||||
</ol>
|
<article className="how-card">
|
||||||
<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>
|
<h2>Prowlarr</h2>
|
||||||
</details>
|
<p className="how-title">The search helper</p>
|
||||||
<details><summary>Report a problem and follow the fix</summary>
|
<p>
|
||||||
<ol>
|
This checks your search sources and reports back what it finds.
|
||||||
<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>
|
</p>
|
||||||
<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>
|
</article>
|
||||||
<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>
|
<article className="how-card">
|
||||||
<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>
|
<h2>qBittorrent</h2>
|
||||||
<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>
|
<p className="how-title">The downloader</p>
|
||||||
</ol>
|
<p>
|
||||||
<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>
|
This downloads the file. Magent can tell if it is downloading, paused, or finished.
|
||||||
</details>
|
</p>
|
||||||
<details><summary>Invite someone</summary>
|
</article>
|
||||||
<ol>
|
<article className="how-card">
|
||||||
<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>
|
<h2>Jellyfin</h2>
|
||||||
<li><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></li>
|
<p className="how-title">The place you watch</p>
|
||||||
<li><strong>Choose how to share it.</strong><p>Copy the link yourself, or enter an email address to send it directly.</p></li>
|
<p>
|
||||||
<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>
|
When the file is ready, Jellyfin shows it in your library so you can watch it.
|
||||||
</ol>
|
</p>
|
||||||
</details>
|
</article>
|
||||||
<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>
|
</section>
|
||||||
<footer>Ready? <a href="/welcome">Choose where to go next →</a></footer>
|
|
||||||
</main>
|
<section className="how-flow">
|
||||||
|
<h2>The pipeline in plain English</h2>
|
||||||
|
<ol className="how-steps">
|
||||||
|
<li>
|
||||||
|
<strong>You request a title</strong> in Jellyseerr.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Sonarr/Radarr adds it</strong> to the library list.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Prowlarr looks for sources</strong> and sends results back.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>qBittorrent downloads</strong> the match.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Sonarr/Radarr imports</strong> it into your library.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Jellyfin shows it</strong> when it is ready to watch.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="how-flow">
|
||||||
|
<h2>Steps and fixes (simple and visual)</h2>
|
||||||
|
<div className="how-step-grid">
|
||||||
|
<article className="how-step-card step-jellyseerr">
|
||||||
|
<div className="step-badge">1</div>
|
||||||
|
<h3>Request sent</h3>
|
||||||
|
<p className="step-note">Jellyseerr holds your request and approval.</p>
|
||||||
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Add to library queue (if it was approved but never added)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="how-step-card step-arr">
|
||||||
|
<div className="step-badge">2</div>
|
||||||
|
<h3>Added to the library list</h3>
|
||||||
|
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
|
||||||
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Search for releases (see options)</li>
|
||||||
|
<li>Search and auto-download (let it pick for you)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="how-step-card step-prowlarr">
|
||||||
|
<div className="step-badge">3</div>
|
||||||
|
<h3>Searching for sources</h3>
|
||||||
|
<p className="step-note">Prowlarr checks your torrent providers.</p>
|
||||||
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Search for releases (show a list to choose)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="how-step-card step-qbit">
|
||||||
|
<div className="step-badge">4</div>
|
||||||
|
<h3>Downloading the file</h3>
|
||||||
|
<p className="step-note">qBittorrent downloads the selected match.</p>
|
||||||
|
<div className="step-fix-title">Fixes you can try</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Resume download (only if it already exists there)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="how-step-card step-jellyfin">
|
||||||
|
<div className="step-badge">5</div>
|
||||||
|
<h3>Ready to watch</h3>
|
||||||
|
<p className="step-note">Jellyfin shows it in your library.</p>
|
||||||
|
<div className="step-fix-title">What to do next</div>
|
||||||
|
<ul className="step-fix-list">
|
||||||
|
<li>Open in Jellyfin (watch it)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="how-callout">
|
||||||
|
<h2>Why Magent sometimes says "waiting"</h2>
|
||||||
|
<p>
|
||||||
|
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
|
||||||
|
That does not mean it is broken. It usually means the release is not available yet.
|
||||||
|
</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>
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user