Compare commits

...
33 Commits
Author SHA1 Message Date
Assclaw 0f4071c970 fix(auth): accept configured public origin for sign-in
Magent CI/CD / verify (push) Successful in 4m15s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-19 12:26:09 +12:00
Assclaw a6a4a9aa24 fix: apply CSP nonce to Next scripts
Magent CI/CD / verify (push) Successful in 4m41s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:51:23 +12:00
Assclaw 91a950a3b0 fix: enforce isolated beta deployment path
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:41:34 +12:00
Assclaw 2525a9eb25 fix: preserve readable deployment source modes
Magent CI/CD / verify (push) Successful in 3m36s
Magent CI/CD / deploy-beta (push) Successful in 1m35s
2026-09-17 22:34:17 +12:00
Assclaw 8a6fe71446 fix: permit beta data ownership setup
Magent CI/CD / verify (push) Successful in 3m39s
Magent CI/CD / deploy-beta (push) Failing after 1m44s
2026-09-17 22:27:07 +12:00
Assclaw 6ab79efc35 fix: use Docker volume for CI smoke data
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Failing after 1m53s
2026-09-17 22:11:28 +12:00
Assclaw f852e7c941 chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 20:03:47 +12:00
Assclaw 5639dbcb83 security: harden data auth and deployment 2026-09-17 18:31:35 +12:00
Assclaw a6d1c73837 chore: patch vulnerable dependencies
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 15:22:26 +12:00
Assclaw aed1bf9256 feat: add seasons from request details
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-16 17:15:40 +12:00
Assclaw 05a540ecbb fix: keep site banner off login page
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 56s
2026-09-16 14:37:00 +12:00
Assclaw d75f36c691 feat: customize site banners and login notices
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-16 14:22:38 +12:00
Assclaw 3465343a69 feat: route ready-title issues through guided workflow
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-15 15:46:09 +12:00
Assclaw 4ba1a5763e feat: simplify ready requests and add global search
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 48s
2026-09-15 15:15:35 +12:00
Assclaw dd51332f3c Wait for Arr download queue hand-off before reporting search outcome
Magent CI/CD / verify (push) Successful in 2m10s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-15 14:15:24 +12:00
Assclaw 6a84e68a03 Show request submission progress in a focused follow-up dialog
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 55s
2026-09-15 14:02:08 +12:00
Assclaw c194db167a Illustrate monthly reports and add viewing pattern breakdowns
Magent CI/CD / verify (push) Successful in 1m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-13 22:21:02 +12:00
Assclaw e232335ca9 Organize diagnostics into horizontal rows and group notification controls
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 50s
2026-09-13 22:08:41 +12:00
Assclaw 8df02fdfd7 Feather the top edge of request artwork
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-13 21:57:26 +12:00
Assclaw ce756c1a65 Restore monitoring on recheck and prevent release-search proxy timeouts
Magent CI/CD / verify (push) Successful in 2m1s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:47:36 +12:00
Assclaw b3b83bda4f Feather request artwork into the page at the sides and bottom
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:27:05 +12:00
Assclaw 0eecab4e0e Add cinematic title artwork backgrounds to request pages
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 51s
2026-09-13 19:22:06 +12:00
Assclaw 765b0d2033 Match Jellyfin punctuation variants using provider metadata and strict fallback
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 17:36:31 +12:00
Assclaw 1debf6053c Keep last verified stage visible while background refresh catches up
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 16:25:00 +12:00
Assclaw d7e5c75cb1 Serve recent stages from SQLite with configurable background refresh
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 16:18:30 +12:00
Assclaw dcd8082c8d Filter recent requests by resolved display stage before pagination
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 12:28:52 +12:00
Assclaw e3332bec1f Explain search outcomes and provide a direct version selection action
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 12:16:28 +12:00
Assclaw f57058eb26 Show a clear spinner while request searches are working
Magent CI/CD / verify (push) Successful in 2m0s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 23:11:14 +12:00
Assclaw 8734f461bb Replace request activity feed with simple current progress
Magent CI/CD / verify (push) Successful in 1m57s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 23:06:14 +12:00
Assclaw 835d9c8de3 Tidy release picker profile toggle and repair display text
Magent CI/CD / verify (push) Successful in 2m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 22:56:21 +12:00
Assclaw 956fb3ecb1 Inherit email link addresses from hosting and proxy settings
Magent CI/CD / verify (push) Successful in 2m2s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 22:46:46 +12:00
Assclaw 4f7853b17b Align missing-episode searches and permit reviewed profile overrides
Magent CI/CD / deploy-beta (push) Successful in 50s
Magent CI/CD / verify (push) Successful in 2m4s
Magent CI/CD / deploy-prod (push) Skipped
2026-09-11 20:26:57 +12:00
Assclaw de25255ea8 Reconcile verified account IDs and make language repairs observable
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 19:56:32 +12:00
193 changed files with 22472 additions and 11348 deletions
+20
View File
@@ -0,0 +1,20 @@
# Provision this as .env on the beta host. Do not copy production secrets or data.
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
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=json
JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters
SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password
AUTH_COOKIE_NAME=magent_beta_auth
AUTH_STATE_COOKIE_NAME=magent_beta_logged_in
AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+18
View File
@@ -0,0 +1,18 @@
# Copy to .env for local development. Never reuse these example values in a deployed environment.
APP_NAME=Magent
CORS_ALLOW_ORIGIN=http://localhost:3000
MAGENT_APPLICATION_URL=http://localhost:3000
MAGENT_API_URL=http://localhost:8000
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=text
# Generate independent values as documented in README.md.
JWT_SECRET=replace-with-at-least-32-random-characters
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+41 -39
View File
@@ -6,6 +6,10 @@ on:
- beta
- main
- prod
pull_request:
branches:
- beta
- main
workflow_dispatch:
concurrency:
@@ -17,15 +21,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
python-version: "3.14"
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
@@ -37,40 +41,40 @@ jobs:
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Verify generated build metadata
run: python scripts/verify_build_metadata.py
- name: Audit frontend production dependencies
working-directory: frontend
run: npm audit --omit=dev --package-lock-only --audit-level=high
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Check frontend formatting
working-directory: frontend
run: npm run format:check
- name: Type-check frontend
working-directory: frontend
run: npm run typecheck
- name: Test frontend
working-directory: frontend
run: npm test
- 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 }}
- name: Validate Compose configuration
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
cp .env.example .env
docker compose -f docker-compose.yml config --quiet
- 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
- name: Build and smoke-test container
run: bash scripts/ci_container_smoke.sh
deploy-beta:
if: github.ref_name == 'beta'
@@ -78,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure SSH key
env:
@@ -86,19 +90,17 @@ jobs:
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
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
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- 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
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_beta_ams_dev01.sh
+5
View File
@@ -1,6 +1,7 @@
.env
bootstrap-admin.json
.venv/
.security-test-venv*/
data/
!data/branding/
!data/branding/**
@@ -8,8 +9,12 @@ backend/__pycache__/
**/__pycache__/
*.pyc
backend/.pytest_cache/
.coverage
coverage.xml
htmlcov/
frontend/node_modules/
frontend/.next/
*.tsbuildinfo
*.log
**/.pytest_cache/
.env.*
+27 -12
View File
@@ -1,4 +1,4 @@
FROM node:24-slim AS frontend-builder
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
WORKDIR /frontend
@@ -13,11 +13,12 @@ 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/proxy.ts ./proxy.ts
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
FROM python:3.14-slim
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
WORKDIR /app
@@ -32,22 +33,36 @@ RUN apt-get update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ARG MAGENT_UID=1000
ARG MAGENT_GID=1000
RUN groupadd --gid ${MAGENT_GID} magent \
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
COPY --chown=magent:magent backend/app ./app
COPY --chown=magent:magent 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 --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
COPY --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
RUN chown -R magent:magent /app
USER magent:magent
EXPOSE 3000 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|| exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
+10
View File
@@ -18,6 +18,8 @@ from `main`; use `prod-<short-commit>` tags to identify an exact release.
1. Run the backend tests and frontend production build. Review only the intended
changes, then commit and push `main`.
The repository workflow verifies `main` but intentionally does not deploy it;
production changes require the remaining explicit release steps below.
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.
@@ -33,6 +35,14 @@ from `main`; use `prod-<short-commit>` tags to identify an exact release.
feature, database integrity and account counts. Do not trigger bulk permission
changes, email sends or user imports as a deployment smoke test.
Include browser-origin POST checks for both `/api/auth/login` and
`/api/auth/jellyfin/login`: an empty form with `Origin` set to the public URL
must reach input validation (422), while an unrelated origin must return 403.
GET-only login/health checks do not detect origin-policy lockouts. Set
`CORS_ALLOW_ORIGIN` to the exact public origin; the state-change guard also
accepts the explicitly configured Hosting & proxy public URL, never a URL
inferred from request Host or forwarded headers.
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.
+44 -9
View File
@@ -66,8 +66,9 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="replace-with-a-long-random-secret"
JWT_EXP_MINUTES="720"
JWT_SECRET="replace-with-at-least-32-random-characters"
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
JWT_EXP_MINUTES="120"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
@@ -114,8 +115,9 @@ $env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="replace-with-a-long-random-secret"
$env:JWT_EXP_MINUTES="720"
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
$env:JWT_EXP_MINUTES="120"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
@@ -134,6 +136,19 @@ Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other local user you create in SQLite).
### Local quality checks
```bash
bash scripts/ci_backend_quality_gate.sh
cd frontend
npm ci
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
```
## Public Hosting Notes
The frontend proxies `/api/*` to the backend container. Set:
@@ -147,21 +162,41 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
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`.
- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
- Push to `main` or `prod`: runs the same verification without automatically changing production.
- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`.
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:
The beta deploy step ships tracked repository files over SSH, preserves beta's own `.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:
The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment.
- `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.
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
Beta always deploys to the isolated `/home/<deployment-user>/magent-beta` directory; the production path secret is intentionally ignored.
## Security and data handling
Generate independent signing and settings-encryption secrets before first startup:
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
## History endpoints
+58
View File
@@ -0,0 +1,58 @@
"""Shared HTTP request and error contracts."""
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class StrictRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class ErrorResponse(BaseModel):
detail: str
COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
400: {"model": ErrorResponse, "description": "Invalid request"},
401: {"model": ErrorResponse, "description": "Authentication required"},
403: {"model": ErrorResponse, "description": "Permission denied"},
404: {"model": ErrorResponse, "description": "Resource not found"},
409: {"model": ErrorResponse, "description": "Request conflict"},
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
500: {"model": ErrorResponse, "description": "Unexpected server error"},
502: {"model": ErrorResponse, "description": "Upstream service error"},
503: {"model": ErrorResponse, "description": "Service unavailable"},
}
class SignupRequest(StrictRequest):
invite_code: str = Field(min_length=1, max_length=256)
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=1024)
email: Optional[str] = Field(default=None, max_length=320)
class ForgotPasswordRequest(StrictRequest):
identifier: Optional[str] = Field(default=None, max_length=320)
username: Optional[str] = Field(default=None, max_length=100)
email: Optional[str] = Field(default=None, max_length=320)
class PasswordResetRequest(StrictRequest):
token: str = Field(min_length=1, max_length=512)
new_password: str = Field(min_length=1, max_length=1024)
class ProfileEmailUpdateRequest(StrictRequest):
email: Optional[str] = Field(default=None, max_length=320)
class ChangePasswordRequest(StrictRequest):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=1, max_length=1024)
def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
"""Keep direct service-level tests compatible while FastAPI validates HTTP input."""
return payload if isinstance(payload, dict) else payload.model_dump()
+4
View File
@@ -159,6 +159,9 @@ def _load_current_user_from_token(
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")
token_version = payload.get("ver")
if not isinstance(token_version, int) or token_version != int(user.get("auth_version") or 1):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked")
user = normalize_user_auth_provider(user)
from .feature_access import permissions
@@ -183,6 +186,7 @@ def _load_current_user_from_token(
"is_expired": bool(user.get("is_expired", False)),
"password_change_supported": bool(user.get("password_change_supported", False)),
"password_provider": user.get("password_provider"),
"auth_version": int(user.get("auth_version") or 1),
}
+17 -11
View File
@@ -1,6 +1,6 @@
import re
from typing import Any, Dict, Optional
import httpx
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
@@ -185,23 +185,33 @@ class JellyfinClient(ApiClient):
) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
url = f"{self.base_url}/Items"
params = {
"SearchTerm": term,
"IncludeItemTypes": ",".join(item_types or []),
"Recursive": "true",
"Fields": "Path,MediaSources",
"Fields": "Path,MediaSources,ProviderIds,OriginalTitle,SortName",
"Limit": limit,
}
headers = self._emby_headers()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
normalized = ' '.join(re.sub(r"[^\w\s]", ' ', term, flags=re.UNICODE).split())
terms = list(dict.fromkeys([term, normalized]))
if normalized != term and normalized.split():
terms.append(max(normalized.split(), key=len))
items = {}
for search_term in dict.fromkeys(terms):
if not search_term:
continue
response = await client.get(url, headers=headers, params={**params, "SearchTerm": search_term})
response.raise_for_status()
payload = response.json()
for item in payload.get('Items', []):
if isinstance(item, dict) and item.get('Id'):
items[item['Id']] = item
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
finish_remote_call(
operation_event_id,
success=True,
@@ -210,7 +220,6 @@ class JellyfinClient(ApiClient):
)
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,
@@ -264,7 +273,6 @@ class JellyfinClient(ApiClient):
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers()
@@ -273,7 +281,6 @@ class JellyfinClient(ApiClient):
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -281,7 +288,6 @@ class JellyfinClient(ApiClient):
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,
-10
View File
@@ -1,7 +1,6 @@
from typing import Any, Dict, Optional
import httpx
import logging
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
@@ -89,7 +88,6 @@ class QBittorrentClient(ApiClient):
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
@@ -97,7 +95,6 @@ class QBittorrentClient(ApiClient):
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,
@@ -106,7 +103,6 @@ class QBittorrentClient(ApiClient):
)
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,
@@ -119,7 +115,6 @@ class QBittorrentClient(ApiClient):
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
@@ -127,7 +122,6 @@ class QBittorrentClient(ApiClient):
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,
@@ -136,7 +130,6 @@ class QBittorrentClient(ApiClient):
)
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,
@@ -149,14 +142,12 @@ class QBittorrentClient(ApiClient):
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -164,7 +155,6 @@ class QBittorrentClient(ApiClient):
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,
+1 -1
View File
@@ -26,7 +26,7 @@ class RadarrClient(ApiClient):
return await self.get("/api/v3/qualityprofile")
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={"movieIds": movie_id, "pageSize": 1000})
async def search_releases(self, movie_id: int) -> Optional[Any]:
return await self.get(
+3
View File
@@ -66,6 +66,9 @@ class SonarrClient(ApiClient):
timeout_seconds=90.0,
)
async def search_episode_releases(self, episode_id: int) -> Optional[Any]:
return await self.get('/api/v3/release', params={'episodeId': episode_id}, timeout_seconds=90.0)
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
+29 -2
View File
@@ -1,9 +1,20 @@
import re
from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG
_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
def normalize_banner_color(value: object) -> Optional[str]:
color = str(value or "").strip().lower()
return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent"
@@ -13,7 +24,12 @@ class Settings(BaseSettings):
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=120, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
jwt_issuer: str = Field(default="magent", validation_alias=AliasChoices("JWT_ISSUER"))
jwt_audience: str = Field(default="magent-web", validation_alias=AliasChoices("JWT_AUDIENCE"))
settings_encryption_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SETTINGS_ENCRYPTION_KEY")
)
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")
@@ -42,7 +58,7 @@ class Settings(BaseSettings):
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
)
auth_cookie_samesite: str = Field(
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
default="strict", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
)
auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
@@ -51,6 +67,7 @@ class Settings(BaseSettings):
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
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")
@@ -67,6 +84,7 @@ class Settings(BaseSettings):
requests_sync_ttl_minutes: int = Field(
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
)
requests_stage_refresh_minutes: int = Field(default=15, ge=1, le=1440, validation_alias=AliasChoices("REQUESTS_STAGE_REFRESH_MINUTES"))
requests_poll_interval_seconds: int = Field(
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
)
@@ -107,6 +125,15 @@ class Settings(BaseSettings):
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_banner_background_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
)
site_banner_border_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
)
site_login_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
)
site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
)
+490 -218
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,13 +1,14 @@
"""Live account permissions. Invite access uses the existing users column."""
from .db import _connect
FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
FEATURES = ("stats", "requests", "new_requests", "issues", "invites", "ignore_profile_limits")
def permissions(user: dict) -> dict[str, bool]:
if user.get("role") == "admin":
return dict.fromkeys(FEATURES, True)
values = dict.fromkeys(FEATURES, True)
values["ignore_profile_limits"] = False
values["invites"] = bool(user.get("invite_management_enabled", False))
with _connect() as conn:
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
+41 -8
View File
@@ -2,6 +2,8 @@ import contextvars
import json
import logging
import os
import re
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from typing import Any, Mapping, Optional
from urllib.parse import parse_qs
@@ -27,6 +29,9 @@ _SENSITIVE_KEYWORDS = (
"token",
)
_MAX_BODY_BYTES = 4096
_SENSITIVE_PATH_PATTERNS = (
re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
)
class RequestContextFilter(logging.Filter):
@@ -35,6 +40,22 @@ class RequestContextFilter(logging.Filter):
return True
class JsonLogFormatter(logging.Formatter):
"""Stable JSON output for production log collectors."""
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"request_id": getattr(record, "request_id", "-"),
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def bind_request_id(request_id: str) -> contextvars.Token[str]:
return REQUEST_ID_CONTEXT.set(request_id or "-")
@@ -47,6 +68,13 @@ def current_request_id() -> str:
return REQUEST_ID_CONTEXT.get("-")
def sanitize_path(path: str) -> str:
sanitized = str(path or "")
for pattern in _SENSITIVE_PATH_PATTERNS:
sanitized = pattern.sub(r"\1[REDACTED]", sanitized)
return sanitized
def _is_sensitive_key(key: str) -> bool:
lowered = key.strip().lower()
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
@@ -55,10 +83,7 @@ def _is_sensitive_key(key: str) -> bool:
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:]}"
return "[REDACTED]"
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
@@ -142,6 +167,7 @@ def configure_logging(
log_file_backup_count: int = 10,
log_http_client_level: Optional[str] = "INFO",
log_background_sync_level: Optional[str] = "INFO",
log_format: Optional[str] = "text",
) -> None:
level_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
@@ -161,13 +187,20 @@ def configure_logging(
backupCount=max(1, int(log_file_backup_count or 10)),
encoding="utf-8",
)
try:
os.chmod(log_path, 0o600)
except OSError:
pass
handlers.append(file_handler)
context_filter = RequestContextFilter()
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if str(log_format or "text").strip().lower() == "json":
formatter: logging.Formatter = JsonLogFormatter()
else:
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers:
handler.addFilter(context_filter)
handler.setFormatter(formatter)
+45 -20
View File
@@ -7,12 +7,14 @@ from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import settings
from .db import has_admin_user, init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
run_local_request_stage_loop,
run_requests_delta_loop,
run_daily_requests_full_sync,
run_daily_db_cleanup,
@@ -46,11 +48,12 @@ from .logging_config import (
configure_logging,
reset_request_id,
sanitize_headers,
sanitize_value,
summarize_http_body,
sanitize_path,
)
from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
from .secret_storage import validate_secret_storage_configuration
from .services.request_origins import is_allowed_request_origin
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
@@ -81,22 +84,32 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
operation_token = begin_operation(
operation_id,
label=request.headers.get("X-Magent-Operation-Label"),
path=request.url.path,
path=sanitize_path(request.url.path),
)
request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "")
if origin and not is_allowed_request_origin(origin):
record_api(request, 403, 0.0)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403)
reset_operation(operation_token)
reset_request_id(token)
return JSONResponse(
status_code=403,
content={"detail": "Cross-origin state change rejected"},
headers={"X-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
body_summary = {
"content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
"declared_bytes": request.headers.get("content-length"),
}
logger.info(
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
"request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s",
request.method,
request.url.path,
sanitize_value(dict(request.query_params)),
sanitize_path(request.url.path),
sorted(set(request.query_params.keys())),
request.client.host if request.client else "-",
sanitize_headers(
{
@@ -123,7 +136,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
logger.exception(
"request failed method=%s path=%s duration_ms=%s",
request.method,
request.url.path,
sanitize_path(request.url.path),
duration_ms,
)
if operation_id and operation_token is not None:
@@ -139,6 +152,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
# 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(
@@ -148,7 +162,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
logger.info(
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
request.method,
request.url.path,
sanitize_path(request.url.path),
response.status_code,
duration_ms,
sanitize_headers(
@@ -202,9 +216,9 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
def _log_security_configuration_warnings() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
if len(jwt_secret) < 32 or jwt_secret == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is unset or still set to the default value"
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
@@ -217,10 +231,17 @@ def _log_security_configuration_warnings() -> None:
)
def _enforce_secure_startup_configuration() -> None:
def _enforce_secret_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.")
if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError(
"JWT_SECRET must be a strong, non-default value of at least 32 characters before startup."
)
validate_secret_storage_configuration()
def _enforce_secure_startup_configuration() -> None:
_enforce_secret_configuration()
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError(
@@ -238,9 +259,11 @@ async def startup() -> None:
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,
log_format=settings.log_format,
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
_enforce_secret_configuration()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
@@ -251,6 +274,7 @@ async def startup() -> None:
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
)
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",
@@ -267,6 +291,7 @@ async def startup() -> None:
return
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("request-local-stages", run_local_request_stage_loop)
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup)
+83 -73
View File
@@ -20,7 +20,8 @@ from ..auth import (
normalize_user_auth_provider,
resolve_user_auth_provider,
)
from ..config import settings as env_settings
from ..config import normalize_banner_color, settings as env_settings
from ..api_models import COMMON_ERROR_RESPONSES
from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
@@ -35,12 +36,9 @@ from ..db import (
get_user_by_id,
get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
delete_user_by_username,
delete_user_activity_by_username,
delete_user_data_by_username,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_email,
@@ -49,6 +47,7 @@ from ..db import (
set_user_profile_id,
set_user_expires_at,
set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state,
set_user_role,
run_integrity_check,
@@ -59,7 +58,6 @@ from ..db import (
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles,
get_user_profile,
create_user_profile,
@@ -69,9 +67,11 @@ from ..db import (
get_signup_invite_by_id,
create_signup_invite,
update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite,
get_signup_invite_by_code,
disable_signup_invites_by_creator,
delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -80,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
clear_user_import_caches,
@@ -108,7 +104,12 @@ from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
responses=COMMON_ERROR_RESPONSES,
)
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
@@ -174,6 +175,11 @@ NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_webhook_url",
}
BANNER_COLOR_SETTING_KEYS = {
"site_banner_background_color",
"site_banner_border_color",
}
SETTING_KEYS: List[str] = [
"jellystat_base_url",
"jellystat_api_key",
@@ -241,6 +247,7 @@ SETTING_KEYS: List[str] = [
"qbittorrent_username",
"qbittorrent_password",
"log_level",
"log_format",
"log_file",
"log_file_max_bytes",
"log_file_backup_count",
@@ -248,6 +255,7 @@ SETTING_KEYS: List[str] = [
"log_background_sync_level",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_stage_refresh_minutes",
"requests_delta_sync_interval_minutes",
"requests_full_sync_time",
"requests_cleanup_time",
@@ -259,6 +267,9 @@ SETTING_KEYS: List[str] = [
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
"site_banner_background_color",
"site_banner_border_color",
"site_login_message",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
@@ -683,6 +694,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
changed_keys.append(key)
continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key == "requests_stage_refresh_minutes":
try:
interval = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Local stage refresh must be a whole number from 1 to 1440 minutes") from exc
if not 1 <= interval <= 1440:
raise HTTPException(status_code=400, detail="Local stage refresh must be from 1 to 1440 minutes")
value_to_store = str(interval)
if key == "issue_confirmation_contact_attempts":
try:
attempts = int(value_to_store)
@@ -703,6 +722,11 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
if key in BANNER_COLOR_SETTING_KEYS:
normalized_color = normalize_banner_color(value_to_store)
if not normalized_color:
raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must be a six-digit hex colour such as #ffc857")
value_to_store = normalized_color
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
@@ -718,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
set_setting(key, value_to_store)
updates += 1
changed_keys.append(key)
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
@@ -729,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
)
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates}
@@ -757,7 +782,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]:
result = await send_test_email(recipient_email=recipient_email)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
logger.info("Admin triggered SMTP test")
return {"status": "ok", **result}
@@ -880,28 +905,10 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
if not jellyseerr_users:
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
from ..services.jellyfin_sync import sync_jellyfin_users
imported = await sync_jellyfin_users()
return {"status": "ok", "matched": len(jellyseerr_users), "skipped": 0, "imported": imported, "total": len(jellyseerr_users)}
updated = 0
skipped = 0
users = get_all_users()
for user in users:
if user.get("jellyseerr_user_id") is not None:
skipped += 1
continue
username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
if matched_email:
set_user_email(username, matched_email)
updated += 1
else:
skipped += 1
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
for key in ("email", "username", "displayName", "name"):
@@ -922,33 +929,9 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
if not jellyseerr_users:
return {"status": "ok", "imported": 0, "cleared": 0}
cleared = delete_non_admin_users()
imported = 0
for user in jellyseerr_users:
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
username = _pick_jellyseerr_username(user)
if not username:
continue
email = extract_jellyseerr_user_email(user)
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
email=email,
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
if created:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
if email:
set_user_email(username, email)
return {"status": "ok", "imported": imported, "cleared": cleared}
from ..services.jellyfin_sync import sync_jellyfin_users
imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported, "cleared": 0}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
@@ -1327,12 +1310,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
if action == "remove":
deleted = delete_user_by_username(username)
activity_deleted = delete_user_activity_by_username(username)
deletion = delete_user_data_by_username(username)
deleted = bool(deletion.get("deleted"))
result["local"] = {
"status": "ok" if deleted else "not_found",
"deleted": bool(deleted),
"activity_deleted": activity_deleted,
"data_cleanup": deletion,
}
if any(
@@ -1594,6 +1577,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
return {"status": "ok", "username": username, "provider": "jellyfin"}
raise HTTPException(
status_code=400,
@@ -1937,6 +1921,11 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
)
if not invite:
raise HTTPException(status_code=404, detail='Invite not found')
invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code())
if not invite:
raise HTTPException(status_code=409, detail='Invite is unavailable')
try:
result = await send_templated_email(
@@ -1950,9 +1939,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info(
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
"Admin sent invite email template: template=%s invite_id=%s username=%s",
template_key,
result.get("recipient_email"),
invite.get("id") if invite else None,
user.get("username") if user else None,
)
@@ -2018,15 +2006,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
"Admin created invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
bool(invite.get("recipient_email")),
send_email,
)
return {
@@ -2049,7 +2036,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
existing = get_signup_invite_by_id(invite_id)
if not existing:
raise HTTPException(status_code=404, detail="Invite not found")
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
requested_code = _normalize_optional_text(payload.get("code"))
if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite":
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "")
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
@@ -2083,6 +2074,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
email_error = None
if send_email:
try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email(
"invited",
invite=invite,
@@ -2092,15 +2087,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
"Admin updated invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
bool(invite.get("recipient_email")),
send_email,
)
return {
@@ -2116,6 +2110,22 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
}
@router.post("/invites/{invite_id}/rotate")
async def rotate_invite(
invite_id: int,
current_user: Dict[str, Any] = Depends(require_admin),
) -> Dict[str, Any]:
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=409, detail="Invite is unavailable")
logger.info(
"Admin rotated invite: invite_id=%s actor=%s",
invite_id,
current_user.get("username"),
)
return {"status": "ok", "invite": invite}
@router.delete("/invites/{invite_id}")
async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
+120 -115
View File
@@ -1,11 +1,8 @@
from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging
import secrets
import string
import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,6 +25,7 @@ from ..db import (
list_signup_invites,
create_signup_invite,
update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite,
reserve_signup_invite_use,
release_signup_invite_use,
@@ -39,6 +37,10 @@ from ..db import (
get_global_request_total,
get_setting,
sync_jellyfin_password_state,
increment_user_auth_version,
get_rate_limit_status,
record_rate_limit_event,
clear_rate_limit_events,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
@@ -58,6 +60,15 @@ from ..auth import (
set_auth_cookies,
)
from ..config import settings
from ..api_models import (
COMMON_ERROR_RESPONSES,
ChangePasswordRequest,
ForgotPasswordRequest,
PasswordResetRequest,
ProfileEmailUpdateRequest,
SignupRequest,
request_data,
)
from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import (
build_jellyseerr_candidate_map,
@@ -79,7 +90,7 @@ from ..services.password_reset import (
verify_password_reset_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES)
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
@@ -87,14 +98,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
"If an account exists for that username or email, a password reset link has been sent."
)
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
_RESET_RATE_LOCK = Lock()
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
@@ -145,12 +148,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str:
return (identifier or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
bucket.popleft()
def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
if not users:
return None
@@ -172,56 +169,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) ->
def _record_login_failure(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
ip_bucket.append(now)
user_bucket.append(now)
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
record_rate_limit_event("login-ip", ip_key)
record_rate_limit_event("login-user", user_key)
logger.warning("login failure recorded")
def _clear_login_failures(request: Request, username: str) -> None:
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
clear_rate_limit_events("login-ip", ip_key)
clear_rate_limit_events("login-user", user_key)
def _enforce_login_rate_limit(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if user_bucket:
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
exceeded = ip_exceeded or user_exceeded
retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
if exceeded:
logger.warning(
"login rate limit exceeded username=%s client=%s retry_after=%s",
user_key,
ip_key,
retry_after,
"login rate limit exceeded retry_after=%s", retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -231,48 +205,28 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
ip_bucket.append(now)
identifier_bucket.append(now)
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
record_rate_limit_event("reset-ip", ip_key)
record_rate_limit_event("reset-identifier", identifier_key)
logger.info("password reset rate event recorded")
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if identifier_bucket:
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
identifier_exceeded, identifier_retry = get_rate_limit_status(
"reset-identifier", identifier_key, window, max_identifier
)
exceeded = ip_exceeded or identifier_exceeded
retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
if exceeded:
logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
identifier_key,
ip_key,
retry_after,
"password reset rate limit exceeded retry_after=%s", retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -400,6 +354,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"email_bound": bool(invite.get("recipient_email")),
"label": invite.get("label"),
"description": invite.get("description"),
@@ -493,6 +448,7 @@ def _serialize_self_invite(invite: dict) -> dict:
return {
"id": invite.get("id"),
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
@@ -576,6 +532,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
return {
"id": invite.get("id"),
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
@@ -664,7 +621,9 @@ async def login(
detail="This account uses external sign-in. Use the external sign-in option.",
)
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
token = create_access_token(
user["username"], user["role"], auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username)
set_last_login(user["username"])
logger.info(
@@ -708,7 +667,9 @@ async def jellyfin_login(
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user")
token = create_access_token(
canonical_username, "user", auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
@@ -733,6 +694,13 @@ async def jellyfin_login(
if not isinstance(auth_response, dict) or not auth_response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
from ..services.jellyfin_identity import user_for_identity
identity_owner = user_for_identity(auth_response['User'].get('Id'), runtime.jellyfin_base_url)
if identity_owner:
preferred_match = identity_owner
user = identity_owner
canonical_username = identity_owner['username']
_assert_user_can_login(user)
if not preferred_match:
create_user_if_missing(
canonical_username,
@@ -768,7 +736,10 @@ async def jellyfin_login(
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
if matched_id is not None:
set_user_jellyseerr_id(canonical_username, matched_id)
token = create_access_token(canonical_username, "user")
refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
@@ -815,8 +786,13 @@ async def jellyseerr_login(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
id_matches = [row for row in get_all_users() if jellyseerr_user_id is not None and row.get('jellyseerr_user_id') == jellyseerr_user_id]
if len(id_matches) > 1:
raise HTTPException(409, 'Multiple Magent accounts claim this Seerr identity. Ask an administrator to repair the links.')
ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
preferred_match = id_matches[0] if id_matches else _pick_preferred_ci_user_match(ci_matches, form_data.username)
if preferred_match and preferred_match.get('jellyseerr_user_id') not in (None, jellyseerr_user_id):
raise HTTPException(409, 'The account name and authenticated identity disagree. Ask an administrator to repair the links.')
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
if not preferred_match:
create_user_if_missing(
@@ -839,7 +815,10 @@ async def jellyseerr_login(
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
if jellyseerr_email:
set_user_email(canonical_username, jellyseerr_email)
token = create_access_token(canonical_username, "user")
refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
logger.info(
@@ -861,7 +840,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
@router.post("/logout")
async def logout(response: Response) -> dict:
async def logout(
response: Response, current_user: dict = Depends(get_current_user)
) -> dict:
increment_user_auth_version(str(current_user.get("username") or ""))
clear_auth_cookies(response)
return {"status": "ok"}
@@ -872,6 +854,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
current_user["username"],
current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
auth_version=int(current_user.get("auth_version") or 1),
)
return {
"stream_token": token,
@@ -895,7 +878,8 @@ async def invite_details(code: str) -> dict:
@router.post("/signup")
async def signup(payload: dict, response: Response) -> dict:
async def signup(payload: SignupRequest, response: Response) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
@@ -911,11 +895,7 @@ async def signup(payload: dict, response: Response) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info(
"signup attempt username=%s invite_code=%s",
username,
invite_code,
)
logger.info("signup attempt username=%s", username)
invite = get_signup_invite_by_code(invite_code)
if not invite:
@@ -1027,7 +1007,7 @@ async def signup(payload: dict, response: Response) -> dict:
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
invited_by_code=f"invite:{invite.get('id')}",
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -1054,15 +1034,18 @@ async def signup(payload: dict, response: Response) -> dict:
# Welcome email delivery is best-effort and must not break signup.
logger.warning("Welcome email send skipped for %s: %s", username, exc)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
refreshed_user = get_user_by_username(username) or created_user or {}
token = create_access_token(
username, role, auth_version=int(refreshed_user.get("auth_version") or 1)
)
set_last_login(username)
logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%s",
username,
role,
created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None,
invite.get("code"),
invite.get("id"),
)
return _auth_success_response(
response,
@@ -1081,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict:
@router.post("/password/forgot")
async def forgot_password(payload: dict, request: Request) -> dict:
async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
@@ -1098,8 +1082,7 @@ async def forgot_password(payload: dict, request: Request) -> dict:
)
client_ip = _auth_client_ip(request)
safe_identifier = identifier.strip().lower()[:256]
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
logger.info("password reset requested")
try:
reset_result = await request_password_reset(
identifier,
@@ -1108,24 +1091,17 @@ async def forgot_password(payload: dict, request: Request) -> dict:
)
if reset_result.get("issued"):
logger.info(
"password reset issued username=%s provider=%s recipient=%s client=%s",
"password reset issued username=%s provider=%s",
reset_result.get("username"),
reset_result.get("auth_provider"),
reset_result.get("recipient_email"),
client_ip,
)
else:
logger.info(
"password reset request completed with no eligible account identifier=%s client=%s",
safe_identifier,
client_ip,
"password reset request completed with no eligible account",
)
except Exception as exc:
logger.warning(
"password reset email dispatch failed identifier=%s client=%s detail=%s",
safe_identifier,
client_ip,
str(exc),
"password reset email dispatch failed detail=%s", type(exc).__name__,
)
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
@@ -1141,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict:
@router.post("/password/reset")
async def password_reset(payload: dict) -> dict:
async def password_reset(payload: PasswordResetRequest) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
token = payload.get("token")
@@ -1204,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email")
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
async def update_profile_email(
payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
username = str(current_user.get("username") or "").strip()
@@ -1359,8 +1339,13 @@ async def update_profile_invite(
_require_self_service_invite_access(current_user)
existing = _get_owned_invite(invite_id, current_user)
requested_code = payload.get("code", existing.get("code"))
if isinstance(requested_code, str) and requested_code.strip():
requested_code = payload.get("code")
if (
isinstance(requested_code, str)
and requested_code.strip()
and not requested_code.strip().startswith("••••")
and requested_code.strip() != "Protected invite"
):
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "").strip()
@@ -1415,6 +1400,10 @@ async def update_profile_invite(
email_error = None
if send_email:
try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email(
"invited",
invite=invite,
@@ -1438,6 +1427,18 @@ async def update_profile_invite(
}
@router.post("/profile/invites/{invite_id}/rotate")
async def rotate_profile_invite(
invite_id: int, current_user: dict = Depends(get_current_user)
) -> dict:
_require_self_service_invite_access(current_user)
_get_owned_invite(invite_id, current_user)
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable")
return {"status": "ok", "invite": _serialize_self_invite(invite)}
@router.delete("/profile/invites/{invite_id}")
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
_require_self_service_invite_access(current_user)
@@ -1449,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
async def change_password(
payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
current_password = payload.get("current_password") if isinstance(payload, dict) else None
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
@@ -1519,6 +1523,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
logger.info("password change completed username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin"}
+24 -6
View File
@@ -1,8 +1,9 @@
import os
import warnings
from io import BytesIO
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image, ImageDraw, ImageFont
@@ -15,6 +16,10 @@ _BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "as
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
_MAX_IMAGE_PIXELS = 25_000_000
_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
def _ensure_branding_dir() -> None:
@@ -110,14 +115,27 @@ async def branding_favicon() -> FileResponse:
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Please upload an image file.")
content = await file.read()
content_type = str(file.content_type or "").lower()
extension = os.path.splitext(str(file.filename or ""))[1].lower()
if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.")
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if not content:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).")
try:
image = Image.open(BytesIO(content))
except OSError as exc:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
candidate = Image.open(BytesIO(content))
if candidate.format not in {"PNG", "JPEG", "WEBP"}:
raise ValueError("Unsupported image format")
if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
raise Image.DecompressionBombError("Image pixel limit exceeded")
candidate.verify()
image = Image.open(BytesIO(content))
image.load()
except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
_ensure_branding_dir()
+1 -1
View File
@@ -3,7 +3,7 @@ import re
import mimetypes
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Response
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
import httpx
+5 -3
View File
@@ -6,6 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import Field, field_validator
from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin
from ..runtime import get_runtime_settings
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
@@ -19,7 +20,7 @@ class Settings(StrictPayload):
weekday: int = Field(ge=0, le=6)
hour: int = Field(ge=0, le=23)
limit_titles: int = Field(ge=1, le=24)
public_url: str = Field(max_length=500)
public_url: str = Field(default="", max_length=500)
intro: str = Field(default='', max_length=2000)
revision: int = Field(ge=1)
_url = field_validator('public_url')(RecapSettings.origin_only.__func__)
@@ -114,10 +115,11 @@ def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = De
@router.put('/admin/newsletters')
def settings(payload: Settings, user: dict = Depends(require_admin)):
try:
ready, detail = service.delivery_ready(payload.public_url)
public_url = magent_public_url(payload.public_url or store.settings()['public_url'])
ready, detail = service.delivery_ready(public_url)
if payload.enabled and not ready:
raise service.NewsletterError(detail)
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
return store.save_settings({**payload.model_dump(), "public_url": public_url}, datetime.now(timezone.utc))
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
+7 -1
View File
@@ -11,6 +11,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..clients.jellyfin import JellyfinClient
from ..db import (
add_portal_item_activity,
@@ -34,7 +35,12 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
router = APIRouter(
prefix="/portal",
tags=["portal"],
dependencies=[Depends(get_current_user), Depends(require_portal_access)],
responses=COMMON_ERROR_RESPONSES,
)
logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
+5 -4
View File
@@ -6,7 +6,8 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user, require_admin
from ..services.public_urls import magent_public_url
from ..auth import require_admin
from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
@@ -31,7 +32,7 @@ class RecapSettings(StrictPayload):
enabled: bool
day: int = Field(ge=1, le=28)
hour: int = Field(ge=0, le=23)
public_url: str = Field(max_length=500)
public_url: str = Field(default="", max_length=500)
@field_validator("public_url")
@classmethod
@@ -114,9 +115,9 @@ def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dic
# Validate against the proposed URL without writing any partial settings.
ready, detail = recaps.smtp_email_config_ready()
runtime = recaps.get_runtime_settings()
if not payload.public_url or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
if not magent_public_url(payload.public_url or store.settings()["public_url"]) or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
return store.save_settings({**payload.model_dump(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, datetime.now(timezone.utc))
@router.get("/admin/email-recaps/preview")
File diff suppressed because it is too large Load Diff
+5
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG
from ..config import normalize_banner_color
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
@@ -15,6 +16,7 @@ _BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip()
login_message = (runtime.site_login_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES:
tone = "info"
@@ -24,8 +26,11 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
"tone": tone,
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
},
"login": {
"message": login_message,
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
+1
View File
@@ -17,6 +17,7 @@ _INT_FIELDS = {
"log_file_backup_count",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_stage_refresh_minutes",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"issue_confirmation_contact_attempts",
+116
View File
@@ -0,0 +1,116 @@
"""Transactional, versioned SQLite schema migrations for Magent."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable
MigrationStep = Callable[[sqlite3.Connection], None]
@dataclass(frozen=True)
class Migration:
version: int
name: str
apply: MigrationStep
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
column = definition.split(maxsplit=1)[0].strip('"')
if column not in _column_names(conn, table):
conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
for definition in (
"email TEXT",
"last_login_at TEXT",
"is_blocked INTEGER NOT NULL DEFAULT 0",
"auth_provider TEXT NOT NULL DEFAULT 'local'",
"jellyfin_password_hash TEXT",
"last_jellyfin_auth_at TEXT",
"jellyseerr_user_id INTEGER",
"auto_search_enabled INTEGER NOT NULL DEFAULT 1",
"invite_management_enabled INTEGER NOT NULL DEFAULT 0",
"profile_id INTEGER",
"expires_at TEXT",
"invited_by_code TEXT",
"invited_at TEXT",
"auth_version INTEGER NOT NULL DEFAULT 1",
):
_add_column(conn, "users", definition)
for definition in ("recipient_email TEXT", "code_hint TEXT"):
_add_column(conn, "signup_invites", definition)
for definition in (
"related_item_id INTEGER",
"workflow_request_status TEXT",
"workflow_media_status TEXT",
"issue_type TEXT",
"issue_resolved_at TEXT",
"metadata_json TEXT",
):
_add_column(conn, "portal_items", definition)
_add_column(conn, "requests_cache", "requested_by_id INTEGER")
statements = (
"CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
"(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
"(related_item_id, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
"CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
"CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at ON requests_cache "
"(requested_by_id, created_at DESC, request_id DESC)",
)
for statement in statements:
conn.execute(statement)
MIGRATIONS = (
Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
)
def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
)
"""
)
applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
completed: list[int] = []
for migration in MIGRATIONS:
if migration.version in applied:
continue
savepoint = f"magent_migration_{migration.version}"
conn.execute(f"SAVEPOINT {savepoint}")
try:
migration.apply(conn)
conn.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
(migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
)
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
except Exception:
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
raise
completed.append(migration.version)
return completed
+74
View File
@@ -0,0 +1,74 @@
import base64
import hashlib
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
ENCRYPTED_PREFIX = "enc:v1:"
SENSITIVE_SETTING_KEYS = frozenset(
{
"jellystat_api_key", "magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"magent_notify_email_smtp_password", "magent_notify_discord_webhook_url",
"magent_notify_telegram_bot_token", "magent_notify_push_token",
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key",
"prowlarr_api_key", "qbittorrent_password",
}
)
def _fernet_key() -> bytes:
configured = str(settings.settings_encryption_key or "").strip()
if configured:
try:
decoded = base64.urlsafe_b64decode(configured.encode("ascii"))
except Exception as exc:
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must be a valid Fernet key") from exc
if len(decoded) != 32:
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must decode to exactly 32 bytes")
return configured.encode("ascii")
jwt_secret = str(settings.jwt_secret or "").strip()
if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError(
"SETTINGS_ENCRYPTION_KEY is required when JWT_SECRET is not a strong migration key"
)
derived = hashlib.sha256(("magent-settings-v1:" + jwt_secret).encode("utf-8")).digest()
return base64.urlsafe_b64encode(derived)
def is_sensitive_setting(key: str) -> bool:
return str(key or "").strip().lower() in SENSITIVE_SETTING_KEYS
def validate_secret_storage_configuration() -> None:
"""Validate the configured or JWT-derived Fernet key without touching stored data."""
Fernet(_fernet_key())
def encrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
if value is None or not is_sensitive_setting(key):
return value
text = str(value)
if text.startswith(ENCRYPTED_PREFIX):
return text
token = Fernet(_fernet_key()).encrypt(text.encode("utf-8")).decode("ascii")
return ENCRYPTED_PREFIX + token
def decrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
if value is None or not is_sensitive_setting(key):
return value
text = str(value)
if not text.startswith(ENCRYPTED_PREFIX):
return text
try:
return Fernet(_fernet_key()).decrypt(
text[len(ENCRYPTED_PREFIX) :].encode("ascii")
).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError(
f"Stored secret '{key}' cannot be decrypted with the configured key"
) from exc
+51 -8
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
import uuid
from typing import Any, Dict, Optional
from passlib.context import CryptContext
@@ -7,9 +8,15 @@ from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_pwd_context = CryptContext(
schemes=["argon2", "pbkdf2_sha256"],
deprecated=["pbkdf2_sha256"],
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
_ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8
MIN_PASSWORD_LENGTH = 12
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
@@ -18,7 +25,17 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password)
try:
return _pwd_context.verify(plain_password, hashed_password)
except (TypeError, ValueError):
return False
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, Optional[str]]:
try:
return _pwd_context.verify_and_update(plain_password, hashed_password)
except (TypeError, ValueError):
return False, None
def validate_password_policy(password: str) -> str:
@@ -34,32 +51,58 @@ def _create_token(
*,
expires_at: datetime,
token_type: str = "access",
auth_version: int = 1,
) -> str:
issued_at = datetime.now(timezone.utc)
payload: Dict[str, Any] = {
"sub": subject,
"role": role,
"typ": token_type,
"exp": expires_at,
"iat": issued_at,
"jti": uuid.uuid4().hex,
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
"ver": max(1, int(auth_version or 1)),
}
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,
*,
auth_version: int = 1,
) -> str:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return _create_token(subject, role, expires_at=expires, token_type="access")
return _create_token(subject, role, expires_at=expires, token_type="access", auth_version=auth_version)
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
def create_stream_token(
subject: str,
role: str,
expires_seconds: int = 120,
*,
auth_version: int = 1,
) -> 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")
return _create_token(subject, role, expires_at=expires, token_type="sse", auth_version=auth_version)
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],
audience=settings.jwt_audience,
issuer=settings.jwt_issuer,
options={"require": ["exp", "iat", "jti", "iss", "aud", "sub", "typ", "ver"]},
)
class TokenError(Exception):
+21
View File
@@ -0,0 +1,21 @@
"""Shared Sonarr/Radarr configuration helpers."""
from typing import Any
class RootFolderNotFoundError(ValueError):
pass
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
configured = str(root_folder or "").strip()
if not configured.isdigit():
return configured
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if isinstance(folder, dict) and folder.get("id") == int(configured):
path = str(folder.get("path") or "").strip()
if path:
return path
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
+20 -14
View File
@@ -1,4 +1,4 @@
"""Reviewed consolidation of same-name Jellyfin accounts, entirely within Magent."""
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
import asyncio
import json
from contextlib import closing
@@ -30,14 +30,19 @@ def account_state(conn, ids):
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
def identity_group(report, target):
identity = target['candidate_jellyfin_id']
return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
def build_preview(report, local, runtime, state, user_id, keep_id=None):
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
if not target:
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
group = [row for row in report['rows'] if review.name_key(row['user']['username']) == review.name_key(target['user']['username'])]
group = identity_group(report, target)
ids = {row['user']['id'] for row in group}
if len(ids) < 2:
raise HTTPException(409, 'No same-name duplicate group remains. Run the account check again.')
raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
jf_id = target['candidate_jellyfin_id']
source = source_key(runtime.jellyfin_base_url)
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
@@ -50,14 +55,14 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
problems.append('Restore all three media-service connections before consolidating accounts.')
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
if target['jellyfin'] and review.name_key(target['jellyfin']['name']) != review.name_key(target['user']['username']):
problems.append('The current Jellyfin name does not match this duplicate group.')
if len([account for account in report['jellyfin_users'] if review.name_key(account['name']) == review.name_key(target['user']['username'])]) != 1:
problems.append('The name must identify exactly one current Jellyfin account.')
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
for row in group:
if row['user']['role'] != 'user' or row['user']['auth_provider'] != 'jellyfin':
problems.append('Only non-admin Jellyfin sign-in accounts can use duplicate consolidation.')
if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
for link in local['links']:
@@ -76,12 +81,12 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
problems.append('Another confirmation owns this identity.')
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
problems.append('An account outside this same-name group also claims the identity.')
problems.append('An account outside this identity group also claims the identity.')
accounts = [account for account in state['users'] if account['id'] in ids]
kept = next(account for account in accounts if account['id'] == keep_id)
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
overrides.get((account['id'], key), True) for account in accounts) for key in FEATURES}
overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
try:
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
@@ -105,7 +110,8 @@ async def prepare(user_id, keep_id=None):
target = next((row for row in local['users'] if row['id'] == user_id), None)
if not target:
raise HTTPException(404, 'Account not found.')
ids = sorted(row['id'] for row in local['users'] if review.name_key(row['username']) == review.name_key(target['username']))
report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
with closing(db._connect()) as conn:
conn.execute('BEGIN')
if review.digest(review.snapshot(conn)) != review.digest(local):
@@ -146,7 +152,7 @@ def consolidate(preview, report, local, runtime, state, admin):
for name in old_values:
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if review.name_key(row['username']) == review.name_key(values['username'])]
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
for entry in activity:
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
for entry in activity:
@@ -165,7 +171,7 @@ def consolidate(preview, report, local, runtime, state, admin):
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM users WHERE id=?', (identity,))
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
conn.execute('''UPDATE users SET username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
values['features']['invites'], values['expires_at'], last_login, keep))
+33 -5
View File
@@ -32,7 +32,7 @@ def worker_enabled() -> bool:
def delivery_ready() -> tuple[bool, str]:
config = store.settings()
if not config["public_url"]:
return False, "Set the public Magent address for email links."
return False, "Set the application URL in Hosting & proxy for email links."
ready, detail = smtp_email_config_ready()
if not ready:
return False, detail
@@ -142,12 +142,40 @@ def completed_month(month: str | None) -> str:
return period["month"]
async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
"""Embed only signed artwork from this account's report; missing art is optional."""
import base64
import re
from .insights_artwork import get_artwork
runtime = get_runtime_settings()
images = []
report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
async def picture(index, row):
match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
if not match:
return
try:
data, mime = await get_artwork(account, runtime, *match.groups())
cid = f"recap-title-{index}@magent"
row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
except Exception:
pass # An unavailable poster must never prevent a personal report.
await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
if not preview:
rendered["inline_images"] = images
return rendered
async def preview(user: dict, month: str | None) -> dict:
account = current_account(user)
selected = completed_month(month)
config = store.settings()
if not config["public_url"]:
raise RecapError("Save the public Magent address before previewing an email.")
raise RecapError("Set the application URL in Hosting & proxy before previewing an email.")
try:
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
except HistoryLimitError as exc:
@@ -156,8 +184,8 @@ async def preview(user: dict, month: str | None) -> dict:
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
if report["state"] != "ready":
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
return {"month": selected, "email": account.get("email"), **mail.render_recap(
report, account["username"], config["public_url"], config["public_url"] + "/profile#monthly-recaps")}
return {"month": selected, "email": account.get("email"), **await illustrated_recap(
report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
@@ -200,7 +228,7 @@ async def process_delivery(delivery: dict) -> None:
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
raise mail.DeliveryError("failed", "A complete personal report is not available.")
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
rendered = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
def before_data():
eligible_delivery(delivery)
+12 -1
View File
@@ -125,6 +125,9 @@ def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
daily_seconds = defaultdict(float)
weekdays = [0.0] * 7
media_minutes = defaultdict(float)
longest_play = 0.0
clients = defaultdict(float)
methods = defaultdict(float)
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
@@ -157,6 +160,9 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
episode_ids.add(str(episode_id))
elif media_type == "movie":
movie_ids.add(item_id)
weekdays[date.weekday()] += duration / 60
media_minutes[media_type] += duration / 60
longest_play = max(longest_play, duration / 60)
seconds += duration
daily_seconds[date.date().isoformat()] += duration
client = str(row.get("Client") or "Unknown player")[:200]
@@ -166,7 +172,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
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 = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
title["minutes"] += duration / 60
title["plays"] += 1
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
@@ -193,6 +199,11 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
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},
"patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0,
"longest_play_minutes": round(longest_play, 1),
"weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0,
"weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))],
"media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]},
"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])],
+13 -10
View File
@@ -35,16 +35,19 @@ def signature(user, runtime, media_id, expires):
def with_artwork(data, user, runtime):
expires = int(time.time()) + TOKEN_SECONDS
recent = []
for play in data.get("recent", []):
row = {**play}
media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
recent.append(row)
return {**data, "recent": recent}
result = {**data}
for field in ("recent", "top_titles"):
rows = []
for play in data.get(field, []):
row = {**play}
media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
rows.append(row)
result[field] = rows
return result
def verify_artwork_token(user, runtime, media_id, token):
+8 -21
View File
@@ -1025,16 +1025,12 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
raise RuntimeError("SMTP email settings are incomplete.")
local_hostname = _derive_mail_hostname(from_address=from_address)
logger.info(
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
recipient_email,
from_address,
"smtp send started host=%s port=%s tls=%s ssl=%s auth=%s",
host,
port,
use_tls,
use_ssl,
bool(username and password),
subject,
local_hostname,
)
if delivery_warning:
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
@@ -1083,11 +1079,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
message=message,
)
logger.info(
"smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s",
recipient_email,
host,
receipt.get("provider_message_id"),
receipt.get("provider_internal_id"),
"smtp send accepted host=%s mode=ssl", host,
)
return receipt
@@ -1100,7 +1092,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
logger.debug("smtp starttls negotiated host=%s port=%s", host, port)
if username and password:
smtp.login(username, password)
logger.debug("smtp login succeeded host=%s username=%s", host, username)
logger.debug("smtp login succeeded host=%s", host)
receipt = _send_via_smtp_session(
smtp,
from_address=from_address,
@@ -1108,11 +1100,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
message=message,
)
logger.info(
"smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s",
recipient_email,
host,
receipt.get("provider_message_id"),
receipt.get("provider_internal_id"),
"smtp send accepted host=%s mode=plain", host,
)
return receipt
@@ -1153,7 +1141,7 @@ async def send_templated_email(
body_text=rendered["body_text"],
body_html=rendered["body_html"],
)
logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email)
logger.info("Email template sent: template=%s", template_key)
return {
"recipient_email": resolved_email,
"subject": rendered["subject"],
@@ -1185,7 +1173,7 @@ async def send_generic_email(
body_text=body_text.strip(),
body_html=body_html.strip(),
)
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
logger.info("Generic email sent")
return {
"recipient_email": resolved_email,
"subject": subject.strip() or f"{env_settings.app_name} notification",
@@ -1284,7 +1272,7 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
body_text=body_text,
body_html=body_html,
)
logger.info("SMTP test email sent: recipient=%s", resolved_email)
logger.info("SMTP test email sent")
result = {"recipient_email": resolved_email, "subject": subject}
result.update(
{
@@ -1383,9 +1371,8 @@ async def send_password_reset_email(
body_html=body_html,
)
logger.info(
"Password reset email sent: username=%s recipient=%s provider=%s",
"Password reset email sent: username=%s provider=%s",
username,
resolved_email,
auth_provider,
)
result = {
+13
View File
@@ -36,3 +36,16 @@ def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> Non
"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)),
)
def user_for_identity(jellyfin_user_id: str, base_url: str | None):
"""Resolve a verified upstream login to its existing local account."""
if not jellyfin_user_id or not base_url:
return None
with closing(db._connect()) as conn:
rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
(source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
if len(rows) > 1:
from fastapi import HTTPException
raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
return db.get_user_by_id(rows[0][0]) if rows else None
+45 -35
View File
@@ -1,4 +1,9 @@
import logging
from collections import Counter
from contextlib import closing
from .. import db
from .jellyfin_identity import source_key
from .identity_review import normalized_id, name_key
from fastapi import HTTPException
@@ -6,18 +11,14 @@ from ..clients.jellyfin import JellyfinClient
from ..db import (
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 .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,
)
@@ -36,43 +37,52 @@ async def sync_jellyfin_users() -> int:
# 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
name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
with closing(db._connect()) as conn:
links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
for user in users:
if not isinstance(user, dict):
continue
name = user.get("Name")
if not name:
name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
if not name or not jf_id or name_counts[name_key(name)] != 1:
continue
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
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
else:
matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
if len(matches) > 1:
continue
matched = matches[0] if matches else None
matched_id = matched.get('id') if matched else None
owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
if len(owners) > 1:
continue
existing = db.get_user_by_id(owners[0]) if owners else None
if not existing and matched_id is not None:
candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
if len(candidates) > 1:
continue
existing = candidates[0] if candidates else None
if not existing:
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)
if existing:
existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
if existing_links and any(value != jf_id for value in existing_links):
continue
if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
continue
canonical = existing['username']
# Never overwrite a stored Seerr identity on name evidence.
if existing.get('jellyseerr_user_id') not in (None, matched_id):
continue
set_user_auth_provider(canonical, 'jellyfin')
else:
canonical = name
if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
imported += 1
if matched_id is not None:
set_user_jellyseerr_id(canonical, matched_id)
link_user(canonical, jf_id, runtime.jellyfin_base_url)
return imported
+55
View File
@@ -0,0 +1,55 @@
"""Manual collector decisions and short-lived, request-bound selection receipts."""
from datetime import datetime, timedelta, timezone
import hashlib
import jwt
from fastapi import HTTPException
from ..config import settings
def can_override(user):
return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
def decision(item):
reasons = [str(r) for r in (item.get('rejections') or [])]
accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
# Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
'larger than', 'smaller than', 'size limit', 'release profile',
)) for reason in reasons)
override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
return accepted, override, reasons
def source_id(url):
return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
def issue_selection(release, request_id, user, source, item_id):
return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
'source': source_id(source), 'item': item_id, 'guid': release['guid'],
'indexer': release['indexerId'], 'title': release.get('title'),
'override': release['requiresOverride'], 'rejections': release['rejections'],
'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
settings.jwt_secret, algorithm='HS256')
def verify_selection(payload, request_id, user, source, item_id):
try:
receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
algorithms=['HS256'], audience='manual-release')
except jwt.InvalidTokenError as exc:
raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
if receipt.get('override'):
if not can_override(user):
raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
if payload.get('ignoreProfileLimits') is not True:
raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
return receipt
+5 -1
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
from .. import db
from . import email_queue
from .recap_store import read_one, transaction
from .public_urls import magent_public_url
class Conflict(ValueError):
@@ -63,6 +64,7 @@ def init_schema(conn):
def settings() -> dict:
result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
result['public_url'] = magent_public_url(result['public_url'])
result['enabled'] = bool(result['enabled'])
return result
@@ -79,6 +81,7 @@ def next_due(now: datetime, weekday: int, hour: int) -> datetime:
def save_settings(values: dict, now: datetime):
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn:
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
if old['revision'] != values['revision']:
@@ -246,7 +249,8 @@ def enqueue_test(sub, identity, revision, request_id, public_url, now):
def enqueue_due(now):
with transaction() as conn:
config = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
config['public_url'] = magent_public_url(config['public_url'])
rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
for raw in rows:
row = unpack(raw)
+2 -2
View File
@@ -32,7 +32,7 @@ def playback_url(runtime) -> str:
def delivery_ready(public_url=None):
config = store.settings()
if not (public_url if public_url is not None else config['public_url']):
return False, 'Set the public Magent address for newsletter email links.'
return False, 'Set the application URL in Hosting & proxy for newsletter email links.'
runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
return False, 'Connect Jellyfin to collect new arrivals.'
@@ -150,7 +150,7 @@ async def preview(identity, revision):
runtime = get_runtime_settings()
config = store.settings()
if not config['public_url'] or not playback_url(runtime):
raise NewsletterError('Set the public Magent and Jellyfin addresses before previewing.')
raise NewsletterError('Check the application URL in Hosting & proxy and the public playback URL in Jellyfin settings before previewing.')
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
+3 -1
View File
@@ -18,6 +18,7 @@ from ..db import (
mark_password_reset_token_used,
set_user_auth_provider,
set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
@@ -243,7 +244,7 @@ async def request_password_reset(
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])
logger.info("password reset requested with no eligible match")
return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32)
@@ -324,6 +325,7 @@ async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
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)
increment_user_auth_version(username)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token)
+29
View File
@@ -0,0 +1,29 @@
"""Configured public email links, independent of request Host/forwarded headers."""
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
def valid_public_url(value):
value = str(value or '').strip().rstrip('/')
try:
parsed = urlsplit(value)
if (parsed.scheme in {'http', 'https'} and parsed.hostname
and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
and (parsed.port is None or parsed.port > 0)
and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)):
return value
except ValueError:
pass
return ''
def magent_public_url(legacy_url=''):
runtime = get_runtime_settings()
proxy = getattr(runtime, 'magent_proxy_base_url', None)
application = getattr(runtime, 'magent_application_url', None)
if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
return valid_public_url(proxy)
if str(application or '').strip():
return valid_public_url(application)
# Preserve pre-existing installations until Hosting & proxy has been configured.
return valid_public_url(legacy_url)
+18 -2
View File
@@ -88,10 +88,26 @@ def render_recap(report: dict, username: str, public_url: str, unsubscribe_url:
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
patterns = report.get("patterns", {})
if patterns:
detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%."
lines.append(detail)
content += f'<p style="padding:18px;background:#242334;border-radius:12px;color:#d8cfff;line-height:1.8">{esc(detail)}</p>'
for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])):
peak = max(1, *(row["minutes"] for row in rows))
content += f'<h2 style="font-size:18px;color:#e5e1e4">{heading}</h2><table role="presentation" width="100%" cellspacing="0" cellpadding="0">'
for row in rows:
width = round(row["minutes"] / peak * 100)
content += f'<tr><td style="padding:8px 0;color:#bdb6c3;font-size:12px;width:100px">{esc(row["name"])}</td><td style="padding:8px"><table role="presentation" width="{width}%" cellspacing="0" cellpadding="0"><tr><td height="8" style="background:{"#8cdbdd" if width else "transparent"};border-radius:4px;font-size:0">&nbsp;</td></tr></table></td><td style="width:65px;color:#e0d8ff;font-size:12px;text-align:right">{number(row["minutes"])} min</td></tr>'
lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
content += '</table>'
top = report.get("top_titles", [])[:3]
if top:
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
for item in top:
artwork = item.get("email_artwork", "")
if artwork.startswith(("cid:", "data:image/")):
content += f'<img src="{esc(artwork, quote=True)}" alt="{esc(item["title"], quote=True)}" width="80" style="display:block;border-radius:10px;margin-top:20px" />'
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
else:
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
@@ -130,8 +146,8 @@ def send_email(recipient: str, rendered: dict, message_id: str, before_data=lamb
html_part = message.get_payload()[-1]
for attachment in rendered.get('inline_images', []):
html_part.add_related(
attachment['data'], maintype='image', subtype='jpeg', cid=f"<{attachment['cid']}>",
filename=attachment['cid'].split('@')[0] + '.jpg', disposition='inline')
attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
payload = message.as_bytes()
smtp, stage = None, "connect"
try:
+4
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from .. import db
from .monthly_reports import shift_month
from . import email_queue
from .public_urls import magent_public_url
def init_schema(conn: sqlite3.Connection) -> None:
@@ -73,6 +74,7 @@ def read_one(sql: str, args=()) -> dict | None:
def settings() -> dict:
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
row["public_url"] = magent_public_url(row["public_url"])
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
@@ -82,6 +84,7 @@ def next_due(now: datetime, day: int, hour: int) -> datetime:
def save_settings(values: dict, now: datetime) -> dict:
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn:
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
@@ -174,6 +177,7 @@ def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: f
def enqueue_due(now: datetime) -> int:
with transaction() as conn:
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
config["public_url"] = magent_public_url(config["public_url"])
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
return 0
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
+69
View File
@@ -58,3 +58,72 @@ async def original_profile(client, default_id):
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
return result["id"]
async def apply_original_to_movie(client, tmdb_id):
movies = await client.get_movie_by_tmdb_id(tmdb_id)
if not isinstance(movies, list):
raise HTTPException(502, "Radarr did not return the movie list.")
matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
if not matches:
return None
if len(matches) != 1:
raise HTTPException(409, "Radarr returned multiple movies for this identity.")
movie = matches[0]
profile_id = await original_profile(client, movie['qualityProfileId'])
if movie['qualityProfileId'] != profile_id:
movie['qualityProfileId'] = profile_id
await client.update_movie(movie)
verified = await client.get_movie(movie['id'])
if not verified or verified.get('qualityProfileId') != profile_id:
raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
return profile_id
async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
command_id = command.get('id') if isinstance(command, dict) else None
if not isinstance(command_id, int):
return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
for attempt in range(attempts):
state = await client.get(f'/api/v3/command/{command_id}')
status = str((state or {}).get('status', '')).lower()
queue = await client.get_queue(movie_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('movieId') == movie_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
if matching:
return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
if status in {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
if status == 'completed':
movie = await client.get_movie(movie_id)
if (movie or {}).get('hasFile'):
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
# Command completion precedes download-client queue refresh. Keep polling.
pass
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
if not ids:
return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
for attempt in range(attempts):
states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
queue = await client.get_queue(series_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('seriesId') == series_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
if matching:
return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
statuses = {str((state or {}).get('status', '')).lower() for state in states}
if statuses & {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
# Even completed commands can precede Sonarr's download queue refresh.
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
+41
View File
@@ -0,0 +1,41 @@
"""State-changing requests may originate only from explicitly configured sites.
The public Hosting & proxy URL can be stored in the database, while the CORS
environment setting still has its localhost default on an upgraded install.
Never infer a trusted origin from request Host or forwarded headers.
"""
from urllib.parse import urlsplit
from ..config import settings
from .public_urls import magent_public_url, valid_public_url
def _origin(value: str, *, configured_url: bool = False) -> tuple[str, str, int] | None:
value = str(value or "")
if any(character.isspace() or ord(character) < 33 or ord(character) == 127 for character in value):
return None
if "?" in value or "#" in value:
return None
validated = valid_public_url(value)
if not validated:
return None
parsed = urlsplit(value)
if parsed.username is not None or parsed.password is not None:
return None
if not configured_url and parsed.path:
return None
return (
parsed.scheme.lower(),
parsed.hostname.lower(),
parsed.port or (443 if parsed.scheme == "https" else 80),
)
def is_allowed_request_origin(origin: str) -> bool:
candidate = _origin(origin)
if candidate is None:
return False
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
return True
return candidate == _origin(magent_public_url(), configured_url=True)
+55 -24
View File
@@ -32,6 +32,7 @@ from ..models import ActionOption, NormalizedState, RequestType, Snapshot, Timel
from .collector_search import read_search_status
from .media_repair import current_cycle_torrents, evaluate_media_repair
from .download_labels import label_episode_downloads
from .arr import RootFolderNotFoundError, resolve_root_folder_path
logger = logging.getLogger(__name__)
@@ -137,12 +138,10 @@ def jellyfin_item_matches_request(
request_provider_ids = extract_request_provider_ids(request_payload or {})
item_provider_ids = extract_request_provider_ids(item)
provider_priority = ("tmdb", "tvdb", "imdb")
for key in provider_priority:
request_id = request_provider_ids.get(key)
item_id = item_provider_ids.get(key)
if request_id and item_id and request_id == item_id:
return True
shared = set(request_provider_ids) & set(item_provider_ids)
if shared:
# Conflicting metadata must never fall through to title matching.
return all(request_provider_ids[key] == item_provider_ids[key] for key in shared)
request_title = _normalize_media_title(title)
if not request_title:
@@ -169,11 +168,6 @@ def jellyfin_item_matches_request(
if request_title in item_titles:
return True
if request_type == RequestType.tv:
for candidate in item_titles:
if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
return True
return False
@@ -376,6 +370,38 @@ def _episode_availability(episodes: Any) -> Dict[str, Any]:
}
def _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]:
"""Describe regular Sonarr seasons that can be added to an existing request."""
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
return []
episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else []
options: List[Dict[str, int]] = []
for season in series["seasons"]:
if not isinstance(season, dict) or season.get("monitored") is not False:
continue
season_number = season.get("seasonNumber")
if not isinstance(season_number, int) or season_number <= 0:
continue
matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number]
statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {}
episode_count = statistics.get("totalEpisodeCount")
if not isinstance(episode_count, int):
episode_count = statistics.get("episodeCount")
if not isinstance(episode_count, int):
episode_count = len(matching)
available = statistics.get("episodeFileCount")
if not isinstance(available, int):
available = sum(1 for episode in matching if episode.get("hasFile") is True)
options.append(
{
"seasonNumber": season_number,
"episodeCount": max(0, episode_count),
"available": max(0, available),
}
)
return sorted(options, key=lambda item: item["seasonNumber"])
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
if not torrents:
return {"state": "idle", "message": "0 active downloads."}
@@ -946,6 +972,7 @@ def _build_presentation(
"missing": missing,
"total": total,
"seasons": availability.get("seasons") or [],
"unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [],
"missingEpisodes": arr_details.get("missingEpisodes") or {},
},
{
@@ -1208,11 +1235,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_item = None
arr_queue = None
episodes = None
media_status = jelly_request.get("media", {}).get("status")
try:
media_status_code = int(media_status) if media_status is not None else None
except (TypeError, ValueError):
media_status_code = None
if snapshot.request_type == RequestType.tv:
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id:
@@ -1247,6 +1269,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
}
arr_details["availability"] = _episode_availability(episodes)
arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, episodes)
counts = arr_details["availability"]
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
missing_by_season = _missing_episode_numbers_by_season(episodes)
@@ -1363,11 +1386,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if radarr_client.configured():
root_folder = await _resolve_root_folder_path(
radarr_client, runtime.radarr_root_folder, "Radarr"
)
try:
root_folder = await resolve_root_folder_path(
radarr_client, runtime.radarr_root_folder, "Radarr"
)
except RootFolderNotFoundError as exc:
logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc)
root_folder = ""
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
if tmdb_id and root_folder:
try:
await radarr_client.add_movie(
int(tmdb_id),
@@ -1382,11 +1409,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if sonarr_client.configured():
root_folder = await _resolve_root_folder_path(
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
)
try:
root_folder = await resolve_root_folder_path(
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
)
except RootFolderNotFoundError as exc:
logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc)
root_folder = ""
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id:
if tvdb_id and root_folder:
try:
await sonarr_client.add_series(
int(tvdb_id),
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
coverage==7.16.1
pip-audit==2.10.1
ruff==0.16.8
+2
View File
@@ -5,6 +5,8 @@ pydantic==2.12.5
pydantic-settings==2.14.2
PyJWT==2.13.0
passlib==1.7.4
argon2-cffi==25.1.0
cryptography==50.0.1
python-multipart==0.0.31
Pillow==12.3.0
prometheus-client==0.22.1
+24
View File
@@ -0,0 +1,24 @@
import unittest
from pydantic import ValidationError
from backend.app.api_models import PasswordResetRequest, SignupRequest
class ApiRequestModelTests(unittest.TestCase):
def test_signup_rejects_unknown_fields(self) -> None:
with self.assertRaises(ValidationError):
SignupRequest(
invite_code="invite",
username="viewer",
password="strong password",
unexpected="value",
)
def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
self.assertEqual(request.new_password, " leading and trailing ")
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
class _ArrClient:
async def get_root_folders(self):
return [{"id": 7, "path": "/media/tv"}]
class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
async def test_resolves_numeric_root_folder_id(self) -> None:
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
async def test_preserves_configured_path(self) -> None:
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
async def test_rejects_missing_root_folder_id(self) -> None:
with self.assertRaises(RootFolderNotFoundError):
await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
if __name__ == "__main__":
unittest.main()
+435 -36
View File
@@ -2,27 +2,29 @@ import os
from types import SimpleNamespace
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, call, patch
import httpx
from fastapi import HTTPException
from passlib.context import CryptContext
from starlette.requests import Request
from backend.app import db
from backend.app.clients.base import _operation_error_message, _operation_result_message
from backend.app.clients.jellyfin import _availability_message
from backend.app.clients.qbittorrent import _torrent_result_message
from backend.app.auth import require_admin
from backend.app.auth import _load_current_user_from_token, require_admin
from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
from backend.app.routers import auth as auth_router
from backend.app.routers import admin as admin_router
from backend.app.routers import branding as branding_router
from backend.app.routers import portal as portal_router
from backend.app.routers import requests as requests_router
from backend.app.routers import site as site_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.security import PASSWORD_POLICY_MESSAGE, create_access_token, validate_password_policy
from backend.app.services import password_reset
from backend.app.services import issue_resolution
from backend.app.services.operation_progress import (
@@ -39,6 +41,7 @@ from backend.app.services.snapshot import (
_build_repair_activity,
_episode_availability,
_torrent_progress,
_unmonitored_season_options,
)
@@ -70,21 +73,16 @@ class TempDatabaseMixin:
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self._original_sqlite_path = settings.sqlite_path
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
self._original_settings_encryption_key = settings.settings_encryption_key
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
settings.sqlite_journal_mode = "DELETE"
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
db.init_db()
def tearDown(self) -> None:
settings.sqlite_path = self._original_sqlite_path
settings.sqlite_journal_mode = self._original_journal_mode
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
settings.settings_encryption_key = self._original_settings_encryption_key
self._tempdir.cleanup()
super_method = getattr(super(), "tearDown", None)
if callable(super_method):
@@ -97,7 +95,204 @@ class PasswordPolicyTests(unittest.TestCase):
validate_password_policy("short")
def test_validate_password_policy_trims_whitespace(self) -> None:
self.assertEqual(validate_password_policy(" password123 "), "password123")
self.assertEqual(validate_password_policy(" password1234 "), "password1234")
class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
self._jwt_secret = patch.object(
settings, "jwt_secret", "security-hardening-tests-secret-123456789"
)
self._jwt_secret.start()
self.addCleanup(self._jwt_secret.stop)
def test_sensitive_settings_are_encrypted_at_rest(self) -> None:
db.set_setting("jellyfin_api_key", "private-api-key")
with db._connect() as conn:
stored = conn.execute(
"SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",)
).fetchone()[0]
self.assertTrue(stored.startswith("enc:v1:"))
self.assertNotIn("private-api-key", stored)
self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key")
def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None:
created = db.create_signup_invite(code="TopSecretInvite42")
invite_id = int(created["id"])
with db._connect() as conn:
stored = conn.execute(
"SELECT code FROM signup_invites WHERE id = ?", (invite_id,)
).fetchone()[0]
self.assertTrue(stored.startswith("sha256:"))
self.assertNotIn("TOPSECRETINVITE42", stored.upper())
self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"])
self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42"))
rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99")
self.assertTrue(rotated["code_available"])
self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42"))
self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99"))
def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None:
created = db.create_signup_invite(code="TemporaryInvite77")
with db._connect() as conn:
conn.execute(
"UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?",
("Legacy-Code-77", int(created["id"])),
)
conn.execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"),
)
db.init_db()
migrated = db.get_signup_invite_by_code("Legacy-Code-77")
self.assertEqual(migrated["id"], created["id"])
self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key")
with db._connect() as conn:
invite_code = conn.execute(
"SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),)
).fetchone()[0]
stored_setting = conn.execute(
"SELECT value FROM settings WHERE key = 'radarr_api_key'"
).fetchone()[0]
self.assertTrue(invite_code.startswith("sha256:"))
self.assertTrue(stored_setting.startswith("enc:v1:"))
def test_legacy_password_hash_is_replaced_with_argon2(self) -> None:
password = "Example-password123!"
db.create_user("legacy", password)
legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password)
with db._connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE username = ?",
(legacy_hash, "legacy"),
)
self.assertIsNotNone(db.verify_user_password("legacy", password))
self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2"))
def test_auth_version_revokes_existing_token(self) -> None:
db.create_user("viewer", "Example-password123!")
user = db.get_user_by_username("viewer")
token = create_access_token(
"viewer", "user", auth_version=int(user["auth_version"])
)
self.assertEqual(_load_current_user_from_token(token)["username"], "viewer")
db.increment_user_auth_version("viewer")
with self.assertRaises(HTTPException) as context:
_load_current_user_from_token(token)
self.assertEqual(context.exception.status_code, 401)
async def test_request_mutations_require_owner_or_admin(self) -> None:
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret"
)
client = SimpleNamespace(
configured=lambda: True,
get_request=AsyncMock(
return_value={"id": 42, "requestedBy": {"username": "owner"}}
),
)
with patch.object(requests_router, "JellyseerrClient", return_value=client):
with self.assertRaises(HTTPException) as context:
await requests_router._ensure_request_mutation_access(
runtime, 42, {"username": "someone-else", "role": "user"}
)
self.assertEqual(context.exception.status_code, 403)
owned = await requests_router._ensure_request_mutation_access(
runtime, 42, {"username": "owner", "role": "user"}
)
self.assertEqual(owned["id"], 42)
self.assertIsNone(
await requests_router._ensure_request_mutation_access(
SimpleNamespace(), 42, {"username": "admin", "role": "admin"}
)
)
def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None:
db.create_user(
"viewer", "Example-password123!", email="viewer@example.test"
)
user = db.get_user_by_username("viewer")
now = "2026-09-17T00:00:00+00:00"
db.upsert_request_cache(
42,
99,
"movie",
2,
"Example",
2026,
"viewer",
"viewer",
int(user["id"]),
now,
now,
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
)
with db._connect() as conn:
conn.execute(
"INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)",
(
"42",
"available",
now,
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
),
)
db.save_action("42", "created", "Created", "ok", "Created by viewer")
item = db.create_portal_item(
kind="issue",
title="Example",
description="Example",
created_by_username="viewer",
created_by_id=int(user["id"]),
)
result = db.delete_user_data_by_username("viewer")
self.assertTrue(result["deleted"])
self.assertIsNone(db.get_user_by_username("viewer"))
with db._connect() as conn:
request_row = conn.execute(
"SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42"
).fetchone()
snapshot_json = conn.execute(
"SELECT payload_json FROM snapshots WHERE request_id = '42'"
).fetchone()[0]
action_message = conn.execute(
"SELECT message FROM actions WHERE request_id = '42'"
).fetchone()[0]
portal_owner = conn.execute(
"SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?",
(item["id"],),
).fetchone()
self.assertEqual(request_row[0], "Deleted user")
self.assertIsNone(request_row[1])
self.assertNotIn("viewer", request_row[2].lower())
self.assertNotIn("viewer", snapshot_json.lower())
self.assertNotIn("viewer", action_message.lower())
self.assertTrue(portal_owner[0].startswith("deleted-user-"))
self.assertIsNone(portal_owner[1])
async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None:
upload = SimpleNamespace(
filename="logo.png",
content_type="image/png",
read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)),
)
with self.assertRaises(HTTPException) as context:
await branding_router.save_branding_image(upload)
self.assertEqual(context.exception.status_code, 413)
upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1)
class NetworkSecurityTests(unittest.TestCase):
@@ -298,6 +493,9 @@ class SiteInfoTests(unittest.TestCase):
site_banner_enabled=False,
site_banner_message="",
site_banner_tone="info",
site_banner_background_color=None,
site_banner_border_color=None,
site_login_message="",
site_login_show_jellyfin_login=True,
site_login_show_local_login=True,
site_login_show_forgot_password=True,
@@ -310,6 +508,49 @@ class SiteInfoTests(unittest.TestCase):
self.assertEqual(info["navigation"], {"showRequests": False})
def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
runtime = settings.model_copy(update={
"site_banner_enabled": True,
"site_banner_message": "Planned maintenance",
"site_banner_tone": "warning",
"site_banner_background_color": "#123ABC",
"site_banner_border_color": "red",
"site_login_message": "Use your Grizzlyflix account to sign in.",
})
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
info = site_router._build_site_info(False)
self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
self.assertIsNone(info["banner"]["borderColor"])
self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
async def test_banner_colours_are_normalized_before_saving(self) -> None:
with patch.object(admin_router, "set_setting") as save:
result = await admin_router.update_settings({
"site_banner_background_color": "#A1B2C3",
"site_banner_border_color": "#010203",
})
self.assertEqual(result, {"status": "ok", "updated": 2})
self.assertEqual(
save.call_args_list,
[
call("site_banner_background_color", "#a1b2c3"),
call("site_banner_border_color", "#010203"),
],
)
async def test_banner_colours_reject_unsafe_css_values(self) -> None:
with self.assertRaises(HTTPException) as raised:
await admin_router.update_settings({
"site_banner_border_color": "red; background: url(example)",
})
self.assertEqual(raised.exception.status_code, 400)
class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None:
@@ -521,6 +762,34 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(availability["missing"], 1)
self.assertEqual(availability["total"], 2)
def test_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None:
series = {
"seasons": [
{"seasonNumber": 0, "monitored": False},
{"seasonNumber": 7, "monitored": True},
{
"seasonNumber": 8,
"monitored": False,
"statistics": {"episodeCount": 16, "episodeFileCount": 2},
},
{"seasonNumber": 9, "monitored": False},
]
}
episodes = [
{"seasonNumber": 9, "episodeNumber": 1, "hasFile": True},
{"seasonNumber": 9, "episodeNumber": 2, "hasFile": False},
]
options = _unmonitored_season_options(series, episodes)
self.assertEqual(
options,
[
{"seasonNumber": 8, "episodeCount": 16, "available": 2},
{"seasonNumber": 9, "episodeCount": 2, "available": 1},
],
)
def test_presentation_hides_download_without_download_evidence(self) -> None:
snapshot = Snapshot(
request_id="3909",
@@ -1128,6 +1397,25 @@ class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from backend.app.config import settings
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
secret.start()
self.addCleanup(secret.stop)
access = patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
def selection(self, payload, request_id, source):
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
{**payload, 'requiresOverride': False, 'rejections': []}, request_id,
{'username': 'viewer'}, source, None)
return payload
@staticmethod
def _runtime() -> SimpleNamespace:
return SimpleNamespace(
@@ -1155,7 +1443,7 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
]
),
search_releases=AsyncMock(
search_episode_releases=AsyncMock(
side_effect=[
[
{
@@ -1194,15 +1482,17 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
"3909", user={"username": "viewer", "role": "user"}
)
sonarr.search_releases.assert_any_await(42, 1)
sonarr.search_releases.assert_any_await(42, 2)
sonarr.search_episode_releases.assert_any_await(101)
sonarr.search_episode_releases.assert_any_await(201)
self.assertEqual(result["collector"], "Sonarr")
self.assertEqual(len(result["releases"]), 1)
self.assertEqual(len(result["releases"]), 2)
self.assertTrue(result["releases"][0]["fullSeason"])
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
self.assertTrue(result["releases"][0]["bestPick"])
self.assertTrue(result["qualityFiltered"])
self.assertFalse(result["qualityFiltered"])
self.assertNotIn("selectionToken", result["releases"][1])
self.assertTrue(result["releases"][1]["requiresOverride"])
async def test_movie_manual_search_uses_radarr(self) -> None:
snapshot = Snapshot(
@@ -1292,14 +1582,14 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
requests_router, "save_action"
):
result = await requests_router.action_grab(
"3909", payload, user={"username": "viewer", "role": "user"}
"3909", self.selection(payload, "3909", self._runtime().sonarr_base_url), user={"username": "viewer", "role": "user"}
)
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
sonarr.push_release.assert_not_awaited()
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
async def test_stale_movie_release_still_routes_through_radarr_push(self) -> None:
async def test_stale_movie_release_requires_fresh_search(self) -> None:
snapshot = Snapshot(
request_id="4000",
title="Example Movie",
@@ -1335,15 +1625,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_grab(
"4000", payload, user={"username": "viewer", "role": "user"}
)
with self.assertRaises(HTTPException) as error:
await requests_router.action_grab(
"4000", self.selection(payload, "4000", self._runtime().radarr_base_url), user={"username": "viewer", "role": "user"})
self.assertEqual(error.exception.status_code, 409)
radarr.push_release.assert_not_awaited()
radarr.push_release.assert_awaited_once()
pushed = radarr.push_release.await_args.args[0]
self.assertEqual(pushed["downloadUrl"], "http://prowlarr.test/download/1")
self.assertEqual(pushed["protocol"], "torrent")
self.assertEqual(result["response"], {"collector": "Radarr", "queued": True})
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
@@ -1542,6 +1829,16 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
access = patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
issue = {"id": 12, "status": "in_progress"}
with (
@@ -1835,6 +2132,103 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
sonarr.search_episodes.assert_awaited_once_with([36899])
sonarr.search.assert_not_awaited()
async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None:
snapshot = Snapshot(
request_id="3580",
title="Suits",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 540}}},
)
refreshed = Snapshot(
request_id="3580",
title="Suits",
request_type=RequestType.tv,
state=NormalizedState.importing,
presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]},
)
original_series = {
"id": 540,
"monitored": True,
"qualityProfileId": 7,
"seasons": [
{"seasonNumber": 7, "monitored": True},
{"seasonNumber": 8, "monitored": False},
{"seasonNumber": 9, "monitored": False},
],
}
updated_series = {
**original_series,
"seasons": [
{"seasonNumber": 7, "monitored": True},
{"seasonNumber": 8, "monitored": True},
{"seasonNumber": 9, "monitored": True},
],
}
episodes = [
{
"id": 801,
"seasonNumber": 8,
"episodeNumber": 1,
"monitored": False,
"hasFile": False,
"airDateUtc": "2018-07-18T00:00:00Z",
},
{
"id": 802,
"seasonNumber": 8,
"episodeNumber": 2,
"monitored": False,
"hasFile": True,
"episodeFileId": 88,
},
{
"id": 901,
"seasonNumber": 9,
"episodeNumber": 1,
"monitored": False,
"hasFile": False,
"airDateUtc": "2019-07-17T00:00:00Z",
},
]
verified_episodes = [{**episode, "monitored": True} for episode in episodes]
sonarr = SimpleNamespace(
configured=lambda: True,
get_series=AsyncMock(side_effect=[original_series, updated_series]),
update_series=AsyncMock(return_value=updated_series),
get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]),
monitor_episodes=AsyncMock(return_value={"monitored": True}),
search_episodes=AsyncMock(return_value={"id": 9001}),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(
requests_router,
"build_snapshot",
new=AsyncMock(side_effect=[snapshot, refreshed]),
),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_add_seasons(
"3580",
{"season_numbers": [8, 9]},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["season_numbers"], [8, 9])
self.assertEqual(result["searched_episode_count"], 2)
self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"])
sonarr.update_series.assert_awaited_once_with(updated_series)
sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True)
sonarr.search_episodes.assert_awaited_once_with([801, 901])
async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
snapshot = Snapshot(
request_id="3914",
@@ -1889,6 +2283,11 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "BazarrClient", return_value=bazarr),
patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
),
patch.object(requests_router, "save_action"),
patch.object(requests_router, "get_portal_item", return_value={
"id": 12,
@@ -2001,28 +2400,28 @@ class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTes
async def test_invite_list_reports_automatic_operational_states(self) -> None:
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
db.increment_signup_invite_use(int(used["id"]))
db.create_signup_invite(
expired = db.create_signup_invite(
code="EXPIRED",
expires_at="2000-01-01T00:00:00+00:00",
recipient_email="expired@example.com",
)
db.create_signup_invite(
no_profile = db.create_signup_invite(
code="NO-PROFILE",
profile_id=999,
recipient_email="profile@example.com",
)
payload = await admin_router.get_invites()
states = {invite["code"]: invite["operational_state"] for invite in payload["invites"]}
states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]}
self.assertEqual(states[ready["code"]], "ready")
self.assertEqual(states["DISABLED"], "disabled")
self.assertEqual(states["USED"], "exhausted")
self.assertEqual(states["EXPIRED"], "expired")
self.assertEqual(states["NO-PROFILE"], "profile_unavailable")
self.assertEqual(states[ready["id"]], "ready")
self.assertEqual(states[disabled["id"]], "disabled")
self.assertEqual(states[used["id"]], "exhausted")
self.assertEqual(states[expired["id"]], "expired")
self.assertEqual(states[no_profile["id"]], "profile_unavailable")
self.assertEqual(payload["summary"]["total"], 5)
self.assertEqual(payload["summary"]["ready"], 1)
self.assertEqual(payload["summary"]["attention"], 4)
+14
View File
@@ -99,6 +99,7 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
with db._connect() as conn:
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
@@ -166,3 +167,16 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
with TestClient(app) as client:
for path in ('check', 'confirm'):
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
with db._connect() as conn:
conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
preview = await duplicates.repair_duplicates(self.keep)
self.assertTrue(preview['can_confirm'], preview['issues'])
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
self.assertIsNone(db.get_user_by_id(self.extra))
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
+28 -2
View File
@@ -1,5 +1,5 @@
import unittest
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from backend.app.config import settings
from fastapi import FastAPI
from fastapi.testclient import TestClient
@@ -16,6 +16,13 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
secret.start()
self.addCleanup(secret.stop)
access = patch.object(
requests,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
db.create_user('feature-viewer', 'Example-password123!', role='user')
db.create_user('feature-admin', 'Example-password123!', role='admin')
self.user = db.get_user_by_username('feature-viewer')
@@ -26,7 +33,7 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
def test_defaults_persist_and_invites_share_existing_setting(self):
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
update_permissions({'stats': False, 'invites': True}, self.user['username'])
db.init_db()
fresh = db.get_user_by_username(self.user['username'])
@@ -118,3 +125,22 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
def test_manual_override_permission_is_checked_again_at_download(self):
from types import SimpleNamespace
from unittest.mock import AsyncMock
from backend.app.models import Snapshot, RequestType
from backend.app.services import manual_releases
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
collector.grab_release.assert_not_awaited()
update_permissions({'ignore_profile_limits':True},self.user['username'])
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
update_permissions({'ignore_profile_limits':False},self.user['username'])
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
collector.grab_release.assert_awaited_once()
+48
View File
@@ -0,0 +1,48 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from backend.app import db
from backend.app.services import jellyfin_sync
from backend.app.services.jellyfin_identity import link_user, user_for_identity
from backend.app.routers import admin
from backend.tests.test_backend_quality import TempDatabaseMixin
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
auto_search_enabled=False, email='kept@example.test')
original = db.get_user_by_username('old@example.test')
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
kept = user_for_identity('a' * 32, 'http://jf')
self.assertEqual(kept['id'], original['id'])
self.assertFalse(kept['auto_search_enabled'])
self.assertEqual(kept['email'], 'kept@example.test')
self.assertIsNone(db.get_user_by_username('NewName'))
self.assertEqual(kept['auth_provider'], 'jellyfin')
async def test_resync_no_longer_deletes_accounts(self):
db.create_user('Keep', 'Password-123456!')
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
patch.object(admin, 'delete_non_admin_users') as delete:
result = await admin.jellyseerr_users_resync()
self.assertEqual(result['cleared'], 0)
delete.assert_not_called()
self.assertIsNotNone(db.get_user_by_username('Keep'))
def test_jellyfin_lookup_is_scoped_to_server(self):
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
link_user('Viewer', 'a' * 32, 'http://jf')
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
+30
View File
@@ -0,0 +1,30 @@
import unittest
from unittest.mock import patch
import httpx
from backend.app.clients.jellyfin import JellyfinClient
from backend.app.services.snapshot import jellyfin_item_matches_request
from backend.app.models import RequestType
class JellyfinMatchingTests(unittest.IsolatedAsyncioTestCase):
async def test_search_includes_punctuation_variant_and_provider_metadata(self):
calls=[]
def handle(request):
calls.append(request)
items=[{'Id':'animated','Name':'Avatar: The Last Airbender','ProductionYear':2005,'ProviderIds':{'Tmdb':'246'}}] if ':' in request.url.params['SearchTerm'] else [{'Id':'live','Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'82452'}}]
return httpx.Response(200,json={'Items':items})
original=httpx.AsyncClient
with patch('backend.app.clients.jellyfin.httpx.AsyncClient',side_effect=lambda **kw:original(transport=httpx.MockTransport(handle),**kw)):
result=await JellyfinClient('http://jellyfin','test').search_items('Avatar: The Last Airbender',['Series'])
self.assertEqual({i['Id'] for i in result['Items']},{'live','animated'})
self.assertTrue(all('ProviderIds' in r.url.params['Fields'] for r in calls))
matches=[i for i in result['Items'] if jellyfin_item_matches_request(i,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload={'tmdbId':82452})]
self.assertEqual([i['Id'] for i in matches],['live'])
def test_fallback_rejects_remakes_prefixes_and_conflicting_ids(self):
def match(item,payload=None):
return jellyfin_item_matches_request(item,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload=payload)
self.assertTrue(match({'Name':'Avatar the Last Airbender','ProductionYear':2024}))
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2005}))
self.assertFalse(match({'Name':'Avatar','ProductionYear':2024}))
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'246'}},{'tmdbId':82452}))
self.assertTrue(match({'Name':'Localized title','ProviderIds':{'Tmdb':'82452'}},{'tmdbId':82452}))
+25
View File
@@ -0,0 +1,25 @@
import json
import logging
import unittest
from backend.app.logging_config import JsonLogFormatter, RequestContextFilter, bind_request_id, reset_request_id
class JsonLoggingTests(unittest.TestCase):
def test_json_formatter_includes_request_context(self) -> None:
token = bind_request_id("request-123")
try:
record = logging.LogRecord("magent.test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
RequestContextFilter().filter(record)
payload = json.loads(JsonLogFormatter().format(record))
finally:
reset_request_id(token)
self.assertEqual(payload["level"], "INFO")
self.assertEqual(payload["logger"], "magent.test")
self.assertEqual(payload["request_id"], "request-123")
self.assertEqual(payload["message"], "hello world")
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from backend.app.config import settings
from backend.app.services import manual_releases as manual
from backend.app.routers import requests
from backend.app.models import Snapshot, RequestType
from backend.app.feature_access import permissions, update_permissions
from backend.app import db
from backend.tests.test_backend_quality import TempDatabaseMixin
class ManualSelectionTests(unittest.TestCase):
def setUp(self):
secret = patch.object(settings, 'jwt_secret', 'manual-selection-test-secret-123456789')
secret.start(); self.addCleanup(secret.stop)
self.user = {'username': 'viewer', 'role': 'user', 'features': {'ignore_profile_limits': True}}
self.release = {'guid': 'release', 'indexerId': 7, 'title': 'Example', 'requiresOverride': True,
'rejections': ['WEBDL-2160p is not wanted in profile']}
self.payload = {**self.release, 'ignoreProfileLimits': True,
'selectionToken': manual.issue_selection(self.release, '42', self.user, 'http://sonarr', 55)}
def test_profile_only_rejections_are_overridable(self):
for reason in ['WEBDL-2160p is not wanted in profile', 'Custom format score below minimum', 'File is larger than maximum size', 'Language is not wanted']:
self.assertTrue(manual.decision({'approved': False, 'rejections': [reason]})[1])
for reason in ['Unknown series', 'Release is blocklisted', 'No download client available', 'Already in queue']:
self.assertFalse(manual.decision({'rejections': [self.release['rejections'][0], reason]})[1])
self.assertFalse(manual.decision({'approved': True, 'downloadAllowed': False})[0])
def test_receipt_binds_request_user_source_item_and_release(self):
self.assertTrue(manual.verify_selection(self.payload, '42', self.user, 'http://sonarr', 55)['override'])
attempts = [({**self.payload, 'guid': 'other'}, '42', self.user, 'http://sonarr', 55),
(self.payload, '43', self.user, 'http://sonarr', 55),
(self.payload, '42', {**self.user, 'username': 'other'}, 'http://sonarr', 55),
(self.payload, '42', self.user, 'http://other', 55),
(self.payload, '42', self.user, 'http://sonarr', 56),
({**self.payload, 'selectionToken': 'forged'}, '42', self.user, 'http://sonarr', 55)]
for args in attempts:
with self.assertRaises(HTTPException): manual.verify_selection(*args)
def test_permission_revocation_and_literal_confirmation_enforced(self):
for payload, user, code in [(self.payload, {**self.user, 'features': {}}, 403),
({**self.payload, 'ignoreProfileLimits': 'true'}, self.user, 400)]:
with self.assertRaises(HTTPException) as error:
manual.verify_selection(payload, '42', user, 'http://sonarr', 55)
self.assertEqual(error.exception.status_code, code)
class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
def test_default_off_individual_and_bulk(self):
for name in ('one', 'two'): db.create_user(name, 'Password123!', role='user')
one, two = [db.get_user_by_username(n) for n in ('one', 'two')]
self.assertFalse(permissions(one)['ignore_profile_limits'])
update_permissions({'ignore_profile_limits': True}, 'one')
self.assertTrue(permissions(one)['ignore_profile_limits'])
self.assertFalse(permissions(two)['ignore_profile_limits'])
update_permissions({'ignore_profile_limits': False})
self.assertFalse(permissions(one)['ignore_profile_limits'])
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
access = patch.object(
requests,
'_ensure_request_mutation_access',
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
active = peak = 0
async def search(identity):
nonlocal active, peak
active += 1; peak = max(peak, active)
await asyncio.sleep(0.001); active -= 1
return []
sonarr = SimpleNamespace(configured=lambda: True, get_episodes=AsyncMock(return_value=episodes), search_episode_releases=AsyncMock(side_effect=search))
runtime = SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
first = await requests.action_search('42', {'username':'viewer','role':'user'})
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=24)
self.assertEqual(first['nextOffset'],3); self.assertIsNone(second['nextOffset'])
self.assertEqual(sonarr.search_episode_releases.await_count,4)
self.assertLessEqual(peak,3)
self.assertEqual(first['totalMissingEpisodes'],25)
async def test_auto_search_preserves_current_profile(self):
for kind, service in [(RequestType.tv,'SonarrClient'),(RequestType.movie,'RadarrClient')]:
client=SimpleNamespace(configured=lambda:True, update_series=AsyncMock(), update_movie=AsyncMock(),
get_episodes=AsyncMock(return_value=[{'id':1,'seasonNumber':1,'monitored':True,'hasFile':False}]),
search_episodes=AsyncMock(return_value={'id':1}), search=AsyncMock(return_value={'id':1}))
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test',radarr_base_url='http://radarr',radarr_api_key='test',sonarr_quality_profile_id=6,radarr_quality_profile_id=6)
snapshot=Snapshot(request_id='42',request_type=kind,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,service,return_value=client),patch.object(requests,'save_action'),patch.object(requests,'series_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})),patch.object(requests,'movie_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})):
await requests.action_search_auto('42',{'username':'admin','role':'admin'})
client.update_series.assert_not_awaited(); client.update_movie.assert_not_awaited()
+57
View File
@@ -0,0 +1,57 @@
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from backend.app.services import public_urls, newsletter_store, recap_store, newsletters, newsletter_catalog
from backend.tests.test_newsletters import NewsletterFixture
class PublicUrlTests(unittest.TestCase):
def resolve(self, application=None, proxy=None, enabled=False, legacy='https://legacy.test'):
with patch.object(public_urls,'get_runtime_settings',return_value=SimpleNamespace(
magent_application_url=application,magent_proxy_base_url=proxy,magent_proxy_enabled=enabled)):
return public_urls.magent_public_url(legacy)
def test_hosting_is_authoritative_with_proxy_and_path_support(self):
self.assertEqual(self.resolve('https://prod.test/'),'https://prod.test')
self.assertEqual(self.resolve('http://internal:3000','https://public.test/magent/',True),'https://public.test/magent')
self.assertEqual(self.resolve('https://prod.test','https://old-proxy.test',False),'https://prod.test')
self.assertEqual(self.resolve(),'https://legacy.test')
def test_invalid_configured_address_does_not_use_stale_legacy(self):
for value in ['javascript:alert(1)','https://user:password@host.test','https://host.test?key=secret','https://host.test/#fragment','https://host.test:99999','https://host.test/ bad']:
self.assertEqual(self.resolve(value),'')
class NewsletterHostingTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
async def test_existing_draft_previews_using_hosting_without_duplicate_url(self):
draft=self.draft()
with newsletter_store.transaction() as c:
c.execute("UPDATE newsletter_settings SET public_url=''")
self.runtime.magent_application_url='https://prod.example.test'
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime),patch.object(newsletter_catalog,'posters',new=AsyncMock(return_value={})):
rendered=await newsletters.preview(draft['id'],draft['revision'])
self.assertIn('https://prod.example.test/profile#newsletters',rendered['body_html'])
self.assertIn('https://watch.example.test',rendered['body_html'])
self.assertNotIn('https://beta.example.test',rendered['body_html'])
self.assertEqual(recap_store.settings()['public_url'],'https://prod.example.test')
def test_scheduled_delivery_uses_current_hosting_address(self):
self.subscribe(when=100)
draft=self.draft()
newsletter_store.publish(draft['id'],draft['revision'],200,150)
self.runtime.magent_application_url='https://prod.example.test'
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime):
newsletter_store.enqueue_due(201)
delivery=newsletter_store.read_one('SELECT * FROM newsletter_deliveries WHERE edition_id=?',(draft['id'],))
self.assertEqual(delivery['public_url'],'https://prod.example.test')
self.runtime.magent_application_url='https://new.example.test'
self.assertEqual(newsletter_store.settings()['public_url'],'https://new.example.test')
def test_saving_schedule_uses_hosting_instead_of_client_address(self):
self.runtime.magent_application_url='https://prod.example.test'
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime):
result=newsletter_store.save_settings({**self.config,'public_url':'https://stale.example.test'},datetime.now(timezone.utc))
self.assertEqual(result['public_url'],'https://prod.example.test')
result=recap_store.save_settings({'enabled':False,'day':2,'hour':9,'public_url':''},datetime.now(timezone.utc))
self.assertEqual(result['public_url'],'https://prod.example.test')
+65
View File
@@ -0,0 +1,65 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, AsyncMock, patch
from contextlib import ExitStack
from backend.app.routers import requests
class RecentStageTests(unittest.IsolatedAsyncioTestCase):
async def test_displayed_stage_controls_filter_and_pagination(self):
runtime = SimpleNamespace(jellyseerr_base_url='', jellyseerr_api_key='',
requests_data_source='prefer_cache', artwork_cache_mode='remote',
jellyfin_base_url='', jellyfin_api_key='')
rows = [dict(request_id=i, title=str(i), status=status, media_type='movie',
requested_by_id=10) for i, status in [(1,5),(2,5),(3,4),(4,6),(5,5),(6,2),(7,1),(8,3)]]
async def available(client, title, *args): return title in {'1','3','4','5'}
with ExitStack() as stack:
for name, value in [('get_runtime_settings', runtime), ('get_request_stage_cache', {1:{'ready':True},3:{'ready':True},4:{'ready':True},5:{'ready':True}}), ('_recent_cache_stale', False),
('active_repair_request_ids', {'5'}), ('get_request_cache_payload', None)]:
stack.enter_context(patch.object(requests, name, return_value=value))
stack.enter_context(patch.dict(requests._recent_cache, {'items':rows}))
stack.enter_context(patch.object(requests, '_request_is_available_in_jellyfin', new=AsyncMock(side_effect=AssertionError('Recent requests must not call Jellyfin'))))
user={'role':'user','username':'viewer','jellyseerr_user_id':10}
expected={'working':[2,5], 'ready':[1,3], 'partial':[4], 'approved':[6],
'pending':[7], 'declined':[8], 'in_progress':[2,4,5,6]}
for stage, ids in expected.items():
result=await requests.recent_requests(take=20,skip=0,days=0,stage=stage,user=user)
self.assertEqual([r['id'] for r in result['results']],ids,stage)
result=await requests.recent_requests(take=1,skip=1,days=0,stage='working',user=user)
self.assertEqual([r['id'] for r in result['results']],[5])
result=await requests.recent_requests(take=1,skip=0,days=0,stage='ready',user=user)
self.assertEqual(result['results'][0]['status'],4)
result=await requests.recent_requests(take=20,skip=0,days=0,stage='all',user={**user,'jellyseerr_user_id':99})
self.assertEqual(result['results'],[])
async def test_background_refresh_skips_fresh_rows_and_preserves_failures(self):
import time
rows=[{'request_id':i,'status':5,'title':str(i),'updated_at':'v1'} for i in [1,2,3]]
runtime=SimpleNamespace(jellyfin_base_url='http://jellyfin',jellyfin_api_key='test',requests_stage_refresh_minutes=15)
with ExitStack() as stack:
stack.enter_context(patch.object(requests,'get_runtime_settings',return_value=runtime))
stack.enter_context(patch.object(requests,'get_cached_requests_since',return_value=rows))
stack.enter_context(patch.object(requests,'get_request_stage_cache',return_value={1:{'source_updated':'v1','checked_at':time.time()}}))
stack.enter_context(patch.object(requests,'get_request_cache_payload',return_value={}))
check=stack.enter_context(patch.object(requests,'_request_is_available_in_jellyfin',new=AsyncMock(side_effect=[True,RuntimeError('offline')])) )
save=stack.enter_context(patch.object(requests,'save_request_stage_cache'))
await requests.refresh_local_request_stages()
self.assertEqual(check.await_count,2)
written=save.call_args.args[0]
self.assertEqual(len(written),1)
self.assertEqual(written[0][:3],(2,'v1',1))
from backend.tests.test_backend_quality import TempDatabaseMixin
from backend.app import db
class StagePersistenceTests(TempDatabaseMixin, unittest.TestCase):
def test_saved_stages_survive_initialization_and_actions_mark_due(self):
with db._connect() as conn:
conn.execute("INSERT INTO requests_cache (request_id, payload_json) VALUES (42, '{}')")
db.save_request_stage_cache([(42,'v1',1,12345)])
db.init_db()
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
db.save_action('42','search_releases','Search','ok')
self.assertEqual(db.get_request_stage_cache()[42]['checked_at'],0)
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
+27
View File
@@ -0,0 +1,27 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from backend.app.routers import requests
from backend.app.models import Snapshot, RequestType
class RecheckMonitoringTests(unittest.IsolatedAsyncioTestCase):
async def test_series_restores_only_requested_seasons_and_episodes(self):
original={'id':10,'monitored':False,'qualityProfileId':7,'seasons':[{'seasonNumber':1,'monitored':False},{'seasonNumber':2,'monitored':False}]}
restored={**original,'monitored':True,'seasons':[{'seasonNumber':1,'monitored':True},{'seasonNumber':2,'monitored':False}]}
client=SimpleNamespace(get_series=AsyncMock(side_effect=[original,restored]),update_series=AsyncMock(),get_episodes=AsyncMock(side_effect=[[{'id':1,'seasonNumber':1,'monitored':False},{'id':2,'seasonNumber':2,'monitored':False}],[{'id':1,'seasonNumber':1,'monitored':True},{'id':2,'seasonNumber':2,'monitored':False}]]),monitor_episodes=AsyncMock())
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.tv,raw={'arr':{'item':{'id':10}}})
runtime=SimpleNamespace(sonarr_base_url='http://sonarr',sonarr_api_key='test')
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'SonarrClient',return_value=client):
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2,'seasons':[{'seasonNumber':1}]}))
client.update_series.assert_awaited_once_with(restored)
client.monitor_episodes.assert_awaited_once_with([1],True)
async def test_movie_monitoring_preserves_profile_and_pending_is_noop(self):
movie={'id':10,'monitored':False,'qualityProfileId':7}
client=SimpleNamespace(get_movie=AsyncMock(side_effect=[movie,{**movie,'monitored':True}]),update_movie=AsyncMock())
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.movie,raw={'arr':{'item':{'id':10}}})
runtime=SimpleNamespace(radarr_base_url='http://radarr',radarr_api_key='test')
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'RadarrClient',return_value=client):
self.assertFalse(await requests._restore_request_monitoring(snapshot,{'status':1}))
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2}))
client.update_movie.assert_awaited_once_with({**movie,'monitored':True})
+34
View File
@@ -0,0 +1,34 @@
import unittest
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from backend.tests.test_insights import play, NOW, LIBRARIES
from backend.tests.test_email_recaps import fixture_report
from backend.app.services.insights import summarize
from backend.app.services.email_recaps import illustrated_recap
class ReportGraphicsTests(unittest.IsolatedAsyncioTestCase):
def test_patterns_deduplicate_and_handle_empty_history(self):
first = play()
second = play("second", PlaybackDuration=1800, EpisodeId="episode", ActivityDateInserted=(NOW-timedelta(days=1)).isoformat())
report = summarize([first, first, second], LIBRARIES, NOW-timedelta(days=7), NOW)
self.assertEqual(report["patterns"]["average_play_minutes"], 45)
self.assertEqual(report["patterns"]["longest_play_minutes"], 60)
self.assertEqual(report["patterns"]["weekend_percent"], 33.3)
self.assertEqual(sum(r["minutes"] for r in report["patterns"]["media"]), 90)
empty = summarize([], [], NOW-timedelta(days=7), NOW)
self.assertEqual(empty["patterns"]["average_play_minutes"], 0)
async def test_artwork_embedded_without_private_links_and_optional_on_failure(self):
report = fixture_report()
report["top_titles"][0]["artwork_url"] = "/insights/artwork/" + "a"*32 + "?token=123." + "b"*64
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(return_value=(b"picture", "image/webp"))):
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
self.assertIn("cid:recap-title-0@magent", rendered["body_html"])
self.assertNotIn("?token=", rendered["body_html"])
self.assertEqual(rendered["inline_images"][0]["subtype"], "webp")
preview = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe", preview=True)
self.assertIn("data:image/webp;base64,", preview["body_html"])
self.assertNotIn("inline_images", preview)
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(side_effect=RuntimeError())):
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
self.assertEqual(rendered["inline_images"], [])
+70 -1
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from backend.app.services.request_language import language_info, original_profile, is_original_profile
from backend.app.services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome
from backend.app.routers import requests
@@ -54,6 +54,7 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
patch.object(requests, 'apply_original_to_movie', new=AsyncMock(return_value=None)), \
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
if expected is None:
@@ -65,3 +66,71 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
await requests.create_request(payload, {'username': 'viewer'})
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
self.assertEqual(clone.await_count, int(expected == 20))
async def test_existing_radarr_movie_is_updated_and_read_back(self):
movie = {'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9, 'monitored': True}
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[movie]),
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={**movie, 'qualityProfileId': 20}))
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
self.assertEqual(await apply_original_to_movie(client, 613), 20)
self.assertEqual(client.update_movie.await_args.args[0]['qualityProfileId'], 20)
self.assertTrue(client.update_movie.await_args.args[0]['monitored'])
async def test_failed_profile_verification_does_not_claim_success(self):
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[{'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9}]),
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={'qualityProfileId': 9}))
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
with self.assertRaises(HTTPException):
await apply_original_to_movie(client, 613)
async def test_search_reports_real_outcomes(self):
for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'),
('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]:
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False}))
result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0)
self.assertEqual(result['status'], expected)
async def test_language_endpoint_checks_consent_identity_and_access(self):
for payload in ({}, {'acceptOriginalLanguage': 'true'}, {'acceptOriginalLanguage': True, 'languageCode': 'es'}):
with patch.object(requests, '_request_language_context', new=AsyncMock(return_value=(SimpleNamespace(), 613, {'code': 'de'}))), \
patch.object(requests, 'apply_original_to_movie', new=AsyncMock()) as apply:
with self.assertRaises(HTTPException):
await requests.accept_request_language('3976', payload, {'role': 'admin'})
apply.assert_not_awaited()
with self.assertRaises(HTTPException):
await requests.accept_request_language('3976', {'acceptOriginalLanguage': True}, {'role': 'user', 'auto_search_enabled': False})
async def test_radarr_queue_filters_before_pagination(self):
from backend.app.clients.radarr import RadarrClient
client = RadarrClient('http://radarr.test', 'test')
with patch.object(client, 'get', new=AsyncMock(return_value={'records': []})) as get:
await client.get_queue(6940)
get.assert_awaited_once_with('/api/v3/queue', params={'movieIds': 6940, 'pageSize': 1000})
async def test_tv_search_distinguishes_no_download_and_queue(self):
from backend.app.services.request_language import series_search_outcome
client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []}))
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'pending')
client.get_queue.return_value = {'records': [{'seriesId': 50}]}
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading')
class SearchHandoffTests(unittest.IsolatedAsyncioTestCase):
async def test_radarr_completed_before_queue_refresh(self):
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
get_queue=AsyncMock(side_effect=[{'records':[]}, {'records':[]}, {'records':[{'movieId':2206}]}]),
get_movie=AsyncMock(return_value={'hasFile':False}))
result = await movie_search_outcome(client, 2206, {'id':1}, attempts=3, delay=0)
self.assertEqual(result['status'], 'downloading')
self.assertEqual(client.get_queue.await_count, 3)
async def test_sonarr_completed_before_queue_refresh(self):
from backend.app.services.request_language import series_search_outcome
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
get_queue=AsyncMock(side_effect=[{'records':[{'seriesId':999}]}, {'records':[{'seriesId':50}]}]))
result = await series_search_outcome(client, 50, [{'id':1}], attempts=2, delay=0)
self.assertEqual(result['status'], 'downloading')
+187
View File
@@ -0,0 +1,187 @@
"""Origin checks use operator configuration, never caller-controlled routing headers."""
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from backend.app import db, main
from backend.app.config import settings
from backend.app.routers import auth as auth_router
from backend.app.services import public_urls
from backend.app.services.request_origins import is_allowed_request_origin
PUBLIC_ORIGIN = "https://watch.example.test"
LOCAL_ORIGIN = "http://localhost:3000"
class RequestOriginTests(unittest.TestCase):
def setUp(self):
self.runtime = SimpleNamespace(
magent_proxy_enabled=False,
magent_proxy_base_url=None,
magent_application_url=PUBLIC_ORIGIN,
)
self.enterContext(patch.object(settings, "cors_allow_origin", LOCAL_ORIGIN))
self.enterContext(patch.object(public_urls, "get_runtime_settings", return_value=self.runtime))
def test_explicit_cors_and_configured_public_url_are_both_allowed(self):
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin("https://unrelated.example.test"))
def test_scheme_hostname_case_and_default_ports_are_canonicalized(self):
for origin in (PUBLIC_ORIGIN, "HTTPS://WATCH.EXAMPLE.TEST", "https://watch.example.test:443"):
with self.subTest(origin=origin):
self.assertTrue(is_allowed_request_origin(origin))
self.runtime.magent_application_url = "http://watch.example.test:80"
self.assertTrue(is_allowed_request_origin("http://WATCH.example.test"))
self.assertFalse(is_allowed_request_origin("https://watch.example.test"))
self.assertFalse(is_allowed_request_origin("http://watch.example.test:8080"))
def test_nondefault_ports_must_match(self):
self.runtime.magent_application_url = "https://watch.example.test:8443/magent"
self.assertTrue(is_allowed_request_origin("https://watch.example.test:8443"))
self.assertFalse(is_allowed_request_origin("https://watch.example.test"))
self.assertFalse(is_allowed_request_origin("https://watch.example.test:443"))
def test_configured_subpath_does_not_become_part_of_origin(self):
self.runtime.magent_application_url = PUBLIC_ORIGIN + "/magent/"
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN + "/magent"))
def test_enabled_proxy_uses_configured_proxy_public_url(self):
self.runtime.magent_proxy_enabled = True
self.runtime.magent_proxy_base_url = "https://proxy.example.test/magent"
self.assertTrue(is_allowed_request_origin("https://proxy.example.test"))
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
def test_unconfigured_public_url_only_allows_explicit_cors(self):
self.runtime.magent_application_url = None
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
def test_invalid_or_non_origin_inputs_are_rejected(self):
for origin in (
"", "null", "*", "watch.example.test", "//watch.example.test",
"ftp://watch.example.test", "javascript:alert(1)",
PUBLIC_ORIGIN + "/", PUBLIC_ORIGIN + "/path",
PUBLIC_ORIGIN + "?query=true", PUBLIC_ORIGIN + "#fragment",
PUBLIC_ORIGIN + "?", PUBLIC_ORIGIN + "#",
"https://user@watch.example.test", "https://user:password@watch.example.test",
"https://watch.example.test@evil.example.test", "https://watch.example.test.evil.example.test",
"https://watch.example.test:0", "https://watch.example.test:65536",
"https://watch.example.test:invalid", "https://[invalid",
PUBLIC_ORIGIN + " https://evil.example.test", PUBLIC_ORIGIN + ",https://evil.example.test",
"https://watch.example.test\\@evil.example.test", PUBLIC_ORIGIN + "\n",
):
with self.subTest(origin=repr(origin)):
self.assertFalse(is_allowed_request_origin(origin))
def test_invalid_configured_public_url_does_not_authorize_an_origin(self):
for configured in (
"https://user:password@watch.example.test", PUBLIC_ORIGIN + "?token=private",
PUBLIC_ORIGIN + "#fragment", "javascript:alert(1)", "https://watch.example.test:65536",
):
with self.subTest(configured=configured):
self.runtime.magent_application_url = configured
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
class RequestOriginHttpTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temporary.cleanup)
self.runtime = SimpleNamespace(
magent_proxy_enabled=False,
magent_proxy_base_url=None,
magent_application_url=PUBLIC_ORIGIN,
)
for name, value in {
"sqlite_path": str(Path(temporary.name) / "origin-tests.db"),
"sqlite_journal_mode": "DELETE",
"jwt_secret": "request-origin-tests-jwt-secret-at-least-32-characters",
"settings_encryption_key": None,
"admin_username": "unused-environment-admin",
"admin_password": "",
"cors_allow_origin": LOCAL_ORIGIN,
"auth_cookie_domain": None,
"auth_cookie_secure": True,
}.items():
self.enterContext(patch.object(settings, name, value))
self.enterContext(patch.object(public_urls, "get_runtime_settings", return_value=self.runtime))
# Constructing without a context deliberately skips production startup:
# no migrations/workers/listeners/log files outside this temporary DB.
db.init_db()
self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN)
self.addCleanup(self.client.close)
def test_public_origin_reaches_both_auth_handlers_with_localhost_cors_default(self):
for path in ("/auth/login", "/auth/jellyfin/login"):
with self.subTest(path=path):
response = self.client.post(path, data={}, headers={"Origin": PUBLIC_ORIGIN})
self.assertEqual(response.status_code, 422, response.text)
self.assertNotEqual(response.json().get("detail"), "Cross-origin state change rejected")
def test_explicit_cors_origin_remains_allowed(self):
response = self.client.post("/auth/login", data={}, headers={"Origin": LOCAL_ORIGIN})
self.assertEqual(response.status_code, 422, response.text)
def test_no_origin_keeps_existing_nonbrowser_behavior(self):
response = self.client.post("/auth/login", data={})
self.assertEqual(response.status_code, 422, response.text)
def test_caller_controlled_host_forwarding_and_fetch_headers_cannot_authorize_evil_origin(self):
for path in ("/auth/login", "/auth/jellyfin/login"):
for routing_headers in (
{},
{"Host": "evil.example.test"},
{"X-Forwarded-Host": "evil.example.test", "X-Forwarded-Proto": "https"},
{"Host": "evil.example.test", "X-Forwarded-Host": "evil.example.test", "Sec-Fetch-Site": "same-origin"},
{"Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test", "Sec-Fetch-Site": "same-origin"},
):
with self.subTest(path=path, routing_headers=routing_headers):
response = self.client.post(path, data={}, headers={"Origin": "https://evil.example.test", **routing_headers})
self.assertEqual(response.status_code, 403, response.text)
self.assertEqual(response.json()["detail"], "Cross-origin state change rejected")
def test_null_path_query_and_userinfo_origins_are_rejected_before_login(self):
for origin in ("null", PUBLIC_ORIGIN + "/", PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?query=1", "https://user@watch.example.test"):
with self.subTest(origin=origin):
response = self.client.post("/auth/login", data={}, headers={"Origin": origin})
self.assertEqual(response.status_code, 403, response.text)
def test_valid_local_login_works_from_configured_public_origin(self):
password = "origin-tests-valid-local-password"
db.create_user("origin-owner", password, role="admin")
response = self.client.post("/auth/login", data={"username": "origin-owner", "password": password}, headers={"Origin": PUBLIC_ORIGIN})
self.assertEqual(response.status_code, 200, response.text)
self.assertIn(settings.auth_cookie_name, self.client.cookies)
profile = self.client.get("/auth/profile")
self.assertEqual(profile.status_code, 200, profile.text)
self.assertEqual(profile.json()["user"]["username"], "origin-owner")
def test_valid_mocked_jellyfin_login_works_from_configured_public_origin(self):
jellyfin_runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin.test:8096", jellyfin_api_key="test-api-key")
upstream = SimpleNamespace(
configured=lambda: True,
authenticate_by_name=AsyncMock(return_value={"User": {"Id": "test-jellyfin-id", "Name": "origin-viewer"}}),
get_users=AsyncMock(return_value=[]),
_extract_user_id=lambda _response: "test-jellyfin-id",
)
with patch.object(auth_router, "get_runtime_settings", return_value=jellyfin_runtime), patch.object(auth_router, "JellyfinClient", return_value=upstream), patch.object(auth_router, "get_cached_jellyseerr_users", return_value=[]):
response = self.client.post("/auth/jellyfin/login", data={"username": "origin-viewer", "password": "origin-tests-jellyfin-password"}, headers={"Origin": PUBLIC_ORIGIN})
self.assertEqual(response.status_code, 200, response.text)
upstream.authenticate_by_name.assert_awaited_once_with("origin-viewer", "origin-tests-jellyfin-password")
self.assertIn(settings.auth_cookie_name, self.client.cookies)
self.assertEqual(db.get_user_by_username("origin-viewer")["auth_provider"], "jellyfin")
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
import sqlite3
import unittest
from backend.app.schema_migrations import run_schema_migrations
class SchemaMigrationTests(unittest.TestCase):
def setUp(self) -> None:
self.conn = sqlite3.connect(":memory:")
self.conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at TEXT)"
)
self.conn.execute(
"CREATE TABLE signup_invites (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at TEXT, updated_at TEXT)"
)
self.conn.execute("CREATE TABLE portal_items (id INTEGER PRIMARY KEY, kind TEXT, updated_at TEXT)")
self.conn.execute("CREATE TABLE requests_cache (request_id INTEGER PRIMARY KEY, created_at TEXT)")
def tearDown(self) -> None:
self.conn.close()
def test_migrations_are_versioned_and_idempotent(self) -> None:
self.assertEqual(run_schema_migrations(self.conn), [1])
self.assertEqual(run_schema_migrations(self.conn), [])
user_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(users)")}
self.assertIn("auth_version", user_columns)
self.assertIn("email", user_columns)
request_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(requests_cache)")}
self.assertIn("requested_by_id", request_columns)
applied = self.conn.execute("SELECT version, name FROM schema_migrations").fetchall()
self.assertEqual(applied, [(1, "legacy_columns_and_indexes")])
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -15,6 +15,8 @@ services:
AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true"
@@ -26,3 +28,10 @@ services:
volumes:
- ./data:/app/data
restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+8 -1
View File
@@ -5,6 +5,13 @@ services:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
- "127.0.0.1:8000:8000"
volumes:
- ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+10
View File
@@ -5,9 +5,19 @@ services:
build: .
env_file:
- ./.env
environment:
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
ports:
- "10.30.1.32:3200:3000"
- "127.0.0.1:8200:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+8 -1
View File
@@ -7,6 +7,13 @@ services:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
- "127.0.0.1:8000:8000"
volumes:
- ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+5 -3
View File
@@ -1,10 +1,10 @@
# Duplicate account repair
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a same-name conflict. An individual user's management overlay also links to this view with their username prefilled.
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a shared-ID conflict. An individual user's management overlay also links to this view with their username prefilled.
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
Eligibility requires a single current Jellyfin account for the normalized name, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
Eligibility requires a single current Jellyfin ID, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin or Seerr sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
The transaction:
@@ -15,8 +15,10 @@ The transaction:
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no automatic bulk merge or self-service undo. The archive supports administrative investigation; unrelated or renamed identities require separate review.
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no unattended bulk merge or self-service undo. An explicitly authorized operator can use `scripts/reconcile_verified_accounts.py --apply --output <new-private-directory>`; it takes a SQLite backup and archives each repair. Without `--apply` it previews only. The archive supports administrative investigation; conflicting identities require separate review.
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
Seerr sync/resync now reconciles against Jellyfin IDs without deleting the directory. Daily imports and verified login reuse linked accounts; account creation also guards against duplicate Seerr IDs.
+1 -1
View File
@@ -63,7 +63,7 @@ Open **My Stats → Monthly reports** (`/insights/reports`). The default is the
New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription.
**Settings → Monthly email recaps** (`/admin/recaps`) controls the public Magent address, monthly schedule, personal preview, test emails and delivery history. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
**Settings → Monthly email recaps** (`/admin/recaps`) controls the monthly schedule, personal preview, test emails and delivery history. Email links inherit the application URL from Hosting & proxy (or the proxy base URL when enabled); this address is shown read-only on the recap page. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token.
+13
View File
@@ -0,0 +1,13 @@
# Manual release selection
Manual TV searches query Sonarr by each missing monitored episode ID, with three concurrent searches and batches of 20. Season packs returned by those searches remain visible. Larger requests offer the next batch. Movie searches use the Radarr movie ID. Both manual and automatic searches retain the assigned quality profile; admin defaults apply when creating requests.
Results include rejection reasons instead of silently filtering everything out. Approved releases can be selected normally. The **Ignore profile limits** permission defaults off for non-admin users and is available in User management > Manage users and Manage this user > Feature access. Administrators retain access.
Permitted users enable the override in the release picker and explicitly confirm each out-of-profile download. Quality, size, language and custom-format/profile rejections can be overridden. Other rejection reasons remain blocked. Downloads go through Sonarr/Radarr's native manual release endpoint without modifying quality profiles or bypassing the collector.
Selections carry a ten-minute signed receipt bound to the user, request, collector, media item and release. The backend rechecks the current permission and explicit override consent on download. Expired collector caches require another search; arbitrary client-provided download URLs are not pushed upstream.
Validation covers per-episode batching, profile preservation, default-off and bulk/individual permissions, permission revocation with an existing login, forged selections, rejection classification, desktop/mobile confirmation and blocked results.
Upstream reference: [Sonarr ReleaseController](https://github.com/Sonarr/Sonarr/blob/develop/src/Sonarr.Api.V3/Indexers/ReleaseController.cs) exposes episode-specific interactive search and the collector's manual grab operation.
+1 -1
View File
@@ -9,7 +9,7 @@ The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects t
## Setup and subscriptions
- Configure Jellyfin and its **public** address for Watch links, plus the existing SMTP email settings. Background automation must be enabled. Jellystat is not required for newsletters.
- Save this environment's public Magent address in Weekly schedule. On first migration it inherits the monthly recap address, if configured. No schedule or subscriptions are enabled by migration.
- Email links inherit the application URL from Hosting & proxy, or the proxy base URL when reverse proxy mode is enabled. Weekly schedule displays the effective address read-only. Existing email-specific addresses remain a fallback only when hosting has not been configured. Watch links use Jellyfin's public playback URL. No schedule or subscriptions are enabled by migration.
- Users opt in at **Profile → New on Grizzlyflix**. This consent is separate from monthly viewing recaps. A current email already confirmed for monthly recaps can be reused after the user explicitly subscribes to newsletters. Otherwise a confirmation email is sent, with a 24-hour expiry and a five-minute resend limit.
- Each subscriber needs a stored Jellyfin account link. Email or identity changes, blocking and account removal invalidate consent. Confirmation and unsubscribe tokens are specific to newsletters. Opening a public link checks it; changing the preference requires pressing its confirmation button.
+3 -1
View File
@@ -6,10 +6,12 @@ Users can leave the normal request settings or explicitly accept original-langua
For movies, consent creates or reuses a `Magent Original …` Radarr quality profile. It copies the current default's quality ordering, allowed qualities, cutoff, upgrade rules and custom-format scores, changing only the language to Original. The existing default is never edited. The copy is selected for this new Seerr request only. Magent's subsequent Search and auto-download action preserves a verified copy instead of resetting it to English. Copies are content-addressed so later default changes do not silently change earlier requests.
TV requests show the same notice and retain their configured Sonarr profile. The inspected Sonarr configuration has no language custom formats. This feature does not bypass custom-format rejection, indexer restrictions, availability or permissions; it does not guarantee that a download is available. Existing requests are not silently modified by selecting an already-requested search result.
TV requests show the same notice and retain their configured Sonarr profile. The inspected Sonarr configuration has no language custom formats. This feature does not bypass custom-format rejection, indexer restrictions, availability or permissions; it does not guarantee that a download is available. Existing requests show a prominent audio panel above the pipeline. **Use <language> audio & search** explicitly updates and reads back the existing Radarr movie profile before searching. Seerr only permits editing pending requests, so an approved request retains its historical Seerr profile field; the live Radarr profile is authoritative for collection.
The first opted-in movie request creates a profile in Radarr. Failed request submission can leave an unused copy, which is reused on retry. Do not rename/edit managed copies if they should retain Magent's recognition during subsequent searches.
Radarr's API represents Original as language ID -2: [language source](https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Languages/Language.cs). Profile fields are defined in its [quality profile resource](https://github.com/Radarr/Radarr/blob/develop/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs).
Validation: backend consent/profile isolation tests and `scripts/review_request_language_ui.cjs` with intercepted APIs; no live requests or downloads are created by these tests.
Manual actions automatically open their progress dialog. The final response distinguishes a queued download, a completed search with no observed download, a failed search and a search still running. Interactive searches expose rejection reasons. Radarr queue reads use the supported `movieIds` filter before pagination, preventing unrelated first-page records from hiding the actual download.
+211 -233
View File
@@ -1,294 +1,261 @@
'use client'
"use client";
import PageHeading from './ui/PageHeading'
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' },
]
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
import {
normalizeRecentResults,
normalizeSearchResults,
type RecentRequest,
type RequestSearchResult,
} from "./lib/request-results";
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
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 router = useRouter();
const [query, setQuery] = useState("");
const [recent, setRecent] = useState<RecentRequest[]>([]);
const [recentError, setRecentError] = useState<string | null>(null);
const [recentLoading, setRecentLoading] = useState(false);
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const [recentDays, setRecentDays] = useState(90);
const [recentStage, setRecentStage] = useState<RequestStage>("all");
const [authReady, setAuthReady] = useState(false);
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
event.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
router.push(`/requests/${encodeURIComponent(trimmed)}`);
return;
}
void runSearch(trimmed)
}
void runSearch(trimmed);
};
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
router.push("/login");
return;
}
let cancelled = false;
const load = async () => {
setRecentLoading(true)
setRecentError(null)
setRecentLoading(true);
setRecentError(null);
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
const baseUrl = getApiBase();
const meResponse = await authFetch(`${baseUrl}/auth/me`);
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
clearToken();
router.push("/login");
return;
}
throw new Error(`Auth failed: ${meResponse.status}`)
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 me = await meResponse.json();
if (cancelled) return;
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)
});
if (recentStage !== "all") {
params.set("stage", recentStage);
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`);
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
clearToken();
router.push("/login");
return;
}
throw new Error(`Recent requests failed: ${response.status}`)
throw new Error(`Recent requests failed: ${response.status}`);
}
const data = await response.json()
const data = await response.json();
if (cancelled) return;
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
setRecent(normalizeRecentResults(data.results));
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
console.error(error);
if (!cancelled) setRecentError("Recent requests are not available right now.");
} finally {
setRecentLoading(false)
if (!cancelled) setRecentLoading(false);
}
}
};
load()
}, [recentDays, recentStage])
void load();
return () => {
cancelled = true;
};
}, [recentDays, recentStage, router]);
useEffect(() => {
if (!authReady) {
return
return;
}
if (!getToken()) {
return
return;
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const baseUrl = getApiBase();
let closed = false;
let source: EventSource | null = null;
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
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)
});
if (recentStage !== "all") {
params.set("recent_stage", recentStage);
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`;
source = new EventSource(streamUrl);
source.onmessage = (event) => {
if (closed) return
if (closed) return;
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
const payload = JSON.parse(event.data);
if (!payload || typeof payload !== "object") {
return;
}
if (payload.type === 'home_recent') {
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)
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
return;
}
} catch (error) {
console.error(error)
console.error(error);
}
}
};
} catch (error) {
if (closed) return
console.error(error)
if (closed) return;
console.error(error);
}
}
};
void connect()
void connect();
return () => {
closed = true
source?.close()
}
}, [authReady, recentDays, recentStage])
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)}`)
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
clearToken();
router.push("/login");
return;
}
throw new Error(`Search failed: ${response.status}`)
throw new Error(`Search failed: ${response.status}`);
}
const data = await response.json()
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)
setSearchResults(normalizeSearchResults(data.results));
setSearchError(null);
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
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}`
}
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()
}
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 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 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 }
}
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>
} />
<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>
<h2>
{searchError
? "Search unavailable"
: `${searchResults.length} match${searchResults.length === 1 ? "" : "es"} found`}
</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
<button
type="button"
className="ghost-button"
onClick={() => {
setSearchResults([]);
setSearchError(null);
}}
>
Clear
</button>
</div>
@@ -298,17 +265,20 @@ export default function HomePage() {
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
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>
<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>
<span>{!item.requestId ? "Not requested" : item.statusLabel || "Already requested"}</span>
</button>
))}
</div>
@@ -317,16 +287,25 @@ export default function HomePage() {
)}
<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>
<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>
<h2>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
@@ -343,21 +322,7 @@ export default function HomePage() {
</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>
)}
{authReady && <RequestStageFilter value={recentStage} onChange={setRecentStage} />}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
@@ -382,30 +347,43 @@ export default function HomePage() {
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
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-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-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 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)}` : ''}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ""}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
<span className="recent-open-cue" aria-hidden="true">
Open
</span>
</button>
))
)}
</div>
</section>
</main>
)
);
}
+1
View File
@@ -36,6 +36,7 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
.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-login-message { color: #ded8ed; border-color: #514a60; background: #26222d; white-space: pre-line; }
.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); }
+137 -47
View File
@@ -1,72 +1,162 @@
'use client'
"use client";
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
type Option = { value: string; label: string }
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
}
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' }],
}
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" },
],
};
const COLOR_DEFAULTS: Record<string, string> = {
site_banner_background_color: "#332814",
site_banner_border_color: "#a27b32",
};
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 }
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 colorDefault = COLOR_DEFAULTS[setting.key];
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault;
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>
<label htmlFor={id}>{label}</label>
{help && <p id={`${id}-help`}>{help}</p>}
</div>
<input
{...aria}
type="checkbox"
role="switch"
aria-checked={value.toLowerCase() === "true"}
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>
<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>
<select {...aria} disabled value={value}>
<option value={value}>Save the connection, then reload available options</option>
</select>
) : colorDefault ? (
<div className="setting-color-control">
<input
id={`${id}-picker`}
type="color"
aria-label={`Choose ${label.toLowerCase()}`}
value={pickerValue}
onChange={(event) => onChange(event.target.value)}
/>
<input
{...aria}
type="text"
value={value}
pattern="#[0-9A-Fa-f]{6}"
placeholder={colorDefault}
autoComplete="off"
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
{value ? (
<button type="button" className="ghost-button" onClick={() => onChange("")}>
Use tone default
</button>
) : null}
</div>
) : 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>)}
{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)} />
<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)} />
<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>
)
);
}
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -1,13 +1,38 @@
'use client'
"use client";
import { useState, type ReactNode } from 'react'
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)
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 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>
)
);
}
+27 -27
View File
@@ -1,36 +1,36 @@
import { notFound } from 'next/navigation'
import SettingsPage from '../SettingsPage'
import { notFound } from "next/navigation";
import SettingsPage from "../SettingsPage";
const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr',
'jellyfin',
'jellystat',
'artwork',
'sonarr',
'radarr',
'bazarr',
'prowlarr',
'qbittorrent',
'requests',
'issue-workflow',
'cache',
'logs',
'maintenance',
'magent',
'general',
'notifications',
'site',
])
"seerr",
"jellyseerr",
"jellyfin",
"jellystat",
"artwork",
"sonarr",
"radarr",
"bazarr",
"prowlarr",
"qbittorrent",
"requests",
"issue-workflow",
"cache",
"logs",
"maintenance",
"magent",
"general",
"notifications",
"site",
]);
type PageProps = {
params: Promise<{ section: string }>
}
params: Promise<{ section: string }>;
};
export default async function AdminSectionPage({ params }: PageProps) {
const { section } = await params
const { section } = await params;
if (!ALLOWED_SECTIONS.has(section)) {
notFound()
notFound();
}
return <SettingsPage section={section} />
return <SettingsPage section={section} />;
}
+5
View File
@@ -50,6 +50,9 @@
.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-color-control { display: grid; grid-template-columns: 52px minmax(140px, 1fr) auto; align-items: center; gap: 8px; }
.config-subsection .setting-color-control input[type=color] { width: 52px; min-width: 52px; padding: 4px; cursor: pointer; }
.config-subsection .setting-color-control .ghost-button { min-height: 42px; padding: 9px 12px; white-space: nowrap; }
.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; }
@@ -92,6 +95,8 @@
@media (max-width: 680px) {
.admin-form .admin-grid { grid-template-columns: 1fr; }
.admin-form .config-subsection { padding: 16px !important; }
.config-subsection .setting-color-control { grid-template-columns: 52px minmax(0, 1fr); }
.config-subsection .setting-color-control .ghost-button { grid-column: 1 / -1; }
.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; }
+97 -34
View File
@@ -1,37 +1,100 @@
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
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' },
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' },
{ href: '/admin/recaps', label: 'Monthly email recaps', description: 'Personal viewing emails, schedule and delivery history' },
{ href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' },
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
{ href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' },
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' },
]},
{ 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' },
]},
]
{
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" },
{
href: "/admin/notifications",
label: "Email & notifications",
description: "Invites, password resets and repair updates",
},
{
href: "/admin/recaps",
label: "Monthly email recaps",
description: "Personal viewing emails, schedule and delivery history",
},
{
href: "/admin/newsletters",
label: "Newsletters",
description: "New arrivals, featured picks and weekly editions",
},
{
href: "/admin/issue-workflow",
label: "Issue follow-up",
description: "Confirmation emails and automatic closure",
},
{ href: "/admin/requests", label: "Request updates", description: "Refresh schedule and history retention" },
{ href: "/users", label: "User management", description: "Accounts, permissions, identity checks and repairs" },
{ href: "/admin/invites", label: "Invite policy & access", description: "Defaults, profiles and issued invites" },
],
},
{
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')
export const serviceStatusLabel = (status?: string) =>
({
up: "Connected",
down: "Unavailable",
degraded: "Needs attention",
not_configured: "Not set up",
})[status ?? ""] ?? "Not checked";
+5 -8
View File
@@ -1,15 +1,12 @@
'use client'
"use client";
import AdminShell from '../../ui/AdminShell'
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
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."
>
<AdminShell title="Diagnostics" subtitle="Check connections and investigate service problems.">
<AdminDiagnosticsPanel />
</AdminShell>
)
);
}
@@ -1,74 +1,232 @@
'use client'
"use client";
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
import { FEATURES, type FeatureAccess } from '../../lib/features'
import type { Row } from './IdentityReviewPanel'
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
import { FEATURES, type FeatureAccess } from "../../lib/features";
import type { Row } from "./IdentityReviewPanel";
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null }
type Account = {
id: number;
username: string;
email: string | null;
profile_id: number | null;
last_login_at: string | null;
};
type Preview = {
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[]
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean }
}
accounts: Account[];
keep_id: number;
recommended_id: number;
revision: string;
can_confirm: boolean;
issues: string[];
proposed: Account & {
jellyfin_user_id: string;
seerr_user_id: number;
features: FeatureAccess;
expires_at: string | null;
is_blocked: boolean;
auto_search_enabled: boolean;
};
};
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) {
const dialog = useRef<HTMLDialogElement>(null)
const controller = useRef<AbortController | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [acknowledged, setAcknowledged] = useState(false)
const [error, setError] = useState('')
export default function DuplicateAccountRepair({
row,
onClose,
onSaved,
}: {
row: Row;
onClose: () => void;
onSaved: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [error, setError] = useState("");
const submit = async (confirm = false, keepId?: number) => {
const abort = new AbortController()
controller.current?.abort(); controller.current = abort
setError(''); setAcknowledged(false)
if (confirm) setSaving(true)
else setBusy(true)
const abort = new AbortController();
controller.current?.abort();
controller.current = abort;
setError("");
setAcknowledged(false);
if (confirm) setSaving(true);
else setBusy(true);
try {
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }),
})
const data = await response.json()
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.')
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) }
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } }
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
}
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? "confirm" : "check"}`, {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: row.user.id,
...(keepId ? { keep_id: keepId } : {}),
...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}),
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(typeof data.detail === "string" ? data.detail : "Could not review these accounts.");
if (!abort.signal.aborted) {
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : "Repair failed. Preview again.");
setPreview(null);
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: The dialog preview runs once when this keyed modal mounts.
useEffect(() => {
const previous = document.activeElement as HTMLElement | null
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'; dialog.current?.showModal()
void submit()
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() }
}, [])
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
<div className="identity-resolve-content">
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header>
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p>
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
{error && <p className="error-banner" role="alert">{error}</p>}
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>}
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}>
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
</select></label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
<h3>Resulting account</h3>
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p>
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p>
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul>
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p>
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p>
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p>
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label>
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button>
</section>}
</div>
</dialog>
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.current?.showModal();
void submit();
return () => {
controller.current?.abort();
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
return (
<dialog
ref={dialog}
className="identity-resolve-dialog"
aria-labelledby="duplicates-title"
onCancel={(event) => {
event.preventDefault();
if (!saving) onClose();
}}
>
<div className="identity-resolve-content">
<header>
<h2 id="duplicates-title">Repair duplicate accounts</h2>
<button type="button" className="ghost-button" disabled={saving} onClick={onClose}>
Close
</button>
</header>
<p>
Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to
the verified Jellyfin identity.
</p>
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{!preview && !busy && (
<button type="button" disabled={saving} onClick={() => void submit()}>
Check again
</button>
)}
{preview && (
<section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>
Magent account to keep
<select
disabled={busy || saving}
value={preview.keep_id}
onChange={(event) => void submit(false, Number(event.target.value))}
>
{preview.accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.username} Magent {account.id}
{account.id === preview.recommended_id ? " (recommended)" : ""}
</option>
))}
</select>
</label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">
{preview.accounts.map((account) => (
<div key={account.id}>
<strong>
Magent {account.id}
{account.id === preview.keep_id ? " · Keep" : " · Consolidate"}
</strong>
<p>{account.username}</p>
<p>
{account.email || "No email"} · Profile {account.profile_id ?? "None"}
</p>
<p>
Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : "Never"}
</p>
</div>
))}
</div>
<h3>Resulting account</h3>
<p>
<strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr{" "}
{preview.proposed.seerr_user_id ?? "Not verified"}
</p>
<p>
Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? "Not verified"}</code>
</p>
<p>
Email: {preview.proposed.email || "None"} · Profile: {preview.proposed.profile_id ?? "None"}
</p>
<p>
Access: {preview.proposed.is_blocked ? "Blocked" : "Not blocked"} · Expiry:{" "}
{preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : "None"} ·
Automatic search: {preview.proposed.auto_search_enabled ? "Enabled" : "Disabled"}
</p>
<ul>
{FEATURES.map((feature) => (
<li key={feature.key}>
{feature.label}: {preview.proposed.features[feature.key] ? "Enabled" : "Disabled"}
</li>
))}
</ul>
<p>
Request, issue, invitation and login activity history is retained. The selected account keeps its email
and profile. Any block, earlier expiry or disabled permission on either row is preserved.
</p>
<p>
Extra Magent rows are removed from the active directory after their details are archived. Their
outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains
its own subscriptions where still eligible. Password reset links must be requested again.
</p>
<p>
Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different
Jellyfin identities or delete upstream users.
</p>
{preview.issues.length > 0 && (
<ul className="identity-issues">
{preview.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={acknowledged}
disabled={busy || saving || !preview.can_confirm}
onChange={(event) => setAcknowledged(event.target.checked)}
/>{" "}
I confirm these rows belong to the same person and have reviewed the account to keep.
</span>
</label>
<button
type="button"
disabled={!preview.can_confirm || !acknowledged || busy || saving}
onClick={() => void submit(true)}
>
{saving ? "Rechecking and repairing..." : "Confirm duplicate repair"}
</button>
</section>
)}
</div>
</dialog>
);
}
@@ -1,171 +1,506 @@
'use client'
"use client";
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
import './identities.css'
import DuplicateAccountRepair from './DuplicateAccountRepair'
import ResolveIdentityLink from './ResolveIdentityLink'
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../../lib/auth";
import "./identities.css";
import DuplicateAccountRepair from "./DuplicateAccountRepair";
import ResolveIdentityLink from "./ResolveIdentityLink";
type Identity = { id: string; name: string }
type Identity = { id: string; name: string };
export type Row = {
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null }
jellyfin: Identity | null
candidate_jellyfin_id: string | null
stored_jellyfin_id: string | null
seerr: { id: number; name: string; jellyfin_id: string }[]
jellystat: { state: string; id?: string; name?: string }
basis: string
issues: string[]
state: string
can_confirm: boolean
confirmed_at: string | null
}
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null };
jellyfin: Identity | null;
candidate_jellyfin_id: string | null;
stored_jellyfin_id: string | null;
seerr: { id: number; name: string; jellyfin_id: string }[];
jellystat: { state: string; id?: string; name?: string };
basis: string;
issues: string[];
state: string;
can_confirm: boolean;
confirmed_at: string | null;
};
type Report = {
revision: string; checked_at: string; server_id: string | null
services: Record<string, string>
counts: Record<string, number>
jellyfin_users: Identity[]
rows: Row[]
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
}
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerrs Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
revision: string;
checked_at: string;
server_id: string | null;
services: Record<string, string>;
counts: Record<string, number>;
jellyfin_users: Identity[];
rows: Row[];
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[];
};
const labels: Record<string, string> = {
ready: "Ready to review",
confirmed: "Confirmed",
conflict: "Conflict",
unlinked: "Missing link",
unavailable: "Check incomplete",
};
const serviceLabels: Record<string, string> = {
available: "Checked",
unavailable: "Unavailable",
not_configured: "Not configured",
not_checked: "No IDs to check",
};
const basisLabels: Record<string, string> = {
confirmed_id: "Confirmed Jellyfin ID",
stored_jellyfin_id: "Stored Jellyfin ID",
stored_seerr_id: "Seerrs Jellyfin ID",
suggested_username: "Suggested from Jellyfin username — review before saving",
none: "No identity match",
};
const statsLabels: Record<string, string> = {
matched: "ID matches",
missing: "ID not found",
unavailable: "Could not check",
not_configured: "Not configured",
not_checked: "No ID to check",
};
export default function IdentityReviewPanel() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [report, setReport] = useState<Report | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const [selected, setSelected] = useState<number[]>([])
const [duplicates, setDuplicates] = useState<Row | null>(null)
const [resolving, setResolving] = useState<Row | null>(null)
const [reviewing, setReviewing] = useState(false)
const controller = useRef<AbortController | null>(null)
const reviewPanel = useRef<HTMLElement | null>(null)
const router = useRouter();
const [ready, setReady] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("all");
const [selected, setSelected] = useState<number[]>([]);
const [duplicates, setDuplicates] = useState<Row | null>(null);
const [resolving, setResolving] = useState<Row | null>(null);
const [reviewing, setReviewing] = useState(false);
const controller = useRef<AbortController | null>(null);
const reviewPanel = useRef<HTMLElement | null>(null);
useEffect(() => {
setQuery(new URLSearchParams(window.location.search).get('user') ?? '')
const abort = new AbortController()
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
if (response.status === 401) { router.replace('/login'); return }
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
if (!abort.signal.aborted) setReady(true)
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => { abort.abort(); controller.current?.abort() }
}, [router])
setQuery(new URLSearchParams(window.location.search).get("user") ?? "");
const abort = new AbortController();
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal })
.then(async (response) => {
if (response.status === 401) {
router.replace("/login");
return;
}
if (!response.ok) throw new Error("Could not check administrator access. Refresh to try again.");
if ((await response.json()).role !== "admin") {
router.replace("/");
return;
}
if (!abort.signal.aborted) setReady(true);
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => {
abort.abort();
controller.current?.abort();
};
}, [router]);
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
useEffect(() => {
if (reviewing) reviewPanel.current?.focus();
}, [reviewing]);
const responseData = async (response: Response) => {
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
return data
}
if (response.status === 401) {
router.replace("/login");
throw new Error("Your session has ended. Sign in again.");
}
if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const data = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof data.detail === "string" ? data.detail : "The identity check could not complete. Try again.",
);
return data;
};
const runCheck = async () => {
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
controller.current?.abort();
const abort = new AbortController();
controller.current = abort;
setBusy(true);
setError("");
setNotice("");
setSelected([]);
setReviewing(false);
setReport(null);
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
if (!abort.signal.aborted) setReport(data)
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }));
if (!abort.signal.aborted) setReport(data);
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
} finally { if (!abort.signal.aborted) setBusy(false) }
}
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not check identities.");
} finally {
if (!abort.signal.aborted) setBusy(false);
}
};
const save = async () => {
if (!report || saving || !selected.length) return
setSaving(true); setError(''); setNotice('')
if (!report || saving || !selected.length) return;
setSaving(true);
setError("");
setNotice("");
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}))
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
const data = await responseData(
await authFetch(`${getApiBase()}/admin/identities/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}),
);
setNotice(
`${data.confirmed} account ${data.confirmed === 1 ? "link" : "links"} confirmed and saved. Run another check to see the updated mappings.`,
);
// The scan describes the previous database state and cannot be reused for another write.
setReport(null); setSelected([]); setReviewing(false)
setReport(null);
setSelected([]);
setReviewing(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save identity links.')
setReport(null); setSelected([]); setReviewing(false)
} finally { setSaving(false) }
}
setError(err instanceof Error ? err.message : "Could not save identity links.");
setReport(null);
setSelected([]);
setReviewing(false);
} finally {
setSaving(false);
}
};
const needle = query.trim().toLowerCase()
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
const needle = query.trim().toLowerCase();
const filtered =
report?.rows.filter(
(row) =>
(filter === "all" || row.state === filter) &&
[
row.user.username,
row.user.id,
row.candidate_jellyfin_id,
row.user.jellyseerr_user_id,
...row.seerr.map((entry) => entry.id),
]
.join(" ")
.toLowerCase()
.includes(needle),
) ?? [];
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? [];
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id);
const toggle = (id: number) => {
setReviewing(false);
setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]));
};
return (
<div className="identity-review">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className="status-banner" role="status">
{notice}
</p>
)}
{!ready && !error && <p role="status">Checking administrator access</p>}
{ready && <>
<section className="identity-intro admin-panel">
<div><h2>Confirm user IDs</h2><p>Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.</p></div>
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
</section>
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
{report && <>
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
<div className="identity-filters">
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
</div>
<div className="identity-selection">
<span>{filtered.length} accounts shown · {selected.length} selected</span>
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
</div>
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
<h2>Save these {selected.length} account links?</h2>
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin <code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}</li>)}</ul>
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
</section>}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
<dl className="identity-mapping">
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
</dl>
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
</article>)}
{ready && (
<>
<section className="identity-intro admin-panel">
<div>
<h2>Confirm user IDs</h2>
<p>
Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same
user ID.
</p>
<p>
Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs.
Duplicate ownership and upstream changes require individual review.
</p>
</div>
<button type="button" onClick={runCheck} disabled={busy || saving}>
{busy ? "Checking all accounts…" : report ? "Run check again" : "Check all user IDs"}
</button>
</section>
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
</>}
</>}
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />}
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
setNotice('Account links repaired and saved. Run another check to see the updated mappings.')
}} />}
{busy && (
<p role="status">
Reading the live user directories and checking Jellystat IDs. This can take up to a minute.
</p>
)}
{report && (
<>
<div className="identity-service-strip">
{Object.entries(report.services).map(([service, state]) => (
<span key={service}>
<strong>{service === "seerr" ? "Seerr" : service === "jellyfin" ? "Jellyfin" : "Jellystat"}</strong>{" "}
{serviceLabels[state] ?? state}
</span>
))}
</div>
<p className="identity-meta">
Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server{" "}
<code>{report.server_id ?? "Unavailable"}</code>
</p>
<div className="identity-counts">
{["magent", "ready", "confirmed", "conflict", "unlinked", "unavailable"].map((state) => (
<div key={state}>
<strong>{report.counts[state]}</strong>
<span>{state === "magent" ? "Magent accounts" : labels[state]}</span>
</div>
))}
</div>
<p className="identity-meta">
Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in
Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.
</p>
<div className="identity-filters">
<label>
Find an account
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Username or user ID"
disabled={saving}
/>
</label>
<label>
Show
<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}>
<option value="all">All accounts</option>
{Object.entries(labels).map(([state, label]) => (
<option key={state} value={state}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="identity-selection">
<span>
{filtered.length} accounts shown · {selected.length} selected
</span>
<button
type="button"
className="ghost-button"
disabled={saving || !eligible.length}
onClick={() => {
setSelected((current) => [...new Set([...current, ...eligible])]);
setReviewing(false);
}}
>
Select ready accounts shown
</button>
<button
type="button"
className="ghost-button"
disabled={saving || !selected.length}
onClick={() => {
setSelected([]);
setReviewing(false);
}}
>
Clear selection
</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>
Review selected links ({selected.length})
</button>
</div>
{reviewing && (
<section
className="identity-confirm-panel"
ref={reviewPanel}
tabIndex={-1}
aria-label="Review links before saving"
>
<h2>Save these {selected.length} account links?</h2>
<p>
Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live
IDs will be checked again before saving.
</p>
<ul>
{selectedRows.map((row) => (
<li key={row.user.id}>
<strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin{" "}
<code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}
</li>
))}
</ul>
<p>
Saving links does not merge or delete accounts. Existing requests and playback history stay with
their service IDs.
</p>
<div className="identity-confirm-actions">
<button type="button" onClick={save} disabled={saving}>
{saving ? "Rechecking and saving…" : "Confirm and save links"}
</button>
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setReviewing(false)}
>
Back to review
</button>
</div>
</section>
)}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => (
<article className="identity-account" key={row.user.id}>
<header>
<div className="identity-account-name">
{row.can_confirm && (
<input
type="checkbox"
aria-label={`Select ${row.user.username} (Magent ${row.user.id})`}
checked={selected.includes(row.user.id)}
disabled={saving}
onChange={() => toggle(row.user.id)}
/>
)}
<div>
<h2>{row.user.username}</h2>
<span>
Magent {row.user.id} ·{" "}
{row.user.auth_provider === "jellyseerr" ? "Seerr" : row.user.auth_provider} sign-in
</span>
</div>
</div>
<span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span>
</header>
<dl className="identity-mapping">
<div>
<dt>Jellyfin user ID</dt>
<dd>
<code>{row.candidate_jellyfin_id ?? "No match"}</code>
{row.jellyfin && <span>{row.jellyfin.name}</span>}
<small>{basisLabels[row.basis]}</small>
{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && (
<small>Stored: {row.stored_jellyfin_id}</small>
)}
</dd>
</div>
<div>
<dt>Seerr user ID</dt>
<dd>
<strong>
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(", ") : "No match"}
</strong>
<span>{row.seerr.map((entry) => entry.name).join(", ")}</span>
<small>Stored in Magent: {row.user.jellyseerr_user_id ?? "Not linked"}</small>
</dd>
</div>
<div>
<dt>Jellystat user ID</dt>
<dd>
<code>{row.jellystat.id ?? "Not verified"}</code>
<span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span>
</dd>
</div>
</dl>
{row.issues.length > 0 && (
<ul className="identity-issues">
{row.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{(row.state === "unlinked" || row.state === "conflict") && (
<div className="identity-resolution-entry">
<p className="identity-meta">
Compare the correct Jellyfin identity with the stored links and review the smallest safe
repair.
</p>
<button
type="button"
className="ghost-button"
disabled={saving || report.services.jellyfin !== "available"}
onClick={() => setResolving(row)}
>
Review repair
</button>
{row.issues.some(
(issue) =>
issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username"),
) && (
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setDuplicates(row)}
>
Repair duplicate accounts
</button>
)}
</div>
)}
{row.state === "unavailable" && (
<p className="identity-meta">
A required service could not be checked. Check its connection and run this again.
</p>
)}
{row.confirmed_at && (
<p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>
)}
</article>
))}
</section>
{report.upstream.length > 0 && (
<details className="identity-upstream">
<summary>{report.upstream.length} upstream accounts need review</summary>
<ul>
{report.upstream.map((entry) => (
<li key={`${entry.platform}-${entry.id}`}>
<strong>
{entry.platform}: {entry.name}
</strong>{" "}
· ID <code>{entry.id}</code>
{entry.jellyfin_id && (
<span>
{" "}
· Jellyfin <code>{entry.jellyfin_id}</code>
</span>
)}
<p>{entry.detail}</p>
</li>
))}
</ul>
</details>
)}
</>
)}
</>
)}
{duplicates && (
<DuplicateAccountRepair
row={duplicates}
onClose={() => setDuplicates(null)}
onSaved={() => {
setDuplicates(null);
void runCheck().then(() => setNotice("Duplicate accounts repaired. History retained and links rechecked."));
}}
/>
)}
{resolving && report && (
<ResolveIdentityLink
row={resolving}
accounts={report.jellyfin_users}
onClose={() => setResolving(null)}
onSaved={() => {
setResolving(null);
setReport(null);
setSelected([]);
setReviewing(false);
setNotice("Account links repaired and saved. Run another check to see the updated mappings.");
}}
/>
)}
</div>
)
);
}
@@ -1,106 +1,290 @@
'use client'
"use client";
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
import type { Row } from './IdentityReviewPanel'
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
import type { Row } from "./IdentityReviewPanel";
type Preview = {
revision: string; server_id: string; row: Row
before: { jellyfin_user_id: string | null; seerr_user_id: number | null }
seerr_users: { id: number; name: string; jellyfin_id: string | null }[]
scope: string
action: string
}
revision: string;
server_id: string;
row: Row;
before: { jellyfin_user_id: string | null; seerr_user_id: number | null };
seerr_users: { id: number; name: string; jellyfin_id: string | null }[];
scope: string;
action: string;
};
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: {
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
export default function ResolveIdentityLink({
row,
accounts,
onClose,
onSaved,
}: {
row: Row;
accounts: { id: string; name: string }[];
onClose: () => void;
onSaved: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null)
const controller = useRef<AbortController | null>(null)
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? '')
const [inspectSeerr, setInspectSeerr] = useState('')
const [createSeerr, setCreateSeerr] = useState(false)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? "");
const [inspectSeerr, setInspectSeerr] = useState("");
const [createSeerr, setCreateSeerr] = useState(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialog.current?.showModal()
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.current?.showModal();
return () => {
controller.current?.abort()
document.body.style.overflow = overflow
previous?.focus()
}
}, [])
controller.current?.abort();
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
const submit = async (confirm: boolean) => {
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return
const abort = new AbortController()
controller.current = abort
setError('')
if (confirm) setSaving(true)
else { setBusy(true); setPreview(null) }
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return;
const abort = new AbortController();
controller.current = abort;
setError("");
if (confirm) setSaving(true);
else {
setBusy(true);
setPreview(null);
}
try {
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? 'confirm' : 'check'}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, create_seerr: createSeerr, ...(confirm ? { revision: preview?.revision } : {}) }),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.')
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? "confirm" : "check"}`, {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: row.user.id,
jellyfin_user_id: chosen,
create_seerr: createSeerr,
...(confirm ? { revision: preview?.revision } : {}),
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
response.status === 401
? "Your session has ended. Sign in again."
: typeof data.detail === "string"
? data.detail
: "Could not check the account links. Try again.",
);
if (!abort.signal.aborted) {
if (confirm) onSaved()
else setPreview(data)
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : 'Could not resolve the link.')
setPreview(null)
setError(err instanceof Error ? err.message : "Could not resolve the link.");
setPreview(null);
}
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
<div className="identity-resolve-content">
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p>
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value)
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} {account.id}</option>)}</select></label>
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label>
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</button>
{error && <p className="error-banner" role="alert">{error}</p>}
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3>
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
<div className="identity-mapping">
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div>
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div>
</div>
<dl className="identity-mapping">
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this users Jellyfin account link in Seerr, then check again.'}</dd></div>
<div><dt>Jellystat</dt><dd><code>{preview.row.jellystat.id ?? 'Not verified'}</code>{preview.row.jellystat.state === 'matched' ? 'Same Jellyfin ID verified' : preview.row.jellystat.state === 'missing' ? 'This ID is missing from Jellystat. Check its Jellyfin sync, then check again.' : 'Could not verify this ID. Check the Jellystat connection and try again.'}</dd></div>
</dl>
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance">
<h3>Check the existing Seerr account</h3>
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label>
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)}
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p>
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p>
</div>}
<p>{preview.scope}</p>
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p>
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>}
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button>
</section>}
</div>
</dialog>
return (
<dialog
ref={dialog}
className="identity-resolve-dialog"
aria-labelledby="resolve-title"
onCancel={(event) => {
event.preventDefault();
if (!saving) onClose();
}}
>
<div className="identity-resolve-content">
<header>
<h2 id="resolve-title">Review account repair</h2>
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
Close
</button>
</header>
<p>
Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm
that these identities belong to the same person before repairing Magent.
</p>
<label>
Jellyfin account
<select
value={chosen}
disabled={saving}
onChange={(event) => {
controller.current?.abort();
setBusy(false);
setPreview(null);
setError("");
setCreateSeerr(false);
setChosen(event.target.value);
}}
>
<option value="">Choose an account</option>
{[...accounts]
.sort((a, b) => a.name.localeCompare(b.name))
.map((account) => (
<option key={account.id} value={account.id}>
{account.name} {account.id}
</option>
))}
</select>
</label>
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={createSeerr}
disabled={busy || saving}
onChange={(event) => {
setCreateSeerr(event.target.checked);
setPreview(null);
}}
/>{" "}
This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.
</span>
</label>
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>
{busy ? "Checking all platform links…" : "Preview repair"}
</button>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
{preview && (
<section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
<h3>{preview.row.can_confirm ? "Ready to repair" : "This link needs attention"}</h3>
<p className="identity-meta">
Jellyfin server <code>{preview.server_id ?? "Unavailable"}</code>
</p>
<div className="identity-mapping">
<div>
<strong>Current Magent links</strong>
<p>
Jellyfin: <code>{preview.before.jellyfin_user_id ?? "Not linked"}</code>
</p>
<p>Seerr: {preview.before.seerr_user_id ?? "Not linked"}</p>
</div>
<div>
<strong>Proposed Magent links</strong>
<p>
Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code>
</p>
<p>
Seerr:{" "}
{preview.row.seerr.length === 1
? preview.row.seerr[0].id
: preview.action === "import_seerr"
? "Assigned by Seerr during import"
: "Not verified"}
</p>
</div>
</div>
<dl className="identity-mapping">
<div>
<dt>Jellyfin</dt>
<dd>
{preview.row.jellyfin?.name ?? "Account not found"}
<code>{preview.row.candidate_jellyfin_id}</code>
</dd>
</div>
<div>
<dt>Seerr</dt>
<dd>
{preview.row.seerr.length
? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(", ")
: "No matching Jellyfin ID. Check this users Jellyfin account link in Seerr, then check again."}
</dd>
</div>
<div>
<dt>Jellystat</dt>
<dd>
<code>{preview.row.jellystat.id ?? "Not verified"}</code>
{preview.row.jellystat.state === "matched"
? "Same Jellyfin ID verified"
: preview.row.jellystat.state === "missing"
? "This ID is missing from Jellystat. Check its Jellyfin sync, then check again."
: "Could not verify this ID. Check the Jellystat connection and try again."}
</dd>
</div>
</dl>
{preview.row.issues.length > 0 && (
<ul className="identity-issues">
{preview.row.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{preview.row.state === "unavailable" && (
<p>A required service is unavailable. Restore its connection and check again.</p>
)}
{preview.row.seerr.length !== 1 && (
<div className="identity-upstream-guidance">
<h3>Check the existing Seerr account</h3>
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
<label>
Seerr account to inspect
<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}>
<option value="">Choose an existing account</option>
{preview.seerr_users.map((account) => (
<option key={account.id} value={account.id}>
{account.name} (ID {account.id})
</option>
))}
</select>
</label>
{preview.seerr_users
.filter((account) => String(account.id) === inspectSeerr)
.map((account) => (
<p key={account.id}>
Current Jellyfin ID: <code>{account.jellyfin_id ?? "Not linked"}</code>
</p>
))}
<p>
If this is the same person, use Seerr's account settings to reconnect their existing account to
Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the
existing Seerr account to preserve its requests and settings.
</p>
<p>
If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page,
then preview again. Do not import a second account to work around an existing identity mismatch.
</p>
</div>
)}
<p>{preview.scope}</p>
<p>
Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate
ownership are rechecked before the change is saved.
</p>
{preview.before.jellyfin_user_id &&
preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && (
<p>
Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to
opt in again.
</p>
)}
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>
{saving
? "Rechecking and saving…"
: preview.action === "import_seerr"
? "Import Seerr account and repair links"
: "Confirm repair"}
</button>
</section>
)}
</div>
</dialog>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
import { redirect } from "next/navigation";
export default function IdentityReviewPage() {
redirect('/users?view=identities')
redirect("/users?view=identities");
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
import PortalClient from '../../portal/PortalClient'
import PortalClient from "../../portal/PortalClient";
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
return <PortalClient workspace="issue" />;
}
File diff suppressed because it is too large Load Diff
+95 -46
View File
@@ -1,77 +1,126 @@
'use client'
"use client";
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import AdminShell from "../ui/AdminShell";
import { CONFIG_GROUPS, serviceStatusLabel } from "./configNavigation";
type ServiceState = { name: string; status: string }
type ServiceState = { name: string; status: string };
export default function AdminLandingPage() {
const router = useRouter()
const [services, setServices] = useState<ServiceState[]>([])
const [ready, setReady] = useState(false)
const [error, setError] = useState('')
const router = useRouter();
const [services, setServices] = useState<ServiceState[]>([]);
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true
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.')
if (!getToken()) {
router.replace("/login");
return;
}
}
void load()
return () => { active = false }
}, [router])
try {
const response = await authFetch(`${getApiBase()}/auth/me`);
if (!response.ok) {
router.replace("/login");
return;
}
if ((await response.json())?.role !== "admin") {
router.replace("/");
return;
}
if (!active) return;
setReady(true);
const status = await authFetch(`${getApiBase()}/status/services`);
if (!status.ok) throw new Error("Status unavailable");
const data = await status.json();
if (active) setServices(Array.isArray(data.services) ? data.services : []);
} catch {
if (active) setError("Connection status is unavailable. Refresh the page to try again.");
}
};
void load();
return () => {
active = false;
};
}, [router]);
return (
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings</p> : (
{!ready ? (
error ? (
<p className="error-banner" role="alert">
{error}
</p>
) : (
<p role="status">Loading settings</p>
)
) : (
<div className="config-directory">
{error && <p className="error-banner" role="alert">{error}</p>}
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
<section className="config-directory-region" key={group.title}>
<header><h2>{group.title}</h2><p>{group.description}</p></header>
<header>
<h2>{group.title}</h2>
<p>{group.description}</p>
</header>
<div className="config-directory-links">
{group.items.map((item) => {
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase());
return (
<a href={item.href} key={item.href} className="config-directory-link">
{item.symbol && <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>
{item.symbol && (
<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>
<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>
))}
{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>
)}
</AdminShell>
)
);
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { redirect } from 'next/navigation'
import { redirect } from "next/navigation";
export default function AdminProfilesRedirectPage() {
redirect('/admin/invites')
redirect("/admin/invites");
}
+506 -111
View File
@@ -1,131 +1,526 @@
'use client'
"use client";
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, getApiBase } from '../../lib/auth'
import '../../email-recaps/recaps.css'
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import AdminShell from "../../ui/AdminShell";
import { authFetch, getApiBase } from "../../lib/auth";
import "../../email-recaps/recaps.css";
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null }
type Delivery = { id: string; month: string; kind: string; email: string; username: string | null; state: string; attempts: number; created_at: number; updated_at: number; next_attempt_at: number; detail: string }
type Overview = { settings: Settings; ready: boolean; detail: string; months: string[]; deliveries: Delivery[]; total: number; subscribers: number; worker_enabled: boolean }
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null }
const monthLabel = (month: string) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' })
const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled'
const stateLabels: Record<string, string> = { queued: 'Queued', preparing: 'Preparing report', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' }
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null };
type Delivery = {
id: string;
month: string;
kind: string;
email: string;
username: string | null;
state: string;
attempts: number;
created_at: number;
updated_at: number;
next_attempt_at: number;
detail: string;
};
type Overview = {
settings: Settings;
ready: boolean;
detail: string;
months: string[];
deliveries: Delivery[];
total: number;
subscribers: number;
worker_enabled: boolean;
};
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null };
const monthLabel = (month: string) =>
new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
const dateLabel = (value?: number | null) =>
value
? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`
: "Not scheduled";
const stateLabels: Record<string, string> = {
queued: "Queued",
preparing: "Preparing report",
sending: "Sending",
sent: "Accepted by mail server",
retry: "Retry scheduled",
failed: "Failed",
unknown: "Needs review",
cancelled: "Cancelled",
};
export default function EmailRecapsAdminPage() {
const router = useRouter()
const [data, setData] = useState<Overview | null>(null)
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: '' })
const [month, setMonth] = useState('')
const [preview, setPreview] = useState<Preview | null>(null)
const [previewMode, setPreviewMode] = useState<'html' | 'text'>('html')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState('')
const [offset, setOffset] = useState(0)
const [revision, setRevision] = useState(0)
const testRequest = useRef<{ month: string; id: string } | null>(null)
const initialized = useRef(false)
const previewController = useRef<AbortController | null>(null)
const router = useRouter();
const [data, setData] = useState<Overview | null>(null);
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: "" });
const [month, setMonth] = useState("");
const [preview, setPreview] = useState<Preview | null>(null);
const [previewMode, setPreviewMode] = useState<"html" | "text">("html");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState("");
const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0);
const testRequest = useRef<{ month: string; id: string } | null>(null);
const initialized = useRef(false);
const previewController = useRef<AbortController | null>(null);
const responseData = useCallback(async (response: Response) => {
if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Frecaps'); throw new Error('Sign in to continue.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Check your settings and try again.')
return result
}, [router])
const responseData = useCallback(
async (response: Response) => {
if (response.status === 401) {
router.replace("/login?next=%2Fadmin%2Frecaps");
throw new Error("Sign in to continue.");
}
if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not complete this action. Check your settings and try again.",
);
return result;
},
[router],
);
useEffect(() => {
const abort = new AbortController()
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal }).then(responseData).then((result: Overview) => {
if (abort.signal.aborted) return
setData(result)
if (!initialized.current) { setSettings(result.settings); setMonth(result.months[0] || ''); initialized.current = true }
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [offset, revision, responseData])
void revision;
const abort = new AbortController();
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal })
.then(responseData)
.then((result: Overview) => {
if (abort.signal.aborted) return;
setData(result);
if (!initialized.current) {
setSettings(result.settings);
setMonth(result.months[0] || "");
initialized.current = true;
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [offset, revision, responseData]);
useEffect(() => {
if (!data?.deliveries.some((delivery) => ['queued', 'preparing', 'sending', 'retry'].includes(delivery.state))) return
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000)
return () => window.clearInterval(timer)
}, [data])
useEffect(() => () => previewController.current?.abort(), [])
if (!data?.deliveries.some((delivery) => ["queued", "preparing", "sending", "retry"].includes(delivery.state)))
return;
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
return () => window.clearInterval(timer);
}, [data]);
useEffect(() => () => previewController.current?.abort(), []);
const dirty = !!data && (settings.enabled !== data.settings.enabled || settings.day !== data.settings.day || settings.hour !== data.settings.hour || settings.public_url !== data.settings.public_url)
const dirty =
!!data &&
(settings.enabled !== data.settings.enabled ||
settings.day !== data.settings.day ||
settings.hour !== data.settings.hour ||
settings.public_url !== data.settings.public_url);
const save = async (event: FormEvent) => {
event.preventDefault()
if (busy) return
setBusy('save'); setError(''); setNotice('')
event.preventDefault();
if (busy) return;
setBusy("save");
setError("");
setNotice("");
try {
const { enabled, day, hour, public_url } = settings
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour, public_url }) })) as Settings
setSettings(result); setData((current) => current ? { ...current, settings: result } : current)
setPreview(null)
setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.')
setRevision((value) => value + 1)
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save the schedule.') }
finally { setBusy('') }
}
const { enabled, day, hour } = settings;
const result = (await responseData(
await authFetch(`${getApiBase()}/admin/email-recaps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, day, hour }),
}),
)) as Settings;
setSettings(result);
setData((current) => (current ? { ...current, settings: result } : current));
setPreview(null);
setNotice(
result.enabled
? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.`
: "Settings saved. Scheduled delivery is paused.",
);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the schedule.");
} finally {
setBusy("");
}
};
const loadPreview = async () => {
if (busy || !month) return
const abort = new AbortController()
previewController.current?.abort(); previewController.current = abort
setBusy('preview'); setError(''); setNotice(''); setPreview(null)
if (busy || !month) return;
const abort = new AbortController();
previewController.current?.abort();
previewController.current = abort;
setBusy("preview");
setError("");
setNotice("");
setPreview(null);
try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal })) as Preview
if (!abort.signal.aborted) setPreview(result)
} catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not prepare your preview.') }
finally { if (!abort.signal.aborted) setBusy('') }
}
const result = (await responseData(
await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal }),
)) as Preview;
if (!abort.signal.aborted) setPreview(result);
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not prepare your preview.");
} finally {
if (!abort.signal.aborted) setBusy("");
}
};
const sendTest = async () => {
if (busy || !preview || preview.month !== month) return
if (!testRequest.current || testRequest.current.month !== month) testRequest.current = { month, id: crypto.randomUUID() }
setBusy('test'); setError(''); setNotice('')
if (busy || !preview || preview.month !== month) return;
if (!testRequest.current || testRequest.current.month !== month)
testRequest.current = { month, id: crypto.randomUUID() };
setBusy("test");
setError("");
setNotice("");
try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, request_id: testRequest.current.id }) }))
setNotice(result.message); testRequest.current = null; setOffset(0); setRevision((value) => value + 1)
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your test.') }
finally { setBusy('') }
}
const result = await responseData(
await authFetch(`${getApiBase()}/admin/email-recaps/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ month, request_id: testRequest.current.id }),
}),
);
setNotice(result.message);
testRequest.current = null;
setOffset(0);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your test.");
} finally {
setBusy("");
}
};
return <AdminShell title="Monthly email recaps" subtitle="Give each user a personal look back at their month in viewing." actions={<a className="ghost-button" href="/admin/notifications">Email settings </a>}>
<div className="recap-admin">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!data && !error && <p role="status">Loading email recaps</p>}
{!data && error && <button className="ghost-button" type="button" onClick={() => { setError(''); setRevision((value) => value + 1) }}>Try again</button>}
{data && <>
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Schedule running' : 'Schedule paused'}</span><p>{data.settings.enabled ? `Next send ${dateLabel(data.settings.next_send_at)}` : 'Start the schedule when youre ready for monthly delivery.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div>
<div className="recap-admin-grid">
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div>
<p>Send the previous months report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p>
<form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" placeholder="https://magent.example.com" maxLength={500} value={settings.public_url} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} disabled={!!busy} required={settings.enabled} /><small>The address users open from this environments emails.</small></label>
<div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div>
<label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label>
<p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p>
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save schedule'}</button>
</form>
{!data.ready && <p className="recap-setup-note">{data.detail} <a href="/admin/notifications">Review email settings </a></p>}
</section>
<section className="admin-panel recap-panel"><span className="recap-eyebrow">Make it yours</span><h2>Preview your recap</h2><p>See your own viewing highlights in the email design. A test goes only to your confirmed profile email.</p>
<label className="recap-month-label" htmlFor="recap-month">Report month<select id="recap-month" value={month} disabled={!!busy} onChange={(event) => { setMonth(event.target.value); setPreview(null); testRequest.current = null }}>{data.months.map((value) => <option key={value} value={value}>{monthLabel(value)}</option>)}</select></label>
<div className="recap-actions"><button type="button" className="account-primary" onClick={() => void loadPreview()} disabled={!!busy || dirty || !month}>{busy === 'preview' ? 'Preparing preview…' : 'Preview my recap'}</button><button type="button" className="account-secondary" onClick={() => void sendTest()} disabled={!!busy || dirty || !preview || !data.ready}>{busy === 'test' ? 'Queuing test…' : 'Send test to me'}</button></div>
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
<div className="recap-preview-guidance"><h3>One email. Your month.</h3><ul><li>Minutes, movies, episodes and requests</li><li>Changes from the previous month</li><li>Most watched titles and your longest run</li><li>A link to the full report and easy unsubscribe</li></ul><a href="/profile#monthly-recaps">Confirm your email in Profile </a></div>
</section>
</div>
{preview && <section className="admin-panel recap-panel recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h2>{preview.subject}</h2><p>For {preview.email || 'your profile email'} · Preview links use your saved public address.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={previewMode === 'html'} onClick={() => setPreviewMode('html')}>Email design</button><button type="button" aria-pressed={previewMode === 'text'} onClick={() => setPreviewMode('text')}>Plain text</button></div></div>{previewMode === 'html' ? <iframe title="Monthly recap email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</section>}
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => { setError(''); setRevision((value) => value + 1) }}>Refresh history</button></div>
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : delivery.kind === 'on_demand' ? 'Requested by user' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>}
</section>
</>}
</div>
</AdminShell>
return (
<AdminShell
title="Monthly email recaps"
subtitle="Give each user a personal look back at their month in viewing."
actions={
<a className="ghost-button" href="/admin/notifications">
Email settings
</a>
}
>
<div className="recap-admin">
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className="status-banner" role="status">
{notice}
</p>
)}
{!data && !error && <p role="status">Loading email recaps</p>}
{!data && error && (
<button
className="ghost-button"
type="button"
onClick={() => {
setError("");
setRevision((value) => value + 1);
}}
>
Try again
</button>
)}
{data && (
<>
<div className="recap-overview-strip">
<div>
<span className={`recap-pill ${data.settings.enabled ? "is-enabled" : ""}`}>
{data.settings.enabled ? "Schedule running" : "Schedule paused"}
</span>
<p>
{data.settings.enabled
? `Next send ${dateLabel(data.settings.next_send_at)}`
: "Start the schedule when youre ready for monthly delivery."}
</p>
</div>
<div className="recap-subscriber-count">
<strong>{data.subscribers}</strong>
<span>confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}</span>
</div>
</div>
<div className="recap-admin-grid">
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Set the rhythm</span>
<h2>Monthly schedule</h2>
</div>
</div>
<p>
Send the previous months report to users who have opted in and confirmed their email. All report
periods and send times use UTC.
</p>
<form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">
Public Magent address
<input id="recap-public-url" type="url" value={settings.public_url} readOnly />
<small>
Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when
that address changes.
</small>
</label>
<div className="recap-schedule-fields">
<label htmlFor="recap-day">
Day of the month
<select
id="recap-day"
value={settings.day}
onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 28 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}
</option>
))}
</select>
</label>
<label htmlFor="recap-hour">
Send time (UTC)
<select
id="recap-hour"
value={settings.hour}
onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 24 }, (_, hour) => (
<option key={hour} value={hour}>
{String(hour).padStart(2, "0")}:00 UTC
</option>
))}
</select>
</label>
</div>
<label className="recap-checkbox">
<input
type="checkbox"
checked={settings.enabled}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })}
/>
<span>Enable scheduled monthly recaps</span>
</label>
<p className="recap-muted">
Starting or changing the schedule begins at its next future send time. Pausing cancels queued
monthly emails.
</p>
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>
{busy === "save" ? "Saving…" : "Save schedule"}
</button>
</form>
{!data.ready && (
<p className="recap-setup-note">
{data.detail} <a href="/admin/notifications">Review email settings </a>
</p>
)}
</section>
<section className="admin-panel recap-panel">
<span className="recap-eyebrow">Make it yours</span>
<h2>Preview your recap</h2>
<p>
See your own viewing highlights in the email design. A test goes only to your confirmed profile email.
</p>
<label className="recap-month-label" htmlFor="recap-month">
Report month
<select
id="recap-month"
value={month}
disabled={!!busy}
onChange={(event) => {
setMonth(event.target.value);
setPreview(null);
testRequest.current = null;
}}
>
{data.months.map((value) => (
<option key={value} value={value}>
{monthLabel(value)}
</option>
))}
</select>
</label>
<div className="recap-actions">
<button
type="button"
className="account-primary"
onClick={() => void loadPreview()}
disabled={!!busy || dirty || !month}
>
{busy === "preview" ? "Preparing preview…" : "Preview my recap"}
</button>
<button
type="button"
className="account-secondary"
onClick={() => void sendTest()}
disabled={!!busy || dirty || !preview || !data.ready}
>
{busy === "test" ? "Queuing test…" : "Send test to me"}
</button>
</div>
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
<div className="recap-preview-guidance">
<h3>One email. Your month.</h3>
<ul>
<li>Minutes, movies, episodes and requests</li>
<li>Changes from the previous month</li>
<li>Most watched titles and your longest run</li>
<li>A link to the full report and easy unsubscribe</li>
</ul>
<a href="/profile#monthly-recaps">Confirm your email in Profile </a>
</div>
</section>
</div>
{preview && (
<section className="admin-panel recap-panel recap-preview">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Email preview</span>
<h2>{preview.subject}</h2>
<p>For {preview.email || "your profile email"} · Preview links use your saved public address.</p>
</div>
<div className="recap-mode-buttons">
<button type="button" aria-pressed={previewMode === "html"} onClick={() => setPreviewMode("html")}>
Email design
</button>
<button type="button" aria-pressed={previewMode === "text"} onClick={() => setPreviewMode("text")}>
Plain text
</button>
</div>
</div>
{previewMode === "html" ? (
<iframe
title="Monthly recap email preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={preview.body_html}
/>
) : (
<pre className="recap-plain-preview">{preview.body_text}</pre>
)}
</section>
)}
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">From queue to inbox</span>
<h2>Delivery history</h2>
<p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p>
</div>
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => {
setError("");
setRevision((value) => value + 1);
}}
>
Refresh history
</button>
</div>
{data.deliveries.length ? (
<>
<div className="recap-history-scroll">
<table className="recap-history">
<thead>
<tr>
<th scope="col">Recipient</th>
<th scope="col">Report</th>
<th scope="col">Delivery</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{data.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td>
<strong>{delivery.username || "Removed account"}</strong>
<small>{delivery.email}</small>
</td>
<td>
{monthLabel(delivery.month)}
<small>
{delivery.kind === "test"
? "Test email"
: delivery.kind === "on_demand"
? "Requested by user"
: "Scheduled recap"}
</small>
</td>
<td>
<span
className={`recap-pill ${delivery.state === "sent" ? "is-enabled" : ["failed", "unknown"].includes(delivery.state) ? "is-attention" : ""}`}
>
{stateLabels[delivery.state] || delivery.state}
</span>
<small>
{delivery.attempts} {delivery.attempts === 1 ? "attempt" : "attempts"} ·{" "}
{delivery.detail || "Waiting for the next worker check."}
</small>
{delivery.state === "retry" && (
<small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>
)}
{delivery.state === "unknown" && (
<small>Automatic retries are stopped to avoid a duplicate email.</small>
)}
</td>
<td>{dateLabel(delivery.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="recap-pagination">
<span>
{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}
</span>
<div className="recap-actions">
<button
className="ghost-button"
type="button"
disabled={!offset}
onClick={() => setOffset(Math.max(0, offset - 50))}
>
Previous
</button>
<button
className="ghost-button"
type="button"
disabled={offset + 50 >= data.total}
onClick={() => setOffset(offset + 50)}
>
Next
</button>
</div>
</div>
</>
) : (
<div className="recap-empty">
<span aria-hidden="true"></span>
<h3>Your first recap starts here</h3>
<p>
Preview your email, send yourself a test, then start the monthly schedule. Delivery results will
appear here.
</p>
</div>
)}
</section>
</>
)}
</div>
</AdminShell>
);
}
+76 -88
View File
@@ -1,115 +1,111 @@
'use client'
"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'
import { useCallback, 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
}
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' },
]
{ 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()
}
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 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])
if (!total || pageSize <= 0) return 1;
return Math.max(1, Math.ceil(total / pageSize));
}, [total, pageSize]);
const load = async () => {
const load = useCallback(async () => {
if (!getToken()) {
router.push('/login')
return
router.push("/login");
return;
}
setLoading(true)
setError(null)
setLoading(true);
setError(null);
try {
const baseUrl = getApiBase()
const skip = (page - 1) * pageSize
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)
});
if (stage !== "all") {
params.set("stage", stage);
}
const response = await authFetch(
`${baseUrl}/admin/requests/all?${params.toString()}`
)
const response = await authFetch(`${baseUrl}/admin/requests/all?${params.toString()}`);
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
clearToken();
router.push("/login");
return;
}
if (response.status === 403) {
router.push('/')
return
router.push("/");
return;
}
throw new Error(`Load failed: ${response.status}`)
throw new Error(`Load failed: ${response.status}`);
}
const data = await response.json()
setRows(Array.isArray(data?.results) ? data.results : [])
setTotal(Number(data?.total ?? 0))
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.')
console.error(err);
setError("Unable to load requests.");
} finally {
setLoading(false)
setLoading(false);
}
}
}, [page, pageSize, router, stage]);
useEffect(() => {
void load()
}, [page, pageSize, stage])
void load();
}, [load]);
useEffect(() => {
if (page > pageCount) {
setPage(pageCount)
setPage(pageCount);
}
}, [pageCount, page])
}, [pageCount, page]);
useEffect(() => {
setPage(1)
}, [stage])
void stage;
setPage(1);
}, [stage]);
return (
<AdminShell
title="All requests"
subtitle="Paginated view of every cached request."
>
<AdminShell title="All requests" subtitle="Paginated view of every cached request.">
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
@@ -160,10 +156,10 @@ export default function AdminRequestsAllPage() {
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''}
{row.year ? ` (${row.year})` : ""}
</span>
<span>{row.statusLabel || 'Unknown'}</span>
<span>{row.requestedBy || 'Unknown'}</span>
<span>{row.statusLabel || "Unknown"}</span>
<span>{row.requestedBy || "Unknown"}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
@@ -179,22 +175,14 @@ export default function AdminRequestsAllPage() {
<span>
Page {page} of {pageCount}
</span>
<button
type="button"
onClick={() => setPage(page + 1)}
disabled={page >= pageCount}
>
<button type="button" onClick={() => setPage(page + 1)} disabled={page >= pageCount}>
Next
</button>
<button
type="button"
onClick={() => setPage(pageCount)}
disabled={page >= pageCount}
>
<button type="button" onClick={() => setPage(pageCount)} disabled={page >= pageCount}>
Last
</button>
</div>
</section>
</AdminShell>
)
);
}
+92 -115
View File
@@ -1,106 +1,106 @@
'use client'
"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'
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
}
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: "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: "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: "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: "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: "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',
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)
const router = useRouter();
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
useEffect(() => {
let active = true
let active = true;
const load = async () => {
if (!getToken()) {
router.push('/login')
return
router.push("/login");
return;
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
clearToken();
router.push("/login");
return;
}
router.push('/')
return
router.push("/");
return;
}
const me = await response.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
const me = await response.json();
if (!active) return;
if (me?.role !== "admin") {
router.push("/");
return;
}
setAuthorized(true)
setAuthorized(true);
} catch (error) {
console.error(error)
router.push('/')
console.error(error);
router.push("/");
} finally {
if (active) setLoading(false)
if (active) setLoading(false);
}
}
void load()
};
void load();
return () => {
active = false
}
}, [router])
active = false;
};
}, [router]);
if (loading) {
return <main className="card">Loading system guide...</main>
return <main className="card">Loading system guide...</main>;
}
if (!authorized) {
return null
return null;
}
const rail = (
@@ -112,26 +112,23 @@ export default function AdminSystemGuidePage() {
<span className="small-pill">Admin only</span>
</div>
</div>
)
);
return (
<AdminShell
title="System guide"
subtitle="Service connections, controls, and recovery paths."
rail={rail}
>
<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.
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-title">
{index + 1}. {stage.title}
</div>
<div className="system-flow-card-row">
<span>Input</span>
<strong>{stage.input}</strong>
@@ -145,7 +142,11 @@ export default function AdminSystemGuidePage() {
<strong>{stage.output}</strong>
</div>
</article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>}
{index < REQUEST_FLOW.length - 1 && (
<div className="system-flow-arrow" aria-hidden="true">
</div>
)}
</div>
))}
</div>
@@ -157,30 +158,23 @@ export default function AdminSystemGuidePage() {
<article className="system-guide-card">
<h3>Magent</h3>
<p>
Handles authentication, request pages, live event updates, invite workflows,
diagnostics, notifications, and admin operations.
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.
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>
<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>
<p>Control queue placement, quality-profile decisions, import handling, and release monitoring.</p>
</article>
<article className="system-guide-card">
<h3>Prowlarr</h3>
@@ -188,10 +182,7 @@ export default function AdminSystemGuidePage() {
</article>
<article className="system-guide-card">
<h3>qBittorrent</h3>
<p>
Executes the download and exposes live progress, paused states, and queue
visibility.
</p>
<p>Executes the download and exposes live progress, paused states, and queue visibility.</p>
</article>
</div>
</div>
@@ -213,10 +204,7 @@ export default function AdminSystemGuidePage() {
</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>
<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>
@@ -225,8 +213,8 @@ export default function AdminSystemGuidePage() {
<article className="system-guide-card">
<h3>Maintenance + diagnostics</h3>
<p>
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
nuclear flush/resync operations.
Connectivity checks, live diagnostics, database repair, cleanup, log review, and nuclear flush/resync
operations.
</p>
</article>
</div>
@@ -235,23 +223,11 @@ export default function AdminSystemGuidePage() {
<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>
<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>
@@ -265,7 +241,8 @@ export default function AdminSystemGuidePage() {
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>.
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>.
@@ -295,5 +272,5 @@ export default function AdminSystemGuidePage() {
</div>
</section>
</AdminShell>
)
);
}
+56 -56
View File
@@ -1,95 +1,95 @@
'use client'
"use client";
import PageHeading from '../ui/PageHeading'
import PageHeading from "../ui/PageHeading";
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type SiteInfo = {
changelog?: string
}
changelog?: string;
};
type ChangelogGroup = {
date: string
entries: string[]
}
date: string;
entries: string[];
};
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
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('|')
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]
const message = messageParts.join("|").trim();
if (!message) continue;
const currentGroup = groups[groups.length - 1];
if (currentGroup?.date === candidateDate) {
currentGroup.entries.push(message)
currentGroup.entries.push(message);
} else {
groups.push({ date: candidateDate, entries: [message] })
groups.push({ date: candidateDate, entries: [message] });
}
continue
continue;
}
if (groups.length === 0) {
groups.push({ date: 'Updates', entries: [line] })
groups.push({ date: "Updates", entries: [line] });
} else {
groups[groups.length - 1].entries.push(line)
groups[groups.length - 1].entries.push(line);
}
}
return groups
}
return groups;
};
export default function ChangelogPage() {
const router = useRouter()
const [groups, setGroups] = useState<ChangelogGroup[]>([])
const [loading, setLoading] = useState(true)
const router = useRouter();
const [groups, setGroups] = useState<ChangelogGroup[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken()
const token = getToken();
if (!token) {
router.push('/login')
return
router.push("/login");
return;
}
let active = true
let active = true;
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/site/info`)
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/site/info`);
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
clearToken();
router.push("/login");
return;
}
throw new Error('Failed to load changelog')
throw new Error("Failed to load changelog");
}
const data: SiteInfo = await response.json()
if (!active) return
setGroups(parseChangelog(data?.changelog ?? ''))
const data: SiteInfo = await response.json();
if (!active) return;
setGroups(parseChangelog(data?.changelog ?? ""));
} catch (err) {
console.error(err)
if (!active) return
setGroups([])
console.error(err);
if (!active) return;
setGroups([]);
} finally {
if (active) setLoading(false)
if (active) setLoading(false);
}
}
void load()
};
void load();
return () => {
active = false
}
}, [router])
active = false;
};
}, [router]);
const content = useMemo(() => {
if (loading) {
return <div className="loading-text">Loading changelog...</div>
return <div className="loading-text">Loading changelog...</div>;
}
if (groups.length === 0) {
return <div className="meta">No updates posted yet.</div>
return <div className="meta">No updates posted yet.</div>;
}
return (
<div className="changelog-groups">
@@ -104,13 +104,13 @@ export default function ChangelogPage() {
</section>
))}
</div>
)
}, [groups, loading])
);
}, [groups, loading]);
return (
<main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content}
</main>
)
);
}
+30 -13
View File
@@ -1,17 +1,34 @@
import './style.css'
import "./style.css";
export const metadata = { title: 'Coming soon | Magent — Grizzlyflix' }
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">Were 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>
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>
<ol className="launch-path" aria-label="Request journey">
{["Request", "Track", "Watch"].map((label, index) => (
<li key={label}>
<span>0{index + 1}</span>
<strong>{label}</strong>
</li>
))}
</ol>
<p className="launch-note">Were 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>
);
}
+3 -3
View File
@@ -5,9 +5,9 @@
.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 { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; padding: 0; list-style: none; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
.launch-path > li { padding: 22px 12px; display: grid; gap: 8px; }
.launch-path > li + li { 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; }

Some files were not shown because too many files have changed in this diff Show More